package main import ( "bufio" "bytes" "encoding/json" "flag" "fmt" "io" "net/http" "os" "strconv" "strings" "time" ) const version = "1.0.0" type client struct { baseURL string http *http.Client site string } func newClient(baseURL, site string) *client { return &client{ baseURL: strings.TrimRight(baseURL, "/"), http: &http.Client{ Timeout: 30 * time.Second, }, site: strings.TrimSpace(site), } } func (c *client) do(method, path string, payload interface{}) ([]byte, int, error) { var body io.Reader if payload != nil { data, err := json.Marshal(payload) if err != nil { return nil, 0, err } body = bytes.NewReader(data) } req, err := http.NewRequest(method, c.baseURL+path, body) if err != nil { return nil, 0, err } if payload != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("X-WPSK-Client", "cli") if strings.TrimSpace(c.site) != "" { req.Header.Set("X-WPSK-Site", c.site) } resp, err := c.http.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, resp.StatusCode, err } return respBody, resp.StatusCode, nil } func main() { defaultAPI := os.Getenv("WPSK_API_URL") if defaultAPI == "" { defaultAPI = "http://localhost:8080" } defaultSite := os.Getenv("WPSK_SITE") apiURL := flag.String("api", defaultAPI, "API base URL") siteID := flag.String("site", defaultSite, "Site ID") versionFlag := flag.Bool("version", false, "Print CLI version") usage := func() { fmt.Fprintf(os.Stderr, "Usage: %s [--api URL] [--site SITE_ID] [args]\n\n", os.Args[0]) fmt.Fprintln(os.Stderr, "Commands:") fmt.Fprintln(os.Stderr, " health") fmt.Fprintln(os.Stderr, " pending [summary|text ]") fmt.Fprintln(os.Stderr, " optimize [language]") fmt.Fprintln(os.Stderr, " status ") fmt.Fprintln(os.Stderr, " changes ") fmt.Fprintln(os.Stderr, " approve ") fmt.Fprintln(os.Stderr, " reject ") fmt.Fprintln(os.Stderr, " ideas [status]") fmt.Fprintln(os.Stderr, " ideas generate") fmt.Fprintln(os.Stderr, " ideas draft ") fmt.Fprintln(os.Stderr, " ideas publish ") fmt.Fprintln(os.Stderr, " ideas delete ") fmt.Fprintln(os.Stderr, " outreach [status]") fmt.Fprintln(os.Stderr, " outreach generate") fmt.Fprintln(os.Stderr, " outreach delete ") fmt.Fprintln(os.Stderr, " newsletter [status]") fmt.Fprintln(os.Stderr, " newsletter generate") fmt.Fprintln(os.Stderr, " newsletter delete ") fmt.Fprintln(os.Stderr, " orphans") fmt.Fprintln(os.Stderr, " translate [language]") fmt.Fprintln(os.Stderr, " audit [limit]") fmt.Fprintln(os.Stderr, " version") fmt.Fprintln(os.Stderr, " shell") fmt.Fprintln(os.Stderr, " help") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Examples:") fmt.Fprintln(os.Stderr, " wp-sk-cli pending") fmt.Fprintln(os.Stderr, " wp-sk-cli changes 123") fmt.Fprintln(os.Stderr, " WPSK_API_URL=http://server:8080 wp-sk-cli pending") fmt.Fprintln(os.Stderr, " wp-sk-cli approve 456 --api http://server:8080") fmt.Fprintln(os.Stderr, " wp-sk-cli --site padre pending") } flag.Usage = usage flag.Parse() if *versionFlag { fmt.Println(version) return } args := flag.Args() if len(args) == 0 { usage() os.Exit(1) } cmd := args[0] args = args[1:] if cmd == "help" || cmd == "-h" || cmd == "--help" { usage() return } c := newClient(*apiURL, *siteID) if cmd != "version" && cmd != "help" { warnPendingOutreach(c) } if cmd == "shell" { runInteractive(c) return } if err := executeCommand(c, cmd, args); err != nil { fmt.Fprintln(os.Stderr, err.Error()) os.Exit(1) } } func handleSimpleGet(c *client, path string) { body, status, err := c.do(http.MethodGet, path, nil) handleResponse(body, status, err) } func handleSimplePost(c *client, path string, payload interface{}) { body, status, err := c.do(http.MethodPost, path, payload) handleResponse(body, status, err) } func handleResponse(body []byte, status int, err error) { if err != nil { die(err.Error()) } if status < 200 || status >= 300 { fmt.Fprintf(os.Stderr, "Request failed: status %d\n", status) fmt.Fprintln(os.Stderr, string(body)) os.Exit(1) } var pretty bytes.Buffer if json.Indent(&pretty, body, "", " ") == nil { fmt.Println(pretty.String()) return } fmt.Println(string(body)) } type pendingResponse struct { Count int `json:"count"` Records []pendingRecord `json:"records"` } type pendingRecord struct { OriginalPostID int `json:"original_post_id"` DraftPostID int `json:"draft_post_id"` PostTitle string `json:"post_title"` } func handlePendingSummary(c *client) { body, status, err := c.do(http.MethodGet, "/api/v1/pending", nil) if err != nil { die(err.Error()) } if status < 200 || status >= 300 { fmt.Fprintf(os.Stderr, "Request failed: status %d\n", status) fmt.Fprintln(os.Stderr, string(body)) os.Exit(1) } var resp pendingResponse if err := json.Unmarshal(body, &resp); err != nil { die("failed to parse response") } if resp.Count == 0 || len(resp.Records) == 0 { fmt.Println("No pending records.") return } for _, record := range resp.Records { if record.OriginalPostID > 0 { fmt.Printf("%d\t%s (original %d)\n", record.DraftPostID, record.PostTitle, record.OriginalPostID) } else { fmt.Printf("%d\t%s\n", record.DraftPostID, record.PostTitle) } } } func executeCommand(c *client, cmd string, args []string) error { switch cmd { case "health": handleSimpleGet(c, "/api/v1/health") case "version": return handleVersion(c) case "pending": if len(args) > 0 && args[0] == "summary" { handlePendingSummary(c) return nil } if len(args) > 0 && args[0] == "text" { if len(args) < 2 { return fmt.Errorf("pending text requires ") } draftID, err := parseIntArg("draft_post_id", args[1]) if err != nil { return err } return handleDraftText(c, draftID) } handleSimpleGet(c, "/api/v1/pending") case "status": if len(args) < 1 { return fmt.Errorf("status requires ") } handleSimpleGet(c, "/api/v1/status/"+args[0]) case "changes": if len(args) < 1 { return fmt.Errorf("changes requires ") } handleSimpleGet(c, "/api/v1/optimization/"+args[0]) case "optimize": if len(args) < 1 { return fmt.Errorf("optimize requires ") } postID, err := parseIntArg("post_id", args[0]) if err != nil { return err } language := "en" if len(args) > 1 { language = args[1] } handleSimplePost(c, "/api/v1/optimize", map[string]interface{}{ "post_id": postID, "language": language, }) case "approve": if len(args) < 1 { return fmt.Errorf("approve requires ") } draftID, err := parseIntArg("draft_post_id", args[0]) if err != nil { return err } handleSimplePost(c, "/api/v1/apply-draft", map[string]interface{}{ "draft_post_id": draftID, }) case "reject": if len(args) < 1 { return fmt.Errorf("reject requires ") } draftID, err := parseIntArg("draft_post_id", args[0]) if err != nil { return err } handleSimplePost(c, "/api/v1/reject-draft", map[string]interface{}{ "draft_post_id": draftID, }) case "ideas": if len(args) == 0 { handleSimpleGet(c, "/api/v1/ideas") return nil } switch args[0] { case "generate": handleSimplePost(c, "/api/v1/ideas/generate", nil) case "draft": if len(args) < 2 { return fmt.Errorf("ideas draft requires ") } handleSimplePost(c, "/api/v1/ideas/"+args[1]+"/draft", nil) case "publish": if len(args) < 2 { return fmt.Errorf("ideas publish requires ") } handleSimplePost(c, "/api/v1/ideas/"+args[1]+"/publish", nil) case "delete": if len(args) < 2 { return fmt.Errorf("ideas delete requires ") } handleSimplePost(c, "/api/v1/ideas/"+args[1]+"/delete", nil) default: status := args[0] handleSimpleGet(c, "/api/v1/ideas?status="+status) } case "outreach": if len(args) == 0 { handleSimpleGet(c, "/api/v1/outreach") return nil } switch args[0] { case "generate": handleSimplePost(c, "/api/v1/outreach/generate", nil) case "delete": if len(args) < 2 { return fmt.Errorf("outreach delete requires ") } handleSimplePost(c, "/api/v1/outreach/"+args[1]+"/delete", nil) default: status := args[0] handleSimpleGet(c, "/api/v1/outreach?status="+status) } case "newsletter": if len(args) == 0 { handleSimpleGet(c, "/api/v1/newsletter") return nil } switch args[0] { case "generate": handleSimplePost(c, "/api/v1/newsletter/generate", nil) case "delete": if len(args) < 2 { return fmt.Errorf("newsletter delete requires ") } handleSimplePost(c, "/api/v1/newsletter/"+args[1]+"/delete", nil) default: status := args[0] handleSimpleGet(c, "/api/v1/newsletter?status="+status) } case "orphans": handleSimpleGet(c, "/api/v1/orphans?source=en&target=es") case "translate": if len(args) < 1 { return fmt.Errorf("translate requires ") } postID, err := parseIntArg("post_id", args[0]) if err != nil { return err } targetLang := "es" if len(args) > 1 { targetLang = args[1] } handleSimplePost(c, "/api/v1/translate", map[string]interface{}{ "post_id": postID, "target_lang": targetLang, }) case "audit": limit := 100 if len(args) > 0 { parsed, err := strconv.Atoi(args[0]) if err != nil || parsed <= 0 { return fmt.Errorf("audit limit must be a positive integer") } limit = parsed } handleSimpleGet(c, fmt.Sprintf("/api/v1/audit?limit=%d", limit)) default: return fmt.Errorf("unknown command: %s", cmd) } return nil } func runInteractive(c *client) { fmt.Println("WPSideKick interactive shell. Type 'help' or 'exit'.") warnPendingOutreach(c) scanner := bufio.NewScanner(os.Stdin) for { fmt.Print("wpsk> ") if !scanner.Scan() { break } line := strings.TrimSpace(scanner.Text()) if line == "" { continue } if line == "exit" || line == "quit" { return } if line == "help" { fmt.Println("Commands: health, version, pending [summary|text ], optimize [lang], status , changes , approve , reject , ideas [status|generate|draft |publish |delete ], outreach [status|generate|delete ], newsletter [status|generate|delete ], orphans, translate [lang], audit [limit], exit") continue } tokens := strings.Fields(line) if len(tokens) == 0 { continue } cmd := tokens[0] args := tokens[1:] if err := executeCommand(c, cmd, args); err != nil { fmt.Fprintln(os.Stderr, err.Error()) } } if err := scanner.Err(); err != nil { fmt.Fprintln(os.Stderr, "interactive error:", err.Error()) } } type draftResponse struct { ID int `json:"id"` Status string `json:"status"` Title string `json:"title"` Excerpt string `json:"excerpt"` Content string `json:"content"` } type healthResponse struct { Status string `json:"status"` Version string `json:"version"` } type outreachResponse struct { Count int `json:"count"` } func handleDraftText(c *client, draftID int) error { body, status, err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/drafts/%d", draftID), nil) if err != nil { return err } if status < 200 || status >= 300 { return fmt.Errorf("request failed: status %d: %s", status, string(body)) } var resp draftResponse if err := json.Unmarshal(body, &resp); err != nil { return fmt.Errorf("failed to parse response") } fmt.Printf("Draft %d (%s)\n", resp.ID, resp.Status) fmt.Println(strings.Repeat("=", 60)) fmt.Println(resp.Title) fmt.Println(strings.Repeat("-", 60)) if strings.TrimSpace(resp.Excerpt) != "" { fmt.Println(wrapText(stripHTML(resp.Excerpt), 100)) fmt.Println(strings.Repeat("-", 60)) } fmt.Println(wrapText(stripHTML(resp.Content), 100)) fmt.Println() return nil } func handleVersion(c *client) error { fmt.Printf("CLI version: %s\n", version) body, status, err := c.do(http.MethodGet, "/api/v1/health", nil) if err != nil { fmt.Printf("Server version: unavailable (%v)\n", err) return nil } if status < 200 || status >= 300 { fmt.Printf("Server version: unavailable (status %d)\n", status) return nil } var resp healthResponse if err := json.Unmarshal(body, &resp); err != nil { fmt.Println("Server version: unavailable (invalid response)") return nil } if resp.Version == "" { fmt.Println("Server version: unavailable") return nil } fmt.Printf("Server version: %s\n", resp.Version) return nil } func warnPendingOutreach(c *client) { body, status, err := c.do(http.MethodGet, "/api/v1/outreach?status=new", nil) if err != nil || status < 200 || status >= 300 { return } var resp outreachResponse if err := json.Unmarshal(body, &resp); err != nil { return } if resp.Count > 0 { fmt.Fprintf(os.Stderr, "⚠ Pending outreach suggestions: %d (run `wp-sk-cli outreach`)\n", resp.Count) } } func parseIntArg(name, value string) (int, error) { parsed, err := strconv.Atoi(value) if err != nil { return 0, fmt.Errorf("%s must be an integer", name) } return parsed, nil } func die(msg string) { fmt.Fprintln(os.Stderr, msg) os.Exit(1) } func stripHTML(value string) string { out := strings.Builder{} inTag := false for _, r := range value { switch r { case '<': inTag = true case '>': inTag = false default: if !inTag { out.WriteRune(r) } } } return strings.TrimSpace(out.String()) } func wrapText(value string, width int) string { if width <= 0 { return value } words := strings.Fields(value) if len(words) == 0 { return "" } var lines []string line := words[0] for _, word := range words[1:] { if len(line)+1+len(word) > width { lines = append(lines, line) line = word } else { line += " " + word } } lines = append(lines, line) return strings.Join(lines, "\n") }