Building CLI Tools
Building CLI Tools
Command-line tools (CLIs) are the highest-frequency, longest-lived category of software that engineers use daily. grep, curl, git โ some of these tools have existed for decades and remain at the heart of modern development workflows. A well-designed CLI dramatically elevates the productivity of everyone who uses it, while demanding almost no maintenance in return (a single binary, no runtime dependencies, cross-platform).
Go has established a decisive lead in the CLI tooling space: docker, kubectl, helm, terraform, caddy, gh (GitHub CLI) โ the most widely-deployed infrastructure tools are almost uniformly written in Go. This is no accident. Go's language characteristics align remarkably well with the engineering requirements of CLI tools.
This chapter starts with why Go is the right choice for CLI tools, progresses through the flag standard library and the cobra framework, surveys the libraries that raise CLI polish to a professional level, and culminates in building a fully featured, ergonomically excellent command-line tool.
Level 1 ยท The Go CLI Advantage
Single Binary: The Gold Standard for Operators
Go compiles to a statically linked, single executable file. No interpreter, no virtual machine, no pre-installed library dependencies. Give a user the binary; they run it. That is everything.
Compare this to alternatives:
- Python: requires the correct interpreter version,
pipto install dependencies,virtualenvto manage environments, and PyInstaller to create a self-contained package that still ends up as a 40 MB zip - Java: requires a JVM; for a CLI tool, JVM startup time alone (200โ500 ms) is a serious usability problem
- Node.js: requires the Node runtime,
npmfor dependencies,node_modulesthat can dwarf the application code itself
A Go binary compiled with go build -ldflags="-s -w" (strip debug symbols) is typically 5โ20 MB, compressible further with upx. One Go binary = complete functionality, zero dependencies.
Startup Speed: Millisecond Responsiveness
A CLI tool's user experience depends heavily on response time. When you type a command, you expect an immediate response โ not a few hundred milliseconds while a runtime initializes.
| Language | Typical CLI startup time |
|---|---|
| Go | 5โ20 ms |
| Rust | 5โ15 ms |
| Python | 100โ300 ms |
| Java | 300โ800 ms |
| Node.js | 100โ400 ms |
Go's 5โ20 ms startup latency places it alongside Rust, far ahead of the alternatives. For tools called frequently โ git status runs on every shell prompt in some setups โ this difference directly shapes daily workflow.
Cross-Platform Compilation: Write Once, Ship Everywhere
Go's cross-compilation is nearly zero-cost:
# Compile a Linux AMD64 binary on macOS
GOOS=linux GOARCH=amd64 go build -o myapp-linux-amd64 .
# Windows
GOOS=windows GOARCH=amd64 go build -o myapp-windows.exe .
# ARM64 (Apple Silicon / Linux ARM)
GOOS=darwin GOARCH=arm64 go build -o myapp-darwin-arm64 .
No cross-compilation toolchain, no Docker container, no VM required. This makes multi-platform CI/CD release trivially simple.
Level 2 ยท Command Parsing: from flag to cobra
The flag Standard Library: Perfect for Simple Cases
The flag package in Go's standard library handles command-line argument parsing. For single-level commands with a small set of flags, flag is entirely sufficient:
package main
import (
"flag"
"fmt"
"os"
"time"
)
func main() {
var (
host = flag.String("host", "localhost", "target host")
port = flag.Int("port", 8080, "port number")
verbose = flag.Bool("verbose", false, "enable verbose output")
timeout = flag.Duration("timeout", 30*time.Second, "request timeout")
)
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [options] <command>\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults()
}
flag.Parse()
// flag.Args() returns all non-flag arguments
args := flag.Args()
if len(args) == 0 {
flag.Usage()
os.Exit(1)
}
if *verbose {
fmt.Printf("Connecting to %s:%d (timeout %v)\n", *host, *port, *timeout)
}
}
The limitations of flag: no subcommand support (myapp serve, myapp deploy), no short flags (-v rather than --verbose), no automatic help generation.
cobra: A Production-Grade CLI Framework
cobra is the CLI framework from spf13 used by kubectl, hugo, gh, and many other major tools. Its central abstraction is the Command:
// cmd/root.go
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var rootCmd = &cobra.Command{
Use: "myapp",
Short: "My command-line tool",
Long: `myapp is a versatile CLI tool supporting a range of operations.
Full documentation at https://example.com/docs`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return initConfig()
},
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func init() {
// Persistent flags: available to all subcommands
rootCmd.PersistentFlags().StringP("config", "c", "", "path to config file")
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "enable verbose output")
// Bind flags to viper (config management)
viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
}
Subcommands and Command Hierarchy
// cmd/serve.go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the HTTP server",
Long: "Start an HTTP server on the specified port, listening for incoming requests",
Example: ` # Start on the default port
myapp serve
# Start on port 9090 with TLS
myapp serve --port 9090 --tls`,
RunE: func(cmd *cobra.Command, args []string) error {
port, _ := cmd.Flags().GetInt("port")
tls, _ := cmd.Flags().GetBool("tls")
return runServer(port, tls)
},
}
func init() {
rootCmd.AddCommand(serveCmd)
// Local flags: only apply to the serve subcommand
serveCmd.Flags().IntP("port", "p", 8080, "listening port")
serveCmd.Flags().Bool("tls", false, "enable TLS")
serveCmd.MarkFlagRequired("port")
}
The Command Tree
cobra supports arbitrarily deep command trees:
myapp
โโโ serve (myapp serve)
โ โโโ --port
โ โโโ --tls
โโโ deploy (myapp deploy)
โ โโโ k8s (myapp deploy k8s)
โ โโโ docker (myapp deploy docker)
โโโ config (myapp config)
โโโ set (myapp config set)
โโโ get (myapp config get)
func init() {
deployCmd.AddCommand(deployK8sCmd)
deployCmd.AddCommand(deployDockerCmd)
rootCmd.AddCommand(deployCmd)
}
viper: Configuration File Integration
Real CLI tools need to support multiple configuration sources with a defined priority order (high to low):
- Command-line flags
- Environment variables
- Configuration file (YAML / JSON / TOML)
- Default values
viper implements this priority mechanism:
func initConfig() error {
// Read environment variables with prefix MYAPP_
viper.SetEnvPrefix("MYAPP")
viper.AutomaticEnv() // MYAPP_PORT automatically maps to "port"
cfgFile, _ := rootCmd.PersistentFlags().GetString("config")
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
viper.AddConfigPath("$HOME/.myapp")
viper.AddConfigPath(".")
viper.SetConfigName("config")
viper.SetConfigType("yaml")
}
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return fmt.Errorf("reading config: %w", err)
}
// Config file not found is acceptable; use defaults
}
return nil
}
// Read configuration values anywhere (merges all sources automatically)
port := viper.GetInt("port")
verbose := viper.GetBool("verbose")
dbURL := viper.GetString("database.url") // nested keys supported
Shell Completion Generation
cobra ships with built-in shell completion generation โ a hallmark of professional CLI tools:
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell auto-completion scripts",
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return rootCmd.GenBashCompletion(os.Stdout)
case "zsh":
return rootCmd.GenZshCompletion(os.Stdout)
case "fish":
return rootCmd.GenFishCompletion(os.Stdout, true)
case "powershell":
return rootCmd.GenPowerShellCompletion(os.Stdout)
}
return nil
},
}
func init() {
rootCmd.AddCommand(completionCmd)
}
Users install completion with:
# Bash
source <(myapp completion bash)
# Zsh
myapp completion zsh > ~/.zsh/completions/_myapp
cobra also supports dynamic completion for specific flag values:
serveCmd.RegisterFlagCompletionFunc("port", func(
cmd *cobra.Command, args []string, toComplete string,
) ([]cobra.ShellCompDirective, cobra.ShellCompDirective) {
return []string{
"8080\tDefault HTTP port",
"443\tHTTPS port",
"9090\tAlternate port",
}, cobra.ShellCompDirectiveNoFileComp
})
Level 3 ยท Professional CLI Experience
Progress Bars: schollz/progressbar
Long-running operations (large file downloads, batch processing) must provide user feedback. schollz/progressbar is the most ergonomic progress bar library in the Go ecosystem:
import "github.com/schollz/progressbar/v3"
func downloadFiles(urls []string) {
bar := progressbar.NewOptions(len(urls),
progressbar.OptionEnableColorCodes(true),
progressbar.OptionShowCount(),
progressbar.OptionShowIts(), // show items/sec rate
progressbar.OptionSetDescription("[cyan]Downloading...[reset]"),
progressbar.OptionSetTheme(progressbar.Theme{
Saucer: "[green]=[reset]",
SaucerHead: "[green]>[reset]",
SaucerPadding: " ",
BarStart: "[",
BarEnd: "]",
}),
)
for _, url := range urls {
download(url)
bar.Add(1)
}
bar.Finish()
}
// Byte-level progress for file downloads
func downloadWithByteProgress(url, destPath string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
f, err := os.Create(destPath)
if err != nil {
return err
}
defer f.Close()
bar := progressbar.DefaultBytes(resp.ContentLength, "Downloading "+filepath.Base(destPath))
// io.MultiWriter writes to both the file and the progress bar
_, err = io.Copy(io.MultiWriter(f, bar), resp.Body)
return err
}
Color Output: fatih/color
Terminal color is not decoration โ color encoding conveys information (green = success, red = error, yellow = warning):
import "github.com/fatih/color"
var (
successColor = color.New(color.FgGreen, color.Bold)
errorColor = color.New(color.FgRed, color.Bold)
warnColor = color.New(color.FgYellow)
infoColor = color.New(color.FgCyan)
dimColor = color.New(color.Faint)
)
func printStatus(items []StatusItem) {
for _, item := range items {
switch item.Status {
case "ok":
successColor.Printf(" โ %s\n", item.Name)
case "error":
errorColor.Printf(" โ %s: %s\n", item.Name, item.Message)
case "warn":
warnColor.Printf(" โ %s: %s\n", item.Name, item.Message)
case "pending":
dimColor.Printf(" โฆ %s\n", item.Name)
}
}
}
func init() {
// Respect the NO_COLOR convention (https://no-color.org)
if os.Getenv("NO_COLOR") != "" {
color.NoColor = true
}
}
A critical invariant: when output is piped (myapp | grep error), colors must be disabled automatically. fatih/color handles this by checking isatty internally.
Table Output: olekukonko/tablewriter
Structured data is most naturally displayed as a table:
import (
"os"
"github.com/olekukonko/tablewriter"
)
type Service struct {
Name string
Status string
Port int
Uptime string
CPU string
}
func printServicesTable(services []Service) {
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"Service", "Status", "Port", "Uptime", "CPU"})
table.SetBorder(true)
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
for _, s := range services {
statusColor := tablewriter.Colors{tablewriter.FgGreenColor}
if s.Status != "running" {
statusColor = tablewriter.Colors{tablewriter.FgRedColor}
}
table.Rich(
[]string{s.Name, s.Status, fmt.Sprintf("%d", s.Port), s.Uptime, s.CPU},
[]tablewriter.Colors{{}, statusColor, {}, {}, {}},
)
}
table.Render()
}
Sample output:
+--------------+---------+------+-----------+-------+
| SERVICE | STATUS | PORT | UPTIME | CPU |
+--------------+---------+------+-----------+-------+
| api-server | running | 8080 | 3d 14h | 12.3% |
| db-postgres | stopped | 5432 | 0 | 0% |
+--------------+---------+------+-----------+-------+
Interactive Prompts: AlecAivazis/survey
When a CLI needs user input โ an init wizard, a destructive-action confirmation โ interactive prompts are more ergonomic than requiring all input as command-line flags:
import "github.com/AlecAivazis/survey/v2"
type DeployConfig struct {
Environment string
Region string
Replicas int
Confirm bool
}
func interactiveDeployConfigure() (*DeployConfig, error) {
config := &DeployConfig{}
questions := []*survey.Question{
{
Name: "Environment",
Prompt: &survey.Select{
Message: "Select deployment environment:",
Options: []string{"development", "staging", "production"},
Default: "staging",
},
Validate: survey.Required,
},
{
Name: "Region",
Prompt: &survey.Select{
Message: "Select deployment region:",
Options: []string{"us-east-1", "eu-west-1", "ap-northeast-1"},
},
},
{
Name: "Replicas",
Prompt: &survey.Input{
Message: "Number of replicas:",
Default: "2",
Help: "More replicas = higher availability and higher cost",
},
Validate: func(val interface{}) error {
str, ok := val.(string)
if !ok {
return fmt.Errorf("invalid input")
}
n, err := strconv.Atoi(str)
if err != nil || n < 1 || n > 10 {
return fmt.Errorf("replica count must be 1โ10")
}
return nil
},
},
}
if err := survey.Ask(questions, config); err != nil {
return nil, err
}
if config.Environment == "production" {
prompt := &survey.Confirm{
Message: fmt.Sprintf("Deploy to production/%s? This action is irreversible.", config.Region),
Default: false,
}
survey.AskOne(prompt, &config.Confirm)
if !config.Confirm {
return nil, fmt.Errorf("operation cancelled")
}
}
return config, nil
}
Spinner: Visual Feedback for Indeterminate Operations
For operations where a percentage cannot be calculated (waiting for an API response, database migration), a spinner signals that the process is alive:
import "github.com/briandowns/spinner"
func waitWithSpinner(operation func() error, message string) error {
s := spinner.New(spinner.CharSets[14], 80*time.Millisecond)
s.Suffix = " " + message
s.Color("cyan")
s.Start()
err := operation()
s.Stop()
if err != nil {
fmt.Printf("\rโ %s: %v\n", message, err)
} else {
fmt.Printf("\rโ %s\n", message)
}
return err
}
func deploy() error {
return waitWithSpinner(func() error {
return applyKubernetesManifests()
}, "Deploying to Kubernetes...")
}
Level 4 ยท Advanced Techniques and Distribution
Plugin System: os/exec Subprocesses
Git's plugin mechanism (git-lfs, git-flow are standalone binaries invoked via git lfs) can be replicated with os/exec:
func findPlugin(name string) (string, bool) {
pluginName := "myapp-" + name
path, err := exec.LookPath(pluginName)
if err != nil {
return "", false
}
return path, true
}
func runPlugin(pluginPath string, args []string) error {
cmd := exec.Command(pluginPath, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(),
"MYAPP_VERSION=1.0.0",
"MYAPP_CONFIG="+viper.ConfigFileUsed(),
)
return cmd.Run()
}
func tryPlugin(args []string) bool {
if len(args) == 0 {
return false
}
pluginPath, ok := findPlugin(args[0])
if !ok {
return false
}
if err := runPlugin(pluginPath, args[1:]); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
os.Exit(1)
}
return true
}
Embedding Static Assets: go:embed
The //go:embed directive (introduced in Go 1.16) bundles files directly into the binary, eliminating external file dependencies:
import (
"embed"
"html/template"
"io/fs"
)
// Embed a single file
//go:embed templates/report.tmpl
var reportTemplate string
// Embed an entire directory
//go:embed static/*
var staticFiles embed.FS
// Embed multiple patterns
//go:embed configs/default.yaml configs/schema.json
var defaultConfigs embed.FS
func generateReport(data interface{}) (string, error) {
tmpl, err := template.New("report").Parse(reportTemplate)
if err != nil {
return "", err
}
var buf strings.Builder
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
func loadDefaultConfig() ([]byte, error) {
return defaultConfigs.ReadFile("configs/default.yaml")
}
// Serve embedded static files as an HTTP file server
func serveStaticFiles() http.Handler {
sub, _ := fs.Sub(staticFiles, "static")
return http.FileServer(http.FS(sub))
}
Practical use cases for //go:embed:
- Version information templates
- Database schema migration files
- Default configuration files
- A built-in web UI (as
pprofdoes)
Self-Update Mechanism
Professional CLI tools should be capable of updating themselves:
import (
"encoding/json"
"fmt"
"net/http"
"os"
"runtime"
)
type Release struct {
Version string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
func checkUpdate(currentVersion string) (*Release, error) {
resp, err := http.Get("https://api.github.com/repos/myorg/myapp/releases/latest")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var release Release
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
}
if release.Version <= currentVersion {
return nil, nil // already up to date
}
return &release, nil
}
func selfUpdate(release *Release) error {
targetName := fmt.Sprintf("myapp-%s-%s", runtime.GOOS, runtime.GOARCH)
if runtime.GOOS == "windows" {
targetName += ".exe"
}
var downloadURL string
for _, asset := range release.Assets {
if asset.Name == targetName {
downloadURL = asset.BrowserDownloadURL
break
}
}
if downloadURL == "" {
return fmt.Errorf("no binary for %s/%s", runtime.GOOS, runtime.GOARCH)
}
tmpFile, err := os.CreateTemp("", "myapp-update-*")
if err != nil {
return err
}
defer os.Remove(tmpFile.Name())
resp, err := http.Get(downloadURL)
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(tmpFile, resp.Body)
tmpFile.Close()
executable, _ := os.Executable()
os.Chmod(tmpFile.Name(), 0755)
return os.Rename(tmpFile.Name(), executable)
}
For production use, prefer github.com/inconshreveable/go-update, which handles Windows file locking, atomic replacement, and rollback on failure.
GoReleaser: Automated Distribution
GoReleaser is the dominant release tool in the Go ecosystem. A single .goreleaser.yaml generates multi-platform binaries, GitHub Releases, and Homebrew formulas:
# .goreleaser.yaml
project_name: myapp
version: 2
before:
hooks:
- go generate ./...
- go test ./...
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:
- format: tar.gz
format_overrides:
- goos: windows
format: zip
name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}"
checksum:
name_template: "checksums.txt"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
brews:
- repository:
owner: myorg
name: homebrew-tap
description: "My CLI tool"
homepage: "https://example.com"
install: |
bin.install "myapp"
bash_completion.install "completions/myapp.bash"
zsh_completion.install "completions/myapp.zsh"
Paired with GitHub Actions, pushing a tag triggers an automatic release:
# .github/workflows/release.yml
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Testing CLI Tools: Golden File Testing
CLI output (colors, tables, progress bars) is difficult to verify with ordinary unit tests. Golden file testing is the industry best practice:
func TestServeCommandOutput(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
}{
{name: "default port", args: []string{"serve", "--dry-run"}},
{name: "custom port", args: []string{"serve", "--port", "9090", "--dry-run"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs(tt.args)
err := rootCmd.Execute()
if (err != nil) != tt.wantErr {
t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr)
}
got := buf.String()
goldenFile := filepath.Join("testdata", t.Name()+".golden")
// Update golden files: go test -update
if *update {
os.WriteFile(goldenFile, []byte(got), 0644)
return
}
want, err := os.ReadFile(goldenFile)
if err != nil {
t.Fatalf("reading golden file: %v", err)
}
if got != string(want) {
t.Errorf("output mismatch\ngot:\n%s\nwant:\n%s", got, want)
}
})
}
}
var update = flag.Bool("update", false, "update golden files")
Advantages of the golden file pattern: output changes are immediately visible as diffs; updating golden files requires only -update; tests cover the full execution path including formatting logic.
Version Information via ldflags
Professional CLI tools support a version command with full build metadata:
var (
version = "dev"
commit = "none"
buildDate = "unknown"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("myapp %s\n", version)
fmt.Printf(" Commit: %s\n", commit)
fmt.Printf(" Built: %s\n", buildDate)
fmt.Printf(" Go: %s\n", runtime.Version())
fmt.Printf(" Platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
},
}
Inject at build time:
go build \
-ldflags="-X main.version=1.2.3 \
-X main.commit=$(git rev-parse --short HEAD) \
-X main.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o myapp .
myapp version then outputs:
myapp 1.2.3
Commit: a1b2c3d
Built: 2026-05-07T10:00:00Z
Go: go1.22.0
Platform: darwin/arm64
Engineering Principles Summary
An excellent Go CLI tool adheres to these principles:
- Single responsibility โ each subcommand does one thing and does it well
- Consistent output convention โ success on stdout, errors on stderr; disable color when piped
- Meaningful exit codes โ 0 for success, non-zero for failure (specific codes per error type)
- Unambiguous error messages โ tell the user what went wrong and how to fix it
- Configuration priority โ flags > environment variables > config file > defaults
- Useful help text โ every command has
--helpthat actually helps - Version information โ
--versionincludes build time and git commit - Testing โ golden file tests cover output formatting
The Go ecosystem (cobra + viper + progressbar + survey) provides a complete CLI development toolkit. With these tools, you can build a command-line experience on par with kubectl in a matter of hours.