From e04b037b33ef5aa0683ce88bb43a4294a39fc391 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Fri, 14 Aug 2026 12:08:52 +0100 Subject: [PATCH] feat: parse all Go CLIs with flaggy; dump bash complete. stdlib flag dropped --hop after the query. One wrapper in internal/cli, source <(./bin/cli/complete.go bash). Gitea #34. --- AGENTS.md | 3 +- PLAN.md | 7 +- bin/cli/complete.go | 91 +++++++++++++++ bin/git/import.go | 61 ++-------- bin/mail/ocr.go | 14 +-- bin/mail/sync/cli.go | 114 +++++++++++-------- bin/markdown/import.go | 29 ++--- bin/qa/stats.go | 22 +--- bin/reasoner/bakeoff.go | 43 ++------ bin/tools/test_bin_layout.py | 16 +++ bin/web/search.go | 80 ++------------ docs/design.md | 4 + docs/roadmap.md | 1 + docs/runbook.md | 1 + go.mod | 1 + go.sum | 2 + internal/brain/rank/args.go | 100 ++++++++--------- internal/brain/rank/readargs.go | 59 ++++++++++ internal/brain/read.go | 61 +++------- internal/brain/search.go | 4 + internal/chats/apply.go | 36 +++--- internal/chats/cli.go | 74 +++++++++++++ internal/chats/facts.go | 14 +-- internal/chats/import.go | 14 +-- internal/chats/sync_linkedin.go | 23 ++-- internal/chats/sync_telegram.go | 23 ++-- internal/cli/cli.go | 190 ++++++++++++++++++++++++++++++++ internal/cli/cli_test.go | 77 +++++++++++++ internal/cli/qa.go | 24 ++++ internal/gitlog/args.go | 51 +++++++++ internal/mdleaves/args.go | 41 +++++++ internal/ocr/args.go | 35 ++++++ internal/reasoner/args.go | 47 ++++++++ internal/websearch/args.go | 63 +++++++++++ 34 files changed, 1005 insertions(+), 420 deletions(-) create mode 100755 bin/cli/complete.go create mode 100644 internal/brain/rank/readargs.go create mode 100644 internal/chats/cli.go create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/cli_test.go create mode 100644 internal/cli/qa.go create mode 100644 internal/gitlog/args.go create mode 100644 internal/mdleaves/args.go create mode 100644 internal/ocr/args.go create mode 100644 internal/reasoner/args.go create mode 100644 internal/websearch/args.go diff --git a/AGENTS.md b/AGENTS.md index 67ad421..6364d7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ bin/postgres/ query.go (read-only YAML) bin/git/ import.go (go-git history; Python shim execs it) bin/web/ search.go (SearXNG; Python shim execs it) bin/reasoner/ bakeoff.go (D18 CPU OpenAI tool-call bake-off) -internal/ shared Go (brain/rank is cgo-free; facts D16; chats; gitlog; websearch; reasoner; duckstats) +internal/ shared Go (brain/rank is cgo-free; facts D16; cli flaggy D23; chats; gitlog; websearch; reasoner; duckstats) bin/qa/ stats.go (DuckDB quantiles / JSONL count; gcc CGO, not Zig) bin/watch/ corpus watcher (used by bin/brain/watch.go) bin/tools/ vendored python libs behind bin/* (kblib, yamlout, websearch) @@ -89,6 +89,7 @@ bin/facts/crm.go [--dry-run] # proof person↔company/comp bin/kb/search "query" [--repo X] # deprecated wrapper → bin/brain/search.go bin/brain/search.go "query" [--root facts|info] # deduction search → YAML bin/brain/search.go "query" --no-web # local graph only +source <(./bin/cli/complete.go bash) # flaggy completions (D23) eval "$(bin/cgo/zig env)" # Zig cc + liblbug (not gcc) bin/brain/index.go --rebuild [--with-mail] [--with-facts] [--with-chats] bin/brain/add.go --text T --root facts --source "a.md x b.md" # incremental write diff --git a/PLAN.md b/PLAN.md index b8fb4a6..1336909 100644 --- a/PLAN.md +++ b/PLAN.md @@ -8,7 +8,8 @@ Status: **v1 in** (epic [#16](https://git.produktor.io/eSlider/2dph/issues/16) c v2 board: milestone [v2](https://git.produktor.io/eSlider/2dph/milestone/13) — OCR [#6](https://git.produktor.io/eSlider/2dph/issues/6) in, [#29](https://git.produktor.io/eSlider/2dph/issues/29) OQ1 in, -[#30](https://git.produktor.io/eSlider/2dph/issues/30) OQ3 in. +[#30](https://git.produktor.io/eSlider/2dph/issues/30) OQ3 in, +[#34](https://git.produktor.io/eSlider/2dph/issues/34) D23 in. Gap: [docs/roadmap.md](docs/roadmap.md). ## What @@ -51,6 +52,7 @@ detective method: **a fact needs ≥2 independent sources or it is | D20 | agent API | OpenAPI + MCP are generated from the same `internal/httpapi.Ops` table as `bin/brain/serve.go` handlers. `GET /openapi.json`, `POST /mcp` (JSON-RPC tools/list + tools/call). Tool names match OpenAPI paths (`search`/`get`/`stats`/`audit`/`ingest`). | | D21 | CGO | Ladybug/tokenizers CGO is compiled with **Zig** (`bin/cgo/zcc` → `zig cc -target …-linux-gnu`), not gcc. `bin/cgo/zig` pins Zig 0.14.1 + liblbug 0.19.1 + libtokenizers 1.27.0. Compose `target: api` has no CPython; write/rebuild is profile `index`. | | D22 | analytics | **duckdb-go** in-process (`internal/duckstats`, `bin/qa/stats.go`) for quantiles/JSONL. Links with **gcc/g++**, not Zig. Ladybug stays the graph; web-search cache stays modernc sqlite. Slice small structured docs with **mikefarah/yq**, not kislyuk/jq. [#30](https://git.produktor.io/eSlider/2dph/issues/30). | +| D23 | CLI | **flaggy** (`github.com/integrii/flaggy`, 0 deps). Flags at any position. Wrapper `internal/cli`. Bash complete: `source <(./bin/cli/complete.go bash)`. No cobra, no stdlib `flag` in Go tools. Search does not intercept the word `completion`. [#34](https://git.produktor.io/eSlider/2dph/issues/34). | ## Architecture @@ -67,7 +69,8 @@ detective method: **a fact needs ≥2 independent sources or it is brain/add.go incremental leaf write (no rebuild) brain/get.go stats.go eval.go # Go read (cgo); Python bin/kb/* CI fallback brain/watch.go - brain/search.go deduction: facts → info → web-search + brain/search.go deduction: facts → info → web + cli/complete.go flaggy bash/zsh/fish complete (D23) brain/serve.go HTTP API in-process + OpenAPI/MCP (D20); Zig CGO (D21) cgo/zig zcc zc++ CGO toolchain (zig cc, not gcc) mail/import.go JSON → markdown (no brain write) diff --git a/bin/cli/complete.go b/bin/cli/complete.go new file mode 100755 index 0000000..f82065a --- /dev/null +++ b/bin/cli/complete.go @@ -0,0 +1,91 @@ +//usr/bin/env go run "$0" "$@"; exit +// +// bin/cli/complete.go - dump flaggy shell completions for all Go shebang tools (D23). +// +// source <(./bin/cli/complete.go bash) +// ./bin/cli/complete.go zsh|fish|powershell|nushell +// +// Search does not steal the word "completion"; this binary dumps scripts. +// NOTE: never run `gofmt -w` on this file — it breaks the shebang. +package main + +import ( + "fmt" + "os" + "strings" + + mailsync "github.com/eSlider/2dph/bin/mail/sync" + "github.com/eSlider/2dph/internal/brain/rank" + "github.com/eSlider/2dph/internal/chats" + "github.com/eSlider/2dph/internal/cli" + "github.com/eSlider/2dph/internal/gitlog" + "github.com/eSlider/2dph/internal/mdleaves" + "github.com/eSlider/2dph/internal/ocr" + "github.com/eSlider/2dph/internal/reasoner" + "github.com/eSlider/2dph/internal/websearch" + "github.com/integrii/flaggy" +) + +func tools() []cli.Tool { + return []cli.Tool{ + {Path: "bin/brain/search.go", Name: "brain-search", New: rank.Parser}, + {Path: "bin/brain/get.go", Name: "brain-get", New: func() *flaggy.Parser { + o := rank.GetOptions{} + return rank.GetParser(&o) + }}, + {Path: "bin/brain/stats.go", Name: "brain-stats", New: rank.StatsParser}, + {Path: "bin/brain/eval.go", Name: "brain-eval", New: rank.EvalParser}, + {Path: "bin/web/search.go", Name: "web-search", New: websearch.Parser}, + {Path: "bin/git/import.go", Name: "git-import", New: gitlog.Parser}, + {Path: "bin/markdown/import.go", Name: "markdown-import", New: mdleaves.Parser}, + {Path: "bin/qa/stats.go", Name: "qa-stats", New: cli.QAParser}, + {Path: "bin/reasoner/bakeoff.go", Name: "reasoner-bakeoff", New: reasoner.Parser}, + {Path: "bin/mail/ocr.go", Name: "mail-ocr", New: ocr.Parser}, + {Path: "bin/mail/sync.go", Name: "mail-sync", New: mailsync.Parser}, + {Path: "bin/chats/sync.go", Name: "chats-sync", New: chats.SyncParser}, + {Path: "bin/chats/import.go", Name: "chats-import", New: chats.ImportParser}, + {Path: "bin/chats/facts.go", Name: "chats-facts", New: chats.FactsParser}, + {Path: "bin/chats/apply.go", Name: "chats-apply", New: chats.ApplyParser}, + } +} + +func main() { + os.Exit(run(os.Args[1:])) +} + +func run(args []string) int { + shell := "bash" + if len(args) > 0 { + switch args[0] { + case "bash", "zsh", "fish", "powershell", "nushell": + shell = args[0] + case "-h", "--help", "help": + fmt.Fprintln(os.Stderr, "usage: bin/cli/complete.go [bash|zsh|fish|powershell|nushell]") + return 0 + default: + fmt.Fprintf(os.Stderr, "cli/complete: unknown shell %q\n", args[0]) + return 2 + } + } + if shell == "bash" { + fmt.Print(cli.BashScript(tools())) + return 0 + } + var b strings.Builder + for _, t := range tools() { + p := t.New() + p.Name = t.Name + switch shell { + case "zsh": + b.WriteString(flaggy.GenerateZshCompletion(p)) + case "fish": + b.WriteString(flaggy.GenerateFishCompletion(p)) + case "powershell": + b.WriteString(flaggy.GeneratePowerShellCompletion(p)) + case "nushell": + b.WriteString(flaggy.GenerateNushellCompletion(p)) + } + } + fmt.Print(b.String()) + return 0 +} diff --git a/bin/git/import.go b/bin/git/import.go index 8ea339e..3118ca3 100755 --- a/bin/git/import.go +++ b/bin/git/import.go @@ -16,9 +16,8 @@ import ( "fmt" "os" "path/filepath" - "strconv" - "time" + cliparse "github.com/eSlider/2dph/internal/cli" "github.com/eSlider/2dph/internal/cmdbin" "github.com/eSlider/2dph/internal/gitlog" ) @@ -28,49 +27,16 @@ func main() { } func run(args []string) int { - var repo, root, since string - limit := 0 - jsonOut := false - i := 0 - for i < len(args) { - a := args[i] - switch { - case a == "--json": - jsonOut = true - case a == "--limit" && i+1 < len(args): - i++ - n, err := strconv.Atoi(args[i]) - if err != nil || n < 0 { - fmt.Fprintf(os.Stderr, "git/import: --limit must be a non-negative integer\n") - return 2 - } - limit = n - case a == "--since" && i+1 < len(args): - i++ - since = args[i] - case a == "--root" && i+1 < len(args): - i++ - root = args[i] - case a == "-h" || a == "--help": - fmt.Fprintln(os.Stderr, `usage: bin/git/import.go [REPO] [--json] [--limit N] [--since DATE] [--root DIR]`) - return 0 - case len(a) > 0 && a[0] != '-': - repo = a - default: - fmt.Fprintf(os.Stderr, "git/import: unknown flag %s\n", a) - return 2 - } - i++ + c, err := gitlog.ParseArgs(args) + if err != nil { + return cliparse.Fail(err) } + repo, root, since, limit, jsonOut := c.Repo, c.Root, c.Since, c.Limit, c.JSONOut - var sinceT time.Time - if since != "" { - var err error - sinceT, err = parseSince(since) - if err != nil { - fmt.Fprintf(os.Stderr, "git/import: %v\n", err) - return 2 - } + sinceT, err := gitlog.ParseSince(since) + if err != nil { + fmt.Fprintf(os.Stderr, "git/import: %v\n", err) + return 2 } repos := []string{} @@ -132,12 +98,3 @@ func run(args []string) int { } return 0 } - -func parseSince(s string) (time.Time, error) { - for _, layout := range []string{time.RFC3339, "2006-01-02"} { - if t, err := time.Parse(layout, s); err == nil { - return t, nil - } - } - return time.Time{}, fmt.Errorf("cannot parse --since %q", s) -} diff --git a/bin/mail/ocr.go b/bin/mail/ocr.go index 1067ac4..d2e4b32 100755 --- a/bin/mail/ocr.go +++ b/bin/mail/ocr.go @@ -17,6 +17,7 @@ import ( "os" "strings" + cliparse "github.com/eSlider/2dph/internal/cli" "github.com/eSlider/2dph/internal/ocr" ) @@ -25,15 +26,12 @@ func main() { } func run(args []string) int { - if len(args) != 1 || strings.HasPrefix(args[0], "-") { - fmt.Fprintln(os.Stderr, `usage: bin/mail/ocr.go `) - return 2 + c, err := ocr.ParseArgs(args) + if err != nil { + return cliparse.Fail(err) } - path := args[0] - var ( - text string - err error - ) + path := c.Path + var text string if strings.HasSuffix(strings.ToLower(path), ".pdf") { text, err = ocr.PDFFile(path) } else { diff --git a/bin/mail/sync/cli.go b/bin/mail/sync/cli.go index 5bfad52..9ec41ec 100644 --- a/bin/mail/sync/cli.go +++ b/bin/mail/sync/cli.go @@ -5,74 +5,103 @@ package sync import ( "context" - "flag" + "errors" "fmt" "os" "path/filepath" "strings" "time" + + cliparse "github.com/eSlider/2dph/internal/cli" + "github.com/integrii/flaggy" ) // CLIConfig is a superset of SyncConfig plus flag parsing results. type CLIConfig struct { - Sync SyncConfig - Env string // .env path; default /.env + Sync SyncConfig + Env string // .env path; default /.env Sources string - Help bool + Help bool } -// ParseCLI reads os.Args into a CLIConfig. Exit codes: 0 ok, 2 usage. +type flagVals struct { + env, out, srcs, query string + workers, limit, offset int + force, dryRun bool +} + +func Parser() *flaggy.Parser { + v := flagVals{workers: 4, query: "in:inbox", srcs: "onlyoffice"} + return bind(&v) +} + +func bind(v *flagVals) *flaggy.Parser { + if v.workers == 0 { + v.workers = 4 + } + if v.query == "" { + v.query = "in:inbox" + } + if v.srcs == "" { + v.srcs = "onlyoffice" + } + p := cliparse.New("mail-sync") + p.Description = "download mail to var/mail" + p.String(&v.env, "", "env", ".env file") + p.String(&v.out, "", "out", "var/mail root") + p.Int(&v.workers, "", "workers", "concurrent downloads") + p.Int(&v.limit, "", "limit", "max messages per source (0 = all)") + p.Int(&v.offset, "", "offset", "skip first N messages per source") + p.Bool(&v.force, "", "force", "overwrite existing message.json") + p.Bool(&v.dryRun, "", "dry-run", "list counts without writing") + p.String(&v.query, "", "query", "Gmail search query") + p.String(&v.srcs, "", "source", "comma list: onlyoffice,gmail") + return p +} + +// ParseCLI reads args into a CLIConfig. Exit codes: 0 ok, 2 usage. func ParseCLI(args []string) (CLIConfig, int, error) { - fs := flag.NewFlagSet("mail/sync", flag.ContinueOnError) - var ( - env = fs.String("env", "", ".env file (default: /.env)") - out = fs.String("out", "", "var/mail root (default: /var/mail)") - workers = fs.Int("workers", 4, "concurrent downloads") - limit = fs.Int("limit", 0, "max messages per source (0 = all)") - offset = fs.Int("offset", 0, "skip first N messages per source") - force = fs.Bool("force", false, "overwrite existing message.json + attachments") - dryRun = fs.Bool("dry-run", false, "list message counts without writing") - query = fs.String("query", "in:inbox", "Gmail search query (gmail source only)") - srcs = fs.String("source", "onlyoffice", "comma list: onlyoffice,gmail (default onlyoffice)") - help = fs.Bool("help", false, "usage") - ) - fs.SetOutput(os.Stderr) - if err := fs.Parse(args); err != nil { + v := flagVals{workers: 4, query: "in:inbox", srcs: "onlyoffice"} + p := bind(&v) + if err := cliparse.Parse(p, args); err != nil { + if errors.Is(err, cliparse.ErrHelp) { + return CLIConfig{Help: true}, 0, nil + } return CLIConfig{}, 2, err } - if *help || fs.NArg() > 0 { + if len(p.TrailingArguments) > 0 { return CLIConfig{Help: true}, 0, nil } wd, err := os.Getwd() if err != nil { return CLIConfig{}, 2, err } - if *env == "" { - *env = filepath.Join(wd, ".env") + if v.env == "" { + v.env = filepath.Join(wd, ".env") } - if *out == "" { - *out = filepath.Join(wd, "var", "mail") + if v.out == "" { + v.out = filepath.Join(wd, "var", "mail") } - envVars := readEnv(*env) + envVars := readEnv(v.env) cfg := SyncConfig{ - Out: *out, - Workers: *workers, - Limit: *limit, - Offset: *offset, - Force: *force, - DryRun: *dryRun, - Query: *query, + Out: v.out, + Workers: v.workers, + Limit: v.limit, + Offset: v.offset, + Force: v.force, + DryRun: v.dryRun, + Query: v.query, Policy: RetryPolicy{}, } - cli := CLIConfig{Sync: cfg, Env: *env, Sources: *srcs} - for _, s := range strings.Split(*srcs, ",") { + out := CLIConfig{Sync: cfg, Env: v.env, Sources: v.srcs} + for _, s := range strings.Split(v.srcs, ",") { switch strings.TrimSpace(s) { case "onlyoffice": u := pick(envVars["ONLYOFFICE_URL"], envVars["OO_URL"]) user := pick(envVars["ONLYOFFICE_USER"], envVars["OO_USER"]) pass := pick(envVars["ONLYOFFICE_PASS"], envVars["OO_PASSWORD"]) if u == "" || user == "" || pass == "" { - return CLIConfig{}, 2, fmt.Errorf("onlyoffice source needs ONLYOFFICE_URL/USER/PASS in %s", *env) + return CLIConfig{}, 2, fmt.Errorf("onlyoffice source needs ONLYOFFICE_URL/USER/PASS in %s", v.env) } cfg.OO = &OOConfig{URL: u, User: user, Password: pass} case "gmail": @@ -85,30 +114,30 @@ func ParseCLI(args []string) (CLIConfig, int, error) { return CLIConfig{}, 2, fmt.Errorf("unknown source %q", s) } } - cli.Sync = cfg - return cli, 0, nil + out.Sync = cfg + return out, 0, nil } // Main is the CLI entry: returns process exit code. func Main(args []string) int { - cli, code, err := ParseCLI(args) + cfg, code, err := ParseCLI(args) if err != nil { fmt.Fprintln(os.Stderr, "mail/sync:", err) return code } - if cli.Help { + if cfg.Help { fmt.Fprintln(os.Stderr, "usage: bin/mail/sync.go [--source onlyoffice,gmail] [--query GMAIL_Q] [--limit N] [--offset N] [--workers N] [--force] [--dry-run]") return 0 } ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour) defer cancel() start := time.Now() - stats, err := Run(ctx, cli.Sync) + stats, err := Run(ctx, cfg.Sync) if err != nil { fmt.Fprintln(os.Stderr, "mail/sync:", err) return 1 } - if cli.Sync.DryRun { + if cfg.Sync.DryRun { fmt.Printf("mail/sync: dry-run checked=%d (no writes)\n", stats.Checked) return 0 } @@ -135,7 +164,6 @@ func readEnv(path string) map[string]string { k, v, _ := strings.Cut(line, "=") out[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), "\"'") } - // env overrides file for _, kv := range os.Environ() { k, v, ok := strings.Cut(kv, "=") if !ok { diff --git a/bin/markdown/import.go b/bin/markdown/import.go index f730f54..9e1d7c2 100755 --- a/bin/markdown/import.go +++ b/bin/markdown/import.go @@ -15,6 +15,7 @@ import ( "os" "strings" + cliparse "github.com/eSlider/2dph/internal/cli" "github.com/eSlider/2dph/internal/mdleaves" ) @@ -23,29 +24,13 @@ func main() { } func run(args []string) int { - jsonOut := false - files := "" - root := "." - for i := 0; i < len(args); i++ { - a := args[i] - switch { - case a == "--json": - jsonOut = true - case a == "--files" && i+1 < len(args): - i++ - files = args[i] - case strings.HasPrefix(a, "--files="): - files = strings.TrimPrefix(a, "--files=") - case a == "-h" || a == "--help": - fmt.Fprintln(os.Stderr, "bin/markdown/import.go [dir] [--files a.md,b.md] [--json]") - return 0 - case strings.HasPrefix(a, "-"): - fmt.Fprintln(os.Stderr, "unknown arg:", a) - return 2 - default: - root = a - } + c, err := mdleaves.ParseArgs(args) + if err != nil { + return cliparse.Fail(err) } + jsonOut := c.JSONOut + files := c.Files + root := c.Root var paths []string if files != "" { diff --git a/bin/qa/stats.go b/bin/qa/stats.go index 65d9365..89a3f62 100755 --- a/bin/qa/stats.go +++ b/bin/qa/stats.go @@ -16,8 +16,8 @@ import ( "fmt" "io" "os" - "strings" + cliparse "github.com/eSlider/2dph/internal/cli" "github.com/eSlider/2dph/internal/duckstats" ) @@ -26,23 +26,11 @@ func main() { } func run(args []string) int { - jsonl := "" - for i := 0; i < len(args); i++ { - a := args[i] - switch { - case a == "--jsonl" && i+1 < len(args): - i++ - jsonl = args[i] - case strings.HasPrefix(a, "--jsonl="): - jsonl = strings.TrimPrefix(a, "--jsonl=") - case a == "-h" || a == "--help": - fmt.Fprintln(os.Stderr, "bin/qa/stats.go [--jsonl FILE] # stdin = JSON [float,…]") - return 0 - default: - fmt.Fprintln(os.Stderr, "unknown arg:", a) - return 2 - } + c, err := cliparse.ParseQAStats(args) + if err != nil { + return cliparse.Fail(err) } + jsonl := c.JSONL if jsonl != "" { n, err := duckstats.CountJSONL(jsonl) if err != nil { diff --git a/bin/reasoner/bakeoff.go b/bin/reasoner/bakeoff.go index 6fa44b7..50ccbfd 100755 --- a/bin/reasoner/bakeoff.go +++ b/bin/reasoner/bakeoff.go @@ -15,8 +15,8 @@ import ( "encoding/json" "fmt" "os" - "strings" + cliparse "github.com/eSlider/2dph/internal/cli" "github.com/eSlider/2dph/internal/duckstats" "github.com/eSlider/2dph/internal/reasoner" ) @@ -26,42 +26,13 @@ func main() { } func run(args []string) int { - base := os.Getenv("REASONER_BASE_URL") - if base == "" { - base = "http://127.0.0.1:11435/v1" + c, err := reasoner.ParseArgs(args) + if err != nil { + return cliparse.Fail(err) } - model := os.Getenv("REASONER_MODEL") - if model == "" { - model = reasoner.OllamaRAM - } - jsonOut := false - device := "cpu" - for i := 0; i < len(args); i++ { - a := args[i] - switch { - case a == "--json": - jsonOut = true - case a == "--model" && i+1 < len(args): - i++ - model = args[i] - case strings.HasPrefix(a, "--model="): - model = strings.TrimPrefix(a, "--model=") - case a == "--base-url" && i+1 < len(args): - i++ - base = args[i] - case a == "--device" && i+1 < len(args): - i++ - device = args[i] - case a == "-h" || a == "--help": - fmt.Fprintln(os.Stderr, "bin/reasoner/bakeoff.go [--model ID] [--base-url URL] [--device cpu] [--json]") - return 0 - default: - fmt.Fprintln(os.Stderr, "unknown arg:", a) - return 2 - } - } - c := reasoner.Client{BaseURL: base, Model: model, Device: device} - rep := reasoner.Run(c) + base, model, jsonOut, device := c.Base, c.Model, c.JSONOut, c.Device + client := reasoner.Client{BaseURL: base, Model: model, Device: device} + rep := reasoner.Run(client) lat := make([]float64, 0, len(rep.Prompts)) for _, p := range rep.Prompts { lat = append(lat, float64(p.LatencyMS)) diff --git a/bin/tools/test_bin_layout.py b/bin/tools/test_bin_layout.py index 169d667..e1e7ed9 100644 --- a/bin/tools/test_bin_layout.py +++ b/bin/tools/test_bin_layout.py @@ -143,6 +143,22 @@ class BinLayoutTest(unittest.TestCase): shebang = (ROOT / "bin" / "facts" / "audit.go").read_text() self.assertIn("contradict", shebang) + def test_d23_flaggy_cli(self) -> None: + self.assertTrue((ROOT / "internal" / "cli" / "cli.go").is_file()) + self.assertIn("github.com/integrii/flaggy", (ROOT / "go.mod").read_text()) + plan = (ROOT / "PLAN.md").read_text() + self.assertIn("D23", plan) + self.assertIn("flaggy", plan) + complete = (ROOT / "bin" / "cli" / "complete.go").read_text() + first = complete.splitlines()[0] + self.assertTrue(first.startswith("//usr/bin/env go run"), first) + self.assertIn("complete.go bash", complete) + self.assertIn("brain-search", complete) + chats_import = (ROOT / "internal" / "chats" / "import.go").read_text() + self.assertNotIn("flag.NewFlagSet", chats_import) + args = (ROOT / "internal" / "brain" / "rank" / "args.go").read_text() + self.assertIn("internal/cli", args) + def test_mail_import_is_shebang_not_brain_write(self) -> None: self._assert_shebang("bin/mail/import.go") index_mail = (ROOT / "bin" / "mail" / "index_mail").read_text() diff --git a/bin/web/search.go b/bin/web/search.go index 7b3518b..c257772 100755 --- a/bin/web/search.go +++ b/bin/web/search.go @@ -16,9 +16,9 @@ import ( "fmt" "net/http" "os" - "strconv" "time" + "github.com/eSlider/2dph/internal/cli" "github.com/eSlider/2dph/internal/websearch" "golang.org/x/sys/unix" ) @@ -28,77 +28,15 @@ func main() { } func run(args []string) int { - var ( - query, site, lang, fresh, category, engines string - limit = websearch.DefaultLimit - jsonOut, refresh, force bool - ttl = float64(websearch.CacheTTL) - timeout = 25 - ) - i := 0 - for i < len(args) { - a := args[i] - switch { - case a == "--json": - jsonOut = true - case a == "--refresh": - refresh = true - case a == "--force": - force = true - case (a == "-n" || a == "--limit") && i+1 < len(args): - i++ - n, err := strconv.Atoi(args[i]) - if err != nil || n < 0 { - fmt.Fprintln(os.Stderr, "web/search: --limit must be a non-negative integer") - return 2 - } - limit = n - case a == "--site" && i+1 < len(args): - i++ - site = args[i] - case a == "--lang" && i+1 < len(args): - i++ - lang = args[i] - case a == "--fresh" && i+1 < len(args): - i++ - fresh = args[i] - case a == "--category" && i+1 < len(args): - i++ - category = args[i] - case a == "--engines" && i+1 < len(args): - i++ - engines = args[i] - case a == "--ttl" && i+1 < len(args): - i++ - v, err := strconv.ParseFloat(args[i], 64) - if err != nil { - fmt.Fprintln(os.Stderr, "web/search: --ttl must be a number") - return 2 - } - ttl = v - case a == "--timeout" && i+1 < len(args): - i++ - n, err := strconv.Atoi(args[i]) - if err != nil || n <= 0 { - fmt.Fprintln(os.Stderr, "web/search: --timeout must be a positive integer") - return 2 - } - timeout = n - case a == "-h" || a == "--help": - fmt.Fprintln(os.Stderr, `usage: bin/web/search.go QUERY [--json] [-n N] [--site HOST] [--lang LANG] [--fresh day|week|month|year] [--category CAT] [--engines LIST] [--refresh] [--force]`) - return 0 - case len(a) > 0 && a[0] != '-' && query == "": - query = a - default: - fmt.Fprintf(os.Stderr, "web/search: unknown flag %s\n", a) - return 2 - } - i++ - } - if query == "" { - fmt.Fprintln(os.Stderr, "web/search: query required") - return 2 + c, err := websearch.ParseArgs(args) + if err != nil { + return cli.Fail(err) } + query, site, lang, fresh, category, engines := c.Query, c.Site, c.Lang, c.Fresh, c.Category, c.Engines + limit := c.Limit + jsonOut, refresh, force := c.JSONOut, c.Refresh, c.Force + ttl := c.TTL + timeout := c.Timeout if site != "" { query = "site:" + site + " " + query } diff --git a/docs/design.md b/docs/design.md index 27e223d..65da0d8 100644 --- a/docs/design.md +++ b/docs/design.md @@ -38,6 +38,10 @@ bin/brain/search.go "question" from each hit (1=File, 2=Commit, 3=Person). Rebuild writes FROM_FILE; git import writes HAS_VERSION/AUTHORED ([#17](https://git.produktor.io/eSlider/2dph/issues/17)). +Go CLIs parse with **flaggy** via `internal/cli` (D23). Flags may appear +after positionals (`search q --hop 1`). Completions: +`source <(./bin/cli/complete.go bash)`. + ## Who / What / How / Where / When + evidence Every assertion edge carries: diff --git a/docs/roadmap.md b/docs/roadmap.md index 3e79fe0..deff1e1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -40,6 +40,7 @@ Epic [#16](https://git.produktor.io/eSlider/2dph/issues/16) closed. [#30](https://git.produktor.io/eSlider/2dph/issues/30) OQ3 duckdb-go — **in**. [#29](https://git.produktor.io/eSlider/2dph/issues/29) OQ1 contradiction resolution — **in** (`temporal_freshness`, `authority_pairing`). +[#34](https://git.produktor.io/eSlider/2dph/issues/34) D23 flaggy CLI — **in**. ## Blockers diff --git a/docs/runbook.md b/docs/runbook.md index 23a50ed..4515400 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -53,6 +53,7 @@ bin/brain/add.go --text "arc-1 runs Matrix" --root facts --source "compose.yml x bin/brain/index.go --rebuild --with-facts --with-chats bin/brain/search.go "LadybugDB vector index" # facts → info → web (D17) bin/brain/search.go "upstream flag" --no-web +source <(./bin/cli/complete.go bash) # D23 flaggy complete bin/brain/get.go --body bin/brain/stats.go ``` diff --git a/go.mod b/go.mod index 3f1a59b..a55b4bb 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/daulet/tokenizers v1.27.0 github.com/duckdb/duckdb-go/v2 v2.10505.0 github.com/go-git/go-git/v5 v5.19.2 + github.com/integrii/flaggy v1.8.0 golang.org/x/sys v0.47.0 golang.org/x/text v0.40.0 modernc.org/sqlite v1.56.0 diff --git a/go.sum b/go.sum index 12da8fd..fa85b6c 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/integrii/flaggy v1.8.0 h1:tC1qWwg4fhF2Qdaj+MpPK04cxlOSq0+HoMZqAW6Arao= +github.com/integrii/flaggy v1.8.0/go.mod h1:QS4c80m87SXG0pmVUT/Lx2RY5EbkLvLp7IKBD2jwcFA= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= diff --git a/internal/brain/rank/args.go b/internal/brain/rank/args.go index c555917..fdfab94 100644 --- a/internal/brain/rank/args.go +++ b/internal/brain/rank/args.go @@ -3,12 +3,15 @@ package rank import ( "fmt" "strconv" - "strings" + + "github.com/eSlider/2dph/internal/cli" + "github.com/integrii/flaggy" ) const Usage = `usage: bin/brain/search.go "query" [--root facts|info] [--repo REPO] [-n N] [--hop N] [--json] [--no-web] bin/brain/search.go serve [port] - bin/brain/search.go --list-model` + bin/brain/search.go --list-model + source <(./bin/cli/complete.go bash)` type Options struct { Query string @@ -21,62 +24,57 @@ type Options struct { NoWeb bool } +// NewParser is the flaggy schema for search (also used by bin/cli/complete.go). +func NewParser(opt *Options) *flaggy.Parser { + if opt.Limit == 0 { + opt.Limit = 20 + } + p := cli.New("brain-search") + p.Description = "deduction search: facts → info → web" + p.String(&opt.Root, "", "root", "facts or info") + p.String(&opt.Repo, "", "repo", "filter by repo") + p.Int(&opt.Limit, "n", "n", "max hits") + p.Int(&opt.Hop, "", "hop", "walk FROM_FILE depth 1-3") + p.Bool(&opt.JSONOut, "", "json", "JSON output") + p.Bool(&opt.NoWeb, "", "no-web", "stay local") + p.Bool(&opt.ListModel, "", "list-model", "print embedding model") + return p +} + // ParseArgs reads flags. Unknown flags are an error: silently dropping them // meant `--hop 1` vanished and its argument `1` was appended to the query. func ParseArgs(args []string) (Options, error) { opt := Options{Limit: 20} - var queryArgs []string - - for i := 0; i < len(args); i++ { - arg := args[i] - wantsValue := arg == "--root" || arg == "--repo" || arg == "-n" || arg == "--hop" - if wantsValue && i+1 >= len(args) { - return opt, fmt.Errorf("%s needs a value", arg) - } - switch arg { - case "--root": - i++ - opt.Root = args[i] - if opt.Root != "facts" && opt.Root != "info" { - return opt, fmt.Errorf("--root must be facts or info, got %q", opt.Root) - } - case "--repo": - i++ - opt.Repo = args[i] - case "-n": - i++ - n, err := strconv.Atoi(args[i]) - if err != nil || n < 1 { - return opt, fmt.Errorf("-n must be a positive integer, got %q", args[i]) - } - opt.Limit = n - case "--hop": - i++ - n, err := strconv.Atoi(args[i]) - if err != nil || n < 1 { - return opt, fmt.Errorf("--hop must be a positive integer, got %q", args[i]) - } - if n > 3 { - return opt, fmt.Errorf("--hop max is 3 (File → Commit → Person)") - } - opt.Hop = n - case "--json": - opt.JSONOut = true - case "--no-web": - opt.NoWeb = true - case "--list-model": - opt.ListModel = true - default: - if strings.HasPrefix(arg, "-") { - return opt, fmt.Errorf("unknown flag %q", arg) - } - queryArgs = append(queryArgs, arg) - } + p := NewParser(&opt) + var q string + p.AddPositionalValue(&q, "query", 1, false, "search query") + if err := cli.Parse(p, args); err != nil { + return opt, err + } + opt.Query = cli.Query(q, p.TrailingArguments) + if opt.Root != "" && opt.Root != "facts" && opt.Root != "info" { + return opt, fmt.Errorf("--root must be facts or info, got %q", opt.Root) + } + if opt.Limit < 1 { + return opt, fmt.Errorf("-n must be a positive integer, got %q", strconv.Itoa(opt.Limit)) + } + if opt.Hop < 0 { + return opt, fmt.Errorf("--hop must be a positive integer, got %q", strconv.Itoa(opt.Hop)) + } + if opt.Hop > 3 { + return opt, fmt.Errorf("--hop max is 3 (File → Commit → Person)") } - - opt.Query = strings.TrimSpace(strings.Join(queryArgs, " ")) if opt.Query == "" && !opt.ListModel { return opt, fmt.Errorf("no query given") } return opt, nil } + +// Parser is the search schema for bin/cli/complete.go. +func Parser() *flaggy.Parser { + opt := Options{Limit: 20} + p := NewParser(&opt) + var q string + p.AddPositionalValue(&q, "query", 1, false, "search query") + return p +} diff --git a/internal/brain/rank/readargs.go b/internal/brain/rank/readargs.go new file mode 100644 index 0000000..bcf1000 --- /dev/null +++ b/internal/brain/rank/readargs.go @@ -0,0 +1,59 @@ +package rank + +import ( + "fmt" + + "github.com/eSlider/2dph/internal/cli" + "github.com/integrii/flaggy" +) + +type GetOptions struct { + ID string + Body bool + JSONOut bool +} + +func GetParser(opt *GetOptions) *flaggy.Parser { + p := cli.New("brain-get") + p.Description = "read one leaf" + p.Bool(&opt.Body, "", "body", "full text instead of snippet") + p.Bool(&opt.JSONOut, "", "json", "JSON output") + p.AddPositionalValue(&opt.ID, "id", 1, false, "leaf id") + return p +} + +func ParseGet(args []string) (GetOptions, error) { + var opt GetOptions + if err := cli.Parse(GetParser(&opt), args); err != nil { + return opt, err + } + if opt.ID == "" { + return opt, fmt.Errorf("id required") + } + return opt, nil +} + +type JSONFlag struct { + JSONOut bool +} + +func bindJSON(name string, opt *JSONFlag) *flaggy.Parser { + p := cli.New(name) + p.Bool(&opt.JSONOut, "", "json", "JSON output") + return p +} + +func StatsParser() *flaggy.Parser { + opt := JSONFlag{} + return bindJSON("brain-stats", &opt) +} + +func EvalParser() *flaggy.Parser { + opt := JSONFlag{} + return bindJSON("brain-eval", &opt) +} + +func ParseJSONFlag(name string, args []string) (JSONFlag, error) { + var opt JSONFlag + return opt, cli.Parse(bindJSON(name, &opt), args) +} diff --git a/internal/brain/read.go b/internal/brain/read.go index d5cdf4c..41b4ff3 100644 --- a/internal/brain/read.go +++ b/internal/brain/read.go @@ -11,30 +11,15 @@ import ( "unicode/utf8" "github.com/eSlider/2dph/internal/brain/rank" + "github.com/eSlider/2dph/internal/cli" ) func MainGet(args []string) int { - id, body, jsonOut := "", false, false - for _, a := range args { - switch { - case a == "--body": - body = true - case a == "--json": - jsonOut = true - case a == "-h" || a == "--help": - fmt.Fprintln(os.Stderr, `usage: bin/brain/get.go [--body] [--json]`) - return 0 - case strings.HasPrefix(a, "-"): - fmt.Fprintf(os.Stderr, "brain/get: unknown flag %s\n", a) - return 2 - default: - id = a - } - } - if id == "" { - fmt.Fprintln(os.Stderr, "brain/get: id required") - return 2 + opt, err := rank.ParseGet(args) + if err != nil { + return cli.Fail(err) } + id, body, jsonOut := opt.ID, opt.Body, opt.JSONOut if err := openBrain(); err != nil { fmt.Fprintf(os.Stderr, "open brain: %v\n", err) return 1 @@ -72,21 +57,11 @@ func MainGet(args []string) int { } func MainStats(args []string) int { - jsonOut := false - for _, a := range args { - switch a { - case "--json": - jsonOut = true - case "-h", "--help": - fmt.Fprintln(os.Stderr, `usage: bin/brain/stats.go [--json]`) - return 0 - default: - if strings.HasPrefix(a, "-") { - fmt.Fprintf(os.Stderr, "brain/stats: unknown flag %s\n", a) - return 2 - } - } + opt, err := rank.ParseJSONFlag("brain-stats", args) + if err != nil { + return cli.Fail(err) } + jsonOut := opt.JSONOut if err := openBrain(); err != nil { fmt.Fprintf(os.Stderr, "open brain: %v\n", err) return 1 @@ -124,21 +99,11 @@ func MainStats(args []string) int { } func MainEval(args []string) int { - jsonOut := false - for _, a := range args { - switch a { - case "--json": - jsonOut = true - case "-h", "--help": - fmt.Fprintln(os.Stderr, `usage: bin/brain/eval.go [--json]`) - return 0 - default: - if strings.HasPrefix(a, "-") { - fmt.Fprintf(os.Stderr, "brain/eval: unknown flag %s\n", a) - return 2 - } - } + opt, err := rank.ParseJSONFlag("brain-eval", args) + if err != nil { + return cli.Fail(err) } + jsonOut := opt.JSONOut if err := openBrain(); err != nil { fmt.Fprintf(os.Stderr, "open brain: %v\n", err) return 1 diff --git a/internal/brain/search.go b/internal/brain/search.go index d275c6e..12dff26 100644 --- a/internal/brain/search.go +++ b/internal/brain/search.go @@ -20,6 +20,7 @@ import ( lbug "github.com/LadybugDB/go-ladybug" "github.com/eSlider/2dph/internal/brain/rank" + "github.com/eSlider/2dph/internal/cli" ) const defaultPort = 17830 @@ -29,6 +30,9 @@ const healthPath = "/health" func runSearch(args []string) int { opt, err := rank.ParseArgs(args) if err != nil { + if errors.Is(err, cli.ErrHelp) { + return 0 + } fmt.Fprintf(os.Stderr, "brain/search: %v\n%s\n", err, rank.Usage) return 2 } diff --git a/internal/chats/apply.go b/internal/chats/apply.go index 3d200b1..2c1b769 100644 --- a/internal/chats/apply.go +++ b/internal/chats/apply.go @@ -3,21 +3,22 @@ package chats import ( "bytes" "encoding/json" - "flag" "fmt" "os" "os/exec" "path/filepath" "strings" + + cliparse "github.com/eSlider/2dph/internal/cli" ) type ooContact struct { - ID int `json:"id"` + ID int `json:"id"` DisplayName string `json:"displayName"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - About string `json:"about"` - CommonData []struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + About string `json:"about"` + CommonData []struct { InfoType int `json:"infoType"` Data string `json:"data"` Category string `json:"categoryName"` @@ -25,16 +26,9 @@ type ooContact struct { } func RunApply(args []string) int { - fs := flag.NewFlagSet("chats apply", flag.ContinueOnError) - dryRun := fs.Bool("dry-run", false, "show what would be done without writing") - help := fs.Bool("help", false, "") - fs.SetOutput(os.Stderr) - if err := fs.Parse(args); err != nil { - return 2 - } - if *help { - fmt.Fprintln(os.Stderr, "usage: chats apply [--dry-run]") - return 0 + dryRun, err := parseApplyFlags(args) + if err != nil { + return cliparse.Fail(err) } ooCLI := findOO() @@ -60,10 +54,10 @@ func RunApply(args []string) int { emailFacts = dedupeFacts(emailFacts) type resolvedFact struct { - Fact ExtractedFact - OoID int - OoName string - Action string // "info-add" or "persons-create" + Fact ExtractedFact + OoID int + OoName string + Action string // "info-add" or "persons-create" } var resolved []resolvedFact @@ -128,7 +122,7 @@ func RunApply(args []string) int { fmt.Printf("\nchats apply: %d actions to apply\n", len(resolved)) - if *dryRun { + if dryRun { for _, r := range resolved { switch r.Action { case "info-add": diff --git a/internal/chats/cli.go b/internal/chats/cli.go new file mode 100644 index 0000000..d950b4c --- /dev/null +++ b/internal/chats/cli.go @@ -0,0 +1,74 @@ +package chats + +import ( + cliparse "github.com/eSlider/2dph/internal/cli" + "github.com/integrii/flaggy" +) + +type syncTelegramFlags struct { + Limit int + Phone string +} + +type syncLinkedInFlags struct { + Limit int + Refresh bool +} + +func SyncParser() *flaggy.Parser { + p := cliparse.New("chats-sync") + p.Description = "download chats to var/chats" + tg := flaggy.NewSubcommand("telegram") + li := flaggy.NewSubcommand("linkedin") + var limit int + var phone string + var refresh bool + tg.Int(&limit, "", "limit", "max messages per chat") + tg.String(&phone, "", "phone", "phone (default TELEGRAM_PHONE)") + li.Int(&limit, "", "limit", "max messages per conversation") + li.Bool(&refresh, "", "refresh", "refresh webtop session") + p.AttachSubcommand(tg, 1) + p.AttachSubcommand(li, 1) + return p +} + +func ImportParser() *flaggy.Parser { + return cliparse.New("chats-import") +} + +func FactsParser() *flaggy.Parser { + return cliparse.New("chats-facts") +} + +func ApplyParser() *flaggy.Parser { + p := cliparse.New("chats-apply") + dry := false + p.Bool(&dry, "", "dry-run", "show without writing") + return p +} + +func parseTelegramFlags(args []string) (syncTelegramFlags, error) { + var f syncTelegramFlags + p := cliparse.New("chats-sync-telegram") + p.Int(&f.Limit, "", "limit", "max messages per chat") + p.String(&f.Phone, "", "phone", "phone (default TELEGRAM_PHONE)") + return f, cliparse.Parse(p, args) +} + +func parseLinkedInFlags(args []string) (syncLinkedInFlags, error) { + var f syncLinkedInFlags + p := cliparse.New("chats-sync-linkedin") + p.Int(&f.Limit, "", "limit", "max messages per conversation") + p.Bool(&f.Refresh, "", "refresh", "refresh webtop session") + return f, cliparse.Parse(p, args) +} + +func parseApplyFlags(args []string) (dryRun bool, err error) { + p := cliparse.New("chats-apply") + p.Bool(&dryRun, "", "dry-run", "show without writing") + return dryRun, cliparse.Parse(p, args) +} + +func parseNoFlags(name string, args []string) error { + return cliparse.Parse(cliparse.New(name), args) +} diff --git a/internal/chats/facts.go b/internal/chats/facts.go index 8213be0..c73ef84 100644 --- a/internal/chats/facts.go +++ b/internal/chats/facts.go @@ -3,12 +3,13 @@ package chats import ( "bufio" "encoding/json" - "flag" "fmt" "os" "path/filepath" "regexp" "strings" + + cliparse "github.com/eSlider/2dph/internal/cli" ) var ( @@ -76,15 +77,8 @@ type ExtractedFact struct { } func RunFacts(args []string) int { - fs := flag.NewFlagSet("chats facts", flag.ContinueOnError) - help := fs.Bool("help", false, "") - fs.SetOutput(os.Stderr) - if err := fs.Parse(args); err != nil { - return 2 - } - if *help { - fmt.Fprintln(os.Stderr, "usage: chats facts") - return 0 + if err := parseNoFlags("chats-facts", args); err != nil { + return cliparse.Fail(err) } root := Dir() diff --git a/internal/chats/import.go b/internal/chats/import.go index 2154e33..4c1fddb 100644 --- a/internal/chats/import.go +++ b/internal/chats/import.go @@ -4,25 +4,19 @@ import ( "bufio" "bytes" "encoding/json" - "flag" "fmt" "html" "os" "path/filepath" "sort" "strings" + + cliparse "github.com/eSlider/2dph/internal/cli" ) func RunImport(args []string) int { - fs := flag.NewFlagSet("chats import", flag.ContinueOnError) - help := fs.Bool("help", false, "") - fs.SetOutput(os.Stderr) - if err := fs.Parse(args); err != nil { - return 2 - } - if *help { - fmt.Fprintln(os.Stderr, "usage: chats import") - return 0 + if err := parseNoFlags("chats-import", args); err != nil { + return cliparse.Fail(err) } root := Dir() diff --git a/internal/chats/sync_linkedin.go b/internal/chats/sync_linkedin.go index 0b02f1b..ae3ea0f 100644 --- a/internal/chats/sync_linkedin.go +++ b/internal/chats/sync_linkedin.go @@ -2,12 +2,13 @@ package chats import ( "context" - "flag" "fmt" "os" "os/exec" "path/filepath" "time" + + cliparse "github.com/eSlider/2dph/internal/cli" ) func checkLinkedInSession(userDataDir string) (bool, error) { @@ -29,18 +30,12 @@ func checkLinkedInSession(userDataDir string) (bool, error) { } func RunSyncLinkedIn(args []string) int { - fs := flag.NewFlagSet("chats sync linkedin", flag.ContinueOnError) - limit := fs.Int("limit", 0, "max messages per conversation (0 = all)") - refresh := fs.Bool("refresh", false, "refresh session from live webtop browser before sync") - help := fs.Bool("help", false, "") - fs.SetOutput(os.Stderr) - if err := fs.Parse(args); err != nil { - return 2 - } - if *help { - fmt.Fprintln(os.Stderr, "usage: chats sync linkedin [--limit N] [--refresh]") - return 0 + f, err := parseLinkedInFlags(args) + if err != nil { + return cliparse.Fail(err) } + limit := f.Limit + refresh := f.Refresh userDataDir := envVar("LINKEDIN_USER_DATA_DIR", "") if userDataDir == "" { @@ -48,7 +43,7 @@ func RunSyncLinkedIn(args []string) int { userDataDir = home + "/.linkedin-mcp/profile" } - if *refresh { + if refresh { if code := refreshLinkedInSession(userDataDir); code != 0 { return code } @@ -72,7 +67,7 @@ func RunSyncLinkedIn(args []string) int { defer cancel() start := time.Now() - if err := src.Sync(ctx, Dir(), *limit); err != nil { + if err := src.Sync(ctx, Dir(), limit); err != nil { fmt.Fprintf(os.Stderr, "chats sync linkedin: %v\n", err) return 1 } diff --git a/internal/chats/sync_telegram.go b/internal/chats/sync_telegram.go index 306ef4d..b8425f0 100644 --- a/internal/chats/sync_telegram.go +++ b/internal/chats/sync_telegram.go @@ -2,33 +2,28 @@ package chats import ( "context" - "flag" "fmt" "os" "path/filepath" "strconv" "strings" "time" + + cliparse "github.com/eSlider/2dph/internal/cli" ) func RunSyncTelegram(args []string) int { - fs := flag.NewFlagSet("chats sync telegram", flag.ContinueOnError) - limit := fs.Int("limit", 0, "max messages per chat (0 = all)") - phone := fs.String("phone", "", "phone number (default env TELEGRAM_PHONE)") - help := fs.Bool("help", false, "") - fs.SetOutput(os.Stderr) - if err := fs.Parse(args); err != nil { - return 2 - } - if *help { - fmt.Fprintln(os.Stderr, "usage: chats sync telegram [--limit N] [--phone PHONE]") - return 0 + f, err := parseTelegramFlags(args) + if err != nil { + return cliparse.Fail(err) } + limit := f.Limit + phone := f.Phone apiIDStr := envVar("TELEGRAM_API_ID", "") apiHash := envVar("TELEGRAM_API_HASH", "") sessionStr := envVar("TELEGRAM_SESSION_STRING", "") - phoneNum := *phone + phoneNum := phone if phoneNum == "" { phoneNum = envVar("TELEGRAM_PHONE", "") } @@ -78,7 +73,7 @@ func RunSyncTelegram(args []string) int { defer cancel() start := time.Now() - if err := src.Sync(ctx, Dir(), *limit); err != nil { + if err := src.Sync(ctx, Dir(), limit); err != nil { fmt.Fprintf(os.Stderr, "chats sync telegram: %v\n", err) return 1 } diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..febbf6a --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,190 @@ +// Package cli is the shared flaggy wrapper (D23). +// +// flaggy: zero deps, flags at any position, shell completion scripts. +// Individual tools keep ShowCompletion off so a query like "completion" is +// not stolen; dump scripts with bin/cli/complete.go. +package cli + +import ( + "errors" + "fmt" + "os" + "strings" + "sync" + + "github.com/integrii/flaggy" +) + +// ErrHelp means -h/--help was requested (exit 0). +var ErrHelp = errors.New("help") + +var parseMu sync.Mutex + +// New returns a per-call parser. Never reuse: flaggy parses once. +func New(name string) *flaggy.Parser { + p := flaggy.NewParser(name) + p.ShowVersionWithVersionFlag = false + p.ShowCompletion = false + // Extra positionals become TrailingArguments (search "two words --json"). + // Unknown dash tokens are rejected in Parse after flaggy returns. + p.ShowHelpOnUnexpected = false + p.ShowHelpWithHFlag = true + return p +} + +// Parse runs p.ParseArgs and turns flaggy's os.Exit into an error. +// Not safe to call in parallel (flaggy.PanicInsteadOfExit is process-global). +func Parse(p *flaggy.Parser, args []string) error { + parseMu.Lock() + defer parseMu.Unlock() + prev := flaggy.PanicInsteadOfExit + flaggy.PanicInsteadOfExit = true + defer func() { flaggy.PanicInsteadOfExit = prev }() + + var exitMsg string + err := func() error { + defer func() { + if r := recover(); r != nil { + exitMsg = fmt.Sprint(r) + } + }() + return p.ParseArgs(args) + }() + if err != nil { + return err + } + if exitMsg != "" { + if strings.Contains(exitMsg, "code: 0") { + return ErrHelp + } + return errors.New(exitMsg) + } + if u := unknownFlags(p, args); len(u) > 0 { + return fmt.Errorf("unknown flag %q", u[0]) + } + return nil +} + +func unknownFlags(p *flaggy.Parser, args []string) []string { + flags := collectFlags(&p.Subcommand) + var out []string + skipNext := false + for _, a := range args { + if skipNext { + skipNext = false + continue + } + if a == "--" { + break + } + name, inline := flagName(a) + if name == "" { + continue + } + if name == "h" || name == "help" { + continue + } + f := findFlag(flags, name) + if f == nil { + out = append(out, a) + continue + } + if !inline && !isBoolFlag(f) { + skipNext = true + } + } + return out +} + +func flagName(a string) (name string, inline bool) { + if a == "-" || !strings.HasPrefix(a, "-") { + return "", false + } + rest := strings.TrimLeft(a, "-") + name, _, inline = strings.Cut(rest, "=") + return name, inline +} + +func collectFlags(sc *flaggy.Subcommand) []*flaggy.Flag { + out := append([]*flaggy.Flag{}, sc.Flags...) + for _, sub := range sc.Subcommands { + out = append(out, collectFlags(sub)...) + } + return out +} + +func findFlag(flags []*flaggy.Flag, name string) *flaggy.Flag { + for _, f := range flags { + if f.HasName(name) { + return f + } + } + return nil +} + +func isBoolFlag(f *flaggy.Flag) bool { + _, ok := f.AssignmentVar.(*bool) + return ok +} + +// Query joins the first positional with leftover trailing words. +func Query(first string, trailing []string) string { + parts := make([]string, 0, 1+len(trailing)) + if s := strings.TrimSpace(first); s != "" { + parts = append(parts, s) + } + for _, t := range trailing { + if s := strings.TrimSpace(t); s != "" { + parts = append(parts, s) + } + } + return strings.Join(parts, " ") +} + +// Code maps parse errors to process exit codes (0 help, 2 usage). +func Code(err error) int { + if err == nil || errors.Is(err, ErrHelp) { + return 0 + } + return 2 +} + +// Fail prints err unless it is help or a flaggy exit that already wrote stderr. +func Fail(err error) int { + if err == nil || errors.Is(err, ErrHelp) { + return 0 + } + if strings.HasPrefix(err.Error(), "Panic instead of exit") { + return 2 + } + fmt.Fprintln(os.Stderr, err) + return 2 +} + +// Tool is one shebang CLI for completion dump. +type Tool struct { + Path string + Name string + New func() *flaggy.Parser +} + +// BashScript concatenates flaggy bash complete scripts and binds each +// function to the shebang path (./bin/subject/method.go). +func BashScript(tools []Tool) string { + var b strings.Builder + b.WriteString("# 2dph flaggy completions (D23). source <(./bin/cli/complete.go bash)\n") + for _, t := range tools { + p := t.New() + p.Name = t.Name + script := flaggy.GenerateBashCompletion(p) + b.WriteString(script) + fn := "_" + strings.ReplaceAll(t.Name, "-", "_") + "_complete" + if t.Path != "" && t.Path != t.Name { + fmt.Fprintf(&b, "complete -F %s %s\n", fn, t.Path) + if !strings.HasPrefix(t.Path, "./") { + fmt.Fprintf(&b, "complete -F %s ./%s\n", fn, t.Path) + } + } + } + return b.String() +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..89dcb7f --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,77 @@ +package cli + +import ( + "errors" + "strings" + "testing" + + "github.com/integrii/flaggy" +) + +func TestParseBoolAndIntAnyPosition(t *testing.T) { + p := New("t") + jsonOut := false + n := 20 + q := "" + p.Bool(&jsonOut, "", "json", "JSON") + p.Int(&n, "n", "n", "limit") + p.AddPositionalValue(&q, "query", 1, false, "q") + if err := Parse(p, []string{"two", "words", "--json", "-n", "5"}); err != nil { + t.Fatal(err) + } + got := Query(q, p.TrailingArguments) + if got != "two words" || !jsonOut || n != 5 { + t.Fatalf("q=%q json=%v n=%d", got, jsonOut, n) + } +} + +func TestParseUnknownFlagIsError(t *testing.T) { + p := New("t") + jsonOut := false + p.Bool(&jsonOut, "", "json", "JSON") + if err := Parse(p, []string{"--nope"}); err == nil { + t.Fatal("unknown flag accepted") + } +} + +func TestParseHelpIsErrHelp(t *testing.T) { + p := New("t") + jsonOut := false + p.Bool(&jsonOut, "", "json", "JSON") + err := Parse(p, []string{"--help"}) + if !errors.Is(err, ErrHelp) { + t.Fatalf("got %v", err) + } +} + +func TestParseMissingFlagValueIsError(t *testing.T) { + p := New("t") + n := 0 + p.Int(&n, "", "hop", "hop") + if err := Parse(p, []string{"--hop"}); err == nil { + t.Fatal("expected missing value error") + } +} + +func TestBashScriptNamesShebangPath(t *testing.T) { + out := BashScript([]Tool{{ + Path: "bin/brain/search.go", + Name: "brain-search", + New: newSearchLike, + }}) + if !strings.Contains(out, "--json") || !strings.Contains(out, "--hop") { + t.Fatalf("flags missing:\n%s", out) + } + if !strings.Contains(out, "complete -F") || !strings.Contains(out, "bin/brain/search.go") { + t.Fatalf("shebang complete missing:\n%s", out) + } +} + +func newSearchLike() *flaggy.Parser { + p := New("brain-search") + jsonOut := false + hop := 0 + p.Bool(&jsonOut, "", "json", "JSON") + p.Int(&hop, "", "hop", "graph hop") + return p +} diff --git a/internal/cli/qa.go b/internal/cli/qa.go new file mode 100644 index 0000000..7fcaf83 --- /dev/null +++ b/internal/cli/qa.go @@ -0,0 +1,24 @@ +package cli + +import "github.com/integrii/flaggy" + +type QAStats struct { + JSONL string +} + +func QAParser() *flaggy.Parser { + c := QAStats{} + return BindQA(&c) +} + +func BindQA(c *QAStats) *flaggy.Parser { + p := New("qa-stats") + p.Description = "DuckDB quantiles / JSONL count" + p.String(&c.JSONL, "", "jsonl", "JSONL file (else stdin JSON [float,…])") + return p +} + +func ParseQAStats(args []string) (QAStats, error) { + var c QAStats + return c, Parse(BindQA(&c), args) +} diff --git a/internal/gitlog/args.go b/internal/gitlog/args.go new file mode 100644 index 0000000..652965b --- /dev/null +++ b/internal/gitlog/args.go @@ -0,0 +1,51 @@ +package gitlog + +import ( + "fmt" + "time" + + "github.com/eSlider/2dph/internal/cli" + "github.com/integrii/flaggy" +) + +type CLI struct { + Repo, Root, Since string + Limit int + JSONOut bool +} + +func Parser() *flaggy.Parser { + c := CLI{} + return Bind(&c) +} + +func Bind(c *CLI) *flaggy.Parser { + p := cli.New("git-import") + p.Description = "go-git history → commit leafs" + p.Bool(&c.JSONOut, "", "json", "JSON output") + p.Int(&c.Limit, "", "limit", "max commits (0 = all)") + p.String(&c.Since, "", "since", "RFC3339 or YYYY-MM-DD") + p.String(&c.Root, "", "root", "scan dir for git repos") + p.AddPositionalValue(&c.Repo, "repo", 1, false, "git repo path") + return p +} + +func ParseArgs(args []string) (CLI, error) { + var c CLI + if err := cli.Parse(Bind(&c), args); err != nil { + return c, err + } + return c, nil +} + +func ParseSince(s string) (time.Time, error) { + if s == "" { + return time.Time{}, nil + } + for _, layout := range []string{time.RFC3339, "2006-01-02"} { + if t, err := time.Parse(layout, s); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("cannot parse --since %q", s) +} diff --git a/internal/mdleaves/args.go b/internal/mdleaves/args.go new file mode 100644 index 0000000..22ec0fa --- /dev/null +++ b/internal/mdleaves/args.go @@ -0,0 +1,41 @@ +package mdleaves + +import ( + "github.com/eSlider/2dph/internal/cli" + "github.com/integrii/flaggy" +) + +type CLI struct { + Root string + Files string + JSONOut bool +} + +func Parser() *flaggy.Parser { + c := CLI{Root: "."} + return Bind(&c) +} + +func Bind(c *CLI) *flaggy.Parser { + if c.Root == "" { + c.Root = "." + } + p := cli.New("markdown-import") + p.Description = "split markdown H2 leafs" + p.Bool(&c.JSONOut, "", "json", "JSON output") + p.String(&c.Files, "", "files", "comma-separated paths") + p.AddPositionalValue(&c.Root, "dir", 1, false, "markdown root") + return p +} + +func ParseArgs(args []string) (CLI, error) { + c := CLI{Root: "."} + p := Bind(&c) + if err := cli.Parse(p, args); err != nil { + return c, err + } + if extra := cli.Query("", p.TrailingArguments); extra != "" && c.Root == "." { + c.Root = extra + } + return c, nil +} diff --git a/internal/ocr/args.go b/internal/ocr/args.go new file mode 100644 index 0000000..ce7ae82 --- /dev/null +++ b/internal/ocr/args.go @@ -0,0 +1,35 @@ +package ocr + +import ( + "fmt" + + "github.com/eSlider/2dph/internal/cli" + "github.com/integrii/flaggy" +) + +type CLI struct { + Path string +} + +func Parser() *flaggy.Parser { + c := CLI{} + return Bind(&c) +} + +func Bind(c *CLI) *flaggy.Parser { + p := cli.New("mail-ocr") + p.Description = "tesseract eng+deu on image or scanned PDF" + p.AddPositionalValue(&c.Path, "file", 1, false, "image or pdf") + return p +} + +func ParseArgs(args []string) (CLI, error) { + var c CLI + if err := cli.Parse(Bind(&c), args); err != nil { + return c, err + } + if c.Path == "" { + return c, fmt.Errorf("usage: bin/mail/ocr.go ") + } + return c, nil +} diff --git a/internal/reasoner/args.go b/internal/reasoner/args.go new file mode 100644 index 0000000..12819e7 --- /dev/null +++ b/internal/reasoner/args.go @@ -0,0 +1,47 @@ +package reasoner + +import ( + "os" + + "github.com/eSlider/2dph/internal/cli" + "github.com/integrii/flaggy" +) + +type CLI struct { + Base string + Model string + Device string + JSONOut bool +} + +func Parser() *flaggy.Parser { + c := NewCLI() + return Bind(&c) +} + +func NewCLI() CLI { + base := os.Getenv("REASONER_BASE_URL") + if base == "" { + base = "http://127.0.0.1:11435/v1" + } + model := os.Getenv("REASONER_MODEL") + if model == "" { + model = OllamaRAM + } + return CLI{Base: base, Model: model, Device: "cpu"} +} + +func Bind(c *CLI) *flaggy.Parser { + p := cli.New("reasoner-bakeoff") + p.Description = "CPU tool-call bake-off" + p.Bool(&c.JSONOut, "", "json", "JSON output") + p.String(&c.Model, "", "model", "Ollama/HF model id") + p.String(&c.Base, "", "base-url", "OpenAI-compatible URL") + p.String(&c.Device, "", "device", "cpu") + return p +} + +func ParseArgs(args []string) (CLI, error) { + c := NewCLI() + return c, cli.Parse(Bind(&c), args) +} diff --git a/internal/websearch/args.go b/internal/websearch/args.go new file mode 100644 index 0000000..247677a --- /dev/null +++ b/internal/websearch/args.go @@ -0,0 +1,63 @@ +package websearch + +import ( + "fmt" + + "github.com/eSlider/2dph/internal/cli" + "github.com/integrii/flaggy" +) + +type CLI struct { + Query, Site, Lang, Fresh, Category, Engines string + Limit int + JSONOut, Refresh, Force bool + TTL float64 + Timeout int +} + +func NewCLI() CLI { + return CLI{Limit: DefaultLimit, TTL: float64(CacheTTL), Timeout: 25} +} + +func Parser() *flaggy.Parser { + c := NewCLI() + return Bind(&c) +} + +func Bind(c *CLI) *flaggy.Parser { + p := cli.New("web-search") + p.Description = "SearXNG second source (throttled ≠ absence)" + p.Bool(&c.JSONOut, "", "json", "JSON output") + p.Bool(&c.Refresh, "", "refresh", "bypass cache") + p.Bool(&c.Force, "", "force", "allow PII in query") + p.Int(&c.Limit, "n", "limit", "max hits") + p.String(&c.Site, "", "site", "restrict to host") + p.String(&c.Lang, "", "lang", "language") + p.String(&c.Fresh, "", "fresh", "day|week|month|year") + p.String(&c.Category, "", "category", "searx category") + p.String(&c.Engines, "", "engines", "engine list") + p.Float64(&c.TTL, "", "ttl", "cache ttl seconds") + p.Int(&c.Timeout, "", "timeout", "http timeout seconds") + return p +} + +func ParseArgs(args []string) (CLI, error) { + c := NewCLI() + p := Bind(&c) + var q string + p.AddPositionalValue(&q, "query", 1, false, "search query") + if err := cli.Parse(p, args); err != nil { + return c, err + } + c.Query = cli.Query(q, p.TrailingArguments) + if c.Query == "" { + return c, fmt.Errorf("query required") + } + if c.Limit < 0 { + return c, fmt.Errorf("--limit must be a non-negative integer") + } + if c.Timeout <= 0 { + return c, fmt.Errorf("--timeout must be a positive integer") + } + return c, nil +} -- 2.54.0