Day 71 — CLI ergonomics
Day 71 — CLI ergonomics
Stage VII · ~3h
Goal: Ship a multi-command CLI (stdlib flag + subcommands or Cobra) with solid --help, exit codes, version, and stderr/stdout discipline—usable as capstone option C scaffolding.
Why this day exists
Many Go programs are CLIs first: devops tools, agents, migration runners, kubectl-style operators. Bad CLIs share traits:
- Usage only discoverable by reading source
- Errors printed to stdout
- Exit code 0 on failure
- Flags that collide with subcommands
Capstone option C needs this day; options A/B still need admin CLIs and cmd/ entrypoints.
Theory 1 — CLI UX contract
| Rule | Practice |
|---|---|
| Help is free | -h / --help prints clear usage |
| Stdout vs stderr | Data → stdout; diagnostics → stderr |
| Exit codes | 0 ok; 2 usage; 1 runtime failure (common convention) |
| Version | --version or version subcommand |
| Non-interactive friendly | Flags over prompts for automation |
| Idempotent where claimed | Document side effects |
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}Pipelines depend on this: day71 hash *.go | while read ... must not interleave errors into the hash stream.
Theory 2 — Stdlib approach: subcommands with flag
package main
import (
"flag"
"fmt"
"os"
)
var version = "dev" // override with -ldflags
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
switch os.Args[1] {
case "hello":
helloCmd(os.Args[2:])
case "version":
fmt.Println(version)
case "-h", "--help", "help":
usage()
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
usage()
os.Exit(2)
}
}
func usage() {
fmt.Fprintf(os.Stderr, `usage: day71 <command> [flags]
commands:
hello greet someone
hash sha256 a file
version print version
`)
}
func helloCmd(args []string) {
fs := flag.NewFlagSet("hello", flag.ExitOnError)
name := fs.String("name", "world", "name to greet")
fs.SetOutput(os.Stderr)
_ = fs.Parse(args)
fmt.Printf("hello, %s\n", *name)
}Why flag.NewFlagSet: each subcommand owns flags; global flag.Parse fights subcommands.
Exit-on-error vs continue
flag.ExitOnError exits on bad flags (usage). For library-style parsing in tests, use flag.ContinueOnError and return errors from run(args []string) error.
Theory 3 — Cobra (optional, common in industry)
go get github.com/spf13/cobra@latestvar rootCmd = &cobra.Command{
Use: "day71",
Short: "Day 71 demo CLI",
}
var helloCmd = &cobra.Command{
Use: "hello",
Short: "Greet",
RunE: func(cmd *cobra.Command, args []string) error {
name, _ := cmd.Flags().GetString("name")
fmt.Printf("hello, %s\n", name)
return nil
},
}
func init() {
helloCmd.Flags().String("name", "world", "name to greet")
rootCmd.AddCommand(helloCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}| Choose stdlib when | Choose Cobra when |
|---|---|
| 1–3 commands, learning | Many commands, nested help, ecosystem (viper) |
| Zero deps | Team already uses Cobra |
Both are valid for the lab. Prefer one well-finished tool over half of each.
Theory 4 — Structure for real tools
cmd/day71/main.go # thin
internal/cli/root.go # command wiring
internal/hello/ # business logic (testable without CLI)
// business logic pure
func Greet(name string) string {
if name == "" {
name = "world"
}
return "hello, " + name
}// test without spawning process
func TestGreet(t *testing.T) {
if Greet("a") != "hello, a" {
t.Fatal()
}
}Config flags vs env (preview Day 77)
CLI flags win for one-shot tools; long-running services lean env/files. Same binary can support both later (--config + env override chain).
Completion and man pages (awareness)
Cobra generates shell completion; nice for option C stretch—not required today.
Theory 5 — Version injection
go build -ldflags "-X main.version=$(git describe --tags --always)" -o day71 .
./day71 version// main.go
var version = "dev"Ship version in --help footer or version subcommand so support tickets can cite a build.
Theory 6 — Testing CLIs
Prefer pure functions for logic. Optional process-level test:
func TestCLIHello(t *testing.T) {
cmd := exec.Command("go", "run", ".", "hello", "--name", "Ada")
cmd.Dir = // module root
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%v: %s", err, out)
}
if !strings.Contains(string(out), "hello, Ada") {
t.Fatal(string(out))
}
}Slow; use sparingly. Better: run(args []string) error tested directly with buffer writers.
func run(args []string, stdout, stderr io.Writer) int {
// return exit code; main calls os.Exit(run(...))
}Worked example — useful mini tool
day71 hash — print sha256 of a file (stdlib only):
func hashCmd(args []string) {
fs := flag.NewFlagSet("hash", flag.ExitOnError)
fs.SetOutput(os.Stderr)
_ = fs.Parse(args)
if fs.NArg() != 1 {
fmt.Fprintln(os.Stderr, "usage: day71 hash <file>")
os.Exit(2)
}
path := fs.Arg(0)
f, err := os.Open(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("%x %s\n", h.Sum(nil), path)
}Cross-compile the CLI with Day 63 scripts for release artifacts (option C path).
Lab
Suggested workspace: ~/lab/90daysofx/01-go/day71
- Build a CLI with ≥2 commands (e.g.
hello,hash/version).
- Correct exit codes for usage vs runtime errors.
--help/ command help on stderr or Cobra default, but usable.
- Unit-test pure logic without
exec.Command(optional process test stretch).
- Embed version via
-ldflagsfrom Day 63.
Acceptance demo
go build -ldflags "-X main.version=lab" -o day71 .
./day71 --help || ./day71 help
./day71 hello --name Ada
./day71 version
./day71 hash README.md
./day71 nope ; echo exit:$? # expect non-zero
./day71 hash /no/such 2>/tmp/err ; echo exit:$? # expect 1; err on stderrCommon gotchas
| Gotcha | Fix |
|---|---|
flag.Parse in subcommand world |
Per-command FlagSet |
| Errors on stdout | os.Stderr |
| Exit 0 on failure | Explicit os.Exit / return err from Cobra |
Logic only in main |
internal/ packages + tests |
| Interactive prompts only | Flags for CI |
| Ignoring Windows paths | filepath when touching files |
Forgetting SetOutput(os.Stderr) |
Help and flag errors on stderr |
Version always dev |
ldflags in release script |
Checkpoint
- ≥2 commands work
- Help text exists
- Exit codes correct in manual test
- Version printable
- At least one unit test on core logic
- Stdout/stderr discipline verified
Commit
git add .
git commit -m "day71: cli ergonomics multi-command"Write three personal gotchas before continuing.
Tomorrow
Day 72 — govulncheck: scan for known vulnerable dependencies and remediate with evidence.