Files
WPSidekick/cmd/cli/main.go
T
alexandrev-tibco 4f5ab09b66 +1
2026-02-02 17:43:53 +01:00

558 lines
14 KiB
Go

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] <command> [args]\n\n", os.Args[0])
fmt.Fprintln(os.Stderr, "Commands:")
fmt.Fprintln(os.Stderr, " health")
fmt.Fprintln(os.Stderr, " pending [summary|text <draft_post_id>]")
fmt.Fprintln(os.Stderr, " optimize <post_id> [language]")
fmt.Fprintln(os.Stderr, " status <job_id>")
fmt.Fprintln(os.Stderr, " changes <post_id>")
fmt.Fprintln(os.Stderr, " approve <draft_post_id>")
fmt.Fprintln(os.Stderr, " reject <draft_post_id>")
fmt.Fprintln(os.Stderr, " ideas [status]")
fmt.Fprintln(os.Stderr, " ideas generate")
fmt.Fprintln(os.Stderr, " ideas draft <idea_id>")
fmt.Fprintln(os.Stderr, " ideas publish <idea_id>")
fmt.Fprintln(os.Stderr, " ideas delete <idea_id>")
fmt.Fprintln(os.Stderr, " outreach [status]")
fmt.Fprintln(os.Stderr, " outreach generate")
fmt.Fprintln(os.Stderr, " outreach delete <id>")
fmt.Fprintln(os.Stderr, " newsletter [status]")
fmt.Fprintln(os.Stderr, " newsletter generate")
fmt.Fprintln(os.Stderr, " newsletter delete <id>")
fmt.Fprintln(os.Stderr, " orphans")
fmt.Fprintln(os.Stderr, " translate <post_id> [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 <draft_post_id>")
}
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 <job_id>")
}
handleSimpleGet(c, "/api/v1/status/"+args[0])
case "changes":
if len(args) < 1 {
return fmt.Errorf("changes requires <post_id>")
}
handleSimpleGet(c, "/api/v1/optimization/"+args[0])
case "optimize":
if len(args) < 1 {
return fmt.Errorf("optimize requires <post_id>")
}
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 <draft_post_id>")
}
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 <draft_post_id>")
}
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 <idea_id>")
}
handleSimplePost(c, "/api/v1/ideas/"+args[1]+"/draft", nil)
case "publish":
if len(args) < 2 {
return fmt.Errorf("ideas publish requires <idea_id>")
}
handleSimplePost(c, "/api/v1/ideas/"+args[1]+"/publish", nil)
case "delete":
if len(args) < 2 {
return fmt.Errorf("ideas delete requires <idea_id>")
}
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 <id>")
}
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 <id>")
}
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 <post_id>")
}
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 <draft_id>], optimize <id> [lang], status <job_id>, changes <post_id>, approve <draft_id>, reject <draft_id>, ideas [status|generate|draft <id>|publish <id>|delete <id>], outreach [status|generate|delete <id>], newsletter [status|generate|delete <id>], orphans, translate <post_id> [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")
}