Automate the Boring Stuff with Go, Ch. 16: Command-Line Tools and Shipping Binaries
Part of Automate the Boring Stuff with Go, a beginner-friendly guide to programming in Go.
New in the 2026 edition.
Every project so far has been a command-line tool, loosely. This chapter makes one properly: subcommands, flags, help text, a version number, exit codes a script can trust, state stored where the operating system expects it, and a build that produces one file per platform you can hand to anyone. The code is under ch16.
Flags
// the flag package: typed options, defaults, help text, and what is left over
package main
import (
"flag"
"fmt"
"os"
"time"
)
func main() {
name := flag.String("name", "world", "who to greet")
times := flag.Int("times", 1, "how many times")
loud := flag.Bool("loud", false, "shout")
pause := flag.Duration("pause", 0, "wait between greetings, e.g. 500ms")
flag.Parse() // reads os.Args; -h or -help prints the usage for free
greeting := "hello, " + *name
if *loud {
greeting = "HELLO, " + *name + "!"
}
for range *times {
fmt.Println(greeting)
time.Sleep(*pause)
}
// whatever was not a flag: positional arguments
if flag.NArg() > 0 {
fmt.Fprintln(os.Stderr, "also received:", flag.Args())
}
}
$ go run ./ch16/examples/flags -name gopher -times 2 extra args
hello, gopher
hello, gopher
also received: [extra args]
Each flag.X call returns a pointer; after flag.Parse it points at the value the user gave or the default. -h prints every flag with its help text and default, which is documentation you did not have to write. What is left over after the flags is flag.Args().
Embedding files
A program that needs a template, a help text or a small data file next to it stops being one file to copy. embed puts the file inside the binary at build time:
// embed: ship a file inside the binary, so the program is still one file to copy
package main
import (
_ "embed"
"fmt"
"os"
"text/template"
)
//go:embed templates/report.tmpl
var reportTemplate string
func main() {
tmpl, err := template.New("report").Parse(reportTemplate)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
data := struct {
Name string
Items []string
}{"Monday", []string{"coffee", "stand-up", "code review"}}
if err := tmpl.Execute(os.Stdout, data); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
The //go:embed comment is read by the compiler. Change the file and rebuild; nothing to install, nothing to forget. (text/template, used here, is worth an afternoon of its own: it is how Go programs fill in reports and emails.)
Exit codes
When a program runs inside a script, its exit code is its only word back. The convention is small and worth following exactly:
// exit codes are how your program talks to scripts: 0 is success, anything else is not
package main
import (
"errors"
"fmt"
"os"
)
var errUsage = errors.New("usage: exit-codes <ok|fail|usage>")
func run(args []string) error {
if len(args) != 1 {
return errUsage
}
switch args[0] {
case "ok":
fmt.Println("all good")
return nil
case "fail":
return errors.New("the thing could not be done")
default:
return errUsage
}
}
func main() {
err := run(os.Args[1:])
switch {
case err == nil:
os.Exit(0) // not needed, main returning does the same; shown for symmetry
case errors.Is(err, errUsage):
fmt.Fprintln(os.Stderr, err)
os.Exit(2) // the convention for "you called me wrong"
default:
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1) // the convention for "I tried and failed"
}
}
$ go run ./ch16/examples/exit-codes fail
error: the thing could not be done
exit status 1
0 is success. 1 is “I tried and failed”. 2 is “you called me wrong”, which is what the go tool and most Unix commands use. A shell script checks $?, or writes todo list || echo "todo failed", and it only works if you are honest with the codes.
Project: a to-do tool
Everything above, plus a JSON file, in one program. It is small enough to read in one sitting and complete enough to use every day.
// todo is a complete command-line tool: subcommands, flags per subcommand, a help text
// embedded in the binary, a version stamped in at build time, proper exit codes, and
// state in a JSON file in the right place for the operating system.
//
// go run ./ch16/projects/todo add buy milk
// go run ./ch16/projects/todo list
// go run ./ch16/projects/todo done 1
package main
import (
_ "embed"
"errors"
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
)
//go:embed usage.txt
var usage string
// version is overwritten at build time:
//
// go build -ldflags "-X main.version=1.0.0" -o todo ./ch16/projects/todo
var version = "dev"
var errUsage = errors.New("usage")
// run executes one command line against the store at path and writes to out.
func run(args []string, path string, out io.Writer, now func() time.Time) error {
if len(args) == 0 {
return errUsage
}
cmd, rest := args[0], args[1:]
if cmd == "version" {
fmt.Fprintln(out, "todo", version)
return nil
}
store, err := Load(path)
if err != nil {
return err
}
switch cmd {
case "add":
text := strings.TrimSpace(strings.Join(rest, " "))
if text == "" {
return fmt.Errorf("add: nothing to add: %w", errUsage)
}
n := store.Add(text, now())
fmt.Fprintf(out, "added #%d: %s\n", n, text)
return store.Save()
case "list":
flags := flag.NewFlagSet("list", flag.ContinueOnError)
flags.SetOutput(out)
all := flags.Bool("all", false, "include done items")
if err := flags.Parse(rest); err != nil {
return err
}
shown := 0
for i, it := range store.Items {
if it.Done != nil && !*all {
continue
}
mark := " "
if it.Done != nil {
mark = "x"
}
fmt.Fprintf(out, "%3d [%s] %s\n", i+1, mark, it.Text)
shown++
}
if shown == 0 {
fmt.Fprintln(out, "nothing to do")
}
return nil
case "done", "rm":
if len(rest) != 1 {
return fmt.Errorf("%s: needs one item number: %w", cmd, errUsage)
}
n, err := strconv.Atoi(rest[0])
if err != nil {
return fmt.Errorf("%s: %q is not a number: %w", cmd, rest[0], errUsage)
}
if cmd == "done" {
err = store.MarkDone(n, now())
} else {
err = store.Remove(n)
}
if err != nil {
return err
}
fmt.Fprintf(out, "%s #%d\n", cmd, n)
return store.Save()
default:
return fmt.Errorf("unknown command %q: %w", cmd, errUsage)
}
}
func main() {
path, err := DefaultPath()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
err = run(os.Args[1:], path, os.Stdout, time.Now)
switch {
case err == nil:
case errors.Is(err, errUsage):
if err != errUsage { // a wrapped usage error says what was wrong; the bare one just wants the help text
fmt.Fprintln(os.Stderr, err)
}
fmt.Fprint(os.Stderr, usage)
os.Exit(2)
default:
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
The store is its own file, so the commands stay readable:
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// Item is one line of the list.
type Item struct {
Text string `json:"text"`
Created time.Time `json:"created"`
Done *time.Time `json:"done,omitempty"` // nil until it is done
}
// Store is the list plus where it lives.
type Store struct {
Path string
Items []Item
}
// Load reads the file, or starts an empty list when there is none yet.
func Load(path string) (*Store, error) {
s := &Store{Path: path}
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, &s.Items); err != nil {
return nil, fmt.Errorf("%s is not a todo file: %w", path, err)
}
return s, nil
}
// Save writes the list back, creating the folder if needed. It writes to a temporary
// file and renames, so a crash mid-write cannot leave you with half a list.
func (s *Store) Save() error {
if err := os.MkdirAll(filepath.Dir(s.Path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(s.Items, "", " ")
if err != nil {
return err
}
tmp := s.Path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.Path)
}
// Add appends an item and returns its 1-based number.
func (s *Store) Add(text string, now time.Time) int {
s.Items = append(s.Items, Item{Text: text, Created: now})
return len(s.Items)
}
// MarkDone sets the done time on item n (1-based).
func (s *Store) MarkDone(n int, now time.Time) error {
if n < 1 || n > len(s.Items) {
return fmt.Errorf("no item %d", n)
}
if s.Items[n-1].Done != nil {
return fmt.Errorf("item %d is already done", n)
}
s.Items[n-1].Done = &now
return nil
}
// Remove deletes item n (1-based).
func (s *Store) Remove(n int) error {
if n < 1 || n > len(s.Items) {
return fmt.Errorf("no item %d", n)
}
s.Items = append(s.Items[:n-1], s.Items[n:]...)
return nil
}
// DefaultPath is $TODO_FILE, or todo/todo.json under the user's config folder.
func DefaultPath() (string, error) {
if p := os.Getenv("TODO_FILE"); p != "" {
return p, nil
}
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "todo", "todo.json"), nil
}
And the help text is a plain file, embedded:
todo: a to-do list that lives in one JSON file.
Usage:
todo add <text...> add an item
todo list show open items (-all shows done ones too)
todo done <number> mark an item done
todo rm <number> delete an item
todo version print the version
The file is $TODO_FILE if set, otherwise todo.json in your user config folder.
A session:
$ go run ./ch16/projects/todo add buy milk
added #1: buy milk
$ go run ./ch16/projects/todo add walk the dog
added #2: walk the dog
$ go run ./ch16/projects/todo done 1
done #1
$ go run ./ch16/projects/todo list -all
1 [x] buy milk
2 [ ] walk the dog
$ go run ./ch16/projects/todo
todo: a to-do list that lives in one JSON file.
...
exit status 2
Things to notice:
- Subcommands are just
args[0]and aswitch. Each subcommand that needs flags makes its ownflag.NewFlagSet, solist -alldoes not leak intoadd. runtakes everything it touches as parameters: the arguments, the file path, the writer, the clock. That is why the test can run a whole session against a temp file with a fixed clock and compare output exactly, and whymainis six lines.- State goes where the OS keeps it.
os.UserConfigDiris~/Library/Application Supporton macOS,~/.configon Linux,%AppData%on Windows.TODO_FILEoverrides it, which is what the tests use. - Save writes a temp file and renames it. A crash halfway through a write can never leave a half-written list.
- Usage errors are wrapped around one sentinel.
maintells a bad call (exit 2, print the help) from a real failure (exit 1) with a singleerrors.Is.
Shipping it
A Go binary is one file with no runtime to install. Building it for other people is a loop over operating systems and architectures, with the version stamped in through the linker:
#!/bin/sh
# Build todo for the common platforms into dist/, with the version stamped in.
# ./ch16/projects/todo/build.sh 1.0.0
set -eu
version=${1:-dev}
cd "$(dirname "$0")"
rm -rf dist && mkdir dist
for target in darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64; do
os=${target%/*}; arch=${target#*/}
out="dist/todo-$os-$arch"
[ "$os" = windows ] && out="$out.exe"
CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -trimpath -ldflags "-s -w -X main.version=$version" -o "$out" .
echo "built $out"
done
(cd dist && shasum -a 256 * > SHA256SUMS)
$ ./ch16/projects/todo/build.sh 0.1.0
built dist/todo-darwin-arm64
built dist/todo-darwin-amd64
built dist/todo-linux-amd64
built dist/todo-linux-arm64
built dist/todo-windows-amd64.exe
$ ./ch16/projects/todo/dist/todo-darwin-arm64 version
todo 0.1.0
-X main.version=0.1.0 sets the version variable without editing the source. -s -w drop debug symbols, which roughly halves the file; -trimpath keeps your home folder’s name out of it; CGO_ENABLED=0 makes a fully static binary that runs on any Linux. The SHA256SUMS file is how someone who downloads it checks that it arrived intact.
Two ways to hand it over. Attach the dist/ files to a GitHub release, which is what the gh release create command does in one line. Or, for anyone who has Go installed, publishing the module is enough: once this chapter is on the repo’s main branch, go install github.com/t0ul/automate-the-boring-stuff-with-go/ch16/projects/todo@latest builds and installs it on their machine with no download page at all. When a project outgrows a shell script, GoReleaser does the loop, the checksums and the release in one configured step.
Conclusion
This is the end of the book’s second part, and of the book as it stands in 2026. You can read spreadsheets, PDFs, CSV and JSON; talk to the web politely and in parallel; schedule work and run other programs; test what you write; and ship it as a single file anyone can run. Go back to the first project you wanted to build when you opened chapter 1. You have everything it needs now.