Go CLI Design
A good CLI is a well-behaved Unix citizen: flags before magic, stdout
for data, stderr for diagnostics, exit codes that scripts can trust,
and Ctrl+C that actually stops it.
1. Structure: Testable main
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("mytool", flag.ContinueOnError)
fs.SetOutput(stderr)
verbose := fs.Bool("v", false, "verbose output")
out := fs.String("o", "-", "output file (- for stdout)")
if err := fs.Parse(args); err != nil {
return err
}
// ...
_ = verbose
_ = out
return nil
}
signal.NotifyContext makes Ctrl+C cancel the context — every
long operation takes ctx and stops cleanly.
run receives args and streams — tests call it directly with
strings.Reader/bytes.Buffer, no subprocess needed.
os.Exit only in main (it skips defers).
2. stdout vs stderr
- stdout: the program's output — data, results, the thing you pipe.
- stderr: logs, progress, warnings, usage errors.
--json or detecting a pipe (!term.IsTerminal(int(os.Stdout.Fd())))
should silence decorations, never change the data.
// ✅ Good — result to stdout, progress to stderr
fmt.Fprintf(stderr, "processed %d files\n", n)
fmt.Fprintln(stdout, result)
// ❌ Bad — mixing both into stdout breaks every pipe
fmt.Printf("processing...\ndone: %s\n", result)
3. Exit Codes
| Code |
Meaning |
| 0 |
Success |
| 1 |
Generic runtime failure |
| 2 |
Usage error (bad flags/arguments) — flag package's convention |
| >2 |
Tool-specific, documented meanings (e.g. grep's 1 = no match) |
Map errors to codes in one place (main), not scattered os.Exit
calls. If scripts will branch on distinct failures, define sentinel
errors and translate: errors.Is(err, ErrNoMatch) → 1.
4. Flags and Arguments
- Flags for options, positional args for the primary operands:
mytool -v convert input.yaml, not mytool --input=input.yaml.
- Every flag has a usage string;
-h/-help output is your primary UX.
- Accept
- as "stdin/stdout" for file arguments.
- Defaults must be safe: destructive behavior behind explicit flags
(
--force), never default-on.
- Read secrets from env or files, never from flags (
ps leaks argv).
5. Subcommands
Standard library, fine up to a handful of commands:
switch fs.Arg(0) {
case "serve":
return runServe(ctx, fs.Args()[1:], stdout, stderr)
case "migrate":
return runMigrate(ctx, fs.Args()[1:], stdout, stderr)
default:
fmt.Fprintln(stderr, usage)
return fmt.Errorf("unknown command %q", fs.Arg(0))
}
Adopt Cobra when you need nested commands, generated help/completions,
and many flags — the structure pays for the dependency:
var rootCmd = &cobra.Command{Use: "mytool", SilenceUsage: true}
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the server",
RunE: func(cmd *cobra.Command, args []string) error {
return serve(cmd.Context(), addr) // RunE returns errors; no os.Exit
},
}
func init() {
serveCmd.Flags().StringVar(&addr, "addr", ":8080", "listen address")
rootCmd.AddCommand(serveCmd)
}
Cobra rules: always RunE (never Run + os.Exit), set
SilenceUsage: true so runtime errors don't dump help, pass
cmd.Context() down. Add Viper only when layered config
(flags > env > file) is a real requirement — for most tools
flag + os.Getenv is enough.
6. Output for Humans and Machines
--json flag for machine consumption; table/text default for humans.
- Never emit ANSI colors when stdout is not a terminal or
NO_COLOR
is set.
- Progress bars/spinners go to stderr and only when it's a terminal.
Verification Checklist
run(ctx, args, stdin, stdout, stderr) pattern — logic testable without subprocess
signal.NotifyContext wired; long operations respect ctx cancellation
- Data on stdout, diagnostics on stderr — verified by piping
- Exit codes: 0 success, 2 usage, documented codes otherwise;
os.Exit only in main
- Every flag has usage text;
-h output reviewed
- accepted for stdin/stdout where files are taken
- Destructive actions require explicit flags
- No secrets via argv
- Cobra (if used): RunE everywhere, SilenceUsage, context propagated
- Colors/spinners disabled for non-TTY and NO_COLOR
1---2name: go-cli3description: Build command-line tools in Go: flag handling, subcommands, stdin/stdout discipline, exit codes, signal handling, and when Cobra/Viper earn their weight over the standard library. Use when: "build a CLI", "add a subcommand", "parse flags", "exit codes", "handle Ctrl+C", "cobra command", "read from stdin", "CLI UX". Not for: HTTP APIs (go-api-design), scaffolding (go-project-layout), service configuration (go-architecture-review).4license: MIT5---6
7# Go CLI Design
8
9A good CLI is a well-behaved Unix citizen: flags before magic, stdout
10for data, stderr for diagnostics, exit codes that scripts can trust,
11and Ctrl+C that actually stops it.
12
13## 1. Structure: Testable main
14
15```go
16func main() {
17 ctx, stop := signal.NotifyContext(context.Background(),
18 os.Interrupt, syscall.SIGTERM)
19 defer stop()
20
21 if err := run(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {
22 fmt.Fprintln(os.Stderr, "error:", err)
23 os.Exit(1)
24 }
25}
26
27func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
28 fs := flag.NewFlagSet("mytool", flag.ContinueOnError)
29 fs.SetOutput(stderr)
30 verbose := fs.Bool("v", false, "verbose output")
31 out := fs.String("o", "-", "output file (- for stdout)")
32 if err := fs.Parse(args); err != nil {
33 return err
34 }
35 // ...
36 _ = verbose
37 _ = out
38 return nil
39}
40```
41
42- `signal.NotifyContext` makes Ctrl+C cancel the context — every
43 long operation takes `ctx` and stops cleanly.
44- `run` receives args and streams — tests call it directly with
45 `strings.Reader`/`bytes.Buffer`, no subprocess needed.
46- `os.Exit` only in `main` (it skips defers).
47
48## 2. stdout vs stderr
49
50- **stdout**: the program's output — data, results, the thing you pipe.
51- **stderr**: logs, progress, warnings, usage errors.
52- `--json` or detecting a pipe (`!term.IsTerminal(int(os.Stdout.Fd()))`)
53 should silence decorations, never change the data.
54
55```go
56// ✅ Good — result to stdout, progress to stderr
57fmt.Fprintf(stderr, "processed %d files\n", n)
58fmt.Fprintln(stdout, result)
59
60// ❌ Bad — mixing both into stdout breaks every pipe
61fmt.Printf("processing...\ndone: %s\n", result)
62```
63
64## 3. Exit Codes
65
66| Code | Meaning |
67|---|---|
68| 0 | Success |
69| 1 | Generic runtime failure |
70| 2 | Usage error (bad flags/arguments) — flag package's convention |
71| >2 | Tool-specific, documented meanings (e.g. grep's 1 = no match) |
72
73Map errors to codes in one place (`main`), not scattered `os.Exit`
74calls. If scripts will branch on distinct failures, define sentinel
75errors and translate: `errors.Is(err, ErrNoMatch) → 1`.
76
77## 4. Flags and Arguments
78
79- Flags for options, positional args for the primary operands:
80 `mytool -v convert input.yaml`, not `mytool --input=input.yaml`.
81- Every flag has a usage string; `-h`/`-help` output is your primary UX.
82- Accept `-` as "stdin/stdout" for file arguments.
83- Defaults must be safe: destructive behavior behind explicit flags
84 (`--force`), never default-on.
85- Read secrets from env or files, never from flags (`ps` leaks argv).
86
87## 5. Subcommands
88
89Standard library, fine up to a handful of commands:
90
91```go
92switch fs.Arg(0) {
93case "serve":
94 return runServe(ctx, fs.Args()[1:], stdout, stderr)
95case "migrate":
96 return runMigrate(ctx, fs.Args()[1:], stdout, stderr)
97default:
98 fmt.Fprintln(stderr, usage)
99 return fmt.Errorf("unknown command %q", fs.Arg(0))
100}
101```
102
103Adopt **Cobra** when you need nested commands, generated help/completions,
104and many flags — the structure pays for the dependency:
105
106```go
107var rootCmd = &cobra.Command{Use: "mytool", SilenceUsage: true}
108
109var serveCmd = &cobra.Command{
110 Use: "serve",
111 Short: "Start the server",
112 RunE: func(cmd *cobra.Command, args []string) error {
113 return serve(cmd.Context(), addr) // RunE returns errors; no os.Exit
114 },
115}
116
117func init() {
118 serveCmd.Flags().StringVar(&addr, "addr", ":8080", "listen address")
119 rootCmd.AddCommand(serveCmd)
120}
121```
122
123Cobra rules: always `RunE` (never `Run` + `os.Exit`), set
124`SilenceUsage: true` so runtime errors don't dump help, pass
125`cmd.Context()` down. Add Viper only when layered config
126(flags > env > file) is a real requirement — for most tools
127`flag` + `os.Getenv` is enough.
128
129## 6. Output for Humans and Machines
130
131- `--json` flag for machine consumption; table/text default for humans.
132- Never emit ANSI colors when stdout is not a terminal or `NO_COLOR`
133 is set.
134- Progress bars/spinners go to stderr and only when it's a terminal.
135
136## Verification Checklist
137
1381. `run(ctx, args, stdin, stdout, stderr)` pattern — logic testable without subprocess
1392. `signal.NotifyContext` wired; long operations respect ctx cancellation
1403. Data on stdout, diagnostics on stderr — verified by piping
1414. Exit codes: 0 success, 2 usage, documented codes otherwise; `os.Exit` only in main
1425. Every flag has usage text; `-h` output reviewed
1436. `-` accepted for stdin/stdout where files are taken
1447. Destructive actions require explicit flags
1458. No secrets via argv
1469. Cobra (if used): RunE everywhere, SilenceUsage, context propagated
14710. Colors/spinners disabled for non-TTY and NO_COLOR