Building Command Line Tools That Developers Love

November 4, 2019

Command line tools are a developer’s daily companions. Git, kubectl, docker, terraform—these tools shape how we work. Great CLI design feels invisible; poor design creates constant friction.

Here’s how to build CLI tools that developers love using.

Design Principles

Predictability

Users should be able to guess how things work:

# Consistent patterns
myapp list users
myapp list projects
myapp list teams

# Not this
myapp users --list
myapp show-projects
myapp team list

Composability

Work well with other tools:

# Output works with grep, jq, awk
myapp list users --format json | jq '.[] | .email'

# Accept stdin
cat user_ids.txt | myapp delete users --stdin

# Exit codes are meaningful
myapp check && deploy.sh

Forgiveness

Make mistakes recoverable:

# Confirmation for destructive actions
myapp delete database production
> This will delete the production database. Type 'production' to confirm:

# Dry run mode
myapp apply --dry-run

# Undo when possible
myapp rollback

Discoverability

Help users learn:

# Built-in help at every level
myapp --help
myapp users --help
myapp users create --help

# Suggestions for typos
myapp usr list
> Did you mean 'users'?

# Examples in help text

Command Structure

Noun-Verb or Verb-Noun

Pick one pattern and stick with it:

Noun-verb (like kubectl):

myapp users list
myapp users create
myapp projects delete

Verb-noun (like git):

myapp list users
myapp create user
myapp delete project

Subcommand Hierarchy

myapp
├── users
│   ├── list
│   ├── create
│   ├── get
│   ├── update
│   └── delete
├── projects
│   ├── list
│   ├── create
│   └── ...
└── config
    ├── set
    ├── get
    └── list

Common Commands

Include standard operations:

myapp version          # Show version
myapp help             # Show help
myapp completion       # Generate shell completion
myapp config           # Manage configuration

Flags and Arguments

Positional Arguments

Use for required, obvious inputs:

# Good - obvious what arguments are
myapp deploy production
myapp ssh web-server-1

# Avoid - not obvious
myapp create user john john@example.com admin

Flags

Use for optional or configurable behavior:

# Long form (readable)
myapp deploy --environment production --replicas 3

# Short form (convenient)
myapp deploy -e production -r 3

# Boolean flags
myapp list --verbose
myapp list -v

Flag Conventions

# Common patterns
--help, -h          # Help
--verbose, -v       # Verbose output
--quiet, -q         # Quiet/silent
--output, -o        # Output format
--force, -f         # Skip confirmations
--dry-run           # Show what would happen
--config, -c        # Config file
--debug             # Debug output

Default Values

Sensible defaults reduce friction:

# Has defaults, can be overridden
myapp deploy  # defaults to current context
myapp deploy --context production

# Show defaults in help
Flags:
  -o, --output string   Output format (default "table")
  -n, --namespace string   Kubernetes namespace (default "default")

Input and Output

Output Formats

Support multiple formats:

# Human-readable (default)
myapp list users
ID    NAME     EMAIL
1     alice    alice@example.com
2     bob      bob@example.com

# JSON (for scripting)
myapp list users --output json
[{"id": 1, "name": "alice", "email": "alice@example.com"}, ...]

# YAML
myapp list users --output yaml

# CSV
myapp list users --output csv

# Just names/IDs (for piping)
myapp list users --output name
alice
bob

Structured Output

// Go example with cobra and table output
func listUsers(cmd *cobra.Command, args []string) error {
    users, err := client.ListUsers()
    if err != nil {
        return err
    }

    format, _ := cmd.Flags().GetString("output")

    switch format {
    case "json":
        return json.NewEncoder(os.Stdout).Encode(users)
    case "yaml":
        return yaml.NewEncoder(os.Stdout).Encode(users)
    case "name":
        for _, u := range users {
            fmt.Println(u.Name)
        }
    default:
        w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
        fmt.Fprintln(w, "ID\tNAME\tEMAIL")
        for _, u := range users {
            fmt.Fprintf(w, "%d\t%s\t%s\n", u.ID, u.Name, u.Email)
        }
        w.Flush()
    }
    return nil
}

Color and Formatting

Use color meaningfully:

// Colored output for terminals
import "github.com/fatih/color"

func printStatus(status string) {
    switch status {
    case "running":
        color.Green("● Running")
    case "stopped":
        color.Red("● Stopped")
    case "pending":
        color.Yellow("● Pending")
    }
}

// Respect NO_COLOR environment variable
if os.Getenv("NO_COLOR") != "" || !term.IsTerminal(int(os.Stdout.Fd())) {
    color.NoColor = true
}

Progress Indication

For long operations:

// Spinner for indeterminate progress
s := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
s.Suffix = " Deploying..."
s.Start()
// ... work ...
s.Stop()

// Progress bar for determinate progress
bar := progressbar.Default(100)
for i := 0; i < 100; i++ {
    bar.Add(1)
    // ... work ...
}

Error Handling

Helpful Error Messages

# Bad - no context
Error: invalid argument

# Good - explains what and how to fix
Error: Invalid region 'us-east-3'
Available regions: us-east-1, us-east-2, us-west-1, us-west-2
Run 'myapp regions list' to see all available regions

Exit Codes

Use meaningful exit codes:

const (
    ExitSuccess         = 0
    ExitError           = 1
    ExitUsageError      = 2
    ExitConfigError     = 3
    ExitNotFound        = 4
    ExitPermissionDenied = 5
)

func main() {
    if err := rootCmd.Execute(); err != nil {
        switch {
        case errors.Is(err, ErrNotFound):
            os.Exit(ExitNotFound)
        case errors.Is(err, ErrPermission):
            os.Exit(ExitPermissionDenied)
        default:
            os.Exit(ExitError)
        }
    }
}

Stderr vs Stdout

// Stdout: data output (for piping)
fmt.Fprintln(os.Stdout, result)

// Stderr: messages, progress, errors
fmt.Fprintln(os.Stderr, "Processing...")
fmt.Fprintln(os.Stderr, "Error:", err)

Configuration

Configuration Sources

Support multiple sources with precedence:

1. Command line flags (highest priority)
2. Environment variables
3. Config file in current directory
4. Config file in home directory
5. Default values (lowest priority)
// Viper handles this well
viper.SetDefault("output", "table")
viper.SetConfigName(".myapp")
viper.AddConfigPath(".")
viper.AddConfigPath("$HOME")
viper.AutomaticEnv()
viper.SetEnvPrefix("MYAPP")
viper.ReadInConfig()

// Flag overrides all
cmd.Flags().StringP("output", "o", "", "Output format")
viper.BindPFlag("output", cmd.Flags().Lookup("output"))

Config File Format

# ~/.myapp.yaml
default_context: production
output: json
contexts:
  production:
    api_url: https://api.example.com
    token: xxx
  staging:
    api_url: https://staging.api.example.com
    token: yyy

Config Management Commands

myapp config set default_context staging
myapp config get default_context
myapp config list
myapp config use-context production

Shell Integration

Completion

Essential for complex CLIs:

// Cobra generates completions
var completionCmd = &cobra.Command{
    Use:   "completion [bash|zsh|fish]",
    Short: "Generate shell completion scripts",
    Run: func(cmd *cobra.Command, args []string) {
        switch args[0] {
        case "bash":
            rootCmd.GenBashCompletion(os.Stdout)
        case "zsh":
            rootCmd.GenZshCompletion(os.Stdout)
        case "fish":
            rootCmd.GenFishCompletion(os.Stdout, true)
        }
    },
}

Usage:

# Bash
source <(myapp completion bash)

# Zsh
myapp completion zsh > "${fpath[1]}/_myapp"

# Fish
myapp completion fish > ~/.config/fish/completions/myapp.fish

Dynamic Completion

Complete based on actual data:

cmd.RegisterFlagCompletionFunc("context", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
    contexts := config.ListContexts()
    return contexts, cobra.ShellCompDirectiveNoFileComp
})

Testing

Unit Testing Commands

func TestListUsers(t *testing.T) {
    // Capture output
    buf := new(bytes.Buffer)
    rootCmd.SetOut(buf)
    rootCmd.SetErr(buf)

    // Execute command
    rootCmd.SetArgs([]string{"users", "list", "--output", "json"})
    err := rootCmd.Execute()

    // Assert
    assert.NoError(t, err)

    var users []User
    json.Unmarshal(buf.Bytes(), &users)
    assert.Len(t, users, 2)
}

Integration Testing

#!/bin/bash
# test.sh

# Setup
export MYAPP_CONFIG=/tmp/test-config.yaml

# Test list
output=$(myapp users list --output json)
count=$(echo "$output" | jq length)
[ "$count" -eq 2 ] || exit 1

# Test create
myapp users create --name "test" --email "test@example.com"
[ $? -eq 0 ] || exit 1

# Cleanup
myapp users delete test --force

echo "All tests passed"

Distribution

Cross-Platform Builds

# Makefile
VERSION := $(shell git describe --tags --always)
LDFLAGS := -X main.version=$(VERSION)

build-all:
    GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o dist/myapp-darwin-amd64
    GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o dist/myapp-darwin-arm64
    GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o dist/myapp-linux-amd64
    GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o dist/myapp-windows-amd64.exe

Installation Methods

# Homebrew (macOS/Linux)
brew install mycompany/tap/myapp

# Go install
go install github.com/mycompany/myapp@latest

# Direct download
curl -sL https://github.com/mycompany/myapp/releases/latest/download/myapp-$(uname -s)-$(uname -m) -o /usr/local/bin/myapp
chmod +x /usr/local/bin/myapp

Auto-Update

func checkForUpdates() {
    latest, _ := github.GetLatestRelease("mycompany", "myapp")
    if latest.Version > version {
        fmt.Fprintf(os.Stderr, "New version available: %s\n", latest.Version)
        fmt.Fprintf(os.Stderr, "Run 'myapp upgrade' to update\n")
    }
}

Key Takeaways

Great CLI tools compound developer productivity. The time invested in good design pays off with every use.