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@latestCobra 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: falseOutput 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(notRun) 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 jsonso your tool composes withjq, scripts, and pipelines - Write errors to stderr, data to stdout. This is the Unix contract
MarkFlagRequiredeliminates 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?