Adding a CLI Command
Contributor guide for pkg/cli. For end-user flag reference see
docs/user/cli-reference.md.
pkg/cli is a user-interaction package built on
urfave/cli/v3. It parses flags, loads
optional config files, formats output, and maps errors to exit codes. It
must not contain business logic. Recipe resolution, bundle
generation, snapshot capture, validation, evidence handling — all of it
lives in functional packages and is composed by the pkg/client/v1
aicr.Client facade. Handlers in pkg/cli are thin adapters over that
facade, the same way pkg/server handlers are. Crossing this boundary
will be rejected in review.
cmd/aicr/main.go is a one-line entry point that calls
cli.Execute(). Execute builds the root command tree, runs it with a
signal.NotifyContext (SIGINT/SIGTERM), and on return calls
os.Exit(errors.ExitCodeFromError(err)). Handlers never call
os.Exit themselves — they return errors.
Command Inventory
Subcommands registered in pkg/cli/root.go (Commands: slice on
newRootCmd):
Each *Cmd() factory returns a *cli.Command. Verb-group parents
(evidence, mirror, trust, bundle verify) declare their
subcommands in their own Commands: slice; see evidence.go for the
canonical shape.
Adding a New Subcommand
Mechanical walkthrough. Pick an existing command of similar shape
(query.go for a read-only single-value command, validate.go for a
cluster-touching one) and follow its pattern rather than inventing one.
1. Create pkg/cli/<name>.go. Export a single factory:
2. Register in root.go. Add myCmdCmd() to the Commands: slice
on newRootCmd. setShellComplete walks subcommands recursively so no
completion wiring is needed beyond withCompletions on enum flags.
3. Tests. Add pkg/cli/<name>_test.go. Build a cli.Command, set
cmd.Writer = &bytes.Buffer{}, call cmd.Run(ctx, args), and assert
on buffer contents and error code. See recipe_test.go or
query_test.go for the template.
4. Docs. Add an entry in docs/user/cli-reference.md (flag table)
and update this file’s Command Inventory table.
The pkg/client/v1 Facade Boundary
pkg/cli calls into the facade; the facade calls into functional
packages (pkg/recipe, pkg/bundler, pkg/snapshotter,
pkg/validator, pkg/evidence, …). Public entry points
(pkg/client/v1/aicr.go):
Construction in CLI happens via recipeClientFromCmd(cmd, cfg) in
root.go — it reads --data (or cfg.Recipe().DataDir()), picks
FilesystemSource vs EmbeddedSource, and threads version through
to Metadata.Version. Callers must defer client.Close(); the
client owns goroutines that drain on Close.
Adding business logic in the handler — recipe resolution loops, bundle
rendering, validator orchestration, OCI pushes — is a boundary
violation. If the facade is missing the surface you need, add it to
pkg/client/v1 first.
Output Writers
User-facing output goes through cmd.Root().Writer, never
fmt.Println / fmt.Printf to stdout. Tests capture by assigning
cmd.Writer = &bytes.Buffer{} before cmd.Run; printing directly to
stdout breaks that capture and the root_test.go pattern.
Long structured output uses pkg/serializer (deterministic YAML/JSON).
Diagnostic / debug messages go through slog, not the user writer.
Flag Factory Pattern
Shared flags are declared as functions returning cli.Flag, not
package vars (see root.go):
Why: urfave/cli/v3 mutates parsed state (Count, parsed value) on
the cli.Flag value itself. A single shared instance leaks parsed
state across cmd.Run invocations — most visible in tests that build
multiple command trees in one process. Each Command gets a fresh
flag instance by calling the factory.
Flag names, category labels (catInput, catOutput, catScheduling,
catOCIRegistry, …) and well-known string constants (flagOutput,
flagPush, flagIdentityToken, …) live in consts.go. goconst
flags any literal repeated ≥ 3 times across the package — extract it
there.
--config and loadCmdConfig
Commands that accept a config file declare configFlag() and call
loadCmdConfig(ctx, cmd). The loader returns (*config.AICRConfig, nil) when --config is set, (nil, nil) when it is not. Errors from
config.Load are returned unchanged so their pkg/errors codes
(ErrCodeNotFound, ErrCodeInvalidRequest, ErrCodeUnavailable)
survive to the exit-code mapper.
Precedence is CLI flag > config file > flag default, implemented
by three helpers in root.go:
Use these helpers everywhere; do not call cmd.IsSet + manual
ternaries inline.
validateSingleValueFlags
urfave/cli/v3 silently accepts repeated single-value flags
(--namespace a --namespace b keeps the last). That’s a usability
trap for flags like --recipe or --output. Every command’s Action
calls validateSingleValueFlags(cmd, names...) as its first step:
It uses cmd.Count(name) to catch repeats and returns
ErrCodeInvalidRequest (→ exit code 2). List every single-value flag
the command declares; omitting one re-introduces the trap.
Error → Exit Code Mapping
Handlers return errors. Execute in root.go calls
os.Exit(errors.ExitCodeFromError(err)). Mapping
(pkg/errors/exitcode.go):
Rules:
- Never call
os.Exitfrom a handler — return the error. - Never
fmt.Errorfin CLI code: usepkg/errors.New/errors.Wrapwith a code. - Don’t re-wrap an error that already has the right code; that overwrites it. Return as-is.
- Validate user input early and return
ErrCodeInvalidRequest. Don’tslog.Warn; continue— a--settypo or malformed flag must not ship a misconfigured artifact.
Shell Completion
completion_values.go defines:
CompletableFlag— interface withCompletions() []stringon top ofcli.Flag.completableStringFlag— wrapscli.StringFlagwith a completion function.withCompletions(flag, fn)— adornment used at flag-declaration time.
For enum flags, declare with withCompletions:
completeWithAllFlags in root.go reads os.Args directly (not
cmd.Args(), because partial flags like --form fail the parser and
never land in cmd.Args) and emits suggestions for flag names, flag
values, and subcommands. setShellComplete recursively wires this on
every subcommand so aliases (e.g., --gpu for --accelerator)
appear in completions.
Skill Plugin Generator
aicr skill --agent claude-code|codex generates an agent skill file
from the live CLI command tree. Adding an agent:
- Define an
agentTypeconstant and add it tosupportedAgents()inskill_generator.go. - Implement
skillGenerator(generate(meta *cliMeta) ([]byte, error),installPath() (string, error)) in a newskill_<agent>.go. - Register it in the
parseAgentType→ generator switch inskill.go.
The reflection over the command tree happens in skill_generator.go
so generators only consume cliMeta.
Logging and Color
Configured in the root Before hook (root.go):
User output (cmd.Root().Writer, defaults to stdout) is separate from
slog output (stderr). Tests should assert on the writer, not on log
capture.
Testing
Pattern (pkg/cli/*_test.go):
Rules:
- Capture user output via
cmd.Writer(orcmd.Root().Writerwhen building the tree by hand). Do not parse stderr. - Anything that resolves a recipe against an actual cluster or
deploys a Job must pass
--no-clusterin tests. The validator and collector honor it; live-cluster tests belong intests/e2eortests/chainsaw. - Use
t.Context()(Go 1.24+) so signal-cancellation is exercised end-to-end. - Table-driven tests for flag-precedence and config-merge cases —
these have many small permutations and the existing tests
(
config_helpers_test.go,bundle_resolve_helpers_test.go) are the template.