Day 71 — CLI ergonomics

Updated

July 30, 2026

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@latest
var 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

  1. Build a CLI with ≥2 commands (e.g. hello, hash/version).
  2. Correct exit codes for usage vs runtime errors.
  3. --help / command help on stderr or Cobra default, but usable.
  4. Unit-test pure logic without exec.Command (optional process test stretch).
  5. Embed version via -ldflags from 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 stderr

Common 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.