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
- Follow consistent patterns: noun-verb or verb-noun throughout
- Support multiple output formats: table (default), json, yaml for scripting
- Provide meaningful error messages with suggestions for fixing
- Use proper exit codes for scripting compatibility
- Implement shell completion for discoverability
- Configuration priority: flags > env vars > config file > defaults
- Separate data (stdout) from messages (stderr)
- Confirm destructive actions; support –dry-run
- Test commands like you test code
- Distribute for all platforms with easy installation
Great CLI tools compound developer productivity. The time invested in good design pays off with every use.