Cobra is the dominant CLI framework in the Go ecosystem, powering tools like kubectl, hugo, gh (GitHub CLI), and docker. It provides a structured approach to building command-line applications with nested subcommands, persistent and local flags, automatic help generation, shell completions, and middleware-like hooks for pre/post-run logic. Cobra works in tandem with Viper for configuration management, giving CLI apps a unified way to read flags, environment variables, and config files.
Building a CLI without a framework quickly devolves into a mess of os.Args parsing, hand-rolled help text, and ad-hoc flag handling. Cobra eliminates this boilerplate by modeling your CLI as a tree of Command objects. Each command has its own flags, arguments, validation, and run function. The framework handles routing user input to the correct command, validating arguments, and rendering consistent help output.
As an example, an embedding-visualization demo might use Cobra to build a CLI that manages embedding generation, index operations, and search queries. Cobra’s subcommand model maps naturally to these distinct operations: embed generate, index create, search query. Persistent flags like --config and --verbose propagate from the root command to all subcommands, while command-specific flags like --model or --top-k are scoped to where they apply.
Cobra models a CLI as a tree of commands. The root command represents the application itself; child commands represent operations. Children can have their own children, forming arbitrarily deep subcommand hierarchies. For example, myapp server start involves a root command (myapp), a group command (server), and a leaf command (start). Group commands typically have no Run function—they exist solely to namespace their children and display help text.
Persistent flags are defined on a parent command and inherited by all descendants. A --verbose flag on the root command is available everywhere. Local flags are scoped to a single command. Cobra uses Go’s standard pflag library (a POSIX-compliant drop-in for flag) which supports both --long and -s short forms, default values, and type-safe binding.
Cobra distinguishes between flags (named parameters like --output json) and args (positional parameters like myapp process file.txt). Built-in validators (cobra.ExactArgs(n), cobra.MinimumNArgs(n), cobra.NoArgs) enforce argument count at the framework level. Custom validators can enforce type, format, or domain-specific constraints before the run function executes.
Commands can use Run (which takes no error return) or RunE (which returns an error). Prefer RunE in production code—it integrates with Cobra’s error handling, sets a non-zero exit code on failure, and avoids the need for log.Fatal or os.Exit calls that make testing difficult. Cobra propagates the error up to the root Execute() call, where you handle it once.
Cobra provides lifecycle hooks that execute before and after the main Run function: PersistentPreRun, PreRun, Run, PostRun, PersistentPostRun. The “Persistent” variants are inherited by child commands. This is the idiomatic place to initialize logging, load configuration, validate authentication tokens, or set up database connections—shared setup that every subcommand needs.
Since Go 1.15+, Cobra supports context.Context propagation via cmd.SetContext() and cmd.Context(). This lets you pass request-scoped values (loggers, database connections, cancellation signals) through the command tree without global variables. A PersistentPreRun on the root command can set up a context with a logger and tracing span, and every subcommand receives it automatically.
A typical Cobra application has a root command that initializes shared state and child commands for each operation. Here’s the standard project layout:
// cmd/root.go
package cmd
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
cfgFile string
verbose bool
)
var rootCmd = &cobra.Command{
Use: "embsearch",
Short: "Embedding search CLI for managing vector indexes",
Long: `embsearch is a CLI tool for generating embeddings,
managing vector indexes, and running similarity searches
against the embedding store.`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Initialize configuration
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
viper.SetConfigName(".embsearch")
viper.AddConfigPath("$HOME")
viper.AddConfigPath(".")
}
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return fmt.Errorf("reading config: %w", err)
}
}
// Set up context with logger
logger := setupLogger(verbose)
ctx := context.WithValue(cmd.Context(), loggerKey, logger)
cmd.SetContext(ctx)
return nil
},
}
func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
"config file (default is $HOME/.embsearch.yaml)")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
"enable verbose output")
// Bind flags to viper for unified config
viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
Each subcommand is defined in its own file and registered with its parent command via AddCommand:
// cmd/index.go
package cmd
import "github.com/spf13/cobra"
var indexCmd = &cobra.Command{
Use: "index",
Short: "Manage vector indexes",
Long: "Create, list, delete, and inspect vector indexes.",
}
func init() {
rootCmd.AddCommand(indexCmd)
}
// cmd/index_create.go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var (
indexDimensions int
indexMetric string
)
var indexCreateCmd = &cobra.Command{
Use: "create [name]",
Short: "Create a new vector index",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
indexName := args[0]
logger := cmd.Context().Value(loggerKey).(*Logger)
logger.Info("creating index",
"name", indexName,
"dimensions", indexDimensions,
"metric", indexMetric,
)
client, err := newIndexClient(cmd.Context())
if err != nil {
return fmt.Errorf("connecting to index service: %w", err)
}
defer client.Close()
return client.CreateIndex(cmd.Context(), indexName, indexDimensions, indexMetric)
},
}
func init() {
indexCmd.AddCommand(indexCreateCmd)
indexCreateCmd.Flags().IntVar(&indexDimensions, "dimensions", 768,
"vector dimensions for the index")
indexCreateCmd.Flags().StringVar(&indexMetric, "metric", "cosine",
"distance metric (cosine, euclidean, dot)")
indexCreateCmd.MarkFlagRequired("dimensions")
}
Cobra generates shell completions for Bash, Zsh, Fish, and PowerShell. Custom completions can suggest dynamic values (index names from the server, available models):
// cmd/completion.go
package cmd
import (
"os"
"github.com/spf13/cobra"
)
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion scripts",
Args: cobra.ExactArgs(1),
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return rootCmd.GenBashCompletion(os.Stdout)
case "zsh":
return rootCmd.GenZshCompletion(os.Stdout)
case "fish":
return rootCmd.GenFishCompletion(os.Stdout, true)
case "powershell":
return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
}
return nil
},
}
// Dynamic completions for index names
func indexNameCompletion(cmd *cobra.Command, args []string,
toComplete string) ([]string, cobra.ShellCompDirective) {
client, err := newIndexClient(cmd.Context())
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
defer client.Close()
names, err := client.ListIndexNames(cmd.Context())
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
return names, cobra.ShellCompDirectiveNoFileComp
}
func init() {
rootCmd.AddCommand(completionCmd)
// Register dynamic completion for commands that take index names
indexDeleteCmd.RegisterFlagCompletionFunc("name", indexNameCompletion)
}
Cobra and Viper are designed to work together. viper.BindPFlag connects a Cobra flag to a Viper key, creating a unified config hierarchy: CLI flags override environment variables, which override config file values, which override defaults. This means --api-key, EMBSEARCH_API_KEY, and api_key in the YAML config all set the same value, with predictable precedence.
For a system managing multiple services, a single CLI binary can mirror the service architecture. For example: myapp worker start, myapp index rebuild, myapp search query. Each service team owns their subcommand subtree, and the root command provides shared infrastructure (auth, logging, service discovery). Cobra’s AddCommand lets teams register commands from separate packages without touching the root.
CLIs built with Cobra can detect whether they’re running in a terminal (interactive) or a pipe (non-interactive) and adjust their behavior. In interactive mode, use progress bars and colored output; in pipe mode, emit machine-parseable JSON or CSV. The --output flag pattern (--output json|table|yaml) is standard in Cobra-based tools and trivial to implement with Cobra’s flag system.
Cobra supports a plugin model where external binaries named myapp-pluginname on the PATH are automatically discovered and available as myapp pluginname. This is how kubectl plugins work. A CLI can use this pattern for community extensions: third-party index analyzers, custom embedding providers, and diagnostic tools plug into the main CLI without modifying its source code.
Cobra CLIs are natural targets for CI/CD scripts. Flags and environment variable bindings make them easy to call from shell scripts, Makefiles, and GitHub Actions. The --output json flag makes it easy to pipe results into jq for further processing. Exit codes from RunE errors integrate with CI failure detection.
Rather than using global variables or singletons, inject dependencies through context.Context. The root command’s PersistentPreRunE constructs dependencies and attaches them to the context:
type contextKey string
const (
loggerKey contextKey = "logger"
dbKey contextKey = "db"
configKey contextKey = "config"
)
// Root PersistentPreRunE builds the dependency graph
func rootPreRun(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: cfg.LogLevel(),
}))
db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("connecting to database: %w", err)
}
ctx := cmd.Context()
ctx = context.WithValue(ctx, configKey, cfg)
ctx = context.WithValue(ctx, loggerKey, logger)
ctx = context.WithValue(ctx, dbKey, db)
cmd.SetContext(ctx)
return nil
}
// Subcommands retrieve dependencies from context
func runSearch(cmd *cobra.Command, args []string) error {
logger := cmd.Context().Value(loggerKey).(*slog.Logger)
db := cmd.Context().Value(dbKey).(*sql.DB)
logger.Info("executing search", "query", args[0])
// Use db for search...
return nil
}
Cobra commands are testable by executing them programmatically with captured output:
func TestIndexCreate(t *testing.T) {
// Set up a test buffer to capture output
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetErr(buf)
// Execute the command with test arguments
rootCmd.SetArgs([]string{
"index", "create", "test-index",
"--dimensions", "384",
"--metric", "cosine",
})
err := rootCmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := buf.String()
if !strings.Contains(output, "created index test-index") {
t.Errorf("expected creation message, got: %s", output)
}
}
// For integration tests, inject a test context with mock dependencies
func TestWithMockDB(t *testing.T) {
mockDB := newMockDB()
ctx := context.WithValue(context.Background(), dbKey, mockDB)
cmd := &cobra.Command{
Use: "test",
RunE: func(cmd *cobra.Command, args []string) error {
return runSearch(cmd, args)
},
}
cmd.SetContext(ctx)
cmd.SetArgs([]string{"test query"})
err := cmd.Execute()
assert.NoError(t, err)
assert.Equal(t, 1, mockDB.SearchCallCount())
}
Cobra supports command groups (cobra.Group) to organize help output. Instead of a flat list of subcommands, group related commands under headings: “Index Management”, “Search Operations”, “Configuration”. This scales well as CLIs grow—kubectl uses groups to organize its 50+ subcommands into manageable sections like “Basic Commands”, “Deploy Commands”, and “Cluster Management”.
PersistentPreRun hooks that open database connections, read config files, or make network calls. Defer expensive initialization until the subcommand that needs it, or use lazy initialization patterns where resources are created on first access.flag package instead. For substantial CLIs, the 5 MB overhead is negligible compared to the functionality gained.cmd.Flags().GetString("name") inside the run function. This keeps each command self-contained.MarkFlagRequired: Without marking required flags, Cobra silently uses zero values when the user omits them. This leads to cryptic errors deep in the run function instead of a clear “required flag not set” message at the CLI level.PersistentPreRun, it completely replaces the parent’s—it does not chain. You must explicitly call the parent’s pre-run from the child, or restructure to use PreRun (local) on the child and PersistentPreRun on the parent.os.Exit inside commands: Calling os.Exit prevents deferred cleanup, breaks testability, and bypasses Cobra’s error handling. Return errors from RunE and let the root Execute() handle the exit code.SilenceUsage and SilenceErrors: By default, Cobra prints usage help on every error, which clutters output for runtime errors (as opposed to usage errors). Set SilenceUsage: true on the root command and handle error display yourself for cleaner output.