Abhinav Gupta | About

Subcommands with Go's flag package

Table of Contents

This article walks through usage of the Go standard library’s flag package to implement support for subcommands in a program.

1. Requirements

Usage of applications with subcommands typically takes the following form.

command [global options] subcommand [subcommand options] [subcommand arguments]

Where,

global options

all subcommands share these

subcommand options

specific to a subcommand

subcommand argument

non-option arguments specific to a subcommand

For example,

                             subcommand
command        subcommand     arguments
 .-'-.          .--'---.      .---'----.
  git --no-pager branch -m foo mybranch
     '-----.----'      '---.--'
        global       subcommand
        options        options

To implement subcommands with the flag package, we need to do the following:

2. Parse global options

Register global options with the flag package and parse them with flag.Parse. This looks like regular usage of the flag package.

var (
  _noPager  = flag.Bool("no-pager", false, "do not pipe into a pager")
  _workTree = flag.String("work-tree", "", "path  to the working tree")
)

func main() {
  flag.Parse()
💡 Tip
Prefix unexported globals with _ to prevent unintentional references or accidental shadowing to them.

3. Dispatch to subcommand

Retrieve the remaining arguments with flag.Args. The first of these is the subcommand name, and the rest are the arguments and options for the subcommand — we will parse those in the subcommand implementation.

args := flag.Args()
if len(args) == 0 {
  log.Fatal("Please specify a subcommand.")
}
cmd, args := args[0], args[1:] (1)
  1. Given the command invocation mygit branch -m foo mybranch, we get,

    cmd = "branch"
    args = ["-m", "foo", "mybranch"]

Match the subcommand by name and invoke the corresponding implementation with the remaining arguments in args.

switch cmd {
case "branch":
  branch(args)
case "checkout":
  checkout(args)
default:
  log.Fatalf("Unrecognized command %q. "+
    "Command must be one of: branch, checkout", cmd)
}
💡 Tip
Use %q for strings inside error messages so that they stand out from the rest of the error message.

4. Parse subcommand options

For each top-level flag parsing function in the flag package, the flag.FlagSet type provides a similar method.

func Arg(i int) string
func Args() []string
func Bool(...) *bool
func Duration(...) *time.Duration
func Float64(...) *float64
func Int(...) *int
func (*FlagSet) Arg(i int) string
func (*FlagSet) Args() []string
func (*FlagSet) Bool(...) *bool
func (*FlagSet) Duration(...) *time.Duration
func (*FlagSet) Float64(...) *float64
func (*FlagSet) Int(...) *int

In fact, if you look closer, you’ll find that the top-level functions are implemented by simply calling the corresponding method on the global flag.CommandLine FlagSet.

package flag

var CommandLine = NewFlagSet(...)

func Int(name string, value int, usage string) *int {
  return CommandLine.Int(name, value, usage)
}

Build a FlagSet for each subcommand and use these methods to parse the subcommand options and retrieve the remaining arguments.

func branch(args []string) {
  flag := flag.NewFlagSet("mygit branch", flag.ExitOnError) (1)
  var (
    del  = flag.Bool("d", false, "delete branch")
    move = flag.String("m", "", "move/rename a branch")
    list = flag.Bool("l", false, "list branch names")
  )
  flag.Parse(args) (2)
  args = flag.Args()
  1. The flag.ExitOnError option specifies that if there is a flag parsing error, the library should print a helpful message and exit. This matches the behavior of flag.CommandLine.

  2. Unlike the top-level flag.Parse function, the FlagSet.Parse method expects the command line arguments to be passed in.

That’s it! The branch subcommand above has options that are not shared with other subcommands.

💡 Tip

Shadow the flag package

In this example, flag := flag.NewFlagSet(..) shadows the flag package with a local variable.

flag := flag.NewFlagSet(...)
// cannot refer to flag package anymore

This is desirable. It ensures that you do not unintentionally use the top-level flag functions instead of methods on the flag set.

For example,

fset := flag.NewFlagSet("mygit branch", flag.ExitOnError)
var (
  del  = fset.Bool("d", false, "delete branch")
  move = flag.String("m", "", "move/rename branch") // OOPS (1)
)
  1. Oops! We registered the -m flag as a global flag instead of registering as a flag on the mygit branch subcommand.

By shadowing flag, we avoid this mistake.

5. Propagate global options

Often, but not always, applications support providing global options next to the subcommand options. For example,

$ git --work-tree=foo branch -l
* main

$ git branch -l --work-tree=foo
error: unknown option `work-tree=foo'

Oops! Git does not actually support this. But this post chose Git as an example, so we’ll implement support anyway.

Like Git, our program will currently reject global options passed to subcommands.

$ mygit --work-tree=foo branch [..]
# SUCCESS

$ mygit branch --work-tree=foo [..]
# ERROR: flag provided but not defined: --work-tree

Unlike Git, we will support this.

The top-level flag functions like flag.Bool and flag.String register flags against the flag.CommandLine flag set. This flag set holds the global options and is completely disconnected from the flag set that we constructed for the mygit branch command.

flag.CommandLine  .-- flag.NewFlagSet("mygit branch", ...)
 |                |
 '-- mygit [...] branch [...]

We can use two tools to solve this problem:

Add the following function to your code:

func registerGlobalFlags(fset *flag.FlagSet) {
  flag.VisitAll(func(f *flag.Flag) { (1)
    fset.Var(f.Value, f.Name, f.Usage) (2)
  })
}
  1. For every global option…​

  2. …​register a copy with the provided FlagSet

Use the new function in branch to register the global options with the new flag set.

    list = flag.Bool("l", false, "list branch names")
  )
  registerGlobalFlags(flag)
  flag.Parse(args)
  args = flag.Args()

The mygit branch subcommand is now aware of the global options and will update the variables on parse.

$ mygit --work-tree=foo branch [..]
# SUCCESS

$ mygit branch --work-tree=foo [..]
# SUCCESS

mygit is now more flexible than real Git — minus the actual functionality of Git, but that’s a discussion for another time.

5.1. The flag.Value interface

We glazed over the mechanics behind the FlagSet.Var method above. This section attempts to cover that in more detail.

The FlagSet.Var method (and the corresponding flag.Var function on flag.CommandLine) have the following signatures:

func (f *FlagSet) Var(value Value, name string, usage string)
func              Var(value Value, name string, usage string)

Both accept an object implementing the flag.Value interface, defined as follows.

type Value interface {
  Set(string) error (1)
  String() string   (2)
}
  1. receives a value from the command line

  2. reports the current value as a string

📢 Important
This is the backbone of the flag package. Every other flag is implemented in terms of this.

For example, when you call flag.Int, it ultimately builds an object that implements this interface around an int. This looks roughly like the following,

type intValue int

func (i *intValue) Set(s string) error {
  v, err := strconv.ParseInt(s, 0, strconv.IntSize)
  if err != nil {
    // ...
  }
  *i = intValue(v)
  return err
}

// String implementation omitted for brevity.

In the previous section, we exploited the fact that all flags are implemented in terms of flag.Value implementations like intValue above. We got access to these Value objects with flag.VisitAll, and re-registered these flags against a different FlagSet. Both FlagSets will feed into the same Value object.

Custom Value objects

You can implement custom flags by implementing this interface yourself and supplying the result to the Var method. For example, if you want a flag that accepts a comma-separated list of values, you can implement it around a []string, using strings.Split to turn the flag value into a slice.

type stringList []string

func (sl *stringList) Set(value string) error {
  for _, s := range strings.Split(value, ",") {
    *sl = append(*sl, s)
  }
  return nil
}

// String implementation omitted for brevity.

// Register the flag like so,
var items stringList
flag.Var(&items, "o", "comma-separated list of options")
// "-o foo,bar,baz" turns into []string{"foo", "bar", "baz"}

This, too, will work as expected with our Value re-registration trick above.

6. Conclusion

The standard library’s flag package is small and flexible. Explore it and see if it satisfies your needs before introducing third-party dependencies.

A future post may cover practices around structuring your usage of the package for better testing.

Written on 2022-08-13.