Adding Subcommands to Go CLIs
Command Line Interfaces (CLIs) use subcommands and flags to enable different program features. A
subcommand is a grouping of related features, and flags are options for controlling those features. The openssl
command provides a great example of subcommands and flags. openssl rand -base64 8
will generate 8 random bytes of
data with hexadecimal output. The subcommand is “rand” and “-base64” is the flag. Other openssl subcommands
like “s_client” or “x509”, provide different features and each has their own options.
When running the openssl command, the shell passes each space separated value into an argument list. It’s possible to
parse the list by checking if the second value is a subcommand, then looping over the rest to figure out
which are flags. Some programs like git
take this further by treating subcommands as seperate executables.
git status
actually runs the git-status
command in a subshell. The approach works but keeping help output and
flag parsing consistent between executables gets messy.
Fortunately several libraries are available to make subcommands and flags easier to manage. The one that I finally chose is urfave/cli which offers a builder style API. The examples below use version 3 of the library which can be imported as:
import "github.com/urfave/cli/v3"
urfave/cli with flags
This is how simple the urfave/cli library is to use. I configure a cli.Command struct, then call Run() to execute it.
func main() {
cmd := &cli.Command{
... struct goes here ...
}
if err := cmd.Run(context.Background(), os.Args); err != nil {
log.Fatal(err)
}
}
So what does the struct inside cli.Command look like? It’s a slice of Flag interfaces. allowing us to freely mix different flag types like StringFlag, BoolFlag, or IntFlag.
Flags: []cli.Flag{
&cli.IntFlag{
...
},
&cli.BoolFlag{
...
},
},
The other struct field is Action which runs a function after parsing the flags. The function can either be inline or passed as a reference. Here is an inline example.
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Bool("hello") {
hello(cmd.String("name"), cmd.Int("count"))
}
return nil
},
This is what my populated main() looks like.
func main() {
cmd := &cli.Command{
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "hello",
Required: false,
Usage: "Will greet a person with 'Hello'",
},
&cli.BoolFlag{
Name: "goodbye",
Required: false,
Usage: "Will tell a person 'Goodbye'",
},
&cli.IntFlag{
Name: "count",
Usage: "How many times to invoke function",
Required: false,
Value: 1,
},
&cli.StringFlag{
Name: "name",
Usage: "Tell me your name",
Required: true, // <- throws an error and prints help statement if name is not defined
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Bool("hello") {
hello(cmd.String("name"), cmd.Int("count"))
}
return nil
},
}
if err := cmd.Run(context.Background(), os.Args); err != nil {
log.Fatal(err)
}
}
A complete working example using flags can be downloaded.
Flags Demo
When invoked without the –name flag which is marked required, urfave/cli will throw an error and print the help statement.
$ go run flag_example.go --count 2 --hello
NAME:
flag_example - A new cli application
USAGE:
flag_example
OPTIONS:
--hello Will greet a person with 'Hello' (default: false)
--goodbye Will tell a person 'Goodbye' (default: false)
--count int How many times to invoke function (default: 1)
--name string Tell me your name
--help, -h show help
2025/10/02 08:36:19 Required flag "name" not set
exit status 1
Adding the –name flag results in it working as expected.
$ go run flag_example.go --count 2 --hello --name Adam
Hello Adam
Hello Adam
urfave/cli with subcommands
Subcommands are added in a similar way, except instead of using a slice of Flag, we are using a slice of Command which I described above. Instead of using –hello or –goodbye as a flag, this example creates the hello and goodbye subcommands.
func hello(ctx context.Context, cmd *cli.Command) error {
fmt.Println("Hello")
return nil
}
func goodbye(ctx context.Context, cmd *cli.Command) error {
fmt.Println("Goodbye")
return nil
}
func main() {
cmd := &cli.Command{
Commands: []*cli.Command{
{
Name: "hello",
Usage: "Greets a person with 'Hello'",
Action: hello,
},
{
Name: "goodbye",
Usage: "Tells a person 'Goodbye'",
Action: goodbye,
},
},
}
if err := cmd.Run(context.Background(), os.Args); err != nil {
log.Fatal(err)
}
}
Commands Demo
The autogenerated help statement when a subcommand isn’t used.
$ go run command_example.go
NAME:
command_example - A new cli application
USAGE:
command_example [global options] [command [command options]]
COMMANDS:
hello Greets a person with 'Hello'
goodbye Tells a person 'Goodbye'
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--help, -h show help
Invoking the goodbye subcommand
$ go run command_example.go goodbye
Goodbye
Combining flags and subcommands
Now that we know how flags and subcommands work, lets combine them to get that openssl like experience. But this is the great part, because subcommands are a slice of Command, and we know that Command accepts a slice of Flag, we already know how this works. Simply define a Flag for each subcommand.
Here’s an example where the hello subcommand supports a name flag and the goodbye subcommand supports a count flag.
func main() {
cmd := &cli.Command{
Commands: []*cli.Command{
{
Name: "hello",
Usage: "Greets a person with 'Hello'",
Action: runIt,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
Usage: "Tell me your name",
Required: true, // <- throws an error and prints help statement if name is not defined
},
},
},
{
Name: "goodbye",
Usage: "Tells a person 'Goodbye'",
Action: runIt,
Flags: []cli.Flag{
&cli.IntFlag{
Name: "count",
Usage: "How many times to invoke function",
Required: false,
Value: 1,
},
},
},
},
}
A complete example that combines flags and subcommands is available for download.
Combination Demo
Running the demo without any options
$ go run combo_example.go
NAME:
combo_example - A new cli application
USAGE:
combo_example [global options] [command [command options]]
COMMANDS:
hello Greets a person with 'Hello'
goodbye Tells a person 'Goodbye'
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--help, -h show help
Running the demo with a subcommand missing a required flag
$ go run combo_example.go hello
NAME:
combo_example hello - Greets a person with 'Hello'
USAGE:
combo_example hello
OPTIONS:
--name string Tell me your name
--help, -h show help
2025/10/05 09:33:19 Required flag "name" not set
exit status 1
Running the demo successfully
% go run combo_example.go hello --name Adam
Hello Adam
urfave is now myfave
I started this post as a few notes for myself while learning urfave/cli, but it turned into a great reminder of how flexible the library is. It keeps command structure clear, couples help statements and actions to flags and subcommands, resulting in polished CLIs.
If you’re building a Go CLI and want a simple API for managing subcommands and options, give urfave/cli a try. You’ll spend more time thinking about command logic and less time manaaging subcommands or flag options.
I can be found on Bluesky if you want to trade notes on building CLIs.