cd ..
$ cat ~/field-notes/writing-cli-tools-go.md
Writing CLI Tools in Go
I've written CLI tools in Python, Ruby, Node, and Go. I reach for Go every time now. The main reason: I hand the binary to someone and it works. No runtime, no version conflicts, no pip install. One file.
Here's the structure I use for every Go CLI.
Why Go for CLIs
Python: great stdlib, terrible distribution
→ "Run pip install -r requirements.txt"
→ "Which python3? I have 3.8 and 3.11"
→ Bundle with PyInstaller for 80MB binary
Node: fast to write, painful to ship
→ node_modules is 200MB
→ pkg/nexe for bundling, but still awkward
Go:
go build -o mytool .
# Ship the binary. Done.
# Cross-compile: GOOS=linux GOARCH=amd64 go build
# Startup: <10ms
# Size: 5-15MB (or smaller with -ldflags="-s -w")
The Minimal Structure
mycli/
├── main.go
├── cmd/
│ ├── root.go
│ ├── list.go
│ └── create.go
└── internal/
└── client/
└── client.go
// main.go — entry point, nothing else
package main
import (
"os"
"mycli/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}
Using Cobra for Subcommands
Cobra is the de facto standard for multi-command CLIs (kubectl, gh, docker all use it):
// cmd/root.go
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "mycli",
Short: "My CLI tool",
Long: "A longer description of what mycli does.",
}
func Execute() error {
return rootCmd.Execute()
}
func init() {
// Global flags
rootCmd.PersistentFlags().String("config", "", "Config file path")
rootCmd.PersistentFlags().Bool("verbose", false, "Verbose output")
}
// cmd/list.go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var listCmd = &cobra.Command{
Use: "list",
Short: "List all items",
RunE: runList, // RunE returns an error; cobra handles it
}
func init() {
rootCmd.AddCommand(listCmd)
listCmd.Flags().StringP("format", "f", "text", "Output format: text|json|csv")
listCmd.Flags().IntP("limit", "n", 20, "Max items to show")
}
func runList(cmd *cobra.Command, args []string) error {
format, _ := cmd.Flags().GetString("format")
limit, _ := cmd.Flags().GetInt("limit")
items, err := fetchItems(limit)
if err != nil {
return fmt.Errorf("fetch failed: %w", err)
}
return printItems(items, format)
}
Exit Codes and Error Handling
// Good: differentiate errors for scripts to react to
const (
ExitOK = 0
ExitError = 1
ExitUsage = 2
)
// With cobra: RunE returning an error exits with code 1
// For custom exit codes, use os.Exit directly
func runDelete(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return fmt.Errorf("requires at least 1 argument")
// cobra prints usage and exits 1
}
if err := deleteItem(args[0]); err != nil {
// Don't fmt.Printf here — just return the error
// cobra handles printing it
return fmt.Errorf("delete %q: %w", args[0], err)
}
fmt.Printf("Deleted %s\n", args[0])
return nil
}
stdin/stdout/stderr
// Write output to stdout
fmt.Println("result")
// Write errors/logs to stderr (don't pollute stdout)
fmt.Fprintln(os.Stderr, "warning: something happened")
// Read from stdin (pipe support)
func readStdin() ([]string, error) {
var lines []string
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines, scanner.Err()
}
// Detect if stdin is a pipe or terminal
func isaPipe() bool {
stat, _ := os.Stdin.Stat()
return (stat.Mode() & os.ModeCharDevice) == 0
}
func runProcess(cmd *cobra.Command, args []string) error {
var items []string
if isaPipe() {
// cat items.txt | mycli process
items, _ = readStdin()
} else {
// mycli process item1 item2
items = args
}
// Process items...
return nil
}
Pretty Output
// Respect NO_COLOR env var (https://no-color.org)
var useColor = os.Getenv("NO_COLOR") == "" && isTerminal(os.Stdout)
func isTerminal(f *os.File) bool {
stat, _ := f.Stat()
return (stat.Mode() & os.ModeCharDevice) != 0
}
// Simple color without a library
const (
colorRed = "\033[31m"
colorGreen = "\033[32m"
colorReset = "\033[0m"
)
func success(msg string) {
if useColor {
fmt.Printf("%s✓ %s%s\n", colorGreen, msg, colorReset)
} else {
fmt.Printf("✓ %s\n", msg)
}
}
func failure(msg string) {
if useColor {
fmt.Fprintf(os.Stderr, "%s✗ %s%s\n", colorRed, msg, colorReset)
} else {
fmt.Fprintf(os.Stderr, "✗ %s\n", msg)
}
}
For tables, the text/tabwriter package from stdlib is enough:
func printTable(items []Item) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tSTATUS\tCREATED")
for _, item := range items {
fmt.Fprintf(w, "%s\t%s\t%s\n",
item.Name,
item.Status,
item.CreatedAt.Format("2006-01-02"),
)
}
w.Flush()
}
Config Files
import (
"encoding/json"
"os"
"path/filepath"
)
type Config struct {
APIKey string `json:"api_key"`
Endpoint string `json:"endpoint"`
}
func loadConfig() (*Config, error) {
// Check XDG_CONFIG_HOME first, then ~/.config/mycli/config.json
configDir := os.Getenv("XDG_CONFIG_HOME")
if configDir == "" {
home, _ := os.UserHomeDir()
configDir = filepath.Join(home, ".config")
}
path := filepath.Join(configDir, "mycli", "config.json")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Config{}, nil // empty config is fine
}
return nil, err
}
var cfg Config
return &cfg, json.Unmarshal(data, &cfg)
}
func saveConfig(cfg *Config) error {
home, _ := os.UserHomeDir()
dir := filepath.Join(home, ".config", "mycli")
os.MkdirAll(dir, 0700)
data, _ := json.MarshalIndent(cfg, "", " ")
return os.WriteFile(filepath.Join(dir, "config.json"), data, 0600)
}
Parallelism
// Process a list of items concurrently
func processAll(items []string) error {
const workers = 10
jobs := make(chan string, len(items))
errs := make(chan error, len(items))
// Start workers
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for item := range jobs {
if err := processItem(item); err != nil {
errs <- fmt.Errorf("%s: %w", item, err)
}
}
}()
}
// Send work
for _, item := range items {
jobs <- item
}
close(jobs)
// Wait and collect errors
wg.Wait()
close(errs)
var allErrors []error
for err := range errs {
allErrors = append(allErrors, err)
}
return errors.Join(allErrors...)
}
Cross-Compilation
# Build for multiple targets
GOOS=linux GOARCH=amd64 go build -o dist/mycli-linux-amd64 .
GOOS=darwin GOARCH=amd64 go build -o dist/mycli-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -o dist/mycli-darwin-arm64 . # M1/M2
GOOS=windows GOARCH=amd64 go build -o dist/mycli-windows-amd64.exe .
# Smaller binary (strip debug info)
go build -ldflags="-s -w" -o mycli .
# Or use goreleaser to automate all of the above + GitHub releases
Making It Testable
// Inject dependencies instead of calling os.Exit in business logic
type App struct {
client *APIClient
out io.Writer // inject output for testing
errOut io.Writer
}
func (a *App) List(format string, limit int) error {
items, err := a.client.List(limit)
if err != nil {
return err
}
return printItems(a.out, items, format)
}
// In tests:
func TestList(t *testing.T) {
var buf bytes.Buffer
app := &App{
client: &mockClient{items: testItems},
out: &buf,
errOut: io.Discard,
}
err := app.List("text", 10)
require.NoError(t, err)
assert.Contains(t, buf.String(), "expected output")
}
The Bottom Line
Go produces fast, self-contained CLIs that ship as a single binary. The pattern above scales from a 200-line tool to a 20,000-line tool without changing structure.
The rules:
- Use cobra for anything with subcommands,
flagpackage for simple tools - Write to stderr for errors and logs; stdout for output (scripts depend on this)
- Respect NO_COLOR and check if stdout is a terminal before coloring
- Support stdin pipes — unix tools compose
- Use
RunEnotRun— return errors, don't print and exit - Cross-compile for your users' platforms before you distribute
Single binary, fast startup, no dependency hell. Go is the right tool for the job.