cd ..

$ cat ~/field-notes/writing-cli-tools-go.md

Writing CLI Tools in Go

I've written CLI tools in Python, Ruby, Node, and Go. I reach for Go every time now. The main reason: I hand the binary to someone and it works. No runtime, no version conflicts, no pip install. One file.

Here's the structure I use for every Go CLI.

Why Go for CLIs

Python: great stdlib, terrible distribution
  → "Run pip install -r requirements.txt"
  → "Which python3? I have 3.8 and 3.11"
  → Bundle with PyInstaller for 80MB binary

Node: fast to write, painful to ship
  → node_modules is 200MB
  → pkg/nexe for bundling, but still awkward

Go:
  go build -o mytool .
  # Ship the binary. Done.
  # Cross-compile: GOOS=linux GOARCH=amd64 go build
  # Startup: <10ms
  # Size: 5-15MB (or smaller with -ldflags="-s -w")

The Minimal Structure

mycli/
├── main.go
├── cmd/
│   ├── root.go
│   ├── list.go
│   └── create.go
└── internal/
    └── client/
        └── client.go
// main.go — entry point, nothing else
package main

import (
    "os"
    "mycli/cmd"
)

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

Using Cobra for Subcommands

Cobra is the de facto standard for multi-command CLIs (kubectl, gh, docker all use it):

// cmd/root.go
package cmd

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

var rootCmd = &cobra.Command{
    Use:   "mycli",
    Short: "My CLI tool",
    Long:  "A longer description of what mycli does.",
}

func Execute() error {
    return rootCmd.Execute()
}

func init() {
    // Global flags
    rootCmd.PersistentFlags().String("config", "", "Config file path")
    rootCmd.PersistentFlags().Bool("verbose", false, "Verbose output")
}
// cmd/list.go
package cmd

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

var listCmd = &cobra.Command{
    Use:   "list",
    Short: "List all items",
    RunE:  runList,   // RunE returns an error; cobra handles it
}

func init() {
    rootCmd.AddCommand(listCmd)
    listCmd.Flags().StringP("format", "f", "text", "Output format: text|json|csv")
    listCmd.Flags().IntP("limit", "n", 20, "Max items to show")
}

func runList(cmd *cobra.Command, args []string) error {
    format, _ := cmd.Flags().GetString("format")
    limit, _ := cmd.Flags().GetInt("limit")

    items, err := fetchItems(limit)
    if err != nil {
        return fmt.Errorf("fetch failed: %w", err)
    }

    return printItems(items, format)
}

Exit Codes and Error Handling

// Good: differentiate errors for scripts to react to
const (
    ExitOK    = 0
    ExitError = 1
    ExitUsage = 2
)

// With cobra: RunE returning an error exits with code 1
// For custom exit codes, use os.Exit directly

func runDelete(cmd *cobra.Command, args []string) error {
    if len(args) == 0 {
        return fmt.Errorf("requires at least 1 argument")
        // cobra prints usage and exits 1
    }

    if err := deleteItem(args[0]); err != nil {
        // Don't fmt.Printf here — just return the error
        // cobra handles printing it
        return fmt.Errorf("delete %q: %w", args[0], err)
    }

    fmt.Printf("Deleted %s\n", args[0])
    return nil
}

stdin/stdout/stderr

// Write output to stdout
fmt.Println("result")

// Write errors/logs to stderr (don't pollute stdout)
fmt.Fprintln(os.Stderr, "warning: something happened")

// Read from stdin (pipe support)
func readStdin() ([]string, error) {
    var lines []string
    scanner := bufio.NewScanner(os.Stdin)
    for scanner.Scan() {
        lines = append(lines, scanner.Text())
    }
    return lines, scanner.Err()
}

// Detect if stdin is a pipe or terminal
func isaPipe() bool {
    stat, _ := os.Stdin.Stat()
    return (stat.Mode() & os.ModeCharDevice) == 0
}

func runProcess(cmd *cobra.Command, args []string) error {
    var items []string

    if isaPipe() {
        // cat items.txt | mycli process
        items, _ = readStdin()
    } else {
        // mycli process item1 item2
        items = args
    }

    // Process items...
    return nil
}

Pretty Output

// Respect NO_COLOR env var (https://no-color.org)
var useColor = os.Getenv("NO_COLOR") == "" && isTerminal(os.Stdout)

func isTerminal(f *os.File) bool {
    stat, _ := f.Stat()
    return (stat.Mode() & os.ModeCharDevice) != 0
}

// Simple color without a library
const (
    colorRed   = "\033[31m"
    colorGreen = "\033[32m"
    colorReset = "\033[0m"
)

func success(msg string) {
    if useColor {
        fmt.Printf("%s✓ %s%s\n", colorGreen, msg, colorReset)
    } else {
        fmt.Printf("✓ %s\n", msg)
    }
}

func failure(msg string) {
    if useColor {
        fmt.Fprintf(os.Stderr, "%s✗ %s%s\n", colorRed, msg, colorReset)
    } else {
        fmt.Fprintf(os.Stderr, "✗ %s\n", msg)
    }
}

For tables, the text/tabwriter package from stdlib is enough:

func printTable(items []Item) {
    w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
    fmt.Fprintln(w, "NAME\tSTATUS\tCREATED")
    for _, item := range items {
        fmt.Fprintf(w, "%s\t%s\t%s\n",
            item.Name,
            item.Status,
            item.CreatedAt.Format("2006-01-02"),
        )
    }
    w.Flush()
}

Config Files

import (
    "encoding/json"
    "os"
    "path/filepath"
)

type Config struct {
    APIKey   string `json:"api_key"`
    Endpoint string `json:"endpoint"`
}

func loadConfig() (*Config, error) {
    // Check XDG_CONFIG_HOME first, then ~/.config/mycli/config.json
    configDir := os.Getenv("XDG_CONFIG_HOME")
    if configDir == "" {
        home, _ := os.UserHomeDir()
        configDir = filepath.Join(home, ".config")
    }

    path := filepath.Join(configDir, "mycli", "config.json")
    data, err := os.ReadFile(path)
    if err != nil {
        if os.IsNotExist(err) {
            return &Config{}, nil // empty config is fine
        }
        return nil, err
    }

    var cfg Config
    return &cfg, json.Unmarshal(data, &cfg)
}

func saveConfig(cfg *Config) error {
    home, _ := os.UserHomeDir()
    dir := filepath.Join(home, ".config", "mycli")
    os.MkdirAll(dir, 0700)

    data, _ := json.MarshalIndent(cfg, "", "  ")
    return os.WriteFile(filepath.Join(dir, "config.json"), data, 0600)
}

Parallelism

// Process a list of items concurrently
func processAll(items []string) error {
    const workers = 10
    jobs := make(chan string, len(items))
    errs := make(chan error, len(items))

    // Start workers
    var wg sync.WaitGroup
    for range workers {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for item := range jobs {
                if err := processItem(item); err != nil {
                    errs <- fmt.Errorf("%s: %w", item, err)
                }
            }
        }()
    }

    // Send work
    for _, item := range items {
        jobs <- item
    }
    close(jobs)

    // Wait and collect errors
    wg.Wait()
    close(errs)

    var allErrors []error
    for err := range errs {
        allErrors = append(allErrors, err)
    }

    return errors.Join(allErrors...)
}

Cross-Compilation

# Build for multiple targets
GOOS=linux  GOARCH=amd64 go build -o dist/mycli-linux-amd64  .
GOOS=darwin GOARCH=amd64 go build -o dist/mycli-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -o dist/mycli-darwin-arm64 .  # M1/M2
GOOS=windows GOARCH=amd64 go build -o dist/mycli-windows-amd64.exe .

# Smaller binary (strip debug info)
go build -ldflags="-s -w" -o mycli .

# Or use goreleaser to automate all of the above + GitHub releases

Making It Testable

// Inject dependencies instead of calling os.Exit in business logic
type App struct {
    client  *APIClient
    out     io.Writer  // inject output for testing
    errOut  io.Writer
}

func (a *App) List(format string, limit int) error {
    items, err := a.client.List(limit)
    if err != nil {
        return err
    }
    return printItems(a.out, items, format)
}

// In tests:
func TestList(t *testing.T) {
    var buf bytes.Buffer
    app := &App{
        client: &mockClient{items: testItems},
        out:    &buf,
        errOut: io.Discard,
    }

    err := app.List("text", 10)
    require.NoError(t, err)
    assert.Contains(t, buf.String(), "expected output")
}

The Bottom Line

Go produces fast, self-contained CLIs that ship as a single binary. The pattern above scales from a 200-line tool to a 20,000-line tool without changing structure.

The rules:

  • Use cobra for anything with subcommands, flag package for simple tools
  • Write to stderr for errors and logs; stdout for output (scripts depend on this)
  • Respect NO_COLOR and check if stdout is a terminal before coloring
  • Support stdin pipes — unix tools compose
  • Use RunE not Run — return errors, don't print and exit
  • Cross-compile for your users' platforms before you distribute

Single binary, fast startup, no dependency hell. Go is the right tool for the job.