Skip to content
7 min read·Lesson 6 of 8

Building CLIs with Cobra and Viper

How kubectl, helm, gh, and most production Go CLIs are built — Cobra for commands and flags, Viper for configuration.

If you have run kubectl get pods --namespace web, you have used Cobra. It is the dominant CLI framework in the Go ecosystem and the structure underlying virtually every cloud-native command-line tool.

The Cobra Mental Model

A CLI is a tree of commands:

myapp                          # root
├── version
├── deploy
│   ├── --env
│   └── --image
├── status
└── config
    ├── get <key>
    └── set <key> <value>

Each node is a *cobra.Command with its own flags, args, and run function. Children attach to parents with AddCommand.

Minimal Example

// cmd/root.go
package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
)

var (
    verbose bool
    rootCmd = &cobra.Command{
        Use:   "myapp",
        Short: "Manage acme deployments",
        Long:  "myapp is the command-line interface for the acme platform.",
        PersistentPreRun: func(cmd *cobra.Command, args []string) {
            if verbose { fmt.Println("verbose mode") }
        },
    }
)

func init() {
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
}

func Execute() error {
    return rootCmd.Execute()
}

// cmd/deploy.go
var (
    env   string
    image string
    deployCmd = &cobra.Command{
        Use:   "deploy",
        Short: "Deploy an application",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            name := args[0]
            return deploy(name, env, image)
        },
    }
)

func init() {
    deployCmd.Flags().StringVar(&env,   "env",   "dev",  "target environment")
    deployCmd.Flags().StringVar(&image, "image", "",     "container image (required)")
    deployCmd.MarkFlagRequired("image")
    rootCmd.AddCommand(deployCmd)
}

// cmd/myapp/main.go
package main

import (
    "os"
    "github.com/acme/myapp/cmd"
)

func main() {
    if err := cmd.Execute(); err != nil {
        os.Exit(1)
    }
}

Run:

myapp deploy checkout --env prod --image ghcr.io/acme/checkout:v2.7.0
myapp deploy --help

Cobra generates the help text, completion scripts, and man pages from the same metadata. myapp completion bash outputs a bash completion script for free.

Flags vs Persistent Flags

  • cmd.Flags() — apply only to that command
  • cmd.PersistentFlags() — apply to this command and all its children

--verbose on the root is persistent; --image on deploy is not.

Argument Validation

HelperBehaviour
cobra.NoArgsNo positional args
cobra.ExactArgs(n)Exactly n
cobra.MinimumNArgs(n)At least n
cobra.MaximumNArgs(n)At most n
cobra.RangeArgs(min, max)Between min and max
Custom PositionalArgs functionAnything you like

Run vs RunE

  • Run — returns nothing; you handle errors yourself
  • RunE — returns an error; Cobra prints it and exits non-zero

Use RunE. Then in main: os.Exit(1) on error.

Viper for Configuration

Viper is Cobra's sibling. It layers configuration sources with a clear precedence:

  1. Explicit --flag on the command line
  2. Environment variable (e.g., MYAPP_ENV=prod)
  3. Config file (~/.myapp.yaml, ./myapp.yaml)
  4. Remote config (etcd, Consul) — rarely used in CLIs
  5. Default value from Viper
import "github.com/spf13/viper"

func init() {
    viper.SetEnvPrefix("MYAPP")
    viper.AutomaticEnv()           // MYAPP_ENV -> "env"
    viper.SetDefault("env", "dev")

    cobra.OnInitialize(func() {
        viper.SetConfigName(".myapp")
        viper.AddConfigPath("$HOME")
        viper.AddConfigPath(".")
        _ = viper.ReadInConfig()
    })

    deployCmd.Flags().String("env", "", "target environment")
    viper.BindPFlag("env", deployCmd.Flags().Lookup("env"))
}

// Read:
env := viper.GetString("env")

The kubectl Look-and-Feel

Two patterns kubectl popularised that are now community standard:

Output formatting flag

myapp get deployments -o json
myapp get deployments -o yaml
myapp get deployments -o wide

Implement once at the root with a persistent --output / -o flag and a small dispatcher (encoding/json, sigs.k8s.io/yaml, tabular for wide).

Resource verbs

myapp create deployment my-app --image ...
myapp get deployments
myapp describe deployment my-app
myapp delete deployment my-app

Commands organised by verb-then-noun. Kubectl made it standard; users expect it.

Interactive Output

Always check isatty (mattn/go-isatty) — if stdout is a pipe, emit plain output.

Distribution with goreleaser

goreleaser is the standard release tool. One config file ships:

  • Cross-compiled binaries for Linux / macOS / Windows on amd64 / arm64
  • SHA256 checksums and signed archives
  • GitHub Releases with auto-generated changelogs
  • Homebrew tap, Scoop bucket, deb/rpm packages, Docker images, SBOM
  • SLSA provenance, cosign signing

A minimal .goreleaser.yml:

builds:
  - main: ./cmd/myapp
    binary: myapp
    goos: [linux, darwin, windows]
    goarch: [amd64, arm64]
    env: [CGO_ENABLED=0]
    ldflags:
      - -s -w -X main.version={{.Version}}

archives:
  - format: tar.gz
    format_overrides:
      - goos: windows
        format: zip

brews:
  - tap:
      owner: acme
      name: homebrew-tap

Triggered from a tag push in GitHub Actions, the entire release is hands-off.

Real-World Reference

Read the Cobra source of kubectl, helm, or gh. The patterns are conventional and worth copying — Cobra, Viper, goreleaser is the modern Go CLI stack.

Key Takeaways

  • Cobra is the de facto CLI framework: kubectl, helm, gh, hugo, docker, openshift-cli all use it.
  • Commands are tree-shaped; each command has its own flags, args, and Run function.
  • Viper layers configuration from defaults, files, env vars, and flags.
  • Sub-commands compose: rootCmd.AddCommand(deployCmd).
  • Distribute with goreleaser for cross-platform binaries, signed releases, and Homebrew/Scoop taps.

Test your knowledge

Try exam-style practice questions to reinforce what you've learned.

Practice Questions →