Skip to content

Building Production Go CLI Tools with Cobra

Terminal showing a Go CLI tool with colored output and subcommands

Go is the language of choice for CLI tools - Docker, Kubernetes, Terraform, and Hugo all prove it. This tutorial walks you through building a production-grade CLI from scratch using Cobra. You will set up a clean project structure, wire up subcommands, manage configuration with Viper, build an HTTP client with retries, handle errors properly, add plugin support, generate shell completions, and ship cross-platform binaries with GoReleaser.

1. Project Setup

Initialize a Go module and create the standard directory layout:

mkdir myctl && cd myctl
go mod init github.com/yourorg/myctl
mkdir -p cmd internal/{errors,config,output,client,plugin}

Here is the go.mod with all dependencies:

module github.com/yourorg/myctl

go 1.22

require (
    github.com/spf13/cobra         v1.8.1
    github.com/spf13/viper         v1.19.0
    github.com/manifoldco/promptui v0.9.0
    github.com/olekukonez/tablewriter v0.0.5
    github.com/fatih/color         v1.17.0
    gopkg.in/yaml.v3               v3.0.1
)
// main.go
package main

import (
    "os"
    "github.com/yourorg/myctl/cmd"
)

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

2. Cobra Basics

The root command sets up global flags and binds them to Viper. PersistentPreRunE runs before every subcommand - perfect for loading config.

// cmd/root.go
package cmd

import (
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
    internalcfg "github.com/yourorg/myctl/internal/config"
)

var cfgFile string

var rootCmd = &cobra.Command{
    Use:   "myctl",
    Short: "A production CLI tool",
    PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
        return internalcfg.Load(cfgFile)
    },
}

func init() {
    rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Verbose output")
    rootCmd.PersistentFlags().StringP("output", "o", "table", "Output format: table, json, yaml")
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "Config file (default $HOME/.myctl.yaml)")
    viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
    viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
}

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

3. Subcommands

Each subcommand lives in its own file under cmd/. Here are five covering interactive prompts, progress, tables, colored logs, and config management.

// cmd/init.go - Interactive prompts with promptui
package cmd

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

var initCmd = &cobra.Command{
    Use: "init", Short: "Initialize a new project",
    RunE: func(cmd *cobra.Command, args []string) error {
        name, err := (&promptui.Prompt{Label: "Project name"}).Run()
        if err != nil { return err }
        _, env, err := (&promptui.Select{Label: "Environment", Items: []string{"dev", "staging", "prod"}}).Run()
        if err != nil { return err }
        fmt.Printf("Created project %s in %s\n", name, env)
        return nil
    },
}

// cmd/deploy.go - Spinner and progress bar
package cmd

import (
    "fmt"
    "time"
    "github.com/spf13/cobra"
    "github.com/yourorg/myctl/internal/output"
)

var deployCmd = &cobra.Command{
    Use: "deploy [service]", Short: "Deploy a service", Args: cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        output.Info("Deploying %s...", args[0])
        for i := 0; i <= 10; i++ {
            bar := make([]byte, i*2)
            for j := range bar { bar[j] = '=' }
            fmt.Printf("\r  Progress: [%-20s] %d%%", string(bar)+">", i*10)
            time.Sleep(200 * time.Millisecond)
        }
        fmt.Println()
        output.Success("Deployed %s", args[0])
        return nil
    },
}

// cmd/status.go - Table output
package cmd

import (
    "os"
    "github.com/olekukonez/tablewriter"
    "github.com/spf13/cobra"
)

var statusCmd = &cobra.Command{
    Use: "status", Short: "Show service status",
    RunE: func(cmd *cobra.Command, args []string) error {
        t := tablewriter.NewWriter(os.Stdout)
        t.SetHeader([]string{"Service", "Status", "Replicas"})
        t.AppendBulk([][]string{{"api", "Running", "3/3"}, {"worker", "Running", "2/2"}, {"cron", "Degraded", "1/2"}})
        t.Render()
        return nil
    },
}

// cmd/logs.go - Colored output
package cmd

import (
    "github.com/spf13/cobra"
    "github.com/yourorg/myctl/internal/output"
)

var logsCmd = &cobra.Command{
    Use: "logs [service]", Short: "Stream service logs", Args: cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        output.Info("[%s] Starting log stream...", args[0])
        output.Success("[%s] Health check passed", args[0])
        output.Warn("[%s] High memory usage: 89%%", args[0])
        output.Error("[%s] Connection timeout to db-primary", args[0])
        return nil
    },
}

// cmd/config.go - Config get/set/list
package cmd

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

var configCmd = &cobra.Command{Use: "config", Short: "Manage configuration"}

func init() {
    configCmd.AddCommand(
        &cobra.Command{Use: "get [key]", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
            fmt.Println(viper.Get(args[0])); return nil
        }},
        &cobra.Command{Use: "set [key] [value]", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error {
            viper.Set(args[0], args[1]); return viper.WriteConfig()
        }},
        &cobra.Command{Use: "list", RunE: func(cmd *cobra.Command, args []string) error {
            for k, v := range viper.AllSettings() { fmt.Printf("%s = %v\n", k, v) }; return nil
        }},
    )
    rootCmd.AddCommand(initCmd, deployCmd, statusCmd, logsCmd, configCmd)
}

4. Output Formatting

Centralize output in internal/output/. The printer switches between table, JSON, and YAML based on the global --output flag.

// internal/output/printer.go
package output

import (
    "encoding/json"
    "fmt"
    "os"
    "github.com/fatih/color"
    "github.com/olekukonez/tablewriter"
    "github.com/spf13/viper"
    "gopkg.in/yaml.v3"
)

func Print(headers []string, rows [][]string, data any) error {
    switch viper.GetString("output") {
    case "json":
        enc := json.NewEncoder(os.Stdout); enc.SetIndent("", "  "); return enc.Encode(data)
    case "yaml":
        return yaml.NewEncoder(os.Stdout).Encode(data)
    default:
        t := tablewriter.NewWriter(os.Stdout); t.SetHeader(headers); t.AppendBulk(rows); t.Render(); return nil
    }
}

var (
    green  = color.New(color.FgGreen).SprintfFunc()
    yellow = color.New(color.FgYellow).SprintfFunc()
    red    = color.New(color.FgRed).SprintfFunc()
    cyan   = color.New(color.FgCyan).SprintfFunc()
)

func Success(f string, a ...any) { fmt.Println(green("✓ "+f, a...)) }
func Warn(f string, a ...any)    { fmt.Println(yellow("⚠ "+f, a...)) }
func Error(f string, a ...any)   { fmt.Println(red("✗ "+f, a...)) }
func Info(f string, a ...any)    { fmt.Println(cyan("ℹ "+f, a...)) }

5. Config Management

Viper searches multiple paths, reads environment variables with a prefix, applies defaults, and unmarshals into a typed struct.

// internal/config/config.go
package config

import "github.com/spf13/viper"

type Config struct {
    APIEndpoint string `mapstructure:"api_endpoint"`
    Token       string `mapstructure:"token"`
    Verbose     bool   `mapstructure:"verbose"`
    Output      string `mapstructure:"output"`
    Timeout     int    `mapstructure:"timeout"`
}

var Cfg Config

func Load(cfgFile string) error {
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
    } else {
        viper.SetConfigName(".myctl"); viper.SetConfigType("yaml")
        viper.AddConfigPath("$HOME"); viper.AddConfigPath(".")
    }
    viper.SetEnvPrefix("MYCTL"); viper.AutomaticEnv()
    viper.SetDefault("api_endpoint", "https://api.example.com")
    viper.SetDefault("timeout", 30); viper.SetDefault("output", "table")
    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok { return err }
    }
    return viper.Unmarshal(&Cfg)
}

6. HTTP Client

A reusable HTTP client with Bearer token auth, 30-second timeout, and exponential backoff retry on 5xx errors.

// internal/client/http.go
package client

import (
    "fmt"; "io"; "math"; "net/http"; "time"
    "github.com/yourorg/myctl/internal/config"
)

type Client struct {
    http       *http.Client
    baseURL    string
    token      string
    maxRetries int
}

func New() *Client {
    return &Client{
        http: &http.Client{Timeout: 30 * time.Second},
        baseURL: config.Cfg.APIEndpoint, token: config.Cfg.Token, maxRetries: 3,
    }
}

func (c *Client) Do(method, path string, body io.Reader) (*http.Response, error) {
    var resp *http.Response
    for attempt := 0; attempt <= c.maxRetries; attempt++ {
        req, err := http.NewRequest(method, c.baseURL+path, body)
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+c.token)
        req.Header.Set("Content-Type", "application/json")
        resp, err = c.http.Do(req)
        if err != nil { return nil, err }
        if resp.StatusCode < 500 { return resp, nil }
        resp.Body.Close()
        if attempt < c.maxRetries {
            time.Sleep(time.Duration(math.Pow(2, float64(attempt))) * time.Second)
        }
    }
    return nil, fmt.Errorf("request failed after %d retries: %d", c.maxRetries, resp.StatusCode)
}

7. Error Handling

Define a CLIError struct with BSD sysexits.h exit codes so scripts can react to specific failure types.

// internal/errors/errors.go
package errors

import "fmt"

const (
    ExOK          = 0  // successful termination
    ExUsage       = 64 // command line usage error
    ExDataErr     = 65 // data format error
    ExNoInput     = 66 // input not found
    ExUnavailable = 69 // service unavailable
    ExSoftware    = 70 // internal software error
    ExConfig      = 78 // configuration error
)

type CLIError struct {
    Message  string
    ExitCode int
    Cause    error
}

func (e *CLIError) Error() string {
    if e.Cause != nil { return fmt.Sprintf("%s: %v", e.Message, e.Cause) }
    return e.Message
}
func (e *CLIError) Unwrap() error { return e.Cause }

func NewUsageError(msg string) *CLIError              { return &CLIError{msg, ExUsage, nil} }
func NewConfigError(msg string, err error) *CLIError   { return &CLIError{msg, ExConfig, err} }
func NewServiceError(msg string, err error) *CLIError  { return &CLIError{msg, ExUnavailable, err} }
func NewInternalError(msg string, err error) *CLIError { return &CLIError{msg, ExSoftware, err} }

Wire it into main.go for proper exit codes:

func main() {
    if err := cmd.Execute(); err != nil {
        var cliErr *errors.CLIError
        if stderrors.As(err, &cliErr) {
            output.Error(cliErr.Message); os.Exit(cliErr.ExitCode)
        }
        os.Exit(1)
    }
}

8. Testing

Test Cobra commands by capturing stdout. Create a helper that executes a command with arguments and returns the output.

// cmd/root_test.go
package cmd

import (
    "bytes"; "strings"; "testing"
)

func executeCommand(args ...string) (string, error) {
    buf := new(bytes.Buffer)
    rootCmd.SetOut(buf); rootCmd.SetErr(buf); rootCmd.SetArgs(args)
    err := rootCmd.Execute()
    return buf.String(), err
}

func TestStatusCommand(t *testing.T) {
    out, err := executeCommand("status")
    if err != nil { t.Fatalf("unexpected error: %v", err) }
    if !strings.Contains(out, "Service") { t.Error("expected table header 'Service'") }
}

func TestDeployRequiresArg(t *testing.T) {
    _, err := executeCommand("deploy")
    if err == nil { t.Error("expected error when no service argument provided") }
}

For integration tests, use build tags:

//go:build integration

package cmd

import "testing"

func TestDeployIntegration(t *testing.T) {
    out, err := executeCommand("deploy", "api")
    if err != nil { t.Fatal(err) }
    if out == "" { t.Error("expected deploy output") }
}

9. Plugins

Follow the kubectl/git pattern: scan PATH for executables named myctl-* and register them as subcommands.

// internal/plugin/discover.go
package plugin

import (
    "os"; "os/exec"; "path/filepath"; "strings"
    "github.com/spf13/cobra"
)

const prefix = "myctl-"

func Discover() []*cobra.Command {
    seen := map[string]bool{}
    var cmds []*cobra.Command
    for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
        entries, err := os.ReadDir(dir)
        if err != nil { continue }
        for _, e := range entries {
            name := e.Name()
            if !strings.HasPrefix(name, prefix) || e.IsDir() { continue }
            sub := strings.TrimPrefix(name, prefix)
            if seen[sub] { continue }
            seen[sub] = true
            binPath := filepath.Join(dir, name)
            cmds = append(cmds, &cobra.Command{
                Use: sub, Short: "Plugin: " + sub, DisableFlagParsing: true,
                RunE: func(cmd *cobra.Command, args []string) error {
                    p := exec.Command(binPath, args...)
                    p.Stdin, p.Stdout, p.Stderr = os.Stdin, os.Stdout, os.Stderr
                    return p.Run()
                },
            })
        }
    }
    return cmds
}

10. Shell Completions

Cobra generates completion scripts for Bash, Zsh, Fish, and PowerShell:

// cmd/completion.go
package cmd

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

var completionCmd = &cobra.Command{
    Use: "completion [bash|zsh|fish|powershell]", Short: "Generate shell completion script",
    Args: cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        switch args[0] {
        case "bash":       return rootCmd.GenBashCompletionV2(os.Stdout, true)
        case "zsh":        return rootCmd.GenZshCompletion(os.Stdout)
        case "fish":       return rootCmd.GenFishCompletion(os.Stdout, true)
        case "powershell": return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
        default:           return fmt.Errorf("unsupported shell: %s", args[0])
        }
    },
}

func init() { rootCmd.AddCommand(completionCmd) }
# Install completions
myctl completion bash > /etc/bash_completion.d/myctl        # Bash
myctl completion zsh > "${fpath[1]}/_myctl"                 # Zsh
myctl completion fish > ~/.config/fish/completions/myctl.fish  # Fish
myctl completion powershell | Out-String | Invoke-Expression   # PowerShell

11. Build and Release

Use GoReleaser for cross-compilation, version injection via ldflags, and automated GitHub releases.

// cmd/version.go
package cmd

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

var (Version, Commit, Date = "dev", "none", "unknown")

var versionCmd = &cobra.Command{
    Use: "version", Short: "Print version info",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Printf("myctl %s (commit: %s, built: %s)\n", Version, Commit, Date)
    },
}

func init() { rootCmd.AddCommand(versionCmd) }
# .goreleaser.yaml
version: 2
builds:
  - main: .
    binary: myctl
    env: [CGO_ENABLED=0]
    goos: [linux, darwin, windows]
    goarch: [amd64, arm64]
    ldflags:
      - -s -w
      - -X github.com/yourorg/myctl/cmd.Version={{.Version}}
      - -X github.com/yourorg/myctl/cmd.Commit={{.Commit}}
      - -X github.com/yourorg/myctl/cmd.Date={{.Date}}
archives:
  - format: tar.gz
    name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
    format_overrides:
      - goos: windows
        format: zip
brews:
  - repository:
      owner: yourorg
      name: homebrew-tap
    homepage: https://github.com/yourorg/myctl
    description: A production CLI tool
# Local build with version injection
go build -ldflags "-X github.com/yourorg/myctl/cmd.Version=1.0.0" -o myctl .

# Tag and release
git tag v1.0.0 && git push origin v1.0.0
goreleaser release --clean

12. Framework Comparison

Cobra is not the only option. Here is how it compares to the other major Go CLI frameworks.

FeatureCobraurfave/cliKong
Subcommand nestingUnlimitedUnlimitedUnlimited
Flag parsingpflag (POSIX)Built-inStruct tags
Config integrationViper (first-party)Manualkong-hcl, kong-yaml
Shell completionsBuilt-in (4 shells)Built-in (3 shells)Plugin
Middleware/hooksPersistentPreRun, PreRun, PostRunBefore, After, ActionHooks via bindings
Code generationcobra-cli scaffoldNoneNone
AdoptionKubernetes, Docker, HugoGitea, DroneSmaller community
API styleBuilder patternStruct literalsStruct tags + reflection
Learning curveModerateLowLow

Recommendation: Use Cobra for deep subcommand trees, shell completions, and Viper integration. Use urfave/cli for simpler tools. Use Kong if you prefer declarative struct-tag definitions.