Cobra — Go CLI Framework
Overview
Cobra structures a Go CLI as a tree of commands (app, app deploy, app config set), each with its own flags, argument rules and help text. It generates --help, usage output, "did you mean" suggestions and shell completion scripts. The current release is v1.10.2 (December 2025); Viper v1.21 is the usual companion for config files and environment variables.
Instructions
Installation and scaffolding
go mod init github.com/acme/deployctl
go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest # only if you want config files and env vars
go install github.com/spf13/cobra-cli@latest # optional generator
cobra-cli init # writes main.go and cmd/root.go
cobra-cli add deploy # writes cmd/deploy.go
cobra-cli is a separate, rarely updated tool (v1.3.0, 2022). It only writes boilerplate with placeholder text you must replace; writing the files by hand is just as easy.
Layout
main.go only calls cmd.Execute(); every command lives in its own file under cmd/ and registers itself with rootCmd.AddCommand(...) in init().
Root command with Viper
// cmd/root.go
package cmd
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
var rootCmd = &cobra.Command{
Use: "deployctl",
Short: "Deploy and inspect services",
SilenceUsage: true, // do not dump usage when RunE returns an error
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return initConfig(cmd)
},
}
func Execute() error { return rootCmd.Execute() }
func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default ./deployctl.yaml)")
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output")
}
func initConfig(cmd *cobra.Command) error {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
viper.AddConfigPath(".")
viper.SetConfigName("deployctl")
}
viper.SetEnvPrefix("DEPLOYCTL")
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
if _, missing := err.(viper.ConfigFileNotFoundError); !missing {
return fmt.Errorf("reading config: %w", err)
}
}
return viper.BindPFlags(cmd.Flags()) // flag > env var > config file > flag default
}
// main.go
func main() {
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}
Cobra already prints the error returned by RunE; do not print it again in main.
Subcommand with arguments, flags and completion
// cmd/deploy.go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var deployCmd = &cobra.Command{
Use: "deploy SERVICE",
Short: "Deploy a service to an environment",
Example: ` deployctl deploy api --env production --version v2.1.0
deployctl deploy worker --dry-run`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return []string{"api", "worker", "scheduler"}, cobra.ShellCompDirectiveNoFileComp
},
RunE: func(cmd *cobra.Command, args []string) error {
env, version := viper.GetString("env"), viper.GetString("version")
if viper.GetBool("dry-run") {
fmt.Fprintf(cmd.OutOrStdout(), "DRY RUN: would deploy %s@%s to %s\n", args[0], version, env)
return nil
}
return runDeploy(cmd.Context(), args[0], env, version)
},
}
func init() {
rootCmd.AddCommand(deployCmd)
deployCmd.Flags().StringP("env", "e", "staging", "target environment")
deployCmd.Flags().String("version", "latest", "version to deploy")
deployCmd.Flags().Bool("dry-run", false, "print the plan without deploying")
_ = deployCmd.RegisterFlagCompletionFunc("env", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return []string{"staging", "production"}, cobra.ShellCompDirectiveNoFileComp
})
}
Other building blocks: Args validators cobra.NoArgs, MinimumNArgs(n), MaximumNArgs(n), RangeArgs(a, b), MatchAll(...); cmd.MarkFlagRequired("env"); MarkFlagsMutuallyExclusive("json", "yaml"); MarkFlagsRequiredTogether; Aliases: []string{"st"}; Hidden: true; Deprecated: "use deploy instead"; rootCmd.AddGroup(&cobra.Group{ID: "ops", Title: "Operations"}) with GroupID: "ops" on commands; rootCmd.ExecuteContext(ctx) to pass a cancellable context to cmd.Context().
Shell completions
Cobra adds a completion command (bash, zsh, fish, powershell) to any app that has subcommands:
source <(deployctl completion bash) # current bash session
deployctl completion zsh > "${fpath[1]}/_deployctl" # zsh, then restart the shell
deployctl completion fish > ~/.config/fish/completions/deployctl.fish
deployctl __complete deploy "" # debug: prints candidates for `deployctl deploy <TAB>`
Examples
Example 1: "Add a status command that completes service names"
var statusCmd = &cobra.Command{
Use: "status [SERVICE]",
Short: "Show the status of one or all services",
Aliases: []string{"st"},
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: completeServices,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return printAllStatus(cmd.OutOrStdout())
}
return printStatus(cmd.OutOrStdout(), args[0])
},
}
func init() { rootCmd.AddCommand(statusCmd) }
deployctl st api now runs the same command, and deployctl status <TAB> offers the service names returned by completeServices.
Example 2: "Let users set the environment from a file or a variable"
echo "version: v2.1.0" > deployctl.yaml
DEPLOYCTL_ENV=production deployctl deploy api --dry-run
# DRY RUN: would deploy api@v2.1.0 to production
deployctl deploy api --dry-run -e staging
# DRY RUN: would deploy api@v2.1.0 to staging
The flag wins over the environment variable, which wins over the config file, which wins over the flag default (this behavior was run against cobra v1.10.2 and viper v1.21.0 with the code above).
Guidelines
- Use
RunE instead of Run so errors go through Cobra; set SilenceUsage: true on the root so a runtime failure does not print the whole usage text.
- A child's
PersistentPreRun(E) replaces the parent's instead of adding to it. Either define it only on the root, call the parent's hook yourself, or set cobra.EnableTraverseRunHooks = true so all of them run.
- Call
viper.BindPFlags inside PersistentPreRunE, not in init(): binding the whole command's flag set in init() would mix flags of different subcommands that share a name.
- Global variables for flags are fine for small tools; for larger ones, put them in a struct created per command so tests can run commands in parallel with
cmd.SetArgs(...) and cmd.SetOut(buf).
- Write command output to
cmd.OutOrStdout() and diagnostics to cmd.ErrOrStderr() so tests can capture them.
- Keep secrets out of flags (they appear in
ps and shell history); read tokens from environment variables.
- For a tiny one-command script, the standard
flag package is enough; Cobra pays off with subcommands.