cli

package
v0.3.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 28, 2026 License: MIT Imports: 13 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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.

View Source
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.

View Source
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

func DashIfEmpty(s string) string

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

func Dispatch(ctx context.Context, root *Group, args []string, stdio IO) error

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

func EnumFlag(target *string, allowed []string) flag.Value

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

func ExitCode(err error) int

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

func FirstLine(s string) string

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

func FlagSetFrom(ctx context.Context) *flag.FlagSet

FlagSetFrom returns the FlagSet stashed by WithFlagSet, or nil if absent. Per stdlib context.Value semantics, ctx must not be nil.

func FlagWasSet

func FlagWasSet(fs *flag.FlagSet, name string) bool

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

func IntListFlag(target *[]int, sep string) flag.Value

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 IsHelpArg added in v0.3.0

func IsHelpArg(s string) bool

IsHelpArg reports whether s is one of the recognized help tokens.

func NewFlagSet

func NewFlagSet(name string) *flag.FlagSet

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

func NewTableWriter(w io.Writer) *tabwriter.Writer

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

func ParseFlags(fs *flag.FlagSet, args []string) error

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

func ReadPassword(r io.Reader) (string, error)

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

func ReadToken(r io.Reader, tokenStdin bool) (string, error)

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

func RedactToken(token string) string

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

func Remarshal(src, dst any) error

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

func RenderResolvedHelp(res *Resolved, root *Group, w io.Writer)

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

func RenderTwoCol(w io.Writer, m map[string]any) error

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

func ReportUsageError(res *Resolved, root *Group, err error, w io.Writer)

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

func RequireFlags(fs *flag.FlagSet, verb string, names ...string) error

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

func RequireIntID(verb string, args []string) (int, error)

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

func RequireMaxPositionals(verb string, max int, args []string) error

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

func RequireNoPositionals(verb string, args []string) error

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

func RequireStringID(verb string, args []string) (string, error)

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

func RootHelp(root *Group) string

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

func SinceFlag(target *time.Time, now func() time.Time) flag.Value

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

func UsageErrf(format string, a ...any) error

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

func WithFlagSet(ctx context.Context, fs *flag.FlagSet) context.Context

WithFlagSet attaches fs to ctx so leaves can fetch it via FlagSetFrom. Per stdlib context.WithValue contract, ctx must not be nil.

func WithGlobal

func WithGlobal(ctx context.Context, g Global) context.Context

WithGlobal returns a child context carrying g. Per stdlib convention ctx must be non-nil; a nil parent panics (mirroring context.WithValue).

func WriteJSON

func WriteJSON(w io.Writer, v any) error

WriteJSON pretty-prints v to w with a 2-space indent and trailing newline, matching the convention used across every --json branch in the CLI. A single helper keeps output byte-identical between verbs.

Types

type Command

type Command interface {
	Run(ctx context.Context, args []string, io IO) error
	Help() string
}

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

type Flagger interface {
	Flags(fs *flag.FlagSet)
}

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

type Global struct {
	JSON      bool
	Endpoint  string
	TokenFile string
	Profile   string
}

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

func GlobalFrom(ctx context.Context) Global

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

type Group struct {
	Summary  string
	Flags    func(*flag.FlagSet)
	Children map[string]Command
}

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

func (g *Group) Help() 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 (*Group) Run

func (g *Group) Run(ctx context.Context, args []string, stdio IO) error

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.

func DefaultIO

func DefaultIO() IO

DefaultIO returns an IO backed by os.Stdin/Stdout/Stderr, os.Getenv, and time.Now.

type Resolved added in v0.3.3

type Resolved struct {
	Leaf     Command
	Path     []*Group
	MergedFS *flag.FlagSet
	Args     []string
}

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

func Resolve(root *Group, args []string) (*Resolved, error)

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

func (r *Resolved) Execute(ctx context.Context, stdio IO) error

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

func (t Token) Format(f fmt.State, _ rune)

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

func (t Token) String() 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

func (t Token) Value() string

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL