Language:English VersionChinese Version

Go has become the default language for CLI tools, and for good reason. Single binary distribution, fast compilation, excellent standard library, and cross-compilation that actually works. But there is a vast gap between a tutorial-level CLI that parses a few flags and a production tool that handles edge cases, provides useful error messages, and behaves like software professionals expect.

I have shipped four production CLI tools in Go over the past two years, ranging from an internal deployment tool to an open-source database migration utility with 2,000+ users. This article covers what I wish I knew before the first one.

Start with Cobra, But Understand What It Does

Nearly every serious Go CLI uses Cobra. It handles subcommands, flags, help generation, and shell completions. The standard scaffolding looks like this:

// cmd/root.go
package cmd

import (
    "fmt"
    "os"

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

var cfgFile string

var rootCmd = &cobra.Command{
    Use:   "mytool",
    Short: "A production-ready CLI tool",
    Long:  `mytool manages deployments, runs migrations,
and handles configuration for your infrastructure.`,
    PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
        return initConfig()
    },
}

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

func init() {
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
        "config file (default is $HOME/.mytool.yaml)")
    rootCmd.PersistentFlags().String("output", "text",
        "output format: text, json, yaml")
    viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
}

What the tutorials skip: Cobra is a framework, not just a library. Once you adopt it, it shapes your entire application structure. The PersistentPreRunE hook is where you initialize configuration, set up logging, and validate global state. Getting this wrong means every subcommand has inconsistent behavior.

Configuration: The Three-Layer Approach

Production CLI tools need configuration from three sources, in order of precedence:

  1. Command-line flags (highest priority)
  2. Environment variables
  3. Configuration file (lowest priority)

Viper handles this naturally, but the binding order matters:

func initConfig() error {
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
    } else {
        home, err := os.UserHomeDir()
        if err != nil {
            return fmt.Errorf("finding home directory: %w", err)
        }
        viper.AddConfigPath(home)
        viper.AddConfigPath(".")
        viper.SetConfigName(".mytool")
        viper.SetConfigType("yaml")
    }

    // Environment variables: MYTOOL_DATABASE_URL, MYTOOL_API_KEY, etc.
    viper.SetEnvPrefix("MYTOOL")
    viper.AutomaticEnv()
    viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))

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

    return nil
}

A common mistake: not documenting which environment variables your tool reads. Users should not have to read your source code to figure out that MYTOOL_DATABASE_URL maps to the --database-url flag. Add a mytool config show subcommand that displays the resolved configuration with sources:

var configShowCmd = &cobra.Command{
    Use:   "show",
    Short: "Display resolved configuration and sources",
    RunE: func(cmd *cobra.Command, args []string) error {
        settings := viper.AllSettings()
        for key, value := range settings {
            source := "default"
            if viper.InConfig(key) {
                source = "config file"
            }
            if os.Getenv("MYTOOL_" + strings.ToUpper(key)) != "" {
                source = "environment"
            }
            if cmd.Flags().Changed(key) {
                source = "flag"
            }
            fmt.Fprintf(os.Stdout, "%-20s = %-30v (source: %s)\n",
                key, value, source)
        }
        return nil
    },
}

Error Handling That Respects Users

The single biggest quality gap between amateur and professional CLI tools is error handling. Compare these two error messages:

// Bad: What every tutorial produces
Error: open config.yaml: no such file or directory

// Good: What production tools should produce
Error: Could not read configuration file "config.yaml"

  The file does not exist at the expected path:
    /home/user/project/config.yaml

  To create a default configuration:
    mytool config init

  To specify a different config file:
    mytool --config /path/to/config.yaml

Build a custom error type that carries context and suggestions:

type CLIError struct {
    Message    string
    Cause      error
    Suggestion string
    ExitCode   int
}

func (e *CLIError) Error() string {
    var b strings.Builder
    b.WriteString("Error: " + e.Message + "\n")
    if e.Cause != nil {
        b.WriteString("\n  Cause: " + e.Cause.Error() + "\n")
    }
    if e.Suggestion != "" {
        b.WriteString("\n  " + e.Suggestion + "\n")
    }
    return b.String()
}

func (e *CLIError) Unwrap() error {
    return e.Cause
}

// Usage in commands
func connectDatabase(dsn string) (*sql.DB, error) {
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        return nil, &CLIError{
            Message:    "Failed to connect to database",
            Cause:      err,
            Suggestion: "Check that the database URL is correct and the server is running.\n  Current URL: " + maskPassword(dsn),
            ExitCode:   1,
        }
    }
    return db, nil
}

Output Formatting: Support Humans and Machines

Every production CLI needs at least two output modes: human-readable text and machine-parseable JSON. Many tools add YAML and table formats as well.

type OutputFormatter interface {
    Format(data interface{}) (string, error)
}

type TextFormatter struct{ Writer io.Writer }
type JSONFormatter struct{ Writer io.Writer; Pretty bool }
type TableFormatter struct{ Writer io.Writer; Columns []string }

func NewFormatter(format string, w io.Writer) OutputFormatter {
    switch format {
    case "json":
        return &JSONFormatter{Writer: w, Pretty: true}
    case "json-compact":
        return &JSONFormatter{Writer: w, Pretty: false}
    case "table":
        return &TableFormatter{Writer: w}
    default:
        return &TextFormatter{Writer: w}
    }
}

// Table output using tablewriter
func (f *TableFormatter) Format(data interface{}) (string, error) {
    table := tablewriter.NewWriter(f.Writer)
    table.SetHeader(f.Columns)
    table.SetBorder(false)
    table.SetAutoWrapText(false)

    // Reflect over data to populate rows...
    rows := toRows(data, f.Columns)
    for _, row := range rows {
        table.Append(row)
    }
    table.Render()
    return "", nil
}

The JSON output is critical for scriptability. Users pipe your CLI into jq, feed it to other tools, and use it in CI pipelines. If your JSON output is inconsistent or missing fields, you will get bug reports.

Progress Indication and Streaming Output

Long-running operations need progress feedback. The github.com/schollz/progressbar library is the standard choice, but there is a subtlety: progress bars must not interfere with JSON output or pipe-friendly behavior.

func runMigration(cmd *cobra.Command, args []string) error {
    migrations, err := loadMigrations(args[0])
    if err != nil {
        return err
    }

    // Only show progress bar for interactive terminals
    isTerminal := term.IsTerminal(int(os.Stderr.Fd()))

    var bar *progressbar.ProgressBar
    if isTerminal {
        bar = progressbar.NewOptions(len(migrations),
            progressbar.OptionSetWriter(os.Stderr), // Progress to stderr
            progressbar.OptionSetDescription("Running migrations"),
            progressbar.OptionShowCount(),
            progressbar.OptionClearOnFinish(),
        )
    }

    for _, m := range migrations {
        if err := m.Execute(db); err != nil {
            return &CLIError{
                Message: fmt.Sprintf("Migration %s failed", m.Name),
                Cause:   err,
            }
        }
        if bar != nil {
            bar.Add(1)
        }
    }

    // Results go to stdout (can be piped)
    return formatter.Format(MigrationResult{
        Applied: len(migrations),
        Status:  "complete",
    })
}

Key principle: progress and status go to stderr, results go to stdout. This lets users do mytool migrate ./migrations | jq .applied without progress bar characters corrupting the JSON.

Testing CLI Tools

Testing CLIs requires testing at multiple levels: unit tests for business logic, integration tests for command behavior, and end-to-end tests for the actual binary.

// Integration test: test command output without running the binary
func TestDeployCommand(t *testing.T) {
    // Capture stdout
    var stdout bytes.Buffer
    rootCmd.SetOut(&stdout)
    rootCmd.SetErr(&bytes.Buffer{})

    // Set args as if called from command line
    rootCmd.SetArgs([]string{"deploy", "--env", "staging", "--dry-run"})

    err := rootCmd.Execute()
    require.NoError(t, err)

    var result DeployResult
    err = json.Unmarshal(stdout.Bytes(), &result)
    require.NoError(t, err)
    assert.Equal(t, "staging", result.Environment)
    assert.True(t, result.DryRun)
}

// End-to-end test: test the actual compiled binary
func TestBinaryE2E(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping e2e test in short mode")
    }

    binary := buildBinary(t) // go build -o tempdir/mytool
    defer os.Remove(binary)

    cmd := exec.Command(binary, "version", "--output", "json")
    output, err := cmd.CombinedOutput()
    require.NoError(t, err, "binary execution failed: %s", output)

    var version VersionInfo
    require.NoError(t, json.Unmarshal(output, &version))
    assert.NotEmpty(t, version.Version)
    assert.NotEmpty(t, version.Commit)
}

Distribution: Cross-Compilation and Release

Gos cross-compilation is a superpower. GoReleaser automates the entire release pipeline:

# .goreleaser.yaml
version: 2
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:
  - formats: [tar.gz]
    format_overrides:
      - goos: windows
        formats: [zip]
    name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"

brews:
  - repository:
      owner: yourorg
      name: homebrew-tap
    homepage: "https://github.com/yourorg/mytool"
    description: "Your CLI tool description"

nfpms:
  - package_name: mytool
    vendor: YourOrg
    homepage: "https://github.com/yourorg/mytool"
    maintainer: "You "
    formats:
      - deb
      - rpm

This generates binaries for 6 platform/architecture combinations, creates a Homebrew formula, and builds .deb and .rpm packages — all from a single goreleaser release command.

Shell Completions: The Professional Touch

Cobra generates shell completions automatically, but custom completions make your tool feel polished:

var deployCmd = &cobra.Command{
    Use:   "deploy [environment]",
    Short: "Deploy to an environment",
    // Dynamic completion for environment names
    ValidArgsFunction: func(cmd *cobra.Command, args []string,
        toComplete string) ([]string, cobra.ShellCompDirective) {
        if len(args) != 0 {
            return nil, cobra.ShellCompDirectiveNoFileComp
        }
        envs, err := listEnvironments()
        if err != nil {
            return nil, cobra.ShellCompDirectiveError
        }
        return envs, cobra.ShellCompDirectiveNoFileComp
    },
    RunE: runDeploy,
}

Install completions with: mytool completion bash > /etc/bash_completion.d/mytool or mytool completion zsh > "${fpath[1]}/_mytool".

Signals, Graceful Shutdown, and Context

Production CLI tools must handle interrupts gracefully. If a user presses Ctrl+C during a database migration, you need to finish the current migration step rather than leaving the database in a half-migrated state:

func runWithGracefulShutdown(fn func(ctx context.Context) error) error {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

    errCh := make(chan error, 1)
    go func() {
        errCh <- fn(ctx)
    }()

    select {
    case err := <-errCh:
        return err
    case sig := <-sigCh:
        fmt.Fprintf(os.Stderr,
            "\nReceived %s, finishing current operation...\n", sig)
        cancel()
        // Wait for graceful completion with timeout
        select {
        case err := <-errCh:
            return err
        case <-time.After(30 * time.Second):
            return fmt.Errorf("operation timed out during shutdown")
        }
    }
}

Version Information: Embed Build Metadata

Every CLI tool needs a version command that shows the build version, commit hash, and build date. Use Gos linker flags to embed this at build time:

// main.go
var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)

var versionCmd = &cobra.Command{
    Use:   "version",
    Short: "Print version information",
    Run: func(cmd *cobra.Command, args []string) {
        info := map[string]string{
            "version":    version,
            "commit":     commit,
            "built":      date,
            "go_version": runtime.Version(),
            "os/arch":    runtime.GOOS + "/" + runtime.GOARCH,
        }
        formatter.Format(info)
    },
}

The Checklist

Before shipping a Go CLI tool, verify:

  • Three-layer configuration (flags > env > config file) works correctly
  • Errors include context and actionable suggestions
  • JSON and text output modes work, with progress on stderr
  • Shell completions are generated and documented
  • Ctrl+C is handled gracefully for long-running operations
  • version command shows build metadata
  • Cross-platform binaries build correctly (test on CI)
  • A man page or comprehensive --help exists for every subcommand

Go makes building CLI tools easy. Making them production-ready requires attention to the details that tutorials skip. The tools and patterns in this article are the result of shipping real software to real users — adopt them early, and your CLI will feel professional from day one.

By

Leave a Reply

Your email address will not be published. Required fields are marked *