Updated Jul 20, 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?

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

Go CLI Advantages

Go compiles to a single static binary. No runtime, no interpreter, no dependency hell on the target machine. Cross-compile for Linux, macOS, and Windows from one GOOS/GOARCH command. That alone makes it the default choice for infrastructure tools.

Cobra powers the biggest CLIs in the ecosystem. It gives you subcommands, flags, shell completions, and help text generation with minimal boilerplate.

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

Cobra Fundamentals

A Cobra app is a tree of cobra.Command structs. The root command represents the binary itself. Subcommands branch off it.

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 arguments prints the auto-generated help. Every subcommand you add appears there automatically.

Subcommands and Command Trees

Add subcommands by creating new cobra.Command values and calling AddCommand:

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
        // actual deployment logic here
        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 init() {
    rootCmd.AddCommand(deployCmd)
    rootCmd.AddCommand(statusCmd)
}

Use RunE (not Run) to return errors. Cobra prints them and sets a non-zero exit code automatically.

Persistent and Local Flags

Local flags apply to one command. Persistent flags propagate to all subcommands:

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 the flag is missing, so no manual validation is needed.

Configuration with Viper

Viper reads config files, environment variables, and flags into one unified config. Bind it to Cobra flags so users can set values any way they prefer:

import "github.com/spf13/viper"

func initConfig() {
    viper.SetConfigName(".devctl")       // filename without extension
    viper.SetConfigType("yaml")
    viper.AddConfigPath("$HOME")          // search in home directory
    viper.AddConfigPath(".")              // also search working directory
    viper.SetEnvPrefix("DEVCTL")          // env vars like DEVCTL_ENV
    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"))
}

Now the environment resolves from (highest priority first): flag --env, env var DEVCTL_ENV, config file .devctl.yaml, then the default.

A ~/.devctl.yaml might look like:

env: production
output: json
verbose: false

Output Formats

DevOps tools need machine-readable output. Support at least JSON and a human-readable table:

import (
    "encoding/json"
    "fmt"
    "os"
    "text/tabwriter"

    "gopkg.in/yaml.v3"
)

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()
    }
}

This pattern (a --output flag driving a switch) is exactly what kubectl uses.

Error Handling and Exit Codes

Follow Unix conventions: exit 0 on success, non-zero on failure. Cobra handles this when you use RunE, but be intentional about different exit codes:

import "errors"

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

var deployCmd = &cobra.Command{
    Use:  "deploy [service]",
    Args: cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        err := performDeploy(args[0])
        if err != nil {
            // Cobra prints the error and exits with code 1
            return fmt.Errorf("deploy %s: %w", args[0], err)
        }
        return nil
    },
}

For custom exit codes, use os.Exit in a wrapper, but only at the top level. Never call os.Exit inside library code:

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)
        }
    }
}

Write to os.Stderr for errors, os.Stdout for data. This lets users pipe output: devctl status --output json | jq '.[] | .name'.

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

  • Go's static binaries make it ideal for distributing CLI tools with zero dependencies
  • Cobra provides subcommands, flags, validation, and auto-generated help out of the box
  • Use RunE (not Run) so errors propagate correctly and set non-zero exit codes
  • Viper unifies config files, env vars, and flags with a clear priority order
  • Always support --output json so your tool composes with jq, scripts, and pipelines
  • Write errors to stderr, data to stdout. This is the Unix contract
  • MarkFlagRequired eliminates boilerplate validation code

🎁 Your CLI can deploy locally, but most infrastructure lives on remote servers. How do you run commands on a machine across the network without shelling out to ssh?

💻 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