Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions internal/cli/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ Argument-dispatch core shared by every verb. Defines the `Command` interface, th

## Files

- `cli.go` — `Command`, `IO`, `DefaultIO`, `Group` (nested-verb dispatcher with auto-generated help listing), and `dispatchChild` (the Group/Dispatch handoff that scans a resolved leaf's args for `--help`/`-h` and renders its `Help()` before `Run` is called, so every leaf gets the flag for free; Groups are skipped so the flag reaches the deepest leaf, and bare positional `help` is left alone so leaves can receive it as an argument).
- `cli.go` — `Command`, `IO`, `DefaultIO`, `Group` (nested-verb dispatcher with auto-generated help listing; optional `Flags` closure declares group-level flags that descend to every leaf via `WithAncestorFlags`), `dispatchChild` (the Group/Dispatch handoff that scans a resolved leaf's args for `--help`/`-h` and renders its `Help()` before `Run` is called — and if the leaf implements `Flagger`, appends a `Flags:` block enumerating own + ancestor flags via `renderFlagsAsText`; Groups are skipped so the flag reaches the deepest leaf, and bare positional `help` is left alone so leaves can receive it as an argument), `Flagger` (opt-in interface: leaves that implement `Flags(fs)` get the ancestor-aware `Flags:` block in `--help`), and `renderFlagsAsText` (sorted `--name <type> usage (default: X)` enumeration). Precedence when ancestor and leaf declare the same name: **leaf wins**, because leaves call `ApplyAncestorFlags` AFTER declaring their own flags, and ancestor registrars use `DeclareString` / `DeclareBool` Lookup-guards that skip already-declared names (stdlib `flag.FlagSet.StringVar` panics on duplicate names, verified empirically).
- `dispatch.go` — `Dispatch` (root entry: short-circuits help, parses globals, routes to the matching verb via `dispatchChild`) and `RootHelp`.
- `root.go` — `Global` shape, `WithGlobal`/`GlobalFrom` context helpers (both require a non-nil ctx per stdlib `context.WithValue` convention — nil panics), and `ParseGlobal` (strips known root flags from argv before verb dispatch).
- `root.go` — `Global` shape, `WithGlobal`/`GlobalFrom` context helpers (both require a non-nil ctx per stdlib `context.WithValue` convention — nil panics), `ParseGlobal` (strips known root flags from argv before verb dispatch), and the Phase 2 flag-registrar stack: `WithAncestorFlags(ctx, reg)` / `ApplyAncestorFlags(ctx, fs)` (context-carried slice of `func(*flag.FlagSet)` closures that `Group.Run` appends to and leaves replay on their own `FlagSet`), plus `DeclareString` / `DeclareBool` (Lookup-guarded wrappers ancestor closures use instead of raw `StringVar` / `BoolVar` to avoid the stdlib redeclaration panic when a leaf already declared the same name).
- `flags.go` — `ParseFlags`, which tolerates positional args interleaved with flags (stdlib `FlagSet.Parse` stops at the first non-flag, silently dropping later flags); `FlagWasSet`, the `fs.Visit` wrapper partial-update verbs use to tell "user left this alone" from "user explicitly passed the zero value"; `RequireFlags`, which emits a single sorted `missing required flags: --a, --b` usage error for any name not explicitly set on fs; and three typed `flag.Value` constructors — `EnumFlag` (allow-list validation at parse time), `IntListFlag` (CSV → `[]int` with whitespace tolerance), `SinceFlag` (accepts non-negative `time.ParseDuration` or RFC3339, stored UTC via an injected clock). The stdlib `flag.Parse` re-wraps `Set` errors with `%v`, so the `ErrUsage` chain survives only through the outer `ParseFlags` wrap — tests that exercise these helpers must go through `ParseFlags`, not bare `fs.Parse`.
- `token.go` — `Token` (named `string` type whose `String`/`Format` always render the `RedactToken` mask, so any accidental `%s`/`%v`/`%+v`/`%#v`/`%q` on a logger or error can't leak a bearer token) and its `Value()` escape hatch. `config.Profile.Token` and `auth.Config.Token` are declared as `cli.Token`; the only authorized raw-emit call sites are `cmd/ana/main.go`'s `tokenFn` (Authorization header) and `auth/keys.go:emitPlaintextToken` (one-shot API key print). Since the underlying kind is `string`, `encoding/json` marshals/unmarshals transparently and untyped string literals still work in tests (`cli.Token("t")` — or just `"t"` in a `Token`-typed field).
- `helpers.go` — shared verb-package helpers extracted from per-verb duplicates: `NewFlagSet`, `UsageErrf`, `WriteJSON`, `Remarshal`, `RenderOutput` (generic JSON-vs-typed-render dispatch: `--json` → `WriteJSON`; else `Remarshal` into `*T` then call the render closure), `RequireStringID`, `RequireIntID`, `RenderTwoCol`, `ReadToken` (shared login/profile-add stdin reader with JWT-sized buffer boost; trims surrounding whitespace), `ReadPassword` (same JWT-sized buffer but strips ONLY the trailing line terminator — passwords can legitimately begin or end with whitespace, so surrounding bytes must not be mutated), `NewTableWriter` (canonical tabwriter config), `FirstLine`, `DashIfEmpty` (table-cell rendering primitives; fall-through chains use stdlib `cmp.Or`), `RedactToken` (masks bearer tokens for human-readable echoes — `(unset)` or `********** (last 4: xxxx)`). Phase 0 of the shared-cli-kit refactor; Phases 1–10 migrate each verb package over and delete its local copies.
Expand Down
81 changes: 81 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package cli

import (
"context"
"flag"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -47,19 +48,34 @@ type Command interface {
// Group is a Command that dispatches its first argument to a named child
// Command. A Group can itself be registered as a child, enabling nested verbs
// (e.g. `ana chat send ...`).
//
// Flags, if set, declares group-level flags that every descendant leaf
// inherits via the ctx-carried registrar stack (see WithAncestorFlags in
// root.go). The closure runs on a leaf's *flag.FlagSet AFTER the leaf has
// declared its own flags, so use DeclareString / DeclareBool (or an
// equivalent Lookup guard) to avoid the stdlib flag-redeclaration panic — the
// guard lets the leaf override a name when it wants to.
type Group struct {
Summary string
Flags func(*flag.FlagSet)
Children map[string]Command
}

// Run dispatches to a child command. With no args or an explicit help flag it
// prints Help() to stdout and returns ErrHelp (exit 0). An unknown child name
// writes to stderr and returns ErrUsage (exit 1).
//
// If Flags is non-nil, Run appends it to the ctx-carried ancestor-flag stack
// before delegating so every descendant leaf can ApplyAncestorFlags and pick
// up the group's declared flags.
func (g *Group) Run(ctx context.Context, args []string, stdio IO) error {
if len(args) == 0 || isHelpArg(args[0]) {
fmt.Fprintln(stdio.Stdout, g.Help())
return ErrHelp
}
if g.Flags != nil {
ctx = WithAncestorFlags(ctx, g.Flags)
}
name := args[0]
child, ok := g.Children[name]
if !ok {
Expand Down Expand Up @@ -89,15 +105,71 @@ func dispatchChild(ctx context.Context, cmd Command, args []string, stdio IO) er
for _, a := range args {
if a == "-h" || a == "--help" {
fmt.Fprintln(stdio.Stdout, cmd.Help())
if fl, ok := cmd.(Flagger); ok {
fs := flag.NewFlagSet("help", flag.ContinueOnError)
fs.SetOutput(io.Discard)
fl.Flags(fs)
ApplyAncestorFlags(ctx, fs)
if block := renderFlagsAsText(fs); block != "" {
fmt.Fprintln(stdio.Stdout)
fmt.Fprintln(stdio.Stdout, "Flags:")
fmt.Fprint(stdio.Stdout, block)
}
}
return ErrHelp
}
}
}
return cmd.Run(ctx, args, stdio)
}

// renderFlagsAsText enumerates fs's flags sorted by name and renders one
// ` --name <type> usage (default: X)` row per flag. Returns "" if fs has
// no flags. The trailing newline is included so callers can Fprint without
// worrying about terminator placement.
func renderFlagsAsText(fs *flag.FlagSet) string {
type row struct {
name, typ, usage, def string
}
var rows []row
fs.VisitAll(func(f *flag.Flag) {
typ, usage := flag.UnquoteUsage(f)
rows = append(rows, row{name: f.Name, typ: typ, usage: usage, def: f.DefValue})
})
if len(rows) == 0 {
return ""
}
slices.SortFunc(rows, func(a, b row) int { return strings.Compare(a.name, b.name) })
nameWidth := 0
for _, r := range rows {
w := len(r.name)
if r.typ != "" {
w += 1 + len(r.typ)
}
if w > nameWidth {
nameWidth = w
}
}
var b strings.Builder
for _, r := range rows {
head := "--" + r.name
if r.typ != "" {
head += " " + r.typ
}
fmt.Fprintf(&b, " %-*s %s", nameWidth+2, head, r.usage)
if r.def != "" && r.def != "false" && r.def != "0" {
fmt.Fprintf(&b, " (default: %s)", r.def)
}
b.WriteByte('\n')
}
return b.String()
}

// Help renders the group's summary (if set) followed by a sorted, two-column
// listing of child commands and the first line of each child's own Help().
// When Flags is set, a trailing "Flags:" block enumerates the group-level
// flags so `ana <group> --help` surfaces inheritable flags even when the
// user hasn't drilled into a leaf.
func (g *Group) Help() string {
var b strings.Builder
if g.Summary != "" {
Expand All @@ -120,6 +192,15 @@ func (g *Group) Help() string {
first := FirstLine(g.Children[n].Help())
fmt.Fprintf(&b, " %-*s %s\n", width, n, first)
}
if g.Flags != nil {
fs := flag.NewFlagSet("help", flag.ContinueOnError)
fs.SetOutput(io.Discard)
g.Flags(fs)
if block := renderFlagsAsText(fs); block != "" {
b.WriteString("\nFlags:\n")
b.WriteString(block)
}
}
// Trim trailing newline so callers can Fprintln without doubling blanks.
return strings.TrimRight(b.String(), "\n")
}
Expand Down
Loading
Loading