12 - CLI Tools & Cross-Compilation
📋 Jump to Takeaways🎁 You can pipe data into a Go program, but does it know whether it's reading from a keyboard or a pipe? And how do you take that program and ship one binary that runs on a colleague's Mac, a Linux server, and a Raspberry Pi, all from a single build command?
Go is a fantastic language for command-line tools: fast startup, a single static binary, and a standard library that covers flags, streams, and exit codes. A static binary packs everything it needs into one file, so it runs on a machine with nothing else installed, unlike a normal binary that expects certain system libraries to already be there. This lesson turns your code into a proper CLI and then builds it for every platform you care about.
Flags with the flag Package
The flag package parses command-line options. Define each flag, call flag.Parse, and read the values. The pointer-returning form is the quickest:
verbose := flag.Bool("v", false, "verbose output")
name := flag.String("name", "world", "who to greet")
flag.Parse()
if *verbose {
fmt.Fprintln(os.Stderr, "starting up")
}
fmt.Printf("hello, %s\n", *name) // hello, worldNow ./greet -name=Go -v sets both. Each flag has a name, a default, and a usage string that flag uses to auto-generate -h help. For a value you'd rather store in an existing variable, flag.StringVar(&myVar, ...) does the same without the pointer.
Positional Arguments
Flags and positional arguments are different things. os.Args is the raw slice (with the program name at os.Args[0]), while after flag.Parse the leftover non-flag arguments are available via flag.Args:
flag.Parse()
fmt.Println("program:", os.Args[0]) // program: ./mytool
fmt.Println("files:", flag.Args()) // files: [a.txt b.txt]
fmt.Println("count:", flag.NArg()) // count: 2So ./mytool -v a.txt b.txt gives you the flag in *verbose and the two filenames in flag.Args(). Mixing them is fine as long as flags come before positional arguments.
Standard Streams and Piping
A well-behaved tool writes results to os.Stdout and diagnostics to os.Stderr, so someone piping your output gets clean data with logs kept separate. You can also detect whether input is a terminal or a pipe by stat-ing os.Stdin, which means asking the OS for its metadata (type, size, permissions) without reading any content:
info, _ := os.Stdin.Stat()
if (info.Mode() & os.ModeCharDevice) == 0 {
// stdin is a pipe or a file, not a terminal: read it
data, _ := io.ReadAll(os.Stdin)
fmt.Printf("read %d piped bytes\n", len(data))
} else {
fmt.Fprintln(os.Stderr, "no input piped; pass a file")
}The os.ModeCharDevice bit is set when stdin is an interactive terminal. When it's clear, someone ran cat data | mytool or mytool < data, and you should read the stream.
Exit Codes
The shell judges your tool by its exit code: 0 means success, anything else means failure. Set it with os.Exit. The catch is that os.Exit skips deferred functions, so it must run after your cleanup, not in the middle of it:
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1) // non-zero: the shell sees failure
}
// implicit exit 0 on success
}
func run() error {
// all the real work, with defers that actually run
return nil
}The run() error pattern keeps os.Exit at the very edge of the program, so every defer inside run still fires. Never scatter os.Exit calls through your logic, or you'll silently skip closes and flushes. For flag-and-env-based configuration together, the approach in Configuration pairs cleanly with this.
Cross-Compilation
Here's Go's superpower for tools. Set GOOS (operating system) and GOARCH (architecture) and go build produces a binary for that target, from your current machine, no cross-toolchain required:
# build a Linux ARM64 binary from a Mac, for example
GOOS=linux GOARCH=arm64 go build -o mytool-linux-arm64
# a fully static binary with no libc dependency
CGO_ENABLED=0 go build -o mytool
# list every supported OS/arch pair
go tool dist listDisabling cgo (CGO_ENABLED=0) is what gives you a truly static binary that runs on a bare container image or an older distro, with nothing to install. cgo is Go's bridge for calling C code, and it makes the binary link against libc, the C standard library most programs depend on at runtime; turning it off keeps the binary fully self-contained. One source tree, every platform.
Shipping a Binary
Distribution is easy once you have a static binary: copy it and run it. Shrink it by stripping debug info (the symbol table and DWARF data the compiler embeds for debuggers, which a release build doesn't need), and embed a version so --version reports what shipped:
# strip the symbol table and DWARF debug info to reduce size
go build -ldflags="-s -w" -o mytool
# stamp a version string into the binary at build time
go build -ldflags="-X main.version=1.4.0" -o mytoolThe -X flag sets a package-level string variable at link time, so var version = "dev" in main becomes 1.4.0 in the release build. (-ldflags passes options to the linker, the final build step that stitches your compiled code into one binary.) You can also read module and VCS info at runtime with runtime/debug.ReadBuildInfo(). Wire a -version flag to print it and your tool tells users exactly what they're running.
Key Takeaways
flagparses options:flag.Bool/String/Intreturn pointers (or useflag.XxxVarinto a variable), thenflag.Parse(). Each flag auto-generates-hhelp.os.Argsis the raw argument slice (os.Args[0]is the program name);flag.Args()/flag.NArg()give the positional arguments left after parsing.- Write results to
os.Stdoutand diagnostics toos.Stderr. Detect piped input by checkingos.Stdin.Stat()for a clearos.ModeCharDevicebit. - Set exit status with
os.Exit(code)(0 success, non-zero failure), but it skipsdefers; keep it at the edge via arun() errorhelper called frommain. - Cross-compile by setting
GOOS/GOARCHbeforego build;CGO_ENABLED=0yields a static binary;go tool dist listshows all targets. - Shrink with
-ldflags="-s -w"and stamp a version with-ldflags="-X main.version=..."(orruntime/debug.ReadBuildInfo()).
🎁 A CLI tool runs, does its job, and exits. But some programs are never supposed to exit: they run for months, restart themselves on failure, and reload config without dropping a request. How do you build one of those, and who takes care of keeping it alive?