35.3 cobra: The Standard for Feature-Rich CLIs
Look, I love the standard library’s flag package. It’s like a trusty old pocket knife: simple, reliable, and gets the job done for small tasks. But when you’re building a CLI that needs more than a single command—think git with its commit, push, pull—you’ll quickly find yourself trying to build a skyscraper with that pocket knife. You could do it, but you’d be welding with a butane lighter and it would be a nightmare.
This is where cobra swoops in. It’s the de facto standard for serious Go CLI tools (Docker, Kubernetes, Helm, Hugo, and many more use it). It’s not just a parser; it’s a full-framework for organizing your application. Let’s get into it.
The Core Concepts: Commands, Flags, and Args
cobra structures your tool around three ideas:
- Commands: The actions.
myapp create,myapp list,myapp delete. The root command ismyappitself. - Flags: The modifiers for those actions.
--name "foo",--force,-v. - Args: The things you act upon.
myapp delete my-resource, wheremy-resourceis the argument.
This structure is intuitive because it’s what users already expect from high-quality tools.
Starting with the Root Command
Every cobra application starts with a root command. It’s the entry point. Let’s build a toy CLI for managing a list of bad ideas we’ve had.
package main
import (
"fmt"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "badideas",
Short: "A registry of profoundly bad ideas",
Long: `A longer description that explains this tool manages a list of ideas we really shouldn't have acted on.`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Welcome to the Bad Ideas registry. Use 'list' or 'add'.")
},
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
Run this with go run main.go, and it prints the welcome message. Use: defines the name, Short and Long are for help text. Run is the function that executes for this specific command. This is your foundation.
Adding Subcommands and Local Flags
Now, let’s add a subcommand to list our ideas. We’ll also add a local flag (--all) that only works with the list command.
var listCmd = &cobra.Command{
Use: "list",
Short: "List all your bad ideas",
Run: func(cmd *cobra.Command, args []string) {
showAll, _ := cmd.Flags().GetBool("all")
if showAll {
fmt.Println("Listing ALL ideas, even the truly unhinged ones...")
} else {
fmt.Println("Listing only the recent, socially acceptable bad ideas...")
}
},
}
func init() {
listCmd.Flags().BoolP("all", "a", false, "Show all ideas, including the terrible ones")
rootCmd.AddCommand(listCmd)
}
The init() function is where we wire everything together. Flags().BoolP adds a flag. The P variant lets you specify a short form (-a) and a long form (--all). We attach the listCmd to the rootCmd. Now badideas list --all works, but badideas --all will scream at you, rightly so, because --all isn’t defined on the root command. This scoping is exactly what you want.
Persistent Flags: For When You Need It Everywhere
What if you need a flag on every command? A --config file path or a --verbose flag, for instance. That’s a persistent flag.
var configFile string
func init() {
rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is $HOME/.badideas.yaml)")
}
Now, badideas --config /tmp/test.yaml list will work. cobra automatically handles this flag for the root command and makes it available to every single subcommand beneath it. It’s brilliantly convenient.
The Rough Edges and Pitfalls
cobra is powerful, but it’s not perfect. Here’s what to watch for:
The Empty
RunProblem: If you define a command with subcommands but noRunfunction, and a user types just that command,cobrawill helpfully print its help text. This is usually what you want. But if you do define aRunfunction for a parent command, its subcommands become unreachable unless called explicitly. It’s a classic “is it a container or an executable?” design choice. Think it through.Flag Parsing Happens Automatically: This is mostly great, but it means your
Runfunction executes aftercobrahas parsed the flags. If you need to do something before parsing (like initializing a configuration based on the command structure itself), you need to usePreRunorPersistentPreRunhooks.Testing Can Be Tricky: You’re no longer just calling a function with
os.Args; you’re testing a complex command structure. The best way is to test thecmd.Execute()path or to usecobra’s own test utilities to simulate command execution and capture output.
Why It’s Worth It
Despite the minor quirks, cobra is indispensable because it forces a clean architecture. Your commands are isolated, your flags are scoped, and your help text is generated automatically and consistently. It handles shell completion scripts for bash, zsh, fish, and PowerShell—a massive usability win that you get for almost free. You’re not just building a command-line tool; you’re building a polished experience, and cobra gives you the toolbox to do it right.