Building CLIs in Go: A Deep Dive into Command-Line Tools
Command-line tools are the backbone of developer workflows. Shell scripts grow hard to maintain; a Go CLI ships as a single static binary, offers type safety, and stays simple even when you need concurrency.
This article walks you from the standard library’s flag package to the popular Cobra framework and all the way to release engineering.
Why Go for CLIs?
| Goal | How Go helps |
|---|---|
| Single-file distribution | go build → one binary per platform |
| Fast startup | Small binaries, low memory footprint |
| Concurrent work | Goroutines and channels |
| Rich ecosystem | Cobra, Viper, urfave/cli, and more |
Go is widely used for DevOps, data tooling, and internal developer tools: traces of Go appear across kubectl, docker, hugo, and the Terraform ecosystem.
1. Project skeleton
mkdir mycli && cd mycli
go mod init github.com/user/mycli
Minimal entrypoint:
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: mycli <command>")
os.Exit(2)
}
fmt.Println("args:", os.Args[1:])
}
os.Args[0] is the program name; the rest are user arguments. That is enough for toys, but real tools scale better with flag or Cobra.
2. The flag package: options and defaults
The standard library covers many cases:
package main
import (
"flag"
"fmt"
"os"
)
func main() {
verbose := flag.Bool("v", false, "verbose output")
outPath := flag.String("o", "", "output file (empty = stdout)")
flag.Parse()
if *verbose {
fmt.Fprintln(os.Stderr, "verbose on")
}
if *outPath != "" {
fmt.Println("output:", *outPath)
}
fmt.Println("positional args:", flag.Args())
}
Note: Flags are not processed until you call flag.Parse(). Positional arguments come from flag.Args().
3. Subcommands with Cobra
Multiple commands (mycli init, mycli deploy) are where Cobra becomes the de facto choice.
go get github.com/spf13/cobra@latest
Example layout:
cmd/
root.go // root command, persistent flags
version.go // mycli version
run.go // mycli run
main.go
Typical root.go:
package cmd
import "github.com/spf13/cobra"
var rootCmd = &cobra.Command{
Use: "mycli",
Short: "Example CLI",
Long: `Long description shown in help text.`,
}
func Execute() error {
return rootCmd.Execute()
}
Adding a subcommand:
var runCmd = &cobra.Command{
Use: "run",
Short: "runs the job",
RunE: func(cmd *cobra.Command, args []string) error {
// business logic; return errors for non-zero exit
return nil
},
}
func init() {
rootCmd.AddCommand(runCmd)
runCmd.Flags().StringP("env", "e", "dev", "environment name")
}
Prefer RunE over Run so errors propagate cleanly and tests stay straightforward.
4. stdin, stdout, and stderr
The usual contract:
- stdout — data meant for machines (pipelines)
- stderr — logs and warnings for humans
- stdin — piped or redirected input
import (
"io"
"os"
)
func copyOut(r io.Reader) {
_, _ = io.Copy(os.Stdout, r)
}
func logErr(msg string) {
_, _ = fmt.Fprintln(os.Stderr, msg)
}
For TTY detection (toggle colors), small helpers like golang.org/x/term or isatty-style packages help; disabling color in CI improves readability of logs.
5. Exit codes
Unix conventions:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Misuse (bad arguments) |
Cobra generally exits with 1 when RunE returns an error. For custom codes you can call os.Exit — use sparingly and keep business logic testable outside main.
Good practice: Keep core logic in pure functions; let a thin main / cmd layer map results to os.Exit. That way go test can validate behavior without real process exit codes.
6. Configuration: flags, env, files
A common precedence stack:
- Command-line flags (highest priority)
- Environment variables (
MYCLI_API_KEY) - User config under
~/.config/mycli/config.yaml
Viper fits the Cobra ecosystem, but small tools often need only os.Getenv and a one-time YAML/JSON read. Match dependency weight to project size.
7. Cross-compilation
Go can build for many targets from one machine:
GOOS=linux GOARCH=amd64 go build -o mycli-linux-amd64 .
GOOS=darwin GOARCH=arm64 go build -o mycli-darwin-arm64 .
GOOS=windows GOARCH=amd64 go build -o mycli-windows-amd64.exe .
CI pipelines commonly run these commands to publish artifacts.
8. Testing strategy
- Unit tests: avoid binding logic to global
os.Argsstate. - Integration: running the built binary via
exec.Commandis heavier but useful for critical paths. - Golden files: snapshot stdout against checked-in expected output.
Injecting io.Writer instead of calling fmt.Printf directly — e.g. Run(out io.Writer, args []string) error — dramatically improves testability.
9. Releases: GoReleaser
GoReleaser can produce multi-platform binaries, checksums, and sometimes package-manager recipes when you push a Git tag. It saves a lot of time for open-source CLIs.
Even without it, go install github.com/user/mycli@latest lets users install from your module path (Go 1.17+).
10. UX tips
- Help text: fill
ShortandLong; show example invocations. - Version: inject with
ldflags(-X main.version=...) from CI or git. - Color: pretty in terminals; messy in log files — gate on TTY.
- Progress: for long jobs, use stderr for progress so stdout stays machine-clean.
Checklist
- Clear separation of stdout vs stderr?
- Errors return actionable messages?
- Exit codes documented for automation?
-
-h/--helpactually helpful? - Cross-build integrated into CI?
Building CLIs in Go gives you a fast path from prototype to long-lived internal tools. Start with flag, graduate to Cobra when complexity grows, and automate distribution early.
The command line is your UI — keep it short, clear, and predictable.
Related posts
JWT Authentication in Go: Access Tokens, Refresh Tokens, and Secure Storage
Sign and verify JWTs in Go; short-lived access tokens, refresh rotation, HttpOnly cookies, and common pitfalls.
gRPC vs REST: When Should You Use Which? A Comparative Guide with Go
gRPC and REST in microservices: protobuf, HTTP/2, browser constraints, and Go examples — complements our Go vs Node.js service comparison.
Rate Limiting with Redis: Token Bucket and Sliding Window in Go
Production-oriented golang rate limiting with Redis — token bucket and sliding window using Lua scripts, atomic updates, and operational tips.