Building CLI Tools & Cross-Compiling
Prerequisites: Security essentials
Go produces a single static binary with no runtime to install, which makes it a popular choice for command-line tools. The standard library covers arguments, flags, and I/O; cross-compiling for another OS is one environment variable away.
os.Args
os.Args holds the raw command line; os.Args[0] is the program name:
package main
import (
"fmt"
"os"
)
func main() {
fmt.Println(len(os.Args)-1, "arguments:", os.Args[1:])
}
$ go run . one two
2 arguments: [one two]
The flag package
flag parses -name value, -name=value, and boolean -verbose flags, and
generates -h help automatically:
package main
import (
"flag"
"fmt"
"os"
"strings"
)
func main() {
name := flag.String("name", "world", "who to greet")
count := flag.Int("n", 1, "number of greetings")
shout := flag.Bool("shout", false, "print in upper case")
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "usage: greet [flags]\n")
flag.PrintDefaults()
}
flag.Parse()
if *count < 1 {
fmt.Fprintln(os.Stderr, "greet: -n must be at least 1")
os.Exit(2)
}
msg := "hello, " + *name
if *shout {
msg = strings.ToUpper(msg)
}
for i := 0; i < *count; i++ {
fmt.Println(msg)
}
}
$ go run . -name Ada -n 2 -shout
HELLO, ADA
HELLO, ADA
Flags must come before positional arguments; flag.Args() returns whatever
is left after parsing. For subcommands (tool add, tool list), create one
flag.NewFlagSet per subcommand and switch on os.Args[1]. Larger tools
often use a third-party library such as spf13/cobra, but flag goes a long
way.
Exit codes, stdout, and stderr
Command-line tools communicate through exit codes: 0 for success, non-zero
for failure (2 conventionally means incorrect usage). Keep normal output
on stdout and errors on stderr, so tool > out.txt still shows errors.
os.Exit skips deferred functions, so a common structure keeps all logic in
a run function that returns an error, and calls os.Exit only in main:
func main() {
if err := run(os.Args[1:], os.Stdin, os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func run(args []string, stdin io.Reader, stdout io.Writer) error {
// parse args with a flag.NewFlagSet, do the work, return errors
return nil
}
Taking the arguments and streams as parameters also makes run easy to test.
Reading from stdin
Tools that compose in pipelines read standard input when no file is given:
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Text()
fmt.Println(strings.ToUpper(line)) // your processing here
}
if err := scanner.Err(); err != nil {
return err
}
bufio.Scanner rejects lines over 64 KB by default; call
scanner.Buffer to raise the limit.
Cross-compiling
Set GOOS and GOARCH to build for another platform — no extra toolchain
needed:
GOOS=linux GOARCH=amd64 go build -o dist/tool-linux-amd64 .
GOOS=linux GOARCH=arm64 go build -o dist/tool-linux-arm64 .
GOOS=darwin GOARCH=arm64 go build -o dist/tool-darwin-arm64 .
GOOS=windows GOARCH=amd64 go build -o dist/tool-windows-amd64.exe .
In PowerShell, set the variables first: $env:GOOS="linux"; $env:GOARCH="arm64"; go build .
Run go tool dist list to see every supported combination.
CGO_ENABLED=0
Some standard packages (net, os/user) can use C libraries through cgo
when it's available. Cross-compiling with cgo needs a C cross-compiler, and a
cgo binary depends on the target's C library. Disabling cgo gives a fully
static, portable binary:
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o tool .
-trimpath removes local file paths from the binary; -ldflags="-s -w"
strips debug symbols to shrink it. If a dependency requires cgo (for
example, mattn/go-sqlite3), you'll need to keep cgo and set up a cross C
toolchain, or pick a pure-Go alternative.
Common mistakes
- Printing errors to stdout, which corrupts piped output.
- Exiting with
0after a failure, so scripts and CI think it succeeded. - Calling
os.Exitorlog.Fataldeep inside the program, skipping deferred cleanup such as flushing files. - Putting flags after positional arguments —
flagstops parsing at the first non-flag argument.
Practice
Write linecount: it prints the number of lines in each file given as an
argument (<count> <filename>), or counts standard input if no files are
given. Exit with status 1 if any file can't be opened, but still process
the rest.
Show solution
package main
import (
"bufio"
"fmt"
"io"
"os"
)
func count(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
n := 0
for sc.Scan() {
n++
}
return n, sc.Err()
}
func main() {
files := os.Args[1:]
if len(files) == 0 {
n, err := count(os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, "linecount:", err)
os.Exit(1)
}
fmt.Println(n)
return
}
failed := false
for _, name := range files {
f, err := os.Open(name)
if err != nil {
fmt.Fprintln(os.Stderr, "linecount:", err)
failed = true
continue
}
n, err := count(f)
f.Close()
if err != nil {
fmt.Fprintln(os.Stderr, "linecount:", name+":", err)
failed = true
continue
}
fmt.Println(n, name)
}
if failed {
os.Exit(1)
}
}