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 of git:

    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.

← Older
A (VS Code) theme of my own
→ Newer
My weird network, or WiFi to Ethernet to Wifi again: will it work?