cli.md

Go CLIs

Read ../cli.md, baseline.md, mise.md, and preferred-libraries.md before applying these Go CLI specifics.

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/v2 only 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 SilenceUsage and SilenceErrors when Fang handles output
  • context.Background() enters at main, 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.BuildInfo directly or let Fang's fallback handle it when unknown is 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.toml when XDG_CONFIG_HOME is 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.