Go · lesson 24 of 25
CLI Tools in Go
Go command-line tools run for real: the flag package and -h, stdin pipes, stdout vs stderr, exit codes and os.Exit, env vars, subcommands, and cobra.
By SkillByExample · Updated
A good command-line tool reads flags and arguments, streams data through stdin and stdout, reports problems on stderr, and exits with a meaningful status. Go’s standard library covers all of it; every example here is built once with go build and then run as a real command.
Flags with the flag package
flag defines typed flags with defaults and help text, and generates -h output for free.
package main
import (
"flag"
"fmt"
"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.Parse()
msg := fmt.Sprintf("Hello, %s!", *name)
if *shout {
msg = strings.ToUpper(msg)
}
for range *count {
fmt.Println(msg)
}
fmt.Println("extra args:", flag.Args())
}
$ go build -o greet .
$ ./greet
Hello, World!
extra args: []
$ ./greet -name Gopher -n 2 --shout extra1 extra2
HELLO, GOPHER!
HELLO, GOPHER!
extra args: [extra1 extra2]
$ ./greet -n two; echo "exit status $?"
invalid value "two" for flag -n: parse error
Usage of ./greet:
-n int
number of greetings (default 1)
-name string
who to greet (default "World")
-shout
print in upper case
exit status 2
$ ./greet -h; echo "exit status $?"
Usage of ./greet:
-n int
number of greetings (default 1)
-name string
who to greet (default "World")
-shout
print in upper case
exit status 0
flag.String and friends return pointers, filled in by flag.Parse(). One or two dashes both work, and boolean flags need no value. Anything after the flags is in flag.Args(). An invalid value prints the error and the usage, then exits with status 2 — the Unix convention for bad usage. -h prints the generated help and exits 0. Note that flag stops at the first non-flag argument: ./greet extra -n 2 would treat -n and 2 as plain arguments.
stdin, stdout and stderr
A filter reads stdin when given no files, writes results to stdout, and sends errors to stderr so they don’t pollute piped data.
package main
import (
"bufio"
"fmt"
"io"
"os"
)
func countLines(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
n := 0
for sc.Scan() {
n++
}
return n, sc.Err()
}
func main() {
if len(os.Args) == 1 {
n, _ := countLines(os.Stdin)
fmt.Println(n)
return
}
failed := false
for _, path := range os.Args[1:] {
f, err := os.Open(path)
if err != nil {
fmt.Fprintln(os.Stderr, "count:", err)
failed = true
continue
}
n, _ := countLines(f)
f.Close()
fmt.Printf("%d %s\n", n, path)
}
if failed {
os.Exit(1)
}
}
$ go build -o count .
$ printf 'a\nb\nc\n' | ./count
3
$ printf 'one\ntwo\n' > two.txt
$ ./count two.txt main.go missing.txt; echo "exit status $?"
2 two.txt
40 main.go
count: open missing.txt: no such file or directory
exit status 1
$ ./count two.txt missing.txt 2>/dev/null
2 two.txt
$ ./count two.txt missing.txt 2>&1 >/dev/null
count: open missing.txt: no such file or directory
With no arguments, the tool read the piped text; with file names it processed each one, carrying on past the missing file but remembering the failure for the exit status. The last two lines separate the streams: 2>/dev/null hides the error and leaves the data, while 2>&1 >/dev/null keeps only the error. A script calling count can test $? and still trust stdout to contain only counts.
Exit codes and os.Exit
os.Exit ends the process immediately — deferred functions do not run.
package main
import (
"errors"
"fmt"
"os"
)
func run() error {
defer fmt.Println("cleanup in run")
return errors.New("config file not found")
}
func main() {
defer fmt.Println("deferred in main: never printed")
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
$ go build -o exitdemo .
$ ./exitdemo; echo "exit status $?"
cleanup in run
error: config file not found
exit status 1
run’s deferred cleanup happened because run returned normally; main’s did not, because os.Exit doesn’t unwind the stack. The usual shape is therefore a small main that calls run() error, does all real work and cleanup in run, and calls os.Exit only at the end. log.Fatal calls os.Exit(1) too, with the same effect on defers.
Environment variables
Configuration that differs per deployment often comes from the environment.
package main
import (
"fmt"
"os"
"strconv"
)
func main() {
port := 8080
if v, ok := os.LookupEnv("APP_PORT"); ok {
p, err := strconv.Atoi(v)
if err != nil {
fmt.Fprintf(os.Stderr, "APP_PORT=%q is not a number\n", v)
os.Exit(2)
}
port = p
}
fmt.Println("port:", port, "| debug:", os.Getenv("APP_DEBUG") == "1")
}
$ go build -o envdemo .
$ ./envdemo
port: 8080 | debug: false
$ APP_PORT=9090 APP_DEBUG=1 ./envdemo
port: 9090 | debug: true
$ APP_PORT=abc ./envdemo; echo "exit status $?"
APP_PORT="abc" is not a number
exit status 2
os.Getenv returns "" for both “unset” and “set to empty”; os.LookupEnv tells them apart with its second result. Validate environment values at startup and fail fast, as here, rather than discovering a bad port deep inside the program.
Subcommands with flag.FlagSet
Each subcommand gets its own FlagSet, with its own flags and help.
package main
import (
"flag"
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: todo <add|list> [flags]")
os.Exit(2)
}
switch os.Args[1] {
case "add":
fs := flag.NewFlagSet("add", flag.ExitOnError)
prio := fs.Int("p", 3, "priority 1-5")
fs.Parse(os.Args[2:])
fmt.Printf("added %q with priority %d\n", fs.Arg(0), *prio)
case "list":
fs := flag.NewFlagSet("list", flag.ExitOnError)
all := fs.Bool("all", false, "include done items")
fs.Parse(os.Args[2:])
fmt.Println("listing, all =", *all)
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1])
os.Exit(2)
}
}
$ go build -o todo .
$ ./todo add -p 1 "ship lesson"
added "ship lesson" with priority 1
$ ./todo list -all
listing, all = true
$ ./todo add -h; echo "exit status $?"
Usage of add:
-p int
priority 1-5 (default 3)
exit status 0
$ ./todo remove; echo "exit status $?"
unknown command "remove"
exit status 2
The go command itself is built this way. Subcommand flags come after the subcommand name, and each FlagSet prints only its own flags for -h.
Cobra
For larger tools with many subcommands, cobra adds GNU-style --long/-s flags, generated help for every command, and shell completion.
package main
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
)
func main() {
var upper bool
root := &cobra.Command{Use: "greet", Short: "A friendly greeter"}
hello := &cobra.Command{
Use: "hello NAME",
Short: "Say hello",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
msg := "hello, " + args[0]
if upper {
msg = strings.ToUpper(msg[:1]) + msg[1:]
}
fmt.Println(msg)
},
}
hello.Flags().BoolVarP(&upper, "capitalize", "c", false, "capitalize the greeting")
root.AddCommand(hello)
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
$ go get github.com/spf13/cobra@v1.10.2
go: downloading github.com/inconshreveable/mousetrap v1.1.0
go: downloading github.com/spf13/pflag v1.0.9
go: added github.com/inconshreveable/mousetrap v1.1.0
go: added github.com/spf13/cobra v1.10.2
go: added github.com/spf13/pflag v1.0.9
$ go build -o greet .
$ ./greet hello Ann
hello, Ann
$ ./greet hello -c Ann
Hello, Ann
$ ./greet hello; echo "exit status $?"
Error: accepts 1 arg(s), received 0
Usage:
greet hello NAME [flags]
Flags:
-c, --capitalize capitalize the greeting
-h, --help help for hello
exit status 1
$ ./greet --help
A friendly greeter
Usage:
greet [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
hello Say hello
help Help about any command
Flags:
-h, --help help for greet
Use "greet [command] --help" for more information about a command.
Cobra validated the argument count (ExactArgs(1)) and printed the error together with the command’s usage. The root command’s help lists subcommands, including the completion command cobra adds automatically for bash, zsh, fish and PowerShell. Whether that’s worth a dependency is a judgment call: for one command with a few flags, flag is enough.
Practice
1. Add a -w flag to count that counts words instead of lines.
Solution
package main
import (
"bufio"
"flag"
"fmt"
"os"
)
func main() {
words := flag.Bool("w", false, "count words instead of lines")
flag.Parse()
sc := bufio.NewScanner(os.Stdin)
if *words {
sc.Split(bufio.ScanWords)
}
n := 0
for sc.Scan() {
n++
}
fmt.Println(n)
}
$ go build -o count .
$ printf 'go is fun\nand fast\n' | ./count
2
$ printf 'go is fun\nand fast\n' | ./count -w
5
bufio.ScanWords changes what one Scan returns; the counting loop doesn’t change.
2. Make greet reject -n values below 1 with a message on stderr and exit status 2.
Solution
package main
import (
"flag"
"fmt"
"os"
)
func main() {
count := flag.Int("n", 1, "number of greetings (>= 1)")
flag.Parse()
if *count < 1 {
fmt.Fprintf(os.Stderr, "greet: -n must be at least 1, got %d\n", *count)
flag.Usage()
os.Exit(2)
}
for range *count {
fmt.Println("Hello!")
}
}
$ go build -o greet .
$ ./greet -n 0; echo "exit status $?"
greet: -n must be at least 1, got 0
Usage of ./greet:
-n int
number of greetings (>= 1) (default 1)
exit status 2
flag only checks that the value is an integer; range checks are yours. Calling flag.Usage() prints the same help -h would.
3. Write a tool that upper-cases stdin to stdout, line by line, so it can sit in the middle of a pipeline.
Solution
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
sc := bufio.NewScanner(os.Stdin)
w := bufio.NewWriter(os.Stdout)
defer w.Flush()
for sc.Scan() {
fmt.Fprintln(w, strings.ToUpper(sc.Text()))
}
if err := sc.Err(); err != nil {
fmt.Fprintln(os.Stderr, "upper:", err)
}
}
$ go build -o upper .
$ printf 'hello\nworld\n' | ./upper | sort -r
WORLD
HELLO
Buffering stdout with bufio.Writer avoids one system call per line, which matters for large inputs; the deferred Flush writes the remainder before exit (this tool never calls os.Exit, so the defer runs).