Updated Aug 11, 2026

01 - CLI Tooling with Cobra

📋 Jump to Takeaways

🎁 kubectl, terraform, docker, gh... what do the biggest infrastructure CLIs all have in common, and how do you build one yourself from zero?

Your team has five bash scripts in a shared repo. They work, mostly. deploy.sh takes positional args, rollback.sh uses --flags, and status.sh reads from environment variables. The --help flag on two of them exits with code 1, which breaks any script that checks exit codes. Half of them use GNU date and sed and fail silently on macOS.

The deployment script "succeeds" even when the container didn't start, because the exit code from kubectl was never checked. The on-call doc says to run deploy.sh then check-health.sh in that order, but check-health.sh was renamed six months ago and nobody updated the doc.

You onboard a new engineer. You spend twenty minutes on a video call explaining which script to use and in what order. The next week they use the wrong one.

A proper CLI solves all of this. You'll build devctl, a single binary that grows through this course: deploy and status today, then ssh, container, and k8s subcommands in the lessons ahead. One binary, distributed via CI, with subcommands that self-document, flags that validate themselves, and exit codes that scripts can actually rely on.

Imports in code snippets are trimmed for brevity. See the full example linked at the end for complete, compilable source.

Why Not Just the flag Package?

The stdlib flag package works well for a tool with a handful of flags. It cannot handle subcommands.

Once you need devctl deploy, devctl status, and devctl rollback as separate commands, each with their own flags, flag falls apart:

// You end up parsing os.Args manually and building FlagSets by hand:
switch os.Args[1] {
case "deploy":
    fs := flag.NewFlagSet("deploy", flag.ExitOnError)
    env := fs.String("env", "staging", "target environment")
    image := fs.String("image", "", "container image")
    fs.Parse(os.Args[2:])
    // --verbose from the root command doesn't propagate here.
    // --help shows only this subcommand's flags, not the parent's.
    // You write all the help text yourself.

case "rollback":
    fs := flag.NewFlagSet("rollback", flag.ExitOnError)
    // another FlagSet, another block of boilerplate...
}

Each subcommand needs its own FlagSet. Parent flags don't propagate. Help text is whatever you write by hand. Tab completions don't exist. By the time you have four subcommands, you're maintaining a mini framework.

Cobra is that framework, already written and battle-tested by kubectl, docker, and terraform. With Cobra, devctl deploy --verbose just works. devctl deploy --help prints only the deploy flags, with --verbose inherited from the root automatically. New engineers run devctl --help and see every subcommand listed. No docs to maintain.

mkdir devctl && cd devctl
go mod init github.com/yourorg/devctl
go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest

The Command Tree

A Cobra app is a tree of cobra.Command structs. The root command is the binary itself. Subcommands branch off it, and Cobra handles routing, help generation, and flag inheritance automatically.

package main

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "devctl",
    Short: "DevOps control plane CLI",
    Long:  "devctl manages deployments, checks service status, and automates common infrastructure tasks.",
}

func main() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Running devctl with no args prints auto-generated help. Every subcommand you add appears there with its Short description.

The non-obvious thing: use RunE, not Run, for every command that can fail.

// ❌ Wrong: errors are invisible to Cobra:
var deployCmd = &cobra.Command{
    Run: func(cmd *cobra.Command, args []string) {
        if err := doTheDeploy(); err != nil {
            fmt.Println(err) // exits 0! The caller has no idea it failed.
        }
    },
}

// ✅ Correct: Cobra prints the error and exits non-zero:
var deployCmd = &cobra.Command{
    RunE: func(cmd *cobra.Command, args []string) error {
        return doTheDeploy() // Cobra handles the exit code
    },
}

With Run, any error your function encounters is invisible outside the process. The command exits 0 even when the deploy failed, so any caller checking the exit code gets a false success. This is the most common mistake people make when setting up a new Cobra command.

Subcommands

Add subcommands by creating cobra.Command values and registering them with AddCommand. Each command defines its own positional args, flags, and behavior:

var deployCmd = &cobra.Command{
    Use:   "deploy [service]",
    Short: "Deploy a service to the target environment",
    Args:  cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        service := args[0]
        env, _ := cmd.Flags().GetString("env")
        image, _ := cmd.Flags().GetString("image")

        fmt.Printf("Deploying %s to %s with image %s\n", service, env, image)
        // Output: Deploying api to staging with image myregistry/api:v1.2.3
        return nil
    },
}

var statusCmd = &cobra.Command{
    Use:   "status [service]",
    Short: "Check the status of a deployed service",
    Args:  cobra.MaximumNArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        output, _ := cmd.Flags().GetString("output")
        if len(args) == 0 {
            return listAllServices(output)
        }
        return showServiceStatus(args[0], output)
    },
}

func listAllServices(output string) error {
    fmt.Printf("%-12s %-10s %-8s\n", "NAME", "ENV", "STATUS")
    fmt.Printf("%-12s %-10s %-8s\n", "api", "production", "running")
    fmt.Printf("%-12s %-10s %-8s\n", "worker", "staging", "running")
    return nil
}

func showServiceStatus(service, output string) error {
    fmt.Printf("service=%s env=production status=running replicas=3\n", service)
    return nil
}

func init() {
    rootCmd.AddCommand(deployCmd)
    rootCmd.AddCommand(statusCmd)
}

func main() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Run it to see how Cobra wires everything together:

go run main.go --help
# Usage:
#   devctl [command]
#
# Available Commands:
#   deploy      Deploy a service to the target environment
#   status      Check the status of a deployed service
#
# Use "devctl [command] --help" for more information about a command.

go run main.go deploy --help
# Usage:
#   devctl deploy [service] [flags]
#
# Flags:
#   -e, --env string     Target environment (default "staging")
#       --image string   Container image to deploy
#
# Global Flags:
#       --verbose   Enable verbose logging

go run main.go deploy api --image myregistry/api:v1.2.3
# Deploying api to staging with image myregistry/api:v1.2.3

go run main.go deploy api
# Error: required flag(s) "image" not set

cobra.ExactArgs(1) means Cobra rejects the command with a clear error if the user doesn't supply exactly one argument. No if-block needed in your RunE.

Persistent and Local Flags

Some flags belong to one command. Others should apply everywhere. Getting this wrong means users have to repeat --verbose on every subcommand, or a flag that should be global only works on one:

func init() {
    // Persistent: available on root and every subcommand
    rootCmd.PersistentFlags().StringP("output", "o", "table", "Output format: table|json|yaml")
    rootCmd.PersistentFlags().Bool("verbose", false, "Enable verbose logging")

    // Local: only on deploy
    deployCmd.Flags().StringP("env", "e", "staging", "Target environment")
    deployCmd.Flags().String("image", "", "Container image to deploy")
    deployCmd.MarkFlagRequired("image")
}

MarkFlagRequired makes Cobra reject the command if --image is missing. No manual validation needed, and the error message is consistent with all other Cobra errors.

MarkFlagRequired only checks that the flag was provided. Validating its value (non-empty, valid format, reachable registry) is still your code's responsibility.

Configuration with Viper

Tools configured by three different sources (flags, environment variables, config files) need a clear priority order. Implementing that merge from scratch is a hundred lines of plumbing. Viper does it in five:

import "github.com/spf13/viper"

func initConfig() {
    viper.SetConfigName(".devctl")
    viper.SetConfigType("yaml")
    viper.AddConfigPath("$HOME")
    viper.AddConfigPath(".")
    viper.SetEnvPrefix("DEVCTL")  // DEVCTL_ENV maps to "env" key
    viper.AutomaticEnv()

    if err := viper.ReadInConfig(); err == nil {
        fmt.Fprintln(os.Stderr, "Using config:", viper.ConfigFileUsed())
        // Output: Using config: /home/user/.devctl.yaml
    }
}

func init() {
    cobra.OnInitialize(initConfig)

    rootCmd.PersistentFlags().StringP("env", "e", "staging", "Target environment")
    viper.BindPFlag("env", rootCmd.PersistentFlags().Lookup("env"))
}

The gotcha: viper.BindPFlag must be called after the flag is registered. Call it before rootCmd.PersistentFlags().StringP(...) and the binding silently fails. Viper returns the default value regardless of what the user passes on the command line. The call order inside init() matters.

Priority from highest to lowest: --env flag, DEVCTL_ENV env var, .devctl.yaml config file, then the default. A ~/.devctl.yaml looks like:

env: production
output: json
verbose: false

Output Formats

Your tool is a building block in someone's pipeline. If it only prints tables, they have to screen-scrape it to feed the next step. Always support --output json:

type ServiceStatus struct {
    Name     string `json:"name" yaml:"name"`
    Env      string `json:"env" yaml:"env"`
    Status   string `json:"status" yaml:"status"`
    Replicas int    `json:"replicas" yaml:"replicas"`
}

func printOutput(format string, services []ServiceStatus) error {
    switch format {
    case "json":
        enc := json.NewEncoder(os.Stdout)
        enc.SetIndent("", "  ")
        return enc.Encode(services)
    case "yaml":
        return yaml.NewEncoder(os.Stdout).Encode(services)
    default: // table
        w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
        fmt.Fprintln(w, "NAME\tENV\tSTATUS\tREPLICAS")
        for _, s := range services {
            fmt.Fprintf(w, "%s\t%s\t%s\t%d\n", s.Name, s.Env, s.Status, s.Replicas)
        }
        // Output:
        // NAME      ENV         STATUS   REPLICAS
        // api       production  running  3
        // worker    staging     running  2
        return w.Flush()
    }
}

Write errors to os.Stderr, data to os.Stdout. This is the Unix contract. It's what lets users run devctl status --output json | jq '.[] | .name' without your error messages corrupting the pipeline.

Error Handling and Exit Codes

Exit 0 on success, non-zero on failure. CI pipelines and shell scripts depend on this. Cobra handles the non-zero exit when RunE returns an error, but you can encode more meaning with specific exit codes:

var (
    ErrServiceNotFound = errors.New("service not found")
    ErrDeployFailed    = errors.New("deployment failed")
)

func main() {
    if err := rootCmd.Execute(); err != nil {
        switch {
        case errors.Is(err, ErrServiceNotFound):
            os.Exit(2)
        case errors.Is(err, ErrDeployFailed):
            os.Exit(3)
        default:
            os.Exit(1)
        }
    }
}

Never call os.Exit inside library code or deep in a command's RunE. Only call it at the top of main. Everything below should return errors and let them bubble up with context: fmt.Errorf("deploy %s: %w", args[0], err).

Build Info and Shell Completions

A binary distributed across a team needs two things: version information so users can report exactly what they're running, and shell completions so tab-complete works in every engineer's terminal.

Embed version metadata at build time with ldflags:

var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)
// go build -ldflags="-X main.version=1.0.0 -X main.commit=abc123 -X main.date=2026-08-07"

Wire those into a version subcommand:

var versionCmd = &cobra.Command{
    Use:   "version",
    Short: "Print version information",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Printf("version=%s commit=%s date=%s\n", version, commit, date)
        // Output: version=1.0.0 commit=abc123 date=2026-08-07
    },
}

Cobra generates shell completions from the command tree you already built:

rootCmd.GenBashCompletion(os.Stdout)
rootCmd.GenZshCompletion(os.Stdout)

Users run devctl completion bash > /etc/bash_completion.d/devctl once and get tab-complete for every subcommand and flag you've defined.

Putting It Together: devctl

Here's the devctl foundation. The deploy and status subcommands are wired in now. The main() function is nine lines. Subcommands register themselves in init(). Viper loads config before any command runs via cobra.OnInitialize. Future subcommands — ssh, container, k8s — will each call rootCmd.AddCommand(...) the same way:

var rootCmd = &cobra.Command{
    Use:   "devctl",
    Short: "DevOps control plane CLI",
}

func main() {
    if err := rootCmd.Execute(); err != nil {
        if errors.Is(err, ErrServiceNotFound) {
            os.Exit(2)
        }
        os.Exit(1)
    }
}

func init() {
    cobra.OnInitialize(initConfig)

    // Tool-wide flags, inherited by all subcommands
    rootCmd.PersistentFlags().StringP("output", "o", "table", "Output format: table|json|yaml")
    rootCmd.PersistentFlags().Bool("verbose", false, "Enable verbose logging")
    viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))

    rootCmd.AddCommand(deployCmd)
    rootCmd.AddCommand(statusCmd)
    rootCmd.AddCommand(versionCmd)
}

Each subcommand calls viper.GetString("output") directly, without parsing flags. The flag, env var, and config file sources are all merged and ready. The bash scripts had five entry points and three flag conventions. The CLI has one.

DevOps CLI Tool

A single-file CLI that ties together Cobra subcommands, Viper config loading, and multiple output formats into a complete devctl control plane tool. It supports deploy and status subcommands with flag validation, environment variable binding, and JSON/table output.

Input: CLI commands like devctl status -o json or devctl deploy api --image myregistry/api:v1.2.3 --env production

Output: Formatted service status tables (or JSON), deployment confirmations written to stderr

Full source: examples/devctl-cli

Key Takeaways

  • The flag package can't handle subcommands. Once you need devctl deploy, devctl status, and devctl rollback as separate commands, you need Cobra
  • Always use RunE not Run. With Run, errors your code encounters are invisible to the caller and the process exits 0 even when something failed
  • cobra.ExactArgs(1) and MarkFlagRequired eliminate boilerplate arg validation. Cobra rejects invalid input before your RunE even runs
  • viper.BindPFlag must be called after the flag is registered on the command. Wrong order silently produces the default value regardless of what the user passes
  • Write errors to os.Stderr, data to os.Stdout. This is what lets your tool compose with jq and shell pipelines
  • Always support --output json. Table output is for humans; JSON is for everything else
  • Shell completions are generated from the command tree you already built. Add them once and tab-complete works for every subcommand and flag

🎁 devctl deploy works locally. But what if the next subcommand — devctl ssh run — could execute commands on 50 remote servers at once, all without shelling out to the ssh binary?

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy