Command-Line Tools
Go is a favourite for command-line tools: programs compile to a single fast binary that's easy to distribute, start instantly and run on every platform. This lesson covers reading arguments with os.Args, options with the flag package, subcommands, environment variables, exit codes, and working with standard input and output so your tool plays well with others.
Arguments: os.Args
os.Args holds the command line: os.Args[0] is the program's path and the rest are the arguments, already split on spaces (quotes are handled by the shell):
package main import ( "fmt" "os" ) func main() { args := os.Args[1:] fmt.Println(len(args), "arguments") for i, a := range args { fmt.Printf("%d: %q\n", i, a) } }
go run . hello "big world"
2 arguments 0: "hello" 1: "big world"
Options with the flag package
The flag package parses options such as -n 5 or -verbose, converts them to the right types, and generates help text. Define flags, call Parse, then use the values; flag.Args() returns whatever is left over:
package main import ( "flag" "fmt" "strings" "time" ) func main() { count := flag.Int("n", 1, "how many times to repeat") upper := flag.Bool("upper", false, "print in capitals") sep := flag.String("sep", " ", "separator between words") delay := flag.Duration("delay", 0, "pause between lines (e.g. 200ms)") flag.Parse() text := strings.Join(flag.Args(), *sep) if *upper { text = strings.ToUpper(text) } for range *count { fmt.Println(text) time.Sleep(*delay) } }
go run . -n 3 -upper go is fun
GO IS FUN GO IS FUN GO IS FUN
Each flag.Int, flag.Bool… returns a pointer, filled in by Parse. Flags may be written -n 3, -n=3 or --n=3, and must come before the other arguments. Running with -h prints the help, built from the descriptions; an unknown flag prints an error and the help, and exits with status 2.
Subcommands
Tools like git and go have subcommands, each with its own flags. Give each one a flag.FlagSet:
package main import ( "flag" "fmt" "os" "strings" ) func main() { if len(os.Args) < 2 { fmt.Fprintln(os.Stderr, "usage: tasks <add|list> [options]") os.Exit(2) } switch os.Args[1] { case "add": fs := flag.NewFlagSet("add", flag.ExitOnError) priority := fs.Int("priority", 1, "priority from 1 (low) to 3 (high)") fs.Parse(os.Args[2:]) fmt.Printf("added %q with priority %d\n", strings.Join(fs.Args(), " "), *priority) case "list": fs := flag.NewFlagSet("list", flag.ExitOnError) all := fs.Bool("all", false, "include finished tasks") fs.Parse(os.Args[2:]) fmt.Println("listing tasks, all =", *all) default: fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1]) os.Exit(2) } }
go run . add -priority 2 write the report
added "write the report" with priority 2
For larger tools with many subcommands, nested help and shell completion, third-party libraries such as cobra are popular, but flag covers a lot of ground.
Environment variables
os.Getenv returns a variable's value, or "" if it isn't set; os.LookupEnv also tells you whether it was set at all. Environment variables are the usual way to configure programs running in containers and cloud platforms:
package main import ( "fmt" "os" ) func envOr(key, fallback string) string { if v, ok := os.LookupEnv(key); ok { return v } return fallback } func main() { os.Setenv("APP_MODE", "production") // normally set outside the program fmt.Println(envOr("APP_MODE", "development")) fmt.Println(envOr("APP_PORT_NOT_SET", "8080")) }
production 8080
Standard input, output and exit codes
Good command-line tools follow a few conventions: read from standard input when no file is given, so they work in pipelines; write results to standard output and messages to standard error; and exit with status 0 on success and non-zero on failure, so scripts can react:
package main import ( "bufio" "fmt" "os" "strings" ) func main() { sc := bufio.NewScanner(os.Stdin) lineNo, problems := 0, 0 for sc.Scan() { lineNo++ line := sc.Text() if strings.Contains(line, "\t") { fmt.Fprintf(os.Stderr, "line %d: contains a tab\n", lineNo) problems++ continue } fmt.Println(strings.TrimRight(line, " ")) } if problems > 0 { os.Exit(1) } }
clean line trailing spaces a tab
clean line trailing spaces line 3: contains a tab exit status 1
Run it as cat notes.txt | go run . > cleaned.txt: the cleaned text goes to the file, while the warning still appears on screen because it was written to standard error. (exit status 1 is printed by go run; a built binary just exits with that status.)
Embedding files
A //go:embed directive puts files into the compiled binary — templates, default config, a web front-end — so your tool stays a single file:
tasks - a tiny task manager Commands: add, list, done
package main import ( _ "embed" "fmt" ) //go:embed help.txt var helpText string func main() { fmt.Print(helpText) }
tasks - a tiny task manager Commands: add, list, done
Embed into a string, a []byte, or an embed.FS for whole folders.
Building and distributing
go build produces one self-contained executable. Cross-compile for other systems by setting GOOS and GOARCH, and add -ldflags="-s -w" to make the binary smaller by leaving out debug information:
GOOS=windows GOARCH=amd64 go build -o tasks.exe .
GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o tasks-linux-arm64 .
Users with Go installed can also install your tool straight from its repository with go install github.com/you/tasks@latest.
Try it yourself
Write a tiny wc: count lines, words and characters on standard input, with a -l flag to print only the line count.
Go makes command-line tools easy
3 5 33
log/slog.