Skip to content
· 9 min read · 0 views

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:

  • Subcommandsgit clone, git commit style nested commands
  • Flags — both persistent (global) and local (command-specific)
  • Help generation — auto-generated --help output
  • 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

  1. Use Cobra for command structure — subcommands, flags, and help generation
  2. Combine flags with config files — defaults → config → env → flags
  3. Support multiple output formats — table, JSON, YAML
  4. Generate shell completions — improves CLI UX significantly
  5. Use RunE for error handling — let Cobra display errors
  6. Embed version info — use ldflags to set version at build time
  7. Cross-platform builds — support Linux, macOS, Windows
  8. 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

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.

Discussion