Writing CLI Applications using the standard Flag Package
January 14th, 2024
The Flag package can do more than we think
It is common practice within the Go community to reach for cobra when writing CLIs in Go. It provides a lot of functionality and is a good choice for many projects. However I believe it is overused. Part of our ethos within the Go community is to stick to the standard library as much as possible and avoid third party dependencies. There is a common misconception that the flag package is handy for parsing simple flag arguments but not for constructing complex CLIs that utilize subcommands. Let's disprove it!
Small Idiosyncrasies: A Great Design
Let's take a simple greeter program as an example. Our program takes in a flag for the language we want to output our greeting in, and an optional positional argument for the name of the person we are greeting.
package main
import (
"flag"
"fmt"
"os"
)
type Config struct {
Language string
}
func main() {
var cfg Config
flag.StringVar(&cfg.Language, "lang", "en", "language to use for greeting")
flag.Parse()
name := flag.Arg(0)
switch cfg.Language {
case "en":
fmt.Printf("Hello %s\n", name)
case "fr":
fmt.Printf("Salut %s\n", name)
default:
fmt.Fprintf(os.Stderr, "language %q not supported\n", cfg.Language)
os.Exit(1)
}
}
Suppose that we compiled this program to the binary greet . Let's look at the output of a couple invocations:
greet Hello greet -lang fr Salut greet -lang fr Alice Salut Alice greet Alice -lang fr Hello Alice
The last result can be surprising to some that haven't seen worked with the flag package before. Flags must come before positional arguments. If they come after the first positional argument then they themselves are considered positional args. We can see this if we print out the parsed args via flag.Args()
In the latter case we would get the result:
[]string{"Alice", "-lang", "fr"}
This may seem odd. It may even be slightly annoying when working with CLIs that won't let you tag on some flags after your last positional argument.
I am sure everybody has run into the difference between:
go build -o ./exe ./cmdand
go build ./cmd -o ./exe
However this design has a useful property: it allows us to build subcommands. The positional args of the parent command can become the flags of a subcommand. This is done by using flag.NewFlagSet .
Let us break our greet command into two subcommands: friend and stranger.
package main
import (
"flag"
"fmt"
"os"
)
type Config struct {
Language string
}
func main() {
var cfg Config
flag.StringVar(&cfg.Language, "lang", "en", "language to use for greeting")
flag.Parse()
if cfg.Language != "en" && cfg.Language != "fr" {
fmt.Fprintf(os.Stderr, "language %q not supported\n", cfg.Language)
os.Exit(1)
}
cmd := flag.Arg(0)
if cmd != "friend" && cmd != "stranger" {
fmt.Fprintf(os.Stderr, "unknown command %q\n", cmd)
os.Exit(1)
}
// We take all the args after our first positional arg to be the commandline args of our subcommand
subCmdArgs := flag.Args()[1:]
switch cmd {
case "friend":
GreetFriend(cfg, subCmdArgs)
case "stranger":
GreetStranger(cfg, subCmdArgs)
}
}
// GreetFriend is our friend subcommand.
// It will parse the args given to it for a positional arg that represents the friends name.
func GreetFriend(cfg Config, args []string) {
commandLine := flag.NewFlagSet("greet friend", flag.ExitOnError)
commandLine.Parse(args) // We parse the args given to us to allow us find the first positionl argument
name := commandLine.Arg(0) // get the first positional arg.
if name == "" {
name = "buddy"
}
switch cfg.Language {
case "en":
fmt.Printf("Hello %s\n", name)
case "fr":
fmt.Printf("Salut %s\n", name)
}
}
type StrangerOptions struct {
Config
Informal bool
}
// GreetStranger is our stranger subcommand.
// We don't know the name of our stranger so use some generic greeting.
// It inherits the parent commands configuration and defines its own flags that we will parse.
func GreetStranger(cfg Config, args []string) {
opts := StrangerOptions{Config: cfg}
commandLine := flag.NewFlagSet("greet stranger", flag.ExitOnError)
commandLine.BoolVar(&opts.Informal, "informal", false, "use a more informal greeting")
commandLine.Parse(args)
switch opts.Language {
case "en":
if opts.Informal {
fmt.Println("Hey, what's up?")
return
}
fmt.Println("Hello, how are you?")
case "fr":
if opts.Informal {
fmt.Println("Salut, ça va ?")
return
}
fmt.Println("Bonjour, comment ça va ?")
}
}
With this approach, we are able to break down our CLI applications into multiple subcommand have different flag sets for each.
greet -lang fr friend Alice Salut Alice greet stranger -informal Hey, what's up?
Conclusion
Although the flag package isn't as batteries included as notable CLI frameworks, it is versatile and flexible. You can easily describe your CLI application as simple functions and have as many nested commands as you desire with nothing more than the standard library.