Reference Build: Production CLI with cobra
This reference build is myctl, a multi-command operator tool: database migrations, cluster status, config-driven targets, a Bubble Tea progress UI, and a goreleaser pipeline that ships signed archives for Linux, macOS, and Windows.
Use it when your team needs more than a single main with flags but less than a full platform product.
Summary
Production CLIs in Go compile to one binary, start fast, and behave predictably in scripts.
This build uses cobra for command trees, viper for layered config, slog for machine-readable logs when --json is set, and Bubble Tea for interactive flows that still respect Ctrl+C cancellation.
Commands separate read operations (safe for CI) from write operations (confirmation prompts unless --yes).
Release automation embeds version, commit, and build date via -ldflags for supportability.
Recipe
myctl --config ~/.config/myctl/config.yaml cluster status --context prod
myctl migrate up --dsn "$DATABASE_URL" --yes
myctl watch jobs --interval 2s
myctl version --output jsonWhen to reach for this layout:
- Operators run the tool in CI, SSH sessions, and laptops against the same commands.
- Subcommands map to domain workflows (migrate, deploy, diagnose), not Go packages.
- You need config file + env + flag precedence without custom parsing code.
- Long tasks benefit from a TUI while short tasks stay plain stdout.
Working Example
// internal/cli/root.go
package cli
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func NewRoot() *cobra.Command {
root := &cobra.Command{
Use: "myctl",
Short: "Operate shop-api infrastructure",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return initConfig(cmd)
},
}
root.PersistentFlags().String("config", "", "config file")
root.PersistentFlags().Bool("json", false, "JSON logs to stderr")
_ = viper.BindPFlag("config", root.PersistentFlags().Lookup("config"))
root.AddCommand(newMigrateCmd(), newStatusCmd(), newWatchCmd(), newVersionCmd())
return root
}
func initConfig(cmd *cobra.Command) error {
viper.SetEnvPrefix("MYCTL")
viper.AutomaticEnv()
if path, _ := cmd.Flags().GetString("config"); path != "" {
viper.SetConfigFile(path)
return viper.ReadInConfig()
}
viper.SetConfigName("config")
viper.AddConfigPath("$HOME/.config/myctl")
_ = viper.ReadInConfig() // optional file
return nil
}// cmd/myctl/main.go
package main
import (
"os"
"example.com/myctl/internal/cli"
)
var version = "dev"
func main() {
root := cli.NewRoot()
root.Version = version
if err := root.Execute(); err != nil {
os.Exit(1)
}
}What this demonstrates:
PersistentPreRunEloads config before every subcommand runs.- Env vars like
MYCTL_CONTEXTmap viaviper.AutomaticEnv(). mainstays a one-liner execute path for easy cross-compilation.
Deep Dive
How It Works
- Invocation: shell calls subcommand → cobra parses flags → viper merges file/env → command
RunEexecutes withcontext.Contextwhen IO is long-running. - Output: human tables to stdout; diagnostics to stderr;
--jsonswitches slog handler on stderr for agents. - Errors: return
errorfromRunE; map known failures to exit codes 2 (usage) and 3 (partial success) inmainif needed. - Release: GoReleaser builds matrix archives, checksums, and optional cosign signatures.
Command Taxonomy
| Command | Type | Automation |
|---|---|---|
version | Read | Safe always |
cluster status | Read | Safe in CI |
migrate up | Write | Requires --yes in CI |
watch jobs | Interactive | TTY required |
Bubble Tea Integration
// internal/cli/watch.go - simplified
func newWatchCmd() *cobra.Command {
return &cobra.Command{
Use: "watch",
RunE: func(cmd *cobra.Command, args []string) error {
p := tea.NewProgram(newJobsModel(fetchJobs), tea.WithContext(cmd.Context()))
_, err := p.Run()
return err
},
}
}- Pass cobra command context into Tea so
SIGINTcancels polling loops. - Fall back to plain
time.Tickeroutput whenisattyfalse.
Go Notes
go build -ldflags="-X main.version=$(git describe --tags --always)" -o myctl ./cmd/myctl- Embed
//go:embedassets only when the CLI ships templates; keep binary size predictable. - Use
cobra.Command.SilenceUsageon runtime errors so users do not see usage spam after a failed migrate.
Gotchas
- Viper global state in tests - parallel tests race on config. Fix: reset viper in
t.Cleanupor inject config struct instead of globals in new commands. - No context on network calls - Ctrl+C does not stop hung API clients. Fix: use
cmd.Context()in everyRunE. - Write commands without guardrails - CI destroys production. Fix: require
--yesor interactive confirm when stdin is not a TTY. - Inconsistent output formats - scripts break on column drift. Fix: stable
--output jsonon read commands; semver the schema. - goreleaser without cross-compiled smoke tests - broken arm64 builds ship silently. Fix:
go testplusmyctl versionon each built archive in CI. - Logging to stdout - pollutes piped workflows. Fix: logs to stderr; data to stdout.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| urfave/cli | Smaller dependency tree, simpler apps | Deep nested command trees and shell completion matter |
| stdlib flag only | One or two modes total | Subcommands and config files already exist |
| Kingpin | Legacy CLI migration | Greenfield - prefer cobra ecosystem breadth |
| Web UI instead of TUI | Non-terminal users dominate | SSH-only operations culture |
FAQs
Why cobra over the flag package?
Subcommands, completion, and persistent flags reduce bespoke parsing code as the tool grows.
Where should business logic live?
internal/ops packages implement migrations and status checks.
internal/cli only parses IO and calls ops functions.
How is secrets handling shown?
DSN comes from env or config file with 0600 perms documented.
Never log flag values marked sensitive in cobra annotations.
Should migrate be idempotent?
Yes.
SQL migrations use version table tracking; migrate up no-ops when current.
How do I test commands?
Call ExecuteC with ioStreams redirected to buffers.
Assert exit code and stdout JSON without a real shell.
What shell completion is supported?
myctl completion bash generated from cobra.
Document install lines in README shipped with releases.
When is Bubble Tea too much?
Skip TUI for commands run primarily in CI or pipes.
Use progress bars only when humans watch long operations.
How are plugins avoided?
This build stays a single static binary for simpler SBOM and signing.
Add plugins only with an explicit threat model.
What exit codes should scripts use?
0 success, 1 general error, 2 usage, 3 partial completion (e.g., migrate stopped mid-chain).
Does Windows matter?
GoReleaser builds GOOS=windows archives.
Avoid ANSI-only TUI assumptions; test myctl.exe in CI.
How often to cut releases?
Align with API changes operators depend on.
Embed semver in version and reject mismatched server APIs in status checks.
Can this CLI call the REST reference service?
Yes.
Share client packages in internal/client with timeouts and structured error decoding.
Related
- cobra & urfave/cli Command Trees - Command tree patterns
- Configuration: viper, Env & Config Files - Config layering
- Bubble Tea Terminal UI & Progress Bars - TUI detail
- CLI Design in Go: Single Binary, Fast Startup - UX principles
- Case Studies Basics - Reading reference builds
- Reference Build: Cloud-Native REST Microservice - Service the CLI operates
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).