CLI tools in Go with the standard flag package
Much of Go’s standard library is basic at first sight, but can take you a long way. http
is like that. flag
is also like that. I think the flag
package is unfairly maligned as underpowered.
Initially, it does seem that you are limited to simple constructions: the binary, then some named flags:
> greet --name Mike
Hi Mike!
But flag
supports so much more than this. Let’s dig in to what you can do.
While there are lots of libraries in Go, as other languages, to create advanced command line interfaces, do we need to pull in an extra library? For many applications, I don’t think you do. flag
has more to it than at first sight. It supports:
Short and long form flags for the same thing:
git commit --message "my commit" # vs git commit -m "my commit"
One or more positional arguments:
git add README.md cmd/discourse/main.go
Subcommands, where
commit
is a subcommand ofgit
:git commit --amend -m "my commit"
Global options followed by subcommands, such as
git
’s-C
:git -C /path/to/repo commit --amend -m "my commit"
Let’s explore how to use flag
to obtain these features.
flag
basics
The canonical example of flag
in Go is:
package main
import (
"flag"
"log"
)
func main() {
name := flag.String("name", "", "who to greet")
flag.Parse()
fmt.Printf("Hello %s!\n", *name)
}
This gives you the interface above:
> greet --name Mike
Hi Mike!
There are a couple of niceties off the bat, such as supporting both -name
and --name
.
Long and short flags
Often a program accepts two flags that mean the same thing. For example, git commit
accepts both --message
and -m
for the commit message.
The way that flag
supports this is somewhat odd, but pretty obvious once you know. You can use flag.*Var
to assign multiple flags to the same destination variable:
var message string
flag.StringVar(&message, "message", "", "commit message")
flag.StringVar(&message, "m", "", "commit message")
This page offers a deep dive into why this is so. An issue with this approach is that the flag
package doesn’t notice the two flags point to the same variable and will print them separately in its default Usage
implementation.
This is one aspect of flag
that feels like it’s potentially unintentional. But it does work.
Positional arguments
The flag
package’s handling of positional arguments is also idiosyncratic but works fine. Positional arguments appear after all named flags. After flag.Parse()
is called they are available using flag.Args()
, which returns a slice of strings, one per argument. The flag.NArg()
function is also available, which returns the same as len(flag.Args())
(as we can see in to source code).
Validation and type conversion of the positional arguments returned by flag.Args()
is your responsibility. On the other hand, the interface really is delightfully simple:
package main
import (
"flag"
"log"
)
func main() {
flag.Parse()
for _, name := range flag.Args() {
fmt.Printf("Hi %s!\n", name)
}
}
Used as:
> greet Mike Will John
Hi Mike!
Hi Will!
Hi John!
Subcommands
Subcommands are used to good effect in tools like kubectl
or git
, where a large amount of functionality is offered in a single tool. Usually they are a verb, such as commit
:
> git commit -m "my commit"
Hidden in plain sight within flag
is the FlagSet
struct. Methods on flag.FlagSet
implement all the functionality the flag
package provides. Functions such as flag.Parse()
delegate to a global default FlagSet
, called CommandLine
. While flag.Parse()
always looks at all the arguments passed to the command, flag.FlagSet
is the key to subcommands. It allows us to parse only those arguments that follow the subcommand.
I find it natural to build a Go package for each subcommand within my application. I use a flag.FlagSet
within each package to define and parse the command line structure for the subcommand, thereby isolating the command line parsing neatly for each subcommand. I like the way that the FlagSet
primitive allows this to happen with little ceremony, or need for explicit subcommand APIs.
This code uses a FlagSet
to read options for a greet
subcommand. The args
passed to Greet
are generated in the main()
function of the application, which we’ll see in a moment.
package greet
import (
"flag"
"fmt"
)
func Greet(args []string) {
log.Printf("Greet got args: %v", args)
fs := flag.NewFlagSet("Greet", flag.ExitOnError)
name := fs.String("name", "", "who to greet")
fs.Parse(args)
fmt.Printf("Good morning %s!\n", *name)
}
As with the global flag
functions, using flag.ExitOnError
will cause the application to exit and print the usage message. The usage message can be overridden if needed by assigning a function to FlagSet.Usage
.
If subcommands accept the same flags, the flag must be defined twice, in each subcommand’s FlagSet
. If the option truly affects global behaviour, however, it is possible to accept global flags before the subcommand name. But that’s getting ahead of ourselves a little.
To bring this application together, we can use a dispatcher pattern within the main()
function of the application. This dispatcher uses the first argument to the program to switch between the entry points of the subcommands. This code is able to dispatch to two or more subcommands:
package main
import (
"fmt"
"os"
"github.ibm.com/mike-rhodes/discourse/internal/greet"
"github.ibm.com/mike-rhodes/discourse/internal/farewell"
)
var commands = map[string]func([]string){
"greet": greet.Greet,
"farewell": farewell.Farewell,
}
func main() {
if len(os.Args) == 1 {
fmt.Println(usage())
os.Exit(1)
}
cmd, ok := commands[os.Args[1]]
if !ok {
fmt.Println(usage())
os.Exit(1)
}
cmd(os.Args[2:])
}
func usage() string {
s := "Usage: sl [command] [options]\nAvailable commands:\n"
for k := range commands {
s += " - " + k + "\n"
}
return s
}
This command is run as:
> discourse greet --name Mike
Good morning Mike!
> discourse farewell --name Mike
Until we meet again, Mike, farewell!
Global options in subcommands
Sometimes an option really is global, and complex enough that you don’t want every subcommand to have to implement its logic. An example might be a debug flag which alters the log level throughout the application; it might be easier to create a single logger in the main()
dispatcher code set to the right log level, then pass that logger to subcommand code.
When passed an array, a FlagSet.Parse()
will consume the flag-like arguments. That is, those that start with a -
, including those flags’ values. After Parse
hits anything un-flag-like, it stops, even if there are further flag-like things later. As we saw in positional arguments, it gives you the rest of the arguments via Args()
.
We can use this to create global options by accepting the global options before the subcommand name. Because the subcommand name looks un-flag-like, it will stop the parsing, meaning the subcommand and its arguments will end up in Args()
.
Invocation looks like this, with the global flags coming before the subcommand name:
> discourse --debug greet --name Mike
^ ^ ^-- subcommand flags
| `-- subcommand
`-- global option
We first need to create a FlagSet
containing the --debug
flag and use it to Parse
all of os.Args
. We can use the default flag
provided FlagSet
for that, using flag.Parse()
to parse the command line arguments. It will stop processing arguments once we hit farewall
(because farewell
doesn’t start with -
) and provide us ["farewall", "--name", "Mike"]
when we call flag.Args()
.
We can look at flag.Args()[0]
for the subcommand, then pass the remaining arguments into the subcommand function for it to process.
func main() {
// Define and parse the global options
debug := flag.Bool("debug", false, "print debug information")
flag.Parse()
// Pull the rest of the original arguments into a "subcommand line"
subcommand := flag.Args()
// Check we have a subcommand to run
if len(subcommand) == 0 {
printUsage()
os.Exit(1)
}
cmd, ok := commands[subcommand[0]]
if !ok {
printUsage()
os.Exit(1)
}
// Use our global option
if *debug {
log.Println("Debug info on")
}
// Call the subcommand with the remaining arguments
cmd(subcommand[1:])
}
Bringing it together
I’ve found that the flag
package provides for many of my needs when writing CLI applications. More than I first thought it would. I’ve found that the package produces decently readable code, particularly when used with the package-subcommand approach outlined above. I can often create natural feeling CLI applications without needing to reach for a more ostensibly feature-rich library.
You can find a complete skeleton application showing this approach on GitHub. It has subcommands; global flags; subcommand full and short-form flags; and positional arguments.