CLIs
Choose the smallest CLI shape that will still feel good to use.
Shape choices
- One or two flags, server-style binary: stdlib flags are fine.
- User-facing command with help/version/completions/subcommands: use Cobra + Fang.
- Do not follow clix/kong guidance from generic Go skills for Amolith projects.
- Interactive prompts: add
huh/v2only when prompts are central, not for a single confirmation. - Full-screen interaction: this is a TUI; read
tui.md.
Cobra + Fang baseline
Use this layout for real CLIs:
cmd/<binary>/main.go # constructs root command and calls fang.Execute
cmd/<binary>/<verb>.go # subcommands
internal/<domain>/... # domain logic; no CLI globals here
Patterns:
- root command has
SilenceUsageandSilenceErrorswhen Fang handles output context.Background()enters atmain, then command contexts carry through- choose a version policy during setup: Fang/Go build info is fine for best-effort CLI display, but exact releases, jj-derived values, MCP metadata, package metadata, or tests need project-owned version resolution
- command functions return errors instead of printing and exiting deep in the stack
- shell completions, especially dynamic, are worthwhile for anything frequent
Version reporting
Fang can display values passed with WithVersion and WithCommit, and it has
best-effort build-info fallback support. Do not rely on that fallback when the
version appears outside CLI help: MCP implementation metadata, package metadata,
release artifacts, tests, and jj-derived versions need project-owned values.
Choose the policy with the user:
- No exact user-visible version: do not add version plumbing just for its own sake.
- Best-effort CLI display: read
runtime/debug.BuildInfodirectly or let Fang's fallback handle it whenunknownis acceptable. - Exact release version, jj-derived version/commit, or multiple version surfaces: keep a project-owned resolver and pass the result to Fang, MCP server metadata, package tasks, and tests.
For projects that inject exact release values, keep the variable boring:
var version = "dev"
Then append an ldflag only in the release/package task:
[tasks."build:release"]
description = "Build a release binary with an explicit version"
env = { CGO_ENABLED = "0" }
run = '''
version=${VERSION:?set VERSION}
ldflags="{{vars.static_ldflags}} -X main.version=$version"
go build \
-trimpath \
-tags '{{vars.static_tags}}' \
-ldflags "$ldflags" \
-o {{vars.binary}} \
{{vars.main}}
'''
For variables outside main, use the full import path plus variable name, such
as example.com/project/internal/server.version, not main.version.
This static build shape is for pure-Go CLIs. If a CLI intentionally needs CGO, keep the same versioning pattern but remove the static-only flags.
Config
- TOML config:
BurntSushi/toml. - Default config file: read from
$XDG_CONFIG_HOME/<app-name>/config.toml, falling back to~/.config/<app-name>/config.tomlwhenXDG_CONFIG_HOMEis unset. - Env vars: read at the boundary, validate early, and store in a typed config struct.
- Config precedence is flag, then environment variable, then config file.
- Config discovery should be explicit and documented; avoid magical precedence unless the project truly needs layering.
UX rules
- Human text goes to stderr when stdout may be piped.
- Machine output needs an explicit
--json,--jsonl, or equivalent. - Destructive commands need names and help text that make the effect obvious.
- Interactive commands need a non-interactive path for scripts and CI.