CLI App with Subcommands in Go

In this article we are going to take a look at parsing command-line arguments with the support of subcommands using the standard library in Go. Our example application, called futil, is a file utility which allows us to copy and delete files. The details are not really important here, as we just need a concrete example of a CLI tool with multiple commands.

futil [flags] <command> [command flags]

The general structure of our command-line tool is as follows:

  • we can provide global flags used for every command
  • specify one command we want to run
  • each command can have specific command flags

Here are some examples of commands we might want to run.

futil -help
futil -debug copy -overwrite input.txt output.txt
futil delete input.txt

You can probably guess what the commands above are supposed to do, but that’s not too important. We are not going to implement the actual command logic. Instead, we will focus on how to parse these commands using the standard library.

The flag package

The important data structure we care about when parsing command-line flags is the flag.FlagSet type. It allows us to specify various types of command-line flags (int, string, etc.), define a help/usage message, and provides easy access to all the command arguments that are not flags.

Creating a new FlagSet is easy:

flagSet := flag.NewFlagSet("flagSetName", flag.ExitOnError)

We can then use functions like flagSet.BoolVar to define a flag name and a variable where the parsed flag value should be stored. Here an example using a string flag with an empty string as default value.

var name string
flagSet.StringVar(&name, "name", "", "usage string")

Once you defined the flags, you can call flagSet.Parse(). This will take a list of strings as input (the command-line arguments) and parses the arguments one by one until it finds a value which is not a flag. If an unknown flag is encountered during parsing, the parse function will either return an error or exit the program if you specify flag.ExitOnError like we did in the example above.

args := []string{"-name", "nameValue", "sub_command", "-subFlag"}
flagSet.Parse(args)

This will parse the -name flag and the corresponding value nameValue and store nameValue into the name variable. Calling flagSet.Args() will now return the remaining arguments ["sub_command", "-subFlag"]. To parse the flags for our subcommand, we repeat the same process: create a new FlagSet and parse flagSet.Args()[1:] from our previous flagSet.

Example CLI App

By now you should have a rough understanding about FlagSets and how they work. Now let’s see how we are going to implement the futil tool described above.

import (
    "flag"
    "fmt"
    "os"
    "slices"
)

func main() {
    // Just an example for parsing a flag before any subcommand, we are not
    // using the value of this flag anywhere in the example program
    var showDebugLog bool
    flag.BoolVar(&showDebugLog, "debug", false, "print debug messages")

    flag.Usage = usage // see below
    flag.Parse()

    // user needs to provide a subcommand
    if len(flag.Args()) < 1 {
        flag.Usage()
        os.Exit(1)
    }

    subCmd := flag.Arg(0)
    subCmdArgs := flag.Args()[1:]

    fmt.Println(subCmd, subCmdArgs)
}

Instead of creating a new FlagSet to parse the arguments, we are using the default FlagSet provided by the standard library. Functions like flag.BoolVar, flag.Usage will manipulate this default FlagSet, which is defined as follows in the flag package of the standard library.

var CommandLine = NewFlagSet(os.Args[0], ExitOnError)

flag.Parse will then simply call CommandLine.Parse(os.Args[1:]), which is exactly what we need in the first step to parse the global flags.

The usage function is pretty simple. Just a bunch of fmt.Fprint... calls.

func usage() {
    intro := `futil is a simple file manipulation program.

Usage:
  futil [flags] <command> [command flags]`
    fmt.Fprintln(os.Stderr, intro)

    fmt.Fprintln(os.Stderr, "\nCommands:")
    // TODO: print commands help

    fmt.Fprintln(os.Stderr, "\nFlags:")
    // Prints a help string for each flag we defined earlier using
    // flag.BoolVar (and related functions)
    flag.PrintDefaults()

    fmt.Fprintln(os.Stderr)
    fmt.Fprintf(os.Stderr, "Run `futil <command> -h` to get help for a specific command\n\n")
}

Running the program using go run . -h will now print the following help message.

futil is a simple file manipulation program.

Usage:
  futil [flags] <command> [command flags]

Commands:

Flags:
  -debug
        print debug messages

Run `futil <command> -h` to get help for a specific command

Note that we did not implement parsing the -h flag. That is automatically handled by the flag.Parse function. Using the -h or -help flag will stop executing our program and call flag.Usage, which we have overwritten to print our custom message.

At this point we have access to the subcommand string inside the subCmd variable. The simplest way to handle the subcommand is probably just a switch on the subCmd and calling a different function for each subcommand. But I not only want to run each subcommand, but also define some help strings for each command which are going to be used in the usage function. So defining a simple Command struct will simplify things a bit.

type Command struct {
    Name string
    Help string
    Run  func(args []string) error
}

var commands = []Command{
    {Name: "copy", Help: "Copy a file", Run: copyFileCmd},
    {Name: "delete", Help: "Delete a file", Run: deleteFileCmd},
    {Name: "help", Help: "Print this help", Run: printHelpCmd},
}

func printHelpCmd(_ []string) error {
    flag.Usage()
    return nil
}

func copyFileCmd(args []string) error {...}
func deleteFileCmd(args []string) error {...}

A command is very simple. It has a name, a help message and a run function which implements the command logic. The printHelpCmd is also very simple, it just calls the flag.Usage function to print the custom usage message we defined earlier. Before we look at one of the subcommands, let’s update our usage function to also print the help messages for each subcommand.

func usage() {
    // ...
    fmt.Fprintln(os.Stderr, "\nCommands:")
    for _, cmd := range commands {
        fmt.Fprintf(os.Stderr, "  %-8s %s\n", cmd.Name, cmd.Help)
    }
    // ...
}

Let’s now look at the copyFileCmd to see how we implement one of the subcommands.

func copyFileCmd(args []string) error {
    var overwrite bool
    flagSet := flag.NewFlagSet("copy", flag.ExitOnError)
    flagSet.BoolVar(&overwrite, "overwrite", false,
        "overwrite the target file if it exists")
    flagSet.Usage = func() {
        fmt.Fprintln(os.Stderr, `Copy a file.

Usage:
  futil copy [flags] SOURCE DESTINATION

Flags:`)
        flagSet.PrintDefaults()
        fmt.Fprintln(os.Stderr)
    }
    flagSet.Parse(args)

    // actual copy implementation goes here
    fmt.Println("Copy", flagSet.Args())

    return nil
}

As you can see, we follow the same pattern as we did in our main function. The only difference is, that we use a custom FlagSet. We create the flagSet, bind a boolean variable to the -overwrite flag, overwrite the Usage function to print a custom message and then finally call flagSet.Parse. The deleteFileCmd follows the exact same pattern, so I’m not going to provide the implementation here.

Now all that is left to do is to actually call the correct subcommand from our main function. So we are going to update our main function as follows:

func main() {
    // ...
    subCmd := flag.Arg(0)
    subCmdArgs := flag.Args()[1:]

    runCommand(subCmd, subCmdArgs)
}

func runCommand(name string, args []string) {
    cmdIdx := slices.IndexFunc(commands, func(cmd Command) bool {
        return cmd.Name == name
    })

    if cmdIdx < 0 {
        fmt.Fprintf(os.Stderr, "command \"%s\" not found\n\n", name)
        flag.Usage()
        os.Exit(1)
    }

    if err := commands[cmdIdx].Run(args); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %s", err.Error())
        os.Exit(1)
    }
}

That’s it. We now have a CLI app including global flags, subcommands and command specific flags, all of which are documented using the Usage functions. Run go run . -h or go run . help to show the help message including a listing of all subcommands. Run go run . copy -h to show the help message of the copy command.

Closing thoughts

Hopefully you saw how easy it is the parse command-line flags and arguments using Go. A bit verbose maybe but still pretty straightforward. And writing an app specific abstraction layer to hide the verbosity behind a few functions and data structures is also quite easy. But if you don’t feel like writing your own abstraction layer, maybe checkout one of the 3rd party libraries for parsing command line arguments like cobra or KONG. I’ve used both in the past and was quite happy with them.