Skip to main content

Command Palette

Search for a command to run...

Building Go CLIs with Cobra: a practical guide

Updated
19 min readView as Markdown
Building Go CLIs with Cobra: a practical guide
F
Hey, I'm Fer, a Senior SRE/DevOps engineer based in Asunción, Paraguay. My day-to-day life is at the intersection of cloud infrastructure, automation, and reliability, working with AWS, Kubernetes, and the tools that keep systems running at scale. A while back, I decided to go deeper into Go. Not just as a scripting language to replace Bash, but as a proper engineering discipline, learning how to design clean systems, write idiomatic Golang code, and build tools that last.

Series: Platform engineering with Go | Topics: Go, Cobra, CLI, Platform Engineering

This is part of the Platform Engineering with Go series. This post builds on the client-go patterns from post 3. Read post 3 first if you haven't yet.


kubectl, helm, and the GitHub CLI all have something in common

They're all built with Cobra.

Cobra is the Go library for building command-line tools. It handles subcommands, flags, help text, shell completions, and argument validation, everything you need to turn a Go program into a proper CLI that behaves the way engineers expect a CLI to behave.

If you've used kubectl get pods, helm install, or gh pr create, you've already used a Cobra CLI without knowing it. In this post, you'll learn how to build one.

By the end, you'll have a working CLI called k8s-info with two subcommands; version and cluster.


Prerequisites

  • Read posts 1–3 of this series (helpful but not required for this post)

Project structure

As always, we start our project by creating the structure. If you don't know how and why this structure is created, you can learn it here.

k8s-info/
├── go.mod
├── Makefile
├── cmd/
│   └── k8s-info/
│       └── main.go              ← 3 lines — just calls Execute()
└── internal/
    └── cmd/
        ├── root.go              ← root command + persistent flags
        ├── version.go           ← "k8s-info version" subcommand
        └── cluster.go           ← "k8s-info cluster" subcommand

This structure scales. When we build our next command in the next post, we add more files under internal/cmd/, the root command never changes.

You can find the whole project code here

Initialize (with your own module name), and install:

mkdir k8s-info && cd k8s-info
go mod init github.com/yourname/k8s-info

# Install Cobra — the CLI framework
go get github.com/spf13/cobra@v1.10.2

# Install the Kubernetes packages needed for the cluster subcommand
# All three must be pinned to the same version matching your server version
# Since our minikube server is v1.35.1, we use v0.35.1
go get k8s.io/client-go@v0.35.1
go get k8s.io/api@v0.35.1
go get k8s.io/apimachinery@v0.35.1

# Fetch all transitive dependencies and clean up
go get ./...
go mod tidy

What Cobra actually is

Before writing any code, it helps to understand how Cobra thinks about a CLI.

Every Cobra CLI is a tree of commands:

k8s-info                     ← root command
├── version                  ← subcommand
└── cluster                  ← subcommand
    ├── --namespace           ← local flag (only for cluster)
    └── --output              ← local flag (only for cluster)

The root command is the entry point, it's what runs when you type just k8s-info. Subcommands are registered on the root with AddCommand(). Flags can be attached to any command, and persistent flags (attached to the root) are inherited by every subcommand.

Cobra handles everything else automatically: --help, error messages, usage text, and shell completion stubs.


The entry point

// cmd/k8s-info/main.go
package main

import "github.com/FerRiosCosta/k8s-info/internal/cmd"

// main is intentionally minimal, all command logic lives in internal/cmd/.
// This keeps the entry point clean and the business logic testable.
func main() {
    cmd.Execute()
}

Three lines. If your main.go is longer than this, something belongs in a package.


The root command

// internal/cmd/root.go
package cmd

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

// kubeconfigPath holds the value of the --kubeconfig persistent flag.
// Declared at package level so every subcommand can read it.
var kubeconfigPath string

// rootCmd is the base command, the one that runs when you type just "k8s-info".
// Every subcommand is registered on it via AddCommand().
var rootCmd = &cobra.Command{
    // Use is the one-line usage shown in help text.
    // The first word must be the binary name.
    Use: "k8s-info",

    // Short is the brief description shown in parent command help.
    Short: "A CLI for inspecting your Kubernetes cluster",

    // Long is the full description shown when you run "k8s-info --help".
    Long: `k8s-info is a platform engineering CLI for inspecting
Kubernetes cluster state. It connects to whatever cluster
your kubeconfig is currently pointing at.`,

    // SilenceUsage prevents Cobra from printing the full usage text
    // on every error, which gets noisy fast. Errors are still shown.
    SilenceUsage: true,
}

func init() {
    // PersistentFlags are inherited by every subcommand automatically.
    // --kubeconfig is something every subcommand might need, so it
    // belongs on the root as a persistent flag.
    rootCmd.PersistentFlags().StringVar(
        &kubeconfigPath,
        "kubeconfig",
        os.Getenv("HOME")+"/.kube/config", // default: ~/.kube/config
        "path to the kubeconfig file",
    )
}

// Execute is the single public entry point called from main.go.
// It parses os.Args, finds the right command, and runs it.
func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Why SilenceUsage matters

By default, Cobra prints the full usage text every time an error occurs. This is often more confusing than helpful; the user sees a wall of text when what they really need is just the error message. SilenceUsage: true on the root command suppresses usage on errors for the entire CLI.


Adding subcommands

version subcommand

// internal/cmd/version.go
package cmd

import (
    "fmt"

    "github.com/spf13/cobra"
)

// Version is set at build time using ldflags.
// Default to "dev" so it's always meaningful, even without a build.
var Version = "dev"

// versionCmd prints the current version of k8s-info.
var versionCmd = &cobra.Command{
    Use:   "version",
    Short: "Print the k8s-info version",

    // Run is used when the command cannot fail.
    // If your command can return an error, use RunE instead (shown below).
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Printf("k8s-info version %s\n", Version)
    },
}

func init() {
    // Register versionCmd on the root command.
    // After this, "k8s-info version" is a valid command.
    rootCmd.AddCommand(versionCmd)
}

cluster subcommand

// internal/cmd/cluster.go
package cmd

import (
    "context"
    "fmt"
    "os"

    "github.com/spf13/cobra"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/tools/clientcmd"
)

// namespace is a local flag, only available on the cluster subcommand,
// not on version or any future subcommand.
var namespace string

// clusterCmd shows a summary of what's running in the cluster.
var clusterCmd = &cobra.Command{
    Use:   "cluster",
    Short: "Show a summary of running pods in your cluster",
    Example: `  k8s-info cluster
  k8s-info cluster -n kube-system
  k8s-info cluster --namespace default`,

    // RunE is the idiomatic choice when a command can fail.
    // It returns an error, Cobra prints it and exits with code 1.
    // Never call os.Exit inside RunE, let Cobra handle it.
    RunE: func(cmd *cobra.Command, args []string) error {
        // Build the Kubernetes config from the kubeconfig flag.
        // kubeconfigPath is inherited from the root persistent flag.
        config, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath)
        if err != nil {
            return fmt.Errorf("connect to cluster: %w", err)
        }

        clientset, err := kubernetes.NewForConfig(config)
        if err != nil {
            return fmt.Errorf("create client: %w", err)
        }

        // List pods in the given namespace.
        pods, err := clientset.CoreV1().Pods(namespace).List(
            context.Background(),
            metav1.ListOptions{},
        )
        if err != nil {
            return fmt.Errorf("list pods namespace=%q: %w", namespace, err)
        }

        // Count pods by phase.
        var running, pending, failed int
        for _, pod := range pods.Items {
            switch pod.Status.Phase {
            case "Running":
                running++
            case "Pending":
                pending++
            case "Failed":
                failed++
            }
        }

        fmt.Printf("Namespace: %s\n", namespace)
        fmt.Printf("Total pods: %d\n", len(pods.Items))
        fmt.Printf("  Running: %d\n", running)
        fmt.Printf("  Pending: %d\n", pending)
        fmt.Printf("  Failed:  %d\n", failed)

        return nil
    },
}

func init() {
    // Local flags only apply to this subcommand.
    // --namespace with -n shorthand, defaults to "default".
    clusterCmd.Flags().StringVarP(
        &namespace,
        "namespace", "n",
        "default",
        "namespace to inspect",
    )

    rootCmd.AddCommand(clusterCmd)
}

Persistent flags vs local flags

This is one of the most important concepts in Cobra and worth understanding clearly before your CLI grows.

Do you always have to type --kubeconfig?

No, and this is the part that trips people up. Because --kubeconfig has a default value set:

rootCmd.PersistentFlags().StringVar(
    &kubeconfigPath,
    "kubeconfig",
    os.Getenv("HOME")+"/.kube/config", // ← default is already set here
    "path to the kubeconfig file",
)

...kubeconfigPath is automatically populated with ~/.kube/config every time any command runs. You never need to type --kubeconfig unless you want to point at a different file:

# Default kubeconfig, flag not needed at all
k8s-info version
k8s-info cluster -n default

# Non-default kubeconfig, only specify when you need a different path
k8s-info --kubeconfig /path/to/other/config version
k8s-info --kubeconfig /path/to/other/config cluster

The real difference between persistent and local

The distinction that actually matters is this:

Persistent flag, declared on rootCmd with PersistentFlags()
    ↓ inherited by ALL subcommands, available everywhere in the CLI
    k8s-info --kubeconfig /other/config version     ✓ works
    k8s-info --kubeconfig /other/config cluster     ✓ works
    k8s-info version --kubeconfig /other/config     ✓ also works (order flexible)

Local flag, declared on a specific command with Flags()
    ↓ only available on THAT specific subcommand
    k8s-info cluster -n kube-system                 ✓ works
    k8s-info version -n kube-system                 ✗ unknown flag: -n

The last two lines are what matters. -n is a local flag on cluster, it doesn't exist on version. If a user tries to pass it to version, Cobra rejects it with a clear error.

The rule

Simple: if every subcommand needs the flag (like --kubeconfig), make it persistent on the root. If only one subcommand needs it (like --namespace for cluster), make it local. When in doubt, start local; you can always promote a flag to persistent later, but removing a persistent flag is a breaking change for your users.


Run vs RunE

Cobra gives you two options for the command function:

// Run, use when the command cannot fail
Run: func(cmd *cobra.Command, args []string) {
    fmt.Println("hello")
},

// RunE, use when the command can return an error
RunE: func(cmd *cobra.Command, args []string) error {
    if err := doSomething(); err != nil {
        return fmt.Errorf("something failed: %w", err)
    }
    return nil
},

Always prefer RunE for anything that does I/O, file reads, network calls, Kubernetes API calls. The error is printed automatically by Cobra and the process exits with code 1. Never call os.Exit inside RunE; that bypasses any deferred cleanup and makes testing harder.


The Makefile

A Makefile is a build automation file; it defines named commands called targets that you run with make <target>. Instead of typing long go build commands with flags every time, you type make build and the Makefile handles the rest.

Every target follows the same basic anatomy:

target_name: optional_dependency
	command to run

The dependency means "run this target first before mine." If install depends on build, running make install automatically runs make build first, you never have to remember the order.

Two rules that trip up everyone writing their first Makefile:

  • Commands must be indented with a real tab character: spaces look identical but Make rejects them with a cryptic error

  • .PHONY tells Make these are command names, not filenames: without it, Make looks for a file called build or install and gets confused when it doesn't find one

Variables work like this:

BINARY := ferctl        # declare a variable
$(BINARY)               # reference it, expands to "ferctl"

The @ prefix on a command suppresses echoing that line to the terminal, only the output is shown, not the command itself.

Here's the k8s-info Makefile with every line explained:

# BINARY is the name of the output binary, change this and everything updates.
BINARY  := k8s-info

# VERSION reads the current git tag automatically at build time.
# git describe returns something like "v1.2.0-3-gabcdef" for tagged commits.
# If you're not in a git repo, it falls back to "dev".
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")

# .PHONY declares these as command names, not file names.
# Without this, Make would look for files called "build", "install", etc.
.PHONY: build install clean

# build compiles the binary for the current platform.
# -ldflags="-s -w" strips debug symbols, reduces binary size by ~30%.
# -X sets a Go package-level variable at build time (explained below).
# -o $(BINARY) names the output file.
build:
	go build \
	  -ldflags="-s -w -X github.com/FerRiosCosta/k8s-info/internal/cmd.Version=$(VERSION)" \
	  -o $(BINARY) \
	  ./cmd/k8s-info/...

# install depends on build, "make install" runs "make build" first automatically.
# Then copies the binary to /usr/local/bin so it's available system-wide.
install: build
	cp $(BINARY) /usr/local/bin/$(BINARY)
	@echo "installed $(BINARY) to /usr/local/bin"
	# @ suppresses printing the echo command, only the output is shown.

# clean removes the compiled binary so you can start fresh.
clean:
	rm -f $(BINARY)

The -X ldflag is new here compared to previous posts. It sets a Go package-level variable at build time; in this case cmd.Version. When you run make build in a git repository, git describe automatically fills in the version tag. When you run it outside git, it falls back to "dev".

The targets work together in a natural workflow:

make build    # compile the binary
make install  # compile + install to /usr/local/bin (runs build first)
make clean    # remove the binary and start fresh

Understanding versions and git describe

The VERSION variable in the Makefile uses git describe to automatically read a version from your git history. To understand how this works, it helps to understand what a version actually is in a git project.

What is a version?

A version is a named point in your git history, a label that says "this is the code that shipped as v1.0.0". In git, this is called a tag. A tag is just a pointer to a specific commit, with a human-readable name.

Versions typically follow semantic versioning (semver):

v1.2.3
│ │ └─── patch: bug fixes, no new features
│ └───── minor: new features, backward compatible
└─────── major: breaking changes

Examples of what each bump means in practice:

v1.0.0  → first public release
v1.0.1  → fixed a bug in the cluster command
v1.1.0  → added a new "nodes" subcommand
v2.0.0  → renamed flags, changed output format (breaking)

How to create a version in git

First make sure your code is committed:

git add .
git commit -m "feat: add cluster subcommand"

Then create a tag:

# Create a tag pointing at the current commit
git tag v1.0.0

# Create a tag with an annotation message (recommended)
git tag -a v1.0.0 -m "First stable release of k8s-info"

# Push the tag to GitHub (tags dont push automatically with git push)
git push origin v1.0.0

# Or push all tags at once
git push origin --tags

How git describe uses your tags

git describe reads the most recent tag in your git history and builds a version string from it:

git describe --tags --always --dirty

The output depends on where you are in your commit history:

v1.0.0                    ← exactly on the tagged commit
v1.0.0-3-gabcdef          ← 3 commits after v1.0.0, current hash is abcdef
v1.0.0-3-gabcdef-dirty    ← same, but you have uncommitted changes
abcdef                    ← no tags exist yet, falls back to commit hash

The flags we use:

Flag What it does
--tags Considers all tags, not just annotated ones
--always Falls back to the commit hash if no tags exist
--dirty Appends -dirty if there are uncommitted changes

And 2>/dev/null || echo "dev" handles the case where git isn't available at all (like in a CI environment without git installed); it silently swallows the error and falls back to "dev".

What this means for your binary

When you run make build with a tagged commit:

git tag -a v1.0.0 -m "first release"
make build
./k8s-info version
# k8s-info version v1.0.0

When you run it after adding more commits:

git commit -m "fix: handle empty namespace"
make build
./k8s-info version
# k8s-info version v1.0.0-1-gabcdef

This tells you exactly which commit the binary was built from, invaluable when debugging a production issue and trying to figure out what version is actually running.

A practical workflow for releasing

# 1. Make sure everything is committed and tested
git status              # should show nothing uncommitted
make test               # all tests pass

# 2. Create the version tag
git tag -a v1.0.0 -m "Initial release of k8s-info"

# 3. Build the binary, version is embedded automatically
make build
./k8s-info version
# k8s-info version v1.0.0

# 4. Install it
make install

# 5. Push the tag to GitHub
git push origin v1.0.0

From this point on, anyone who clones your repo and runs make build on that commit gets a binary that reports v1.0.0. No manual version files to maintain, no risk of forgetting to update a constant; git is the source of truth.


Running the CLI

Build and install:

make install

Try the commands:

# Show help — generated automatically by Cobra
k8s-info --help

# Show version
k8s-info version
# k8s-info version dev

# Show cluster summary for the default namespace
k8s-info cluster

# Namespace: default
# Total pods: 3
# Running: 3
# Pending: 0
# Failed:  0

# Inspect kube-system
k8s-info cluster -n kube-system

# Namespace: kube-system
# Total pods: 7
# Running: 7
# Pending: 0
# Failed:  0

# Show help for the cluster subcommand
k8s-info cluster --help

What Cobra generates automatically

Run k8s-info --help and notice what you didn't have to write:

A CLI for inspecting your Kubernetes cluster

Usage:
  k8s-info [command]

Available Commands:
  cluster     Show a summary of running pods in your cluster
  completion  Generate the autocompletion script for the specified shell
  help        Help about any command
  version     Print the k8s-info version

Flags:
  -h, --help                help for k8s-info
      --kubeconfig string   path to the kubeconfig file (default "/Users/yourname/.kube/config")

Use "k8s-info [command] --help" for more information about a command.

The completion and help subcommands, the --help flag, the usage text, the default values, all generated for free. That's what using Cobra buys you.


How init() wires everything together

You may have noticed that every file calls rootCmd.AddCommand() inside an init() function. This is Go's package initialization mechanism; init() runs automatically when the package is imported, before main().

main() calls cmd.Execute()
      │
      ▼
Go initializes the cmd package
      │
      ├── root.go   init() runs → registers --kubeconfig flag
      ├── version.go init() runs → registers versionCmd on root
      └── cluster.go init() runs → registers clusterCmd on root
      │
      ▼
rootCmd.Execute() runs with all subcommands registered

This is why you can add a new subcommand by creating a new file and calling rootCmd.AddCommand() in its init(), no other file needs to change. The root command picks it up automatically.


Common gotchas

Using Run instead of RunE for fallible commands

If your command calls a Kubernetes API or reads a file and you use Run instead of RunE, you have to handle errors yourself, usually with fmt.Fprintln(os.Stderr, err); os.Exit(1). This is verbose, inconsistent, and bypasses Cobra's error handling. Always use RunE for anything that can fail.

Forgetting SilenceUsage

Without SilenceUsage: true, every error prints the full usage text, which is almost never what you want when a flag value is wrong or a Kubernetes call fails. Add it to the root command and save your users the confusion.

Persistent flags vs local flags confusion

If you declare a flag with Flags() on the root command, it's still a local flag, only available directly on the root, not inherited by subcommands. Use PersistentFlags() on the root for flags that should be available everywhere.

Flag order matters to users

Cobra parses flags after the command name. This means:

k8s-info --kubeconfig ~/.kube/config cluster -n default   ✓ works
k8s-info cluster --kubeconfig ~/.kube/config -n default   ✓ also works
k8s-info cluster -n default --kubeconfig ~/.kube/config   ✓ also works

All three are equivalent; Cobra is flexible about flag position. But document a consistent order in your examples to avoid confusing users.

init() order is not guaranteed across files

Go guarantees that init() functions within a single file run in order, but the order between files in the same package is not guaranteed. This is fine for Cobra; AddCommand() calls are independent of each other. But don't write init() functions that depend on each other across files.


Summary

Cobra turns a Go program into a proper CLI in three concepts:

  • rootCmd: the entry point, owns persistent flags, registers subcommands

  • AddCommand(): how subcommands attach to the tree

  • RunE: the function that runs when a command is invoked, returns an error or nil

The init() pattern means adding a new subcommand is always: create a file, write the command, call rootCmd.AddCommand() in init(). Nothing else changes.

This is exactly the structure the next command uses in post 5; the root command, the persistent --kubeconfig flag, the top subcommand registered in its own file, all wired together by init().


What's next

In nex post we take this Cobra foundation and build ferctl top on top of it, adding the metrics-server client, resource threshold warnings, and tabwriter output. The k8s-info cluster command becomes the starting point for the richer ferctl top command.


Let's connect

One of the best parts of writing in public is the people you meet along the way, engineers at different stages of their journey, working on similar problems from completely different angles.

If something in this post resonated, if you spotted a bug, or if you just want to talk Go, Kubernetes, platform engineering, jazz, or jiu-jitsu. I'm always happy to hear from you.

Building from Asunción, Paraguay 🇵🇾

Platform Engineering with Go

Part 1 of 4

Platform engineering is how you scale engineering teams without scaling headcount. Go is how you build the tools that make it possible. This series covers both: from Kubernetes internals to production Go CLIs, operators, admission webhooks, and automation tooling. Each post either teaches a platform engineering concept, builds a real Go tool, or both. The series never ends. As the platform engineering landscape evolves, Kubernetes, ArgoCD, Backstage, Prometheus, Helm, Terraform, new posts get added. Follow along if you want to build the layer that makes everything else work. Who this is for: Senior DevOps and SRE engineers who want to go beyond YAML and build real internal tooling with Go. Go developers who want to apply their skills to infrastructure and platform problems.

Up next

Talking to Kubernetes from Go: a practical client-go guide

Series: Platform engineering with Go | Topics: Go, Kubernetes, client-go, Platform Engineering