Chapter 30

Building CLI Tools

Building CLI Tools

Command-line tools (CLIs) are the highest-frequency, longest-lived category of software that engineers use daily. grep, curl, git โ€” some of these tools have existed for decades and remain at the heart of modern development workflows. A well-designed CLI dramatically elevates the productivity of everyone who uses it, while demanding almost no maintenance in return (a single binary, no runtime dependencies, cross-platform).

Go has established a decisive lead in the CLI tooling space: docker, kubectl, helm, terraform, caddy, gh (GitHub CLI) โ€” the most widely-deployed infrastructure tools are almost uniformly written in Go. This is no accident. Go's language characteristics align remarkably well with the engineering requirements of CLI tools.

This chapter starts with why Go is the right choice for CLI tools, progresses through the flag standard library and the cobra framework, surveys the libraries that raise CLI polish to a professional level, and culminates in building a fully featured, ergonomically excellent command-line tool.


Level 1 ยท The Go CLI Advantage

Single Binary: The Gold Standard for Operators

Go compiles to a statically linked, single executable file. No interpreter, no virtual machine, no pre-installed library dependencies. Give a user the binary; they run it. That is everything.

Compare this to alternatives:

A Go binary compiled with go build -ldflags="-s -w" (strip debug symbols) is typically 5โ€“20 MB, compressible further with upx. One Go binary = complete functionality, zero dependencies.

Startup Speed: Millisecond Responsiveness

A CLI tool's user experience depends heavily on response time. When you type a command, you expect an immediate response โ€” not a few hundred milliseconds while a runtime initializes.

Language Typical CLI startup time
Go 5โ€“20 ms
Rust 5โ€“15 ms
Python 100โ€“300 ms
Java 300โ€“800 ms
Node.js 100โ€“400 ms

Go's 5โ€“20 ms startup latency places it alongside Rust, far ahead of the alternatives. For tools called frequently โ€” git status runs on every shell prompt in some setups โ€” this difference directly shapes daily workflow.

Cross-Platform Compilation: Write Once, Ship Everywhere

Go's cross-compilation is nearly zero-cost:

# Compile a Linux AMD64 binary on macOS
GOOS=linux GOARCH=amd64 go build -o myapp-linux-amd64 .

# Windows
GOOS=windows GOARCH=amd64 go build -o myapp-windows.exe .

# ARM64 (Apple Silicon / Linux ARM)
GOOS=darwin GOARCH=arm64 go build -o myapp-darwin-arm64 .

No cross-compilation toolchain, no Docker container, no VM required. This makes multi-platform CI/CD release trivially simple.


Level 2 ยท Command Parsing: from flag to cobra

The flag Standard Library: Perfect for Simple Cases

The flag package in Go's standard library handles command-line argument parsing. For single-level commands with a small set of flags, flag is entirely sufficient:

package main

import (
    "flag"
    "fmt"
    "os"
    "time"
)

func main() {
    var (
        host    = flag.String("host", "localhost", "target host")
        port    = flag.Int("port", 8080, "port number")
        verbose = flag.Bool("verbose", false, "enable verbose output")
        timeout = flag.Duration("timeout", 30*time.Second, "request timeout")
    )

    flag.Usage = func() {
        fmt.Fprintf(os.Stderr, "Usage: %s [options] <command>\n\n", os.Args[0])
        fmt.Fprintf(os.Stderr, "Options:\n")
        flag.PrintDefaults()
    }

    flag.Parse()

    // flag.Args() returns all non-flag arguments
    args := flag.Args()
    if len(args) == 0 {
        flag.Usage()
        os.Exit(1)
    }

    if *verbose {
        fmt.Printf("Connecting to %s:%d (timeout %v)\n", *host, *port, *timeout)
    }
}

The limitations of flag: no subcommand support (myapp serve, myapp deploy), no short flags (-v rather than --verbose), no automatic help generation.

cobra: A Production-Grade CLI Framework

cobra is the CLI framework from spf13 used by kubectl, hugo, gh, and many other major tools. Its central abstraction is the Command:

// cmd/root.go
package cmd

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var rootCmd = &cobra.Command{
    Use:   "myapp",
    Short: "My command-line tool",
    Long: `myapp is a versatile CLI tool supporting a range of operations.
Full documentation at https://example.com/docs`,
    PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
        return initConfig()
    },
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

func init() {
    // Persistent flags: available to all subcommands
    rootCmd.PersistentFlags().StringP("config", "c", "", "path to config file")
    rootCmd.PersistentFlags().BoolP("verbose", "v", false, "enable verbose output")

    // Bind flags to viper (config management)
    viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
}

Subcommands and Command Hierarchy

// cmd/serve.go
package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
)

var serveCmd = &cobra.Command{
    Use:   "serve",
    Short: "Start the HTTP server",
    Long:  "Start an HTTP server on the specified port, listening for incoming requests",
    Example: `  # Start on the default port
  myapp serve

  # Start on port 9090 with TLS
  myapp serve --port 9090 --tls`,
    RunE: func(cmd *cobra.Command, args []string) error {
        port, _ := cmd.Flags().GetInt("port")
        tls, _ := cmd.Flags().GetBool("tls")
        return runServer(port, tls)
    },
}

func init() {
    rootCmd.AddCommand(serveCmd)
    // Local flags: only apply to the serve subcommand
    serveCmd.Flags().IntP("port", "p", 8080, "listening port")
    serveCmd.Flags().Bool("tls", false, "enable TLS")
    serveCmd.MarkFlagRequired("port")
}

The Command Tree

cobra supports arbitrarily deep command trees:

myapp
โ”œโ”€โ”€ serve          (myapp serve)
โ”‚   โ”œโ”€โ”€ --port
โ”‚   โ””โ”€โ”€ --tls
โ”œโ”€โ”€ deploy         (myapp deploy)
โ”‚   โ”œโ”€โ”€ k8s        (myapp deploy k8s)
โ”‚   โ””โ”€โ”€ docker     (myapp deploy docker)
โ””โ”€โ”€ config         (myapp config)
    โ”œโ”€โ”€ set        (myapp config set)
    โ””โ”€โ”€ get        (myapp config get)
func init() {
    deployCmd.AddCommand(deployK8sCmd)
    deployCmd.AddCommand(deployDockerCmd)
    rootCmd.AddCommand(deployCmd)
}

viper: Configuration File Integration

Real CLI tools need to support multiple configuration sources with a defined priority order (high to low):

  1. Command-line flags
  2. Environment variables
  3. Configuration file (YAML / JSON / TOML)
  4. Default values

viper implements this priority mechanism:

func initConfig() error {
    // Read environment variables with prefix MYAPP_
    viper.SetEnvPrefix("MYAPP")
    viper.AutomaticEnv() // MYAPP_PORT automatically maps to "port"

    cfgFile, _ := rootCmd.PersistentFlags().GetString("config")
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
    } else {
        viper.AddConfigPath("$HOME/.myapp")
        viper.AddConfigPath(".")
        viper.SetConfigName("config")
        viper.SetConfigType("yaml")
    }

    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            return fmt.Errorf("reading config: %w", err)
        }
        // Config file not found is acceptable; use defaults
    }

    return nil
}

// Read configuration values anywhere (merges all sources automatically)
port    := viper.GetInt("port")
verbose := viper.GetBool("verbose")
dbURL   := viper.GetString("database.url") // nested keys supported

Shell Completion Generation

cobra ships with built-in shell completion generation โ€” a hallmark of professional CLI tools:

var completionCmd = &cobra.Command{
    Use:       "completion [bash|zsh|fish|powershell]",
    Short:     "Generate shell auto-completion scripts",
    ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
    Args:      cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
    RunE: func(cmd *cobra.Command, args []string) error {
        switch args[0] {
        case "bash":
            return rootCmd.GenBashCompletion(os.Stdout)
        case "zsh":
            return rootCmd.GenZshCompletion(os.Stdout)
        case "fish":
            return rootCmd.GenFishCompletion(os.Stdout, true)
        case "powershell":
            return rootCmd.GenPowerShellCompletion(os.Stdout)
        }
        return nil
    },
}

func init() {
    rootCmd.AddCommand(completionCmd)
}

Users install completion with:

# Bash
source <(myapp completion bash)

# Zsh
myapp completion zsh > ~/.zsh/completions/_myapp

cobra also supports dynamic completion for specific flag values:

serveCmd.RegisterFlagCompletionFunc("port", func(
    cmd *cobra.Command, args []string, toComplete string,
) ([]cobra.ShellCompDirective, cobra.ShellCompDirective) {
    return []string{
        "8080\tDefault HTTP port",
        "443\tHTTPS port",
        "9090\tAlternate port",
    }, cobra.ShellCompDirectiveNoFileComp
})

Level 3 ยท Professional CLI Experience

Progress Bars: schollz/progressbar

Long-running operations (large file downloads, batch processing) must provide user feedback. schollz/progressbar is the most ergonomic progress bar library in the Go ecosystem:

import "github.com/schollz/progressbar/v3"

func downloadFiles(urls []string) {
    bar := progressbar.NewOptions(len(urls),
        progressbar.OptionEnableColorCodes(true),
        progressbar.OptionShowCount(),
        progressbar.OptionShowIts(),                  // show items/sec rate
        progressbar.OptionSetDescription("[cyan]Downloading...[reset]"),
        progressbar.OptionSetTheme(progressbar.Theme{
            Saucer:        "[green]=[reset]",
            SaucerHead:    "[green]>[reset]",
            SaucerPadding: " ",
            BarStart:      "[",
            BarEnd:        "]",
        }),
    )

    for _, url := range urls {
        download(url)
        bar.Add(1)
    }
    bar.Finish()
}

// Byte-level progress for file downloads
func downloadWithByteProgress(url, destPath string) error {
    resp, err := http.Get(url)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    f, err := os.Create(destPath)
    if err != nil {
        return err
    }
    defer f.Close()

    bar := progressbar.DefaultBytes(resp.ContentLength, "Downloading "+filepath.Base(destPath))

    // io.MultiWriter writes to both the file and the progress bar
    _, err = io.Copy(io.MultiWriter(f, bar), resp.Body)
    return err
}

Color Output: fatih/color

Terminal color is not decoration โ€” color encoding conveys information (green = success, red = error, yellow = warning):

import "github.com/fatih/color"

var (
    successColor = color.New(color.FgGreen, color.Bold)
    errorColor   = color.New(color.FgRed, color.Bold)
    warnColor    = color.New(color.FgYellow)
    infoColor    = color.New(color.FgCyan)
    dimColor     = color.New(color.Faint)
)

func printStatus(items []StatusItem) {
    for _, item := range items {
        switch item.Status {
        case "ok":
            successColor.Printf("  โœ“ %s\n", item.Name)
        case "error":
            errorColor.Printf("  โœ— %s: %s\n", item.Name, item.Message)
        case "warn":
            warnColor.Printf("  โš  %s: %s\n", item.Name, item.Message)
        case "pending":
            dimColor.Printf("  โ€ฆ %s\n", item.Name)
        }
    }
}

func init() {
    // Respect the NO_COLOR convention (https://no-color.org)
    if os.Getenv("NO_COLOR") != "" {
        color.NoColor = true
    }
}

A critical invariant: when output is piped (myapp | grep error), colors must be disabled automatically. fatih/color handles this by checking isatty internally.

Table Output: olekukonko/tablewriter

Structured data is most naturally displayed as a table:

import (
    "os"
    "github.com/olekukonko/tablewriter"
)

type Service struct {
    Name   string
    Status string
    Port   int
    Uptime string
    CPU    string
}

func printServicesTable(services []Service) {
    table := tablewriter.NewWriter(os.Stdout)
    table.SetHeader([]string{"Service", "Status", "Port", "Uptime", "CPU"})
    table.SetBorder(true)
    table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
    table.SetAlignment(tablewriter.ALIGN_LEFT)

    for _, s := range services {
        statusColor := tablewriter.Colors{tablewriter.FgGreenColor}
        if s.Status != "running" {
            statusColor = tablewriter.Colors{tablewriter.FgRedColor}
        }
        table.Rich(
            []string{s.Name, s.Status, fmt.Sprintf("%d", s.Port), s.Uptime, s.CPU},
            []tablewriter.Colors{{}, statusColor, {}, {}, {}},
        )
    }

    table.Render()
}

Sample output:

+--------------+---------+------+-----------+-------+
|   SERVICE    | STATUS  | PORT |  UPTIME   |  CPU  |
+--------------+---------+------+-----------+-------+
| api-server   | running | 8080 | 3d 14h    | 12.3% |
| db-postgres  | stopped | 5432 | 0         | 0%    |
+--------------+---------+------+-----------+-------+

Interactive Prompts: AlecAivazis/survey

When a CLI needs user input โ€” an init wizard, a destructive-action confirmation โ€” interactive prompts are more ergonomic than requiring all input as command-line flags:

import "github.com/AlecAivazis/survey/v2"

type DeployConfig struct {
    Environment string
    Region      string
    Replicas    int
    Confirm     bool
}

func interactiveDeployConfigure() (*DeployConfig, error) {
    config := &DeployConfig{}

    questions := []*survey.Question{
        {
            Name: "Environment",
            Prompt: &survey.Select{
                Message: "Select deployment environment:",
                Options: []string{"development", "staging", "production"},
                Default: "staging",
            },
            Validate: survey.Required,
        },
        {
            Name: "Region",
            Prompt: &survey.Select{
                Message: "Select deployment region:",
                Options: []string{"us-east-1", "eu-west-1", "ap-northeast-1"},
            },
        },
        {
            Name: "Replicas",
            Prompt: &survey.Input{
                Message: "Number of replicas:",
                Default: "2",
                Help:    "More replicas = higher availability and higher cost",
            },
            Validate: func(val interface{}) error {
                str, ok := val.(string)
                if !ok {
                    return fmt.Errorf("invalid input")
                }
                n, err := strconv.Atoi(str)
                if err != nil || n < 1 || n > 10 {
                    return fmt.Errorf("replica count must be 1โ€“10")
                }
                return nil
            },
        },
    }

    if err := survey.Ask(questions, config); err != nil {
        return nil, err
    }

    if config.Environment == "production" {
        prompt := &survey.Confirm{
            Message: fmt.Sprintf("Deploy to production/%s? This action is irreversible.", config.Region),
            Default: false,
        }
        survey.AskOne(prompt, &config.Confirm)
        if !config.Confirm {
            return nil, fmt.Errorf("operation cancelled")
        }
    }

    return config, nil
}

Spinner: Visual Feedback for Indeterminate Operations

For operations where a percentage cannot be calculated (waiting for an API response, database migration), a spinner signals that the process is alive:

import "github.com/briandowns/spinner"

func waitWithSpinner(operation func() error, message string) error {
    s := spinner.New(spinner.CharSets[14], 80*time.Millisecond)
    s.Suffix = " " + message
    s.Color("cyan")
    s.Start()

    err := operation()

    s.Stop()
    if err != nil {
        fmt.Printf("\rโœ— %s: %v\n", message, err)
    } else {
        fmt.Printf("\rโœ“ %s\n", message)
    }
    return err
}

func deploy() error {
    return waitWithSpinner(func() error {
        return applyKubernetesManifests()
    }, "Deploying to Kubernetes...")
}

Level 4 ยท Advanced Techniques and Distribution

Plugin System: os/exec Subprocesses

Git's plugin mechanism (git-lfs, git-flow are standalone binaries invoked via git lfs) can be replicated with os/exec:

func findPlugin(name string) (string, bool) {
    pluginName := "myapp-" + name
    path, err := exec.LookPath(pluginName)
    if err != nil {
        return "", false
    }
    return path, true
}

func runPlugin(pluginPath string, args []string) error {
    cmd := exec.Command(pluginPath, args...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    cmd.Env = append(os.Environ(),
        "MYAPP_VERSION=1.0.0",
        "MYAPP_CONFIG="+viper.ConfigFileUsed(),
    )
    return cmd.Run()
}

func tryPlugin(args []string) bool {
    if len(args) == 0 {
        return false
    }
    pluginPath, ok := findPlugin(args[0])
    if !ok {
        return false
    }
    if err := runPlugin(pluginPath, args[1:]); err != nil {
        if exitErr, ok := err.(*exec.ExitError); ok {
            os.Exit(exitErr.ExitCode())
        }
        os.Exit(1)
    }
    return true
}

Embedding Static Assets: go:embed

The //go:embed directive (introduced in Go 1.16) bundles files directly into the binary, eliminating external file dependencies:

import (
    "embed"
    "html/template"
    "io/fs"
)

// Embed a single file
//go:embed templates/report.tmpl
var reportTemplate string

// Embed an entire directory
//go:embed static/*
var staticFiles embed.FS

// Embed multiple patterns
//go:embed configs/default.yaml configs/schema.json
var defaultConfigs embed.FS

func generateReport(data interface{}) (string, error) {
    tmpl, err := template.New("report").Parse(reportTemplate)
    if err != nil {
        return "", err
    }
    var buf strings.Builder
    if err := tmpl.Execute(&buf, data); err != nil {
        return "", err
    }
    return buf.String(), nil
}

func loadDefaultConfig() ([]byte, error) {
    return defaultConfigs.ReadFile("configs/default.yaml")
}

// Serve embedded static files as an HTTP file server
func serveStaticFiles() http.Handler {
    sub, _ := fs.Sub(staticFiles, "static")
    return http.FileServer(http.FS(sub))
}

Practical use cases for //go:embed:

Self-Update Mechanism

Professional CLI tools should be capable of updating themselves:

import (
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "runtime"
)

type Release struct {
    Version string `json:"tag_name"`
    Assets  []struct {
        Name               string `json:"name"`
        BrowserDownloadURL string `json:"browser_download_url"`
    } `json:"assets"`
}

func checkUpdate(currentVersion string) (*Release, error) {
    resp, err := http.Get("https://api.github.com/repos/myorg/myapp/releases/latest")
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    var release Release
    if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
        return nil, err
    }

    if release.Version <= currentVersion {
        return nil, nil // already up to date
    }
    return &release, nil
}

func selfUpdate(release *Release) error {
    targetName := fmt.Sprintf("myapp-%s-%s", runtime.GOOS, runtime.GOARCH)
    if runtime.GOOS == "windows" {
        targetName += ".exe"
    }

    var downloadURL string
    for _, asset := range release.Assets {
        if asset.Name == targetName {
            downloadURL = asset.BrowserDownloadURL
            break
        }
    }
    if downloadURL == "" {
        return fmt.Errorf("no binary for %s/%s", runtime.GOOS, runtime.GOARCH)
    }

    tmpFile, err := os.CreateTemp("", "myapp-update-*")
    if err != nil {
        return err
    }
    defer os.Remove(tmpFile.Name())

    resp, err := http.Get(downloadURL)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    io.Copy(tmpFile, resp.Body)
    tmpFile.Close()

    executable, _ := os.Executable()
    os.Chmod(tmpFile.Name(), 0755)
    return os.Rename(tmpFile.Name(), executable)
}

For production use, prefer github.com/inconshreveable/go-update, which handles Windows file locking, atomic replacement, and rollback on failure.

GoReleaser: Automated Distribution

GoReleaser is the dominant release tool in the Go ecosystem. A single .goreleaser.yaml generates multi-platform binaries, GitHub Releases, and Homebrew formulas:

# .goreleaser.yaml
project_name: myapp
version: 2

before:
  hooks:
    - go generate ./...
    - go test ./...

builds:
  - env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64
    ldflags:
      - -s -w
      - -X main.version={{.Version}}
      - -X main.commit={{.Commit}}
      - -X main.date={{.Date}}

archives:
  - format: tar.gz
    format_overrides:
      - goos: windows
        format: zip
    name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}"

checksum:
  name_template: "checksums.txt"

changelog:
  sort: asc
  filters:
    exclude:
      - "^docs:"
      - "^test:"

brews:
  - repository:
      owner: myorg
      name: homebrew-tap
    description: "My CLI tool"
    homepage: "https://example.com"
    install: |
      bin.install "myapp"
      bash_completion.install "completions/myapp.bash"
      zsh_completion.install "completions/myapp.zsh"

Paired with GitHub Actions, pushing a tag triggers an automatic release:

# .github/workflows/release.yml
name: Release
on:
  push:
    tags:
      - 'v*'
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - uses: goreleaser/goreleaser-action@v6
        with:
          version: latest
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Testing CLI Tools: Golden File Testing

CLI output (colors, tables, progress bars) is difficult to verify with ordinary unit tests. Golden file testing is the industry best practice:

func TestServeCommandOutput(t *testing.T) {
    tests := []struct {
        name    string
        args    []string
        wantErr bool
    }{
        {name: "default port", args: []string{"serve", "--dry-run"}},
        {name: "custom port",  args: []string{"serve", "--port", "9090", "--dry-run"}},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            var buf bytes.Buffer
            rootCmd.SetOut(&buf)
            rootCmd.SetErr(&buf)
            rootCmd.SetArgs(tt.args)

            err := rootCmd.Execute()
            if (err != nil) != tt.wantErr {
                t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr)
            }

            got := buf.String()
            goldenFile := filepath.Join("testdata", t.Name()+".golden")

            // Update golden files: go test -update
            if *update {
                os.WriteFile(goldenFile, []byte(got), 0644)
                return
            }

            want, err := os.ReadFile(goldenFile)
            if err != nil {
                t.Fatalf("reading golden file: %v", err)
            }
            if got != string(want) {
                t.Errorf("output mismatch\ngot:\n%s\nwant:\n%s", got, want)
            }
        })
    }
}

var update = flag.Bool("update", false, "update golden files")

Advantages of the golden file pattern: output changes are immediately visible as diffs; updating golden files requires only -update; tests cover the full execution path including formatting logic.

Version Information via ldflags

Professional CLI tools support a version command with full build metadata:

var (
    version   = "dev"
    commit    = "none"
    buildDate = "unknown"
)

var versionCmd = &cobra.Command{
    Use:   "version",
    Short: "Print version information",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Printf("myapp %s\n", version)
        fmt.Printf("  Commit:    %s\n", commit)
        fmt.Printf("  Built:     %s\n", buildDate)
        fmt.Printf("  Go:        %s\n", runtime.Version())
        fmt.Printf("  Platform:  %s/%s\n", runtime.GOOS, runtime.GOARCH)
    },
}

Inject at build time:

go build \
  -ldflags="-X main.version=1.2.3 \
            -X main.commit=$(git rev-parse --short HEAD) \
            -X main.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -o myapp .

myapp version then outputs:

myapp 1.2.3
  Commit:    a1b2c3d
  Built:     2026-05-07T10:00:00Z
  Go:        go1.22.0
  Platform:  darwin/arm64

Engineering Principles Summary

An excellent Go CLI tool adheres to these principles:

  1. Single responsibility โ€” each subcommand does one thing and does it well
  2. Consistent output convention โ€” success on stdout, errors on stderr; disable color when piped
  3. Meaningful exit codes โ€” 0 for success, non-zero for failure (specific codes per error type)
  4. Unambiguous error messages โ€” tell the user what went wrong and how to fix it
  5. Configuration priority โ€” flags > environment variables > config file > defaults
  6. Useful help text โ€” every command has --help that actually helps
  7. Version information โ€” --version includes build time and git commit
  8. Testing โ€” golden file tests cover output formatting

The Go ecosystem (cobra + viper + progressbar + survey) provides a complete CLI development toolkit. With these tools, you can build a command-line experience on par with kubectl in a matter of hours.

Rate this chapter
4.7  / 5  (3 ratings)

๐Ÿ’ฌ Comments