Building Production Go CLI Tools with Cobra
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.
| Feature | Cobra | urfave/cli | Kong |
|---|---|---|---|
| Subcommand nesting | Unlimited | Unlimited | Unlimited |
| Flag parsing | pflag (POSIX) | Built-in | Struct tags |
| Config integration | Viper (first-party) | Manual | kong-hcl, kong-yaml |
| Shell completions | Built-in (4 shells) | Built-in (3 shells) | Plugin |
| Middleware/hooks | PersistentPreRun, PreRun, PostRun | Before, After, Action | Hooks via bindings |
| Code generation | cobra-cli scaffold | None | None |
| Adoption | Kubernetes, Docker, Hugo | Gitea, Drone | Smaller community |
| API style | Builder pattern | Struct literals | Struct tags + reflection |
| Learning curve | Moderate | Low | Low |
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.