package main import ( "context" "encoding/json" "errors" "flag" "fmt" "io" "os" "strconv" "strings" "gitea.ssig33.com/ssig33/esacli/esa" ) const usage = `esacli - esa.io API CLI Usage: esacli [options] Commands: get Fetch a single post search Search posts create Create a new post update Update an existing post help Show this help Environment variables: ESA_TOKEN esa.io API access token (required) ESA_TEAM esa.io team name (required) Run "esacli -h" for command-specific options. ` func main() { if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } func run(args []string, stdout, stderr io.Writer) error { if len(args) == 0 { fmt.Fprint(stderr, usage) return errors.New("no command specified") } cmd := args[0] rest := args[1:] switch cmd { case "help", "-h", "--help": fmt.Fprint(stdout, usage) return nil case "get": return runGet(rest, stdout) case "search": return runSearch(rest, stdout) case "create": return runCreate(rest, stdout) case "update": return runUpdate(rest, stdout) default: fmt.Fprint(stderr, usage) return fmt.Errorf("unknown command: %s", cmd) } } func newClient() (*esa.Client, error) { token := os.Getenv("ESA_TOKEN") if token == "" { return nil, errors.New("ESA_TOKEN is not set") } team := os.Getenv("ESA_TEAM") if team == "" { return nil, errors.New("ESA_TEAM is not set") } c := esa.NewClient(team, token) if base := os.Getenv("ESA_BASE_URL"); base != "" { c.BaseURL = base } return c, nil } func runGet(args []string, stdout io.Writer) error { fs := flag.NewFlagSet("get", flag.ContinueOnError) number := fs.Int("number", 0, "post number (required)") bodyOnly := fs.Bool("body", false, "print only body_md instead of JSON") if err := fs.Parse(args); err != nil { return err } if *number <= 0 { return errors.New("get: -number is required") } c, err := newClient() if err != nil { return err } post, err := c.GetPost(context.Background(), *number) if err != nil { return err } if *bodyOnly { fmt.Fprint(stdout, post.BodyMD) return nil } return printJSON(stdout, post) } func runSearch(args []string, stdout io.Writer) error { fs := flag.NewFlagSet("search", flag.ContinueOnError) query := fs.String("q", "", "search query") page := fs.Int("page", 0, "page number") perPage := fs.Int("per-page", 0, "results per page (max 100)") sort := fs.String("sort", "", "sort field (updated, created, number, stars, watches, comments, best_match)") order := fs.String("order", "", "order (desc, asc)") if err := fs.Parse(args); err != nil { return err } c, err := newClient() if err != nil { return err } resp, err := c.SearchPosts(context.Background(), esa.SearchOptions{ Query: *query, Page: *page, PerPage: *perPage, Sort: *sort, Order: *order, }) if err != nil { return err } return printJSON(stdout, resp) } func runCreate(args []string, stdout io.Writer) error { fs := flag.NewFlagSet("create", flag.ContinueOnError) name := fs.String("name", "", "post title (required)") bodyFile := fs.String("body-file", "", "path to file containing body_md ('-' for stdin)") body := fs.String("body", "", "body_md (use -body-file for longer content)") tagsCSV := fs.String("tags", "", "comma-separated tags") category := fs.String("category", "", "category") wip := fs.Bool("wip", true, "WIP flag") message := fs.String("message", "", "commit-like message") if err := fs.Parse(args); err != nil { return err } if *name == "" { return errors.New("create: -name is required") } bodyMD, err := resolveBody(*body, *bodyFile) if err != nil { return err } c, err := newClient() if err != nil { return err } wipVal := *wip post, err := c.CreatePost(context.Background(), esa.PostInput{ Name: *name, BodyMD: bodyMD, Tags: splitCSV(*tagsCSV), Category: *category, WIP: &wipVal, Message: *message, }) if err != nil { return err } return printJSON(stdout, post) } func runUpdate(args []string, stdout io.Writer) error { fs := flag.NewFlagSet("update", flag.ContinueOnError) number := fs.Int("number", 0, "post number (required)") name := fs.String("name", "", "new title") bodyFile := fs.String("body-file", "", "path to file containing body_md ('-' for stdin)") body := fs.String("body", "", "body_md (use -body-file for longer content)") tagsCSV := fs.String("tags", "", "comma-separated tags") category := fs.String("category", "", "category") wipFlag := fs.String("wip", "", "WIP flag (true/false); empty leaves unchanged") message := fs.String("message", "", "commit-like message") if err := fs.Parse(args); err != nil { return err } if *number <= 0 { return errors.New("update: -number is required") } bodyMD, err := resolveBody(*body, *bodyFile) if err != nil { return err } input := esa.PostInput{ Name: *name, BodyMD: bodyMD, Tags: splitCSV(*tagsCSV), Category: *category, Message: *message, } if *wipFlag != "" { v, err := strconv.ParseBool(*wipFlag) if err != nil { return fmt.Errorf("update: -wip: %w", err) } input.WIP = &v } c, err := newClient() if err != nil { return err } post, err := c.UpdatePost(context.Background(), *number, input) if err != nil { return err } return printJSON(stdout, post) } func resolveBody(inline, file string) (string, error) { if inline != "" && file != "" { return "", errors.New("specify only one of -body or -body-file") } if file == "" { return inline, nil } if file == "-" { b, err := io.ReadAll(os.Stdin) if err != nil { return "", fmt.Errorf("read stdin: %w", err) } return string(b), nil } b, err := os.ReadFile(file) if err != nil { return "", fmt.Errorf("read body file: %w", err) } return string(b), nil } func splitCSV(s string) []string { if s == "" { return nil } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } func printJSON(w io.Writer, v any) error { enc := json.NewEncoder(w) enc.SetIndent("", " ") return enc.Encode(v) }