Building CLI Tools with Cobra and Go
A practical guide to building production CLI tools with Cobra and Go — command structure, flags, arguments, configuration, and publishing.
// table of contents (16 sections)
Go is an excellent choice for building CLI tools — single binary distribution, fast compilation, and excellent standard library. Combined with Cobra, the framework used by Kubernetes, Helm, and Hugo, you get a professional CLI experience with minimal effort.
This post covers building production CLI tools with Cobra: project structure, commands, flags, configuration, shell completions, and distribution.
Why Cobra?
Cobra provides:
- Subcommands —
git clone,git commitstyle nested commands - Flags — both persistent (global) and local (command-specific)
- Help generation — auto-generated
--helpoutput - Shell completions — bash, zsh, fish, powershell
- Smart argument parsing — positional args, remaining args
go get -u github.com/spf13/cobra@latest
Project Structure
A clean CLI project structure:
cmd/
├── root.go # Root command
├── get.go # 'get' subcommand
├── set.go # 'set' subcommand
└── version.go # 'version' subcommand
internal/
├── config/ # Configuration loading
├── api/ # API client
└── output/ # Output formatting
pkg/
└── utils/ # Reusable utilities
main.go
go.mod
README.md
Basic Setup
// main.go
package main
import "mycli/cmd"
func main() {
cmd.Execute()
}
// cmd/root.go
package cmd
import (
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "mycli",
Short: "A brief description of your application",
Long: `A longer description that spans multiple lines and explains
what your application does in detail.`,
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func init() {
// Global flags
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.mycli.yaml)")
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().StringP("output", "o", "table", "output format: table, json, yaml")
}
Adding Subcommands
// cmd/get.go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var getCmd = &cobra.Command{
Use: "get [resource]",
Short: "Get a resource",
Long: `Retrieve and display information about a resource.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
resource := args[0]
return getResource(resource)
},
}
var (
watch bool
fieldSelector string
)
func init() {
rootCmd.AddCommand(getCmd)
// Local flags for 'get' command
getCmd.Flags().BoolVarP(&watch, "watch", "w", false, "watch for changes")
getCmd.Flags().StringVarP(&fieldSelector, "field-selector", "l", "", "field selector query")
}
func getResource(resource string) error {
fmt.Printf("Getting resource: %s\n", resource)
if watch {
fmt.Println("Watching for changes...")
}
if fieldSelector != "" {
fmt.Printf("Field selector: %s\n", fieldSelector)
}
return nil
}
// cmd/set.go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var setCmd = &cobra.Command{
Use: "set [key] [value]",
Short: "Set a configuration value",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
key := args[0]
value := args[1]
return setValue(key, value)
},
}
func init() {
rootCmd.AddCommand(setCmd)
}
func setValue(key, value string) error {
fmt.Printf("Setting %s = %s\n", key, value)
return nil
}
Command Validation
Use Args and PreRunE for validation:
var validateCmd = &cobra.Command{
Use: "validate [file]",
Short: "Validate a configuration file",
Args: cobra.ExactArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
// Check file exists
if _, err := os.Stat(args[0]); os.IsNotExist(err) {
return fmt.Errorf("file not found: %s", args[0])
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return validateFile(args[0])
},
}
// Built-in validators
// cobra.ExactArgs(n)
// cobra.MaximumNArgs(n)
// cobra.MinimumNArgs(n)
// cobra.NoArgs
// cobra.OnlyValidArgs // With ValidArgs field
Configuration Management
Combine flags with config files:
// internal/config/config.go
package config
import (
"os"
"github.com/spf13/viper"
)
type Config struct {
Server ServerConfig
Auth AuthConfig
Output string
Verbose bool
}
type ServerConfig struct {
Host string
Port int
}
type AuthConfig struct {
Token string
APIKey string
}
func Load(cfgFile string) (*Config, error) {
v := viper.New()
// Set defaults
v.SetDefault("server.host", "localhost")
v.SetDefault("server.port", 8080)
v.SetDefault("output", "table")
v.SetDefault("verbose", false)
// Read from config file
if cfgFile != "" {
v.SetConfigFile(cfgFile)
} else {
v.SetConfigName(".mycli")
v.SetConfigType("yaml")
v.AddConfigPath("$HOME")
v.AddConfigPath(".")
}
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, err
}
}
// Read from environment
v.SetEnvPrefix("MYCLI")
v.AutomaticEnv()
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
Bind flags to viper:
func init() {
// Bind flags to viper
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file")
viper.BindPFlag("config", rootCmd.PersistentFlags().Lookup("config"))
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output")
viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
}
Output Formatting
Support multiple output formats:
// internal/output/formatter.go
package output
import (
"encoding/json"
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Formatter interface {
Print(any) error
}
type TableFormatter struct{}
type JSONFormatter struct{}
type YAMLFormatter struct{}
func New(format string) Formatter {
switch format {
case "json":
return &JSONFormatter{}
case "yaml":
return &YAMLFormatter{}
default:
return &TableFormatter{}
}
}
func (f *JSONFormatter) Print(v any) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
}
func (f *YAMLFormatter) Print(v any) error {
return yaml.NewEncoder(os.Stdout).Encode(v)
}
func (f *TableFormatter) Print(v any) error {
// Use a table library like '/table' or implement custom
fmt.Printf("%#v\n", v)
return nil
}
Interactive Prompts
Use survey for interactive input:
go get github.com/AlecAivazis/survey/v2
import (
"github.com/AlecAivazis/survey/v2"
)
func promptForConfig() (string, error) {
var result string
q := &survey.Input{
Message: "Enter your API key:",
Default: os.Getenv("MYCLI_API_KEY"),
}
err := survey.AskOne(q, &result, survey.WithValidator(survey.Required))
return result, err
}
// Multiple prompts
func interactiveSetup() (*Config, error) {
var answers struct {
APIKey string
Server string
Verbose bool
}
questions := []*survey.Question{
{
Name: "APIKey",
Prompt: &survey.Input{
Message: "Enter your API key:",
},
Validate: survey.Required,
},
{
Name: "Server",
Prompt: &survey.Input{
Message: "Server address:",
Default: "localhost:8080",
},
},
{
Name: "Verbose",
Prompt: &survey.Confirm{
Message: "Enable verbose output?",
Default: false,
},
},
}
if err := survey.Ask(questions, &answers); err != nil {
return nil, err
}
return &Config{
Auth: AuthConfig{APIKey: answers.APIKey},
Server: ServerConfig{Host: answers.Server},
Verbose: answers.Verbose,
}, nil
}
Shell Completions
Cobra auto-generates shell completions:
// cmd/completion.go
package cmd
import (
"github.com/spf13/cobra"
)
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion script",
Long: `To load completions:
Bash:
$ source <(mycli completion bash)
# To load completions for each session, execute once:
# Linux:
$ mycli completion bash > /etc/bash_completion.d/mycli
# macOS:
$ mycli completion bash > /usr/local/etc/bash_completion.d/mycli
Zsh:
# If shell completion is not already enabled in your environment,
# you will need to enable it. You can execute the following once:
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
# To load completions for each session, execute once:
$ mycli completion zsh > "${fpath[1]}/_mycli"
# You will need to start a new shell for this setup to take effect.
fish:
$ mycli completion fish | source
# To load completions for each session, execute once:
$ mycli completion fish > ~/.config/fish/completions/mycli.fish
PowerShell:
PS> mycli completion powershell | Out-String | Invoke-Expression
# To load completions for every new session, run:
PS> mycli completion powershell > mycli.ps1
# and source this file from your PowerShell profile.
`,
DisableFlagsInUseLine: true,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.ExactValidArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
}
return nil
},
}
func init() {
rootCmd.AddCommand(completionCmd)
}
Add dynamic completions:
func init() {
getCmd.RegisterFlagCompletionFunc("resource", resourceCompletion)
}
func resourceCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Return list of resources that start with toComplete
resources := []string{"pods", "services", "deployments", "configmaps"}
var matches []string
for _, r := range resources {
if strings.HasPrefix(r, toComplete) {
matches = append(matches, r)
}
}
return matches, cobra.ShellCompDirectiveNoFileComp
}
Error Handling
Return errors from RunE and Cobra will display them:
var problematicCmd = &cobra.Command{
Use: "problematic",
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("something went wrong")
},
}
// Silence usage output on error
// cmd.SilenceUsage = true
// cmd.SilenceErrors = true
Version Command
Embed version info:
// cmd/version.go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var (
version = "dev"
commit = "none"
date = "unknown"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print the version number",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("mycli %s (commit: %s, built at: %s)\n", version, commit, date)
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}
Set version via ldflags:
go build -ldflags "-X main.version=1.0.0 -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
Building and Distribution
Cross-platform builds:
# Makefile
VERSION := $(shell git describe --tags --always --dirty)
LDFLAGS := -ldflags "-X main.version=$(VERSION) -X main.commit=$(shell git rev-parse HEAD) -X main.date=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)"
.PHONY: build
build:
go build $(LDFLAGS) -o bin/mycli .
.PHONY: build-all
build-all:
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/mycli-linux-amd64 .
GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o bin/mycli-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o bin/mycli-darwin-arm64 .
GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o bin/mycli-windows-amd64.exe .
.PHONY: clean
clean:
rm -rf bin/
Homebrew Tap:
# Formula/mycli.rb
class Mycli < Formula
desc "A CLI tool for doing things"
homepage "https://github.com/you/mycli"
url "https://github.com/you/mycli/archive/v1.0.0.tar.gz"
sha256 "..."
license "MIT"
depends_on "go" => :build
def install
system "go", "build", *std_go_args(ldflags: "-s -w")
end
test do
system "#{bin}/mycli", "version"
end
end
Scoop for Windows:
{
"version": "1.0.0",
"homepage": "https://github.com/you/mycli",
"license": "MIT",
"url": "https://github.com/you/mycli/releases/download/v1.0.0/mycli-windows-amd64.exe",
"hash": "sha256:...",
"bin": "mycli-windows-amd64.exe",
"checkver": "github",
"autoupdate": {
"url": "https://github.com/you/mycli/releases/download/v$version/mycli-windows-amd64.exe"
}
}
Key Takeaways
- Use Cobra for command structure — subcommands, flags, and help generation
- Combine flags with config files — defaults → config → env → flags
- Support multiple output formats — table, JSON, YAML
- Generate shell completions — improves CLI UX significantly
- Use
RunEfor error handling — let Cobra display errors - Embed version info — use ldflags to set version at build time
- Cross-platform builds — support Linux, macOS, Windows
- Distribute via package managers — Homebrew, Scoop, apt
Cobra provides a professional foundation for CLI tools. Combined with good configuration management and output formatting, you get a tool that developers enjoy using.
You might also like
Rate Limiting Strategies for APIs: Protect Your Backend in 2026
Master API rate limiting with practical strategies and implementations. Compare token bucket, sliding window, and fixed window algorithms with real code examples in Go, Node.js, and Redis.
Building RESTful APIs with Go and Chi
A practical guide to building production RESTful APIs with Go and Chi router — routing, middleware, request validation, error handling, and clean architecture.
Error Handling Patterns in Go
Production-ready error handling patterns in Go — wrapping, sentinel errors, custom error types, error groups, and structured logging for backend services.
More Posts
API Gateway Patterns: The Front Door to Your Microservices
Web Components 2026: Building Framework-Agnostic UI Libraries
Building Autonomous AI Workflows with LangGraph: A Practical Guide
Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas
Database Connection Pooling: Patterns for High-Performance Applications
Prompt Caching: Reduce LLM Costs by 90% with Smart Context Management
Enjoyed This Post?
Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.
