Bootstrap your project, add commands and flags, and create a usable CLI tool
Command-line interfaces (CLIs) are a powerful way to interact with your OS. They are often used for repetitive tasks or requiring a lot of precision. Golang is a modern programming language known for its performance, simplicity, and concurrency support.
By the end of this article, you will have a working knowledge of how to use Cobra and Viper to create your own Golang CLI tools. The code referenced in this article is available on GitHub. I’ve created a separate branch for each step to easily isolate the step’s concept.
Step 1: Project Setup
Follow along: https://github.com/mwiater/golangcliscaffold/tree/step1
First, let’s set up the project directory, install Cobra, and initialize the CLI. It’s good practice to set up your repository ahead of time and then use that for your Go Module name. Substitute your own module name for the github.com/mwiater/golangcliscaffold I use in this project.
mkdir golangcliscaffold && cd golangcliscaffold
go mod init github.com/mwiater/golangcliscaffold
go install github.com/spf13/cobra-cli@latest
cobra-cli init
Your project structure will now look like this:
.
├── cmd
│ └── root.go
├── go.mod
├── go.sum
├── LICENSE
└── main.go
1 directory, 5 files
Next, let’s update the cmd/root.go file. This file is where your base command and global flags are set up. Let’s modify this file to give us some meaningful output and remove the existing default flags:
var rootCmd = &cobra.Command{
Use: "getsize",
Short: "List the size of a local directory.",
Long: `This command will display the size of a directory with several different options.`,
}
...
func init() {
}
Next, build and run your app.
Note: Since we don’t always want to build the app with the name of the Go Module, we can output our executable as a different name. Since we named it getsizeabove, we’ll build it as bin/getsize.
Build: go build -o bin/getsize
Run: ./bin/getsize –help
For now, you’ll only see the minimal description that we used above:
This command will display the size of a directory with several different options.
Step 2: Add Your First Subcommand
Follow along: https://github.com/mwiater/golangcliscaffold/tree/step2
Cobra makes it easy to scaffold out subcommands. For this example, we’ll add a files subcommand: cobra-cli add files After issuing that command, our directory tree will look like this:
.
├── bin
│ └── getsize
├── cmd
│ ├── files.go
│ └── root.go
├── common
│ └── common.go
├── go.mod
├── go.sum
├── LICENSE
└── main.go
Note that cmd/files.go was added. To see how your app changed by invoking this new subcommand, build and run: go build -o bin/getsize && ./bin/getsize –help
This command will display the size of a directory with several different options.
Usage:
getsize [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
files A brief description of your command
help Help about any command
Flags:
-h, --help help for directorylist
Use "getsize [command] --help" for more information about a command.
Note that the files command was added to Available Commands. Let’s update the cmd/list.go file to give it a mildly better description:
...
// filesCmd represents the files command
var filesCmd = &cobra.Command{
Use: "files",
Short: "Show the largest files in the given path.",
Long: `Quickly scan a directory and find large files.`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("files called")
},
}
...
As we did above, we can now show the description for the files subcommand via go build -o bin/getsize && ./bin/getsize files –help:
Quickly scan a directory and find large files.
Usage:
getsize files [flags]
Flags:
-h, --help help for files
Notice that even though we’re in our subcommand, it’s inherited the default global — and automatically included (thanks, Cobra!) –help flag.
Step 3: Global Flags
Follow along: https://github.com/mwiater/golangcliscaffold/tree/step3
Global flags are flags that apply to both the main command getsize and any subcommand, e.g., files. In the following example, we add global verbose and debug boolean flags. We’d likely want to flip these flags for all available commands. This is as opposed to flags specific for subcommands, which we’ll see later.
cmd/root.go
var Verbose bool
var Debug bool
...
func init() {
rootCmd.PersistentFlags().BoolVarP(&Verbose, "verbose", "v", false, "Display more verbose output in console output. (default: false)")
viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
rootCmd.PersistentFlags().BoolVarP(&Debug, "debug", "d", false, "Display debugging output in the console. (default: false)")
viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug"))
}
The following two commands handle each flag:
- rootCmd.PersistentFlags().BoolVarP(): This Cobra command sets the long and short names for the flag, the default value, and the description. These all get assigned to a variable, e.g., we assign the debug values to the Debug variable, which we can access from within our subcommand. The BoolVarP() bit allows us to define the flag type, in this case, a Boolean flag, accepting false as the default. Other types are declared as you’d expect: StringVarP(), IntVarP(), etc.
- viper.BindPFlag: I can’t state it better than the docs, so:
“pflag is a drop-in replacement for Go’s flag package, implementing POSIX/GNU-style — flags.”
The PersistentFlags()function means we want to persist this flag here and under any related subcommand. Since we are in the rootCmd, flags defined in this way are considered Global flags. All other commands belong to rootCmd.
We can see this in action by running help on a subcommand, e.g., go build -o bin/getsize && ./bin/getsize files –help.
Quickly scan a directory and find large files.
Usage:
getsize files [flags]
Flags:
-h, --help help for files
Global Flags:
-d, --debug Display debugging output in the console. (default: false)
-v, --verbose Display more verbose output in console output. (default: false)
The new global flags, as expected, are inherited by the subcommand and made available for use. But how can we take advantage of these new flags with our subcommand?
Step 4: Meta-Exploration — Creating a CLI Package and Self-Testing Flags
Follow along: https://github.com/mwiater/golangcliscaffold/tree/step4
Any easy way to illustrate global flag use in a subcommand is by making use of our debug flag. Let’s modify our files subcommand at files/files.go.
package cmd
import (
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// filesCmd represents the files command
var filesCmd = &cobra.Command{
Use: "files",
Short: "Show the largest files in the given path.",
Long: `Quickly scan a directory and find large files.`,
Run: func(cmd *cobra.Command, args []string) {
for key, value := range viper.GetViper().AllSettings() {
log.WithFields(log.Fields{
key: value,
}).Info("Command Flag")
}
},
}
func init() {
rootCmd.AddCommand(filesCmd)
}
Above, we are doing a few things:
- Added Logrus for clean logging output.
- If the debug flag is set to true, we iterate over viper.GetViper().AllSettings() and print out each flag key and value we’ve set so far. At the moment, these are the two global flags: debug and verbose.
This can be tested by running go build -o bin/getsize && ./bin/getsize files which shows no output: by default, the debug flag is false. When we explicitly set the debug flag to true like this go build -o bin/getsize && ./bin/getsize files –debug, we now get this:
INFO[0000] Command Flag verbose=false
INFO[0000] Command Flag debug=true
And now, we’re eating our own dog food: we’re setting a variable to show us which variables are set! Sure, kinda useless as far as applicability, but I find it a good way to illustrate the concept without dirtying it up with unrelated code.
But wait, there’s more!
Note: If you just want to pull a single variable, you can just directly call its key, e.g., viper.GetBool(“debug”). Viper works hand-in-hand with Cobra and calls out value types by function. Since debug is a Boolean, we use GetBool(). Likewise, we could use GetInt(), GetString(), etc., for other variable types.
Step 5: Subcommand Flags
Follow along: https://github.com/mwiater/golangcliscaffold/tree/step5
As a practical example, let’s say we wanted to build a local directory scanner. Confession: I constantly build VMs too small, probably hoping to be more thoughtful about the dependencies I install. But then I get on a random LLM kick, and poof: best-laid plans laid to waste. So, I’m constantly figuring out what nightmarish tangent I was on that ate up all of my VM disk space. So:
- getsize files: Given a –path, this command reports the largest files recursively within that path. So that we can limit the number of results in directories containing a lot of files, we’ll set thresholds for the number of files returned (–filecount) and the minimum file size to report (–minfilesize). As a bonus, we’ll create a minimum threshold number in megabytes so that we can highlight large files that we want to target in the results (–highlight).
- getsize dirs: Given a –path, this command reports the largest directories recursively within that path, summing up the file sizes in each. Again, so that we can limit the number of results in directories containing a lot of subdirectories, we’ll set thresholds for the maximum depth of directories to report (–depth) and the minimum directory size to report (–mindirsize). Again, we’ll create a minimum threshold number in megabytes so that we can highlight large files that we want to target in the results (–highlight).
By planning ahead for the above, we can already see that the two different commands share flags that do the same thing: –path and –highlight. For this example, these make sense as global flags. The other flags will only apply to their specific subcommand. The setup for this scenario is as follows:
The Root Command: getsize
As discussed earlier, the cmd/root.go file is where the global flags should go, as these flags will apply to all subcommands:
...
func init() {
rootCmd.PersistentFlags().BoolVarP(&Verbose, "verbose", "v", false, "Display more verbose output in console output. (default: false)")
viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
rootCmd.PersistentFlags().BoolVarP(&Debug, "debug", "d", false, "Display debugging output in the console. (default: false)")
viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug"))
rootCmd.PersistentFlags().IntVarP(&Highlight, "highlight", "", 500, "Highlight files/directories over this threshold, in MB")
viper.BindPFlag("highlight", rootCmd.PersistentFlags().Lookup("highlight"))
rootCmd.PersistentFlags().StringVarP(&Path, "path", "p", "Define the path to scan.")
rootCmd.MarkPersistentFlagRequired("path")
viper.BindPFlag("path", rootCmd.PersistentFlags().Lookup("path"))
}
...
Nothing too different from what we’ve already done previously, but we have added: rootCmd.MarkPersistentFlagRequired(“path”). Since we don’t want our CLI tool to scan arbitrary default paths, we mark this as required, and the command will error out before running if this flag is not explicitly set.
Let’s check: go build -o bin/getsize && ./bin/getsize –help:
This command will display the size of a directory with several different options.
Usage:
getsize [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
dirs Show the largest directories in the given path.
files Show the largest files in the given path.
help Help about any command
Flags:
-d, --debug Display debugging output in the console. (default: false)
-h, --help help for getsize
--highlight int Highlight files/directories over this threshold, in MB (default 500)
-p, --path string Define the path to scan. (required)
-v, --verbose Display more verbose output in console output. (default: false)
Use "getsize [command] --help" for more information about a command.
All looks good! Since we’re looking at the main getsize command, note that the flags we created are simply under Flags:.
The Subcommand: getsize files
The cmd/files.go file is where the flags that only apply to this subcommand go:
...
func init() {
rootCmd.AddCommand(filesCmd)
filesCmd.PersistentFlags().IntVarP(&Filecount, "filecount", "f", 10, "Limit the number of files returned")
viper.BindPFlag("filecount", filesCmd.PersistentFlags().Lookup("filecount"))
filesCmd.PersistentFlags().Int64VarP(&Minfilesize, "minfilesize", "", 50, "Minimum size for files in search in MB.")
viper.BindPFlag("minfilesize", filesCmd.PersistentFlags().Lookup("minfilesize"))
}
...
Above, we’ve defined filecount and minfilesize flags to limit what is returned in the results. Now, we can specify how many files to list and a threshold for what size files we’re concerned about. It’s also important to note that we’ve set reasonable defaults if these flags are not explicitly defined.
Let’s check: go build -o bin/getsize && ./bin/getsize files –help:
Quickly scan a directory and find large files. . Use the flags below to target the output.
Usage:
getsize files [flags]
Flags:
-f, --filecount int Limit the number of files returned (default 10)
-h, --help help for files
--minfilesize int Minimum size for files in search in MB. (default 50)
Global Flags:
-d, --debug Display debugging output in the console. (default: false)
--highlight int Highlight files/directories over this threshold, in MB (default 500)
-p, --path string Define the path to scan. (default "/home/matt")
-v, --verbose Display more verbose output in console output. (default: false)
Again, the help command shows us exactly what we expect. This time, however, since we’re viewing the help for the files subcommand, the flags specific to this command are labeled Global Flags.
The Subcommand: getsize dirs
The cmd/dirs.go file is where the flags that only apply to this subcommand go:
...
func init() {
rootCmd.AddCommand(dirsCmd)
dirsCmd.PersistentFlags().IntVarP(&Depth, "depth", "", 2, "Depth of directory tree to display")
viper.BindPFlag("depth", dirsCmd.PersistentFlags().Lookup("depth"))
dirsCmd.PersistentFlags().IntVarP(&Mindirsize, "mindirsize", "", 100, "Only display directories larger than this threshold in MB.")
viper.BindPFlag("mindirsize", dirsCmd.PersistentFlags().Lookup("mindirsize"))
}
...
Let’s check the output: go build -o bin/getsize && ./bin/getsize dirs –help:
Quickly scan a directory and find large directories. Use the flags below to target the output.
Usage:
getsize dirs [flags]
Flags:
--depth int Depth of directory tree to display (default 2)
-h, --help help for dirs
--mindirsize int Only display directories larger than this threshold in MB. (default 100)
Global Flags:
-d, --debug Display debugging output in the console. (default: false)
--highlight int Highlight files/directories over this threshold, in MB (default 500)
-p, --path string Define the path to scan. (default "/home/matt")
-v, --verbose Display more verbose output in console output. (default: false)
As expected, our subcommand flags are organized exactly how we want them!
Step 6: Build Something “Useful”
Follow along: https://github.com/mwiater/golangcliscaffold/tree/step6
Before we get started, put your flamethrowers away! I realize that bashing out native Linux commands like du -h /home/matt | sort -n -r | head -n 10 is likely faster, already exists, and is not reinventing the wheel. But as an older engineer juggling more than ten language-specific syntaxes, I can’t remember it all. Anyone that has had go vet complain about the fact that you accidentally wrote a quick “golang” function in TypeScript, well, welcome to my world.
So, if I find myself looking up command syntax more than once a week, reinventing the wheel might be worth it. In fact, as an engineer, data is everything. Even if it is reinventing the wheel, it makes sense in my current context of exploring a new language like Go. It’s always great to have built-in guardrails and expectations. By replicating something that exists natively, there are already existing benchmarks like:
- Expected output: Does your created output match a reliable, existing tool?
- Expected throughput: Is the performance of your newly invented wheel performing close to the standard?
Given that wordy caveat, let’s take a look at a simple example application making use of the ideas above: helpful –help flags, global flags, and persistent and unique flags for multiple subcommands. While I won’t go through all of the code line-by-line, I’ll refer to the important bits. You can look at the gory details here: files/files.go and dirs/dirs.go.
It’s a personal preference of mine to create separate packages for each subcommand. As we are building a CLI with two subcommands — files and dirs — I’ve isolated them in files/files.go and dirs/dirs.go respectively. You’re under no obligation to organize your project in this way. In case you peer deep into the abyss, you’ll know about my organizational methods. My project structure looks like this:
.
├── bin
├── cmd
│ ├── dirs.go
│ ├── files.go
│ └── root.go
├── common
│ └── common.go
├── dirs
│ └── dirs.go
├── files
│ ├── files.go
├── go.mod
├── go.sum
├── LICENSE
├── main.go
└── README.md
Examples
Files
./bin/getsize files –path=/home/matt –highlight=100 –debug
In the above example, I’ve set the path to my home directory /home/matt, the highlight flag to 100 and set debug to true, everything else is set to the defaults.
Dirs
./bin/getsize dirs –path=/home/matt –highlight=500 –mindirsize=200 –debug
To limit the length of the output for this example, I set the mindirsizeto 200 and set the highlight flag to files over 500MB, and have the debug flag set to true again — just so we can ensure that our explicit flags are correct and our implicit flag defaults are carried through.
Conclusion
Voila! Apparently, VS Code servers are kinda hefty. Who knew? I didn’t — and now I do! But they’re handy — and kinda essential in my workflow — so now I can account for that in the future.
Now, go build something extraordinary! Well, at least useful. You can no longer say that you don’t know how.
How to Use Cobra and Viper to Create Your First Golang CLI Tool was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.