Documentation
¶
Overview ¶
Package cli provides argument-dispatch glue for the ana CLI. It defines the Command interface, an IO struct carrying stdio/env/clock dependencies, the Group helper that stitches together a verb tree, and the resolve-then-parse pipeline (Resolve + Dispatch) that walks argv against that tree, parses every flag against a single merged FlagSet, and hands off to the resolved leaf. The package is pure dispatch logic — it has no dependency on transport or config.
The flag pipeline mirrors mainstream "scoped flag set" CLIs (Cobra, Click, urfave-cli, clap): each *Group may declare persistent flags via its Flags closure that descendant leaves inherit, and each leaf may declare local flags by implementing Flagger. Names a leaf re-declares automatically SHADOW the ancestor declaration of the same name — see internal/cli/resolve.go for the mechanism.
Index ¶
- Variables
- func DashIfEmpty(s string) string
- func Dispatch(ctx context.Context, root *Group, args []string, stdio IO) error
- func EnumFlag(target *string, allowed []string) flag.Value
- func ExitCode(err error) int
- func FirstLine(s string) string
- func FlagSetFrom(ctx context.Context) *flag.FlagSet
- func FlagWasSet(fs *flag.FlagSet, name string) bool
- func IntListFlag(target *[]int, sep string) flag.Value
- func IsHelpArg(s string) bool
- func NewFlagSet(name string) *flag.FlagSet
- func NewTableWriter(w io.Writer) *tabwriter.Writer
- func ParseFlags(fs *flag.FlagSet, args []string) error
- func ReadPassword(r io.Reader) (string, error)
- func ReadToken(r io.Reader, tokenStdin bool) (string, error)
- func RedactToken(token string) string
- func Remarshal(src, dst any) error
- func RenderOutput[T any](w io.Writer, raw map[string]any, jsonFlag bool, typed *T, ...) error
- func RenderResolvedHelp(res *Resolved, root *Group, w io.Writer)
- func RenderTwoCol(w io.Writer, m map[string]any) error
- func ReportUsageError(res *Resolved, root *Group, err error, w io.Writer)
- func RequireFlags(fs *flag.FlagSet, verb string, names ...string) error
- func RequireIntID(verb string, args []string) (int, error)
- func RequireMaxPositionals(verb string, max int, args []string) error
- func RequireNoPositionals(verb string, args []string) error
- func RequireStringID(verb string, args []string) (string, error)
- func RootHelp(root *Group) string
- func SinceFlag(target *time.Time, now func() time.Time) flag.Value
- func UsageErrf(format string, a ...any) error
- func WithFlagSet(ctx context.Context, fs *flag.FlagSet) context.Context
- func WithGlobal(ctx context.Context, g Global) context.Context
- func WriteJSON(w io.Writer, v any) error
- type Command
- type Flagger
- type Global
- type Group
- type IO
- type Resolved
- type Token
Constants ¶
This section is empty.
Variables ¶
var ErrHelp = errors.New("help")
ErrHelp marks an explicit help request (`--help`, `-h`, or `help`). Dispatch returns this after printing help text. ExitCode maps it to 0 because the user asked for help and got it — that is success, not a usage error.
var ErrReported = errors.New("reported")
ErrReported marks errors whose diagnostic text has already been written to stderr by the callee. main()'s fallback stderr print skips these to avoid double-reporting. Wrap with errors.Join(err, ErrReported) after emitting the diagnostic yourself.
var ErrUsage = errors.New("usage")
ErrUsage marks a usage-related failure — missing arg, unknown command, malformed flag. Commands that want the root to exit with code 1 should return an error that wraps ErrUsage via %w.
Functions ¶
func DashIfEmpty ¶
DashIfEmpty renders an em-dash placeholder for empty cells so tabwriter keeps table columns aligned. Every verb's list/show table uses this fallback.
func Dispatch ¶
Dispatch is the root entry point. It walks args against the verb tree under root, parses every flag token (root persistent + intermediate group persistent + leaf local) against a single merged FlagSet, stashes the resulting Global in ctx, and hands off to the resolved leaf.
A bare help token (`help` / `-h` / `--help`) at the very front prints the root group's help and returns ErrHelp; deeper help tokens are handled by Resolve and rendered against the resolved leaf or group.
Errors:
- ErrHelp (exit 0): help was requested.
- usage errors (exit 1): unknown verb or unknown / malformed flag, also wrapped with ErrReported so main() doesn't double-print.
- any error returned by leaf.Run: passed through unchanged.
func EnumFlag ¶
EnumFlag returns a flag.Value that validates against a fixed allow-list at parse time: unknown values yield a UsageErr before the verb body runs, so downstream code can trust *target. The allowed values show up in the Set error, which fs.Parse wraps as `invalid value "X" for flag -Y: allowed: ...`. Pairs naturally with RequireFlags when the flag is also mandatory.
func ExitCode ¶
ExitCode maps a returned error to the process exit code.
nil -> 0 ErrHelp -> 0 (user asked for help; help was shown) ErrUsage -> 1 (including wrapped) authError -> 3 (including wrapped; IsAuthError must return true) otherwise -> 2
func FirstLine ¶
FirstLine returns the first line of s (without the newline). Exported so verb packages that render streaming/multi-line payloads one-row-per-frame can reuse the same definition the help renderer uses.
func FlagSetFrom ¶ added in v0.3.3
FlagSetFrom returns the FlagSet stashed by WithFlagSet, or nil if absent. Per stdlib context.Value semantics, ctx must not be nil.
func FlagWasSet ¶
FlagWasSet reports whether fs saw name as an explicit argument. Partial- update verbs use it to distinguish "user left this alone" (keep server's current value) from "user passed the zero value on purpose". Uses the stdlib-documented `fs.Visit` idiom, which only traverses flags that were actually set; an unknown name is reported as not-set rather than panicking. A nil fs (e.g. a leaf invoked outside the resolver) is treated as "no flags set" so leaves migrating to the resolver pipeline don't have to guard the call site themselves.
func IntListFlag ¶
IntListFlag returns a flag.Value that parses a separator-delimited list of ints into *target (whitespace around each entry is tolerated). Empty input and non-integer tokens produce a UsageErr; on success *target is guaranteed non-empty.
func NewFlagSet ¶
NewFlagSet returns a *flag.FlagSet configured the way every leaf verb wants it: ContinueOnError so we can wrap parse failures as usage errors, and output silenced (io.Discard) so each command's own Help() is the sole source of usage text. Currently duplicated in every verb package — Phases 1–10 of the shared-cli-kit refactor switch them over to this helper.
func NewTableWriter ¶
NewTableWriter returns a *tabwriter.Writer configured the way every verb's list/show table wants it: no min-width, no tab-stop, two-space padding. Callers must call Flush() (or defer it) once they finish writing rows.
func ParseFlags ¶
ParseFlags parses args into fs, tolerating positional arguments interleaved with flags. Go's stdlib FlagSet.Parse stops at the first non-flag token, which silently drops any flags that follow — so `cmd <id> --flag v` would parse the positional but ignore --flag. This helper iterates: parse, collect a non-flag token, parse the remainder, repeat. A final Parse with a "--" separator then re-seeds fs.Args() with the collected positionals so callers can read them through the normal flag API.
On any underlying Parse failure the error is wrapped with ErrUsage so the root dispatcher maps it to exit code 1.
func ReadPassword ¶
ReadPassword reads a single password line from r and returns the bytes exactly as supplied, stripping only the trailing line terminator (\n or \r\n). Unlike ReadToken, no whitespace trimming is applied: a password may legitimately contain leading or trailing spaces or tabs, and silently mutating it would cause hard-to-diagnose auth failures. The same JWT-sized scanner buffer is used so very long credentials round-trip intact.
func ReadToken ¶
ReadToken consumes stdin and returns a trimmed token. With tokenStdin=true the whole stream is consumed; otherwise a single newline-terminated line is read. Whitespace is trimmed in both modes so common pipe quirks (trailing newline from `echo`) don't poison the saved value. Centralised here because auth login and profile add share this exact behaviour.
func RedactToken ¶
RedactToken returns a user-facing display for a bearer token or API key. Empty tokens render as "(unset)" so operators can see the slot needs a login; any other value shows a fixed mask plus the last four characters so two tokens can be disambiguated at a glance without leaking them. Tokens shorter than four bytes (never expected from a real API key but exercised defensively) still get fully masked — echoing the whole value in the "last 4:" slot would defeat the redaction for short/malformed/test tokens.
func Remarshal ¶
Remarshal round-trips src through JSON into dst, letting commands have one Unary decode into map[string]any and still derive a typed view for table rendering without a second RPC.
func RenderOutput ¶
func RenderOutput[T any]( w io.Writer, raw map[string]any, jsonFlag bool, typed *T, render func(w io.Writer, typed *T) error, ) error
RenderOutput picks the JSON-vs-table branch every `--json` verb repeats. On jsonFlag the raw map is dumped via WriteJSON; otherwise raw is Remarshal'd into typed and handed to render. The remarshal error is wrapped with "decode response" so verbs preserve their historical error string under a `verb: %w` wrap at the call site.
func RenderResolvedHelp ¶ added in v0.3.3
RenderResolvedHelp is the exported variant of renderResolvedHelp so cmd/ana can render leaf+ancestor help without duplicating the merged-FS formatting logic. For a Group leaf the group's Help is printed (which already contains the Commands listing + persistent Flags block); for a non-Group leaf the leaf's Help is printed followed by the merged-FS Flags block (every flag in scope — leaf + ancestor persistent). A nil res falls back to RootHelp(root).
func RenderTwoCol ¶
RenderTwoCol prints top-level scalar fields then any nested map fields (e.g. postgresMetadata) as an indented sub-block. Keys are sorted so the output is stable across runs for snapshot-style tests. Output is byte-identical to the pre-refactor connector/get.go::renderTwoCol.
func ReportUsageError ¶ added in v0.3.3
ReportUsageError writes a syntax-error report to w in the modern-CLI convention: the error on the first line, a blank separator, then the help text for the deepest scope the resolver reached. A nil res falls back to the root help. Callers wrap the returned-from-Run / Resolve error with ErrReported so main()'s fallback printer doesn't double-emit it.
The trailing `: usage` sentinel suffix that errors.Is(err, ErrUsage) leaves in err.Error() is stripped from the printed line — it is a routing tag for callers, not user-facing diagnostic text.
func RequireFlags ¶
RequireFlags returns a UsageErr listing any flag from names that was not explicitly set on fs. Built on FlagWasSet, so "explicit zero value" (e.g. --port 0) still counts as supplied — callers that care about the value's content validate it themselves. The verb prefix is prepended so the error reads `verb: missing required flags: --a, --b`. A nil fs (leaf reached without going through the resolver) reports every name as missing.
func RequireIntID ¶
RequireIntID extracts an integer positional <id> from args[0]. Missing or empty input returns the same usage error as RequireStringID; a non-numeric input returns a usage error that quotes the underlying strconv error. Behaviour matches the pre-refactor connector.atoiID with the addition of the args-slice indirection.
func RequireMaxPositionals ¶ added in v0.3.3
RequireMaxPositionals returns a UsageErr when args has more than max elements. Verb leaves with bounded positional arity (e.g. `[<id>]` or `<id> [<title>]`) call this at the top of Run so the error wording stays consistent.
func RequireNoPositionals ¶ added in v0.3.3
RequireNoPositionals returns a UsageErr when args is non-empty. Verb leaves that take no positional arguments call this at the top of Run so the "unexpected positional arguments" message is identical across the CLI. The verb prefix appears in the error so callers can grep for it.
func RequireStringID ¶
RequireStringID extracts a non-empty positional <id> from args[0]. An empty or whitespace-only first arg (or a missing arg entirely) is rejected with a usage error. The strictest pre-refactor variant (dashboard's strings.TrimSpace check) is used here so all verb packages converge on identical behaviour after migration.
func RootHelp ¶
RootHelp renders the root group's help — the sorted listing of top-level verbs each followed by its summary line, plus the trailing Flags block describing the four root persistent flags.
func SinceFlag ¶
SinceFlag returns a flag.Value that accepts either a non-negative duration (e.g. "1h", "24h") — interpreted as `now() - dur` — or an absolute RFC3339 timestamp, and stores the UTC-normalised result in *target. The injected now lets tests fix the clock so --since assertions are deterministic. Negative durations are rejected rather than silently producing a future timestamp, which would mask operator typos.
func UsageErrf ¶
UsageErrf builds a user-facing error that wraps ErrUsage so the root dispatcher maps it to exit code 1 via errors.Is. Use this anywhere a verb detects a missing/invalid arg or other shape problem the caller could fix by re-running with different inputs.
func WithFlagSet ¶ added in v0.3.3
WithFlagSet attaches fs to ctx so leaves can fetch it via FlagSetFrom. Per stdlib context.WithValue contract, ctx must not be nil.
func WithGlobal ¶
WithGlobal returns a child context carrying g. Per stdlib convention ctx must be non-nil; a nil parent panics (mirroring context.WithValue).
Types ¶
type Command ¶
Command is the consumer interface implemented by every verb or subcommand. Run receives the args remaining after the verb path has been consumed — pure positionals (the resolver has already routed every flag token to its rightful FlagSet target).
type Flagger ¶ added in v0.3.0
Flagger is the opt-in declaration interface for leaves with flags. The resolver calls Flags(fs) on the resolved leaf BEFORE walking the ancestor path, so a name a leaf declares automatically shadows the same name in any ancestor Group's Flags closure (the resolver merges ancestor declarations onto fs only if the name isn't already present).
A leaf without flags simply omits this method.
type Global ¶
Global holds the root-level flags that apply to every verb. Command implementations read it from context via GlobalFrom; the resolver populates it from the parsed merged FlagSet via globalFromFlagSet.
Names match the root group's persistent Flags closure declared in cmd/ana/main.go: --json, --endpoint, --token-file, --profile.
func GlobalFrom ¶
GlobalFrom extracts the Global from ctx, or a zero value if absent. Per stdlib convention ctx must be non-nil; a nil ctx panics (mirroring context.Value semantics).
type Group ¶
Group is a verb-tree node. Children dispatches its leading positional argument to a named child Command (which may itself be a *Group, enabling nested verbs like `ana chat send …`).
Flags, if set, declares persistent flags that every descendant leaf inherits. The closure runs against the resolver's merged FlagSet via a shadow set so a leaf that re-declares the same name wins automatically — callers can use raw fs.StringVar / fs.BoolVar / fs.IntVar without worrying about the stdlib redeclaration panic.
func (*Group) Help ¶
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 (*Group) Run ¶
Run dispatches as a Command — the path used when a Group is registered as a child of another Group. Resolve handles the descent walk; Run is a thin wrapper that re-enters the resolver rooted at this Group so callers (and tests) can treat any Group as a self-contained dispatcher.
Empty args or an explicit help token prints the group's help and returns ErrHelp. An unknown child name returns ErrUsage.
type IO ¶
type IO struct {
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Env func(string) string
Now func() time.Time
}
IO carries the ambient dependencies a Command needs: standard streams, an environment accessor, and a clock. Pass this through to subcommands rather than reaching for package globals so tests can inject fakes.
type Resolved ¶ added in v0.3.3
Resolved is the output of walking argv against a verb tree.
Leaf is the resolved Command. It may itself be a *Group when the user typed only a group prefix (e.g. `ana profile`) — Dispatch renders the group's help in that case.
Path lists the *Group ancestors descended through, root first. The leaf's parent (if any) is the last entry; Path always contains at least root.
MergedFS holds every persistent flag declared by any ancestor on Path plus every local flag the leaf declared via Flagger. Names a leaf re-declares SHADOW the ancestor of the same name (leaf-wins) — see Resolve below for the mechanism. The FlagSet has already been parsed against argv's flag tokens by the time Resolve returns; leaves call FlagSetFrom(ctx) when they need post-parse helpers like RequireFlags / FlagWasSet.
Args is the leaf's positional remainder after parsing.
func Resolve ¶ added in v0.3.3
Resolve walks args left-to-right against root, descending into Children when it encounters a positional matching a child name. Flag tokens are SKIPPED during the walk; we use a shape-only FlagSet built from already-traversed ancestor declarations to know which names take a value (so a `--profile prod chat` argv doesn't mistake `prod` for the verb).
Once the leaf is identified, Resolve assembles MergedFS in leaf-wins order: the leaf's own Flagger.Flags is called first; then each ancestor Group's Flags closure is invoked on a private shadow FlagSet, and only the entries whose names are NOT already on MergedFS are copied across. That way an ancestor declaration of, say, `--endpoint` is silently superseded by a leaf declaration of the same name — making the "global flag stomps leaf flag" class of bug structurally impossible.
Errors:
- unknown verb name during walk → usage-wrapped error. Resolved is non-nil with Leaf set to the deepest *Group reached so callers can render that group's help alongside the error.
- parse failure of a flag token → ParseFlags' wrapped usage error. Resolved is non-nil with Leaf+MergedFS already populated so callers can render the resolved leaf's help (with its merged Flags block).
- presence of `--help` / `-h` anywhere → returns Resolved with ErrHelp; callers (Dispatch) render help instead of running the leaf.
func (*Resolved) Execute ¶ added in v0.3.3
Execute runs the leaf identified by Resolve against the given context and stdio. If Leaf is a *Group (the user typed only a group prefix) the group's help is printed and ErrHelp is returned. Otherwise the parsed merged FlagSet is plumbed through ctx via WithFlagSet (so leaves can fetch it for cli.RequireFlags / FlagWasSet) and Run is invoked with the leaf's positional args.
Execute is the single chokepoint for the modern-CLI-convention guarantee: any error from the leaf that wraps ErrUsage but is NOT yet tagged ErrReported is rewritten to "<error>\n\n<help>" on stdio.Stderr (using the resolved leaf's own help block) and re-tagged with ErrReported so main()'s fallback printer does not double-emit. Callers therefore never need to repeat that wrap themselves.
Execute does NOT call WithGlobal — the caller (Dispatch or cmd/ana) owns that decision so it can route any ancestor-bound Global pointer it cares to read.
type Token ¶
type Token string
Token wraps a bearer token so any accidental `%s`/`%v`/`%q` on a logger or error renders the redacted form (same mask RedactToken produces). Code that needs the raw value calls Value() — the single explicit escape hatch.
The underlying kind is string, so the type is JSON-transparent (persists and loads as a plain string), comparisons against string literals still work, and tests can write `cli.Token("abcdefgh")` with no extra plumbing.
func (Token) Format ¶
Format also intercepts `%q`, `%+v`, and `%#v`, which would otherwise bypass String() and dump the raw bytes (fmt special-cases string kinds for %q/%#v specifically). Returning the same redacted form for every verb means no format directive can accidentally leak a token.
func (Token) String ¶
String returns the redacted representation. Triggered by any verb that doesn't override it (`%s`, `%v`, default Sprintln, error chains, etc.).
func (Token) Value ¶
Value returns the raw token string. This is the intended escape hatch for the two legitimate consumers: the transport layer building an Authorization header, and the auth-keys verb that prints a freshly minted key once. Adding new callers is a design smell — prefer passing the Token through.