CLI Tools Best Practices
Testing CLIs, versioning, and distributing via go install.
Apply these rules in code review, release pipelines, and internal platform templates so every Go binary installs predictably, fails clearly, and stays testable without global flag pollution.
How to Use This List
- Tick rules as your CLI template adopts them.
- Encode Tier A items in CI (golden help output, exit code tests) where possible.
- Publish exit code and config precedence tables in the tool README.
- Revisit when adding TUI modes, new subcommands, or cross-platform releases.
A - UX and streams
- Reserve stdout for machine-readable contract output. Keep prose, colors, and progress on stderr.
- Print usage and parse errors to stderr. Pipes and
kubectlwrappers stay clean. - Honor
NO_COLORand disable ANSI when not a TTY. CI logs and accessibility policies depend on it. - Document exit codes beyond 0 and 1. Scripts branch on config vs auth failures when documented.
- Fail fast on config and flag errors before I/O. Save wall time and avoid partial mutations.
B - Structure and dependencies
- Keep
mainthin; put logic ininternal/packages. Enables unit tests withoutos.Exit. - Avoid global
flag.CommandLinein libraries. UseFlagSetor cobra per command. - One parsing framework per binary. Do not mix kingpin, cobra, and stdlib flag on the same argv.
- Share domain types with services in
internal/. CLI and API stay aligned on enums and IDs. - Defer heavy imports for CI-hot subcommands when profiling proves need. Split binaries only with data.
C - Configuration
- Document precedence: flags > env > file > defaults. Operators must know which value wins.
- Use env prefix and key replacer for nested viper keys. Maps
API_URLtoapi.urlpredictably. - Never commit secrets in example config files. Ship
config.example.yamlwith placeholders. - Validate required settings after merge. Return sentinel errors mapped to exit code 2.
- Reset or isolate viper in tests. Prevent parallel test pollution from global config.
D - Command trees and flags
- Use cobra or urfave/cli when subcommands exceed two manual branches. Completion and help repay the dependency cost.
- Attach shared flags with
PersistentFlagson the root only. Avoid duplicate flag names on parent and child. - Validate arity with
Argshelpers or enums. Wrong arg counts produce actionable messages. - Generate and document shell completion for operator tools. Reduces typos on long command paths.
- Mark deprecated commands with warnings before removal. Give consumers a release cycle to migrate.
E - Testing and quality
- Test
run(args []string) errorwithout parsing realos.Args. Core logic stays parallel-safe. - Use
cobra.Command.SetArgsor isolatedFlagSet.Parsein CLI tests. No cross-test flag bleed. - Add
os/execintegration tests for exit codes and help output. Catch wiring mistakes inmain. - Golden-test
--helpwhen command surface is stable. Unintended flag renames break scripts. - Run
go test ./...with race detector for concurrent CLIs. Workers and TUIs expose races early.
F - Build, version, and distribution
- Stamp version with
-ldflags "-X main.version=…"in CI. Support can map bug reports to builds. - Expose
--versionorversionsubcommand. Matches container image tags and git tags. - Publish via
go install module/path@versionfor public modules. Document the import path in README. - Attach cross-compiled release archives per GOOS/GOARCH. Linux, macOS, and Windows operators get native binaries.
- Record
go.sumintegrity and reproducible build notes in releases. Supply-chain review depends on it.
G - Terminal UI and long operations
- Detect non-TTY and downgrade TUI to log lines. GitHub Actions and cron lack alt-screen support.
- Run blocking work off the bubbletea Update goroutine. Send completion messages back to the model.
- Handle
ctrl+cand signals for batch CLIs. Partial writes should not corrupt remote state. - Show progress on stderr for minutes-long jobs. Operators need feedback without breaking stdout JSON.
- Quit bubbletea programs cleanly to restore cursor state. Avoid leaving terminals hidden or raw.
FAQs
Which rules should block merge in review?
Stdout/stderr separation, documented exit codes, thin main, config validation, and at least one CLI integration test are strong CI blockers.
Document exceptions in the tool README.
Do these apply to cobra-only tools?
Yes.
cobra handles parsing; streams, testing, versioning, and distribution rules still apply.
How do best practices relate to the basics page?
Basics teaches flag and subcommand mechanics.
This list encodes production habits for teams shipping CLIs to operators and CI.
When is stdlib flag enough for production?
When subcommands are manual, flags are few, and help text is hand-maintained.
Re-evaluate when completion or persistent flags become todo items every sprint.
Should internal CLIs skip cross-compile releases?
Even internal tools benefit from Linux CI binaries.
macOS developers still need native builds or go install from module tags.
Related
- CLI Design in Go: Single Binary, Fast Startup - architectural anchor
- CLI Tools Basics - runnable intro examples
- cobra & urfave/cli Command Trees - command tree patterns
- Configuration: Viper, Env & Config Files - layered config
- Color Output & User-Friendly Errors - stderr and exit codes
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).