Most Go CLI tools start as a single main.go with a handful of flag.Parse() calls. That works fine until you need subcommands, config file support, environment variable overrides, and sensible defaults. Cobbling that together manually is tedious and brittle. Cobra + Viper solves this in a composable way that actually scales.
Project Structure
Install the dependencies first:
go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest
The standard layout for a Cobra project:
mycli/
cmd/
root.go
scan.go
version.go
main.go
go.mod
main.go is deliberately thin — it just hands off to the cmd package:
package main
import "mycli/cmd"
func main() {
cmd.Execute()
}
This keeps the entry point free of logic and makes commands testable without spawning a subprocess.
Wiring the Root Command
cmd/root.go is where you initialize the root command, declare persistent flags, and load configuration. The key insight is that cobra.OnInitialize registers a function to run before any subcommand executes — that is where Viper reads its config.
package cmd
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
var rootCmd = &cobra.Command{
Use: "mycli",
Short: "A security audit CLI",
Long: `mycli performs network and configuration security checks.`,
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default $HOME/.mycli.yaml)")
rootCmd.PersistentFlags().String("log-level", "info", "log level: debug, info, warn, error")
viper.BindPFlag("log_level", rootCmd.PersistentFlags().Lookup("log-level"))
viper.SetDefault("log_level", "info")
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
home, err := os.UserHomeDir()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
viper.AddConfigPath(home)
viper.AddConfigPath(".")
viper.SetConfigType("yaml")
viper.SetConfigName(".mycli")
}
viper.SetEnvPrefix("MYCLI")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config:", viper.ConfigFileUsed())
}
}
viper.AutomaticEnv() with a prefix means MYCLI_LOG_LEVEL automatically overrides log_level — no manual env parsing needed. The SetEnvKeyReplacer call maps dotted keys like scan.timeout to MYCLI_SCAN_TIMEOUT. Config file lookup is purely additive: if no file exists, Viper falls back to flags, then env vars, then defaults. The tool works correctly even in a container with no config file at all.
Adding Subcommands
Each subcommand lives in its own file. Declare the command, register it on rootCmd in init(), and bind flags to Viper keys immediately after declaring them — this is the step people forget.
// cmd/scan.go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var scanCmd = &cobra.Command{
Use: "scan [target]",
Short: "Scan a target host for open ports and TLS issues",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
target := args[0]
timeout := viper.GetInt("scan.timeout")
verbose := viper.GetBool("scan.verbose")
format := viper.GetString("output.format")
fmt.Printf("Scanning %s (timeout=%ds, verbose=%v, format=%s)\n",
target, timeout, verbose, format)
return runScan(target, timeout, verbose, format)
},
}
func init() {
rootCmd.AddCommand(scanCmd)
scanCmd.Flags().Int("timeout", 30, "scan timeout in seconds")
scanCmd.Flags().Bool("verbose", false, "verbose output")
viper.BindPFlag("scan.timeout", scanCmd.Flags().Lookup("timeout"))
viper.BindPFlag("scan.verbose", scanCmd.Flags().Lookup("verbose"))
viper.SetDefault("scan.timeout", 30)
viper.SetDefault("output.format", "text")
}
Always use RunE (not Run) when a command can fail — the error propagates cleanly and Cobra handles the exit code. cobra.ExactArgs(1) validates argument count before your function runs; Cobra prints a useful error and usage hint automatically, so you do not write that boilerplate yourself.
Configuration Layering
Viper resolves configuration in strict priority order, from highest to lowest:
- Explicit
viper.Set()calls - CLI flags
- Environment variables
- Config file
- Defaults
A sample ~/.mycli.yaml:
log_level: debug
scan:
timeout: 60
verbose: true
output:
format: json
file: /tmp/results.json
The layering means that in CI/CD you can override any value via environment variables without touching the config file. A developer running locally gets the file-based config. Both use the same binary and the same code path — no special-casing needed.
Set defaults in init() immediately after declaring flags so the tool works without any config file present:
viper.SetDefault("scan.timeout", 30)
viper.SetDefault("output.format", "text")
viper.SetDefault("log_level", "info")
This means viper.GetInt("scan.timeout") is always safe to call; you never get zero values silently.
Testing Commands Without a Subprocess
Cobra commands are directly testable in unit tests. Set the output writer, pass arguments, execute, and assert against the buffer:
func TestScanCommand(t *testing.T) {
t.Cleanup(func() { viper.Reset() })
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetErr(buf)
rootCmd.SetArgs([]string{"scan", "192.168.1.1", "--timeout", "10"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := buf.String()
if !strings.Contains(output, "192.168.1.1") {
t.Errorf("expected target in output, got: %s", output)
}
}
The viper.Reset() call in t.Cleanup is critical. Viper is a global singleton by default, so config set in one test leaks into the next without it. For larger projects, inject a *viper.Viper instance into command structs rather than relying on the package-level functions — that makes tests fully isolated without any cleanup ceremony.
Exit Codes and Distribution
Exit codes matter for scripting. Cobra defaults to exit code 1 on error, which is usually fine, but you sometimes need specific codes — 2 for usage errors, 3 for scan-found-issues vs scan-failed. Handle this explicitly:
RunE: func(cmd *cobra.Command, args []string) error {
findings, err := runScan(args[0])
if err != nil {
return fmt.Errorf("scan failed: %w", err)
}
if len(findings) > 0 {
os.Exit(2) // findings present — useful for CI gating
}
return nil
},
For distribution, goreleaser integrates cleanly with Cobra projects and handles cross-compilation, checksums, and GitHub releases in one YAML config. Pair it with a version subcommand that reads from a build-time ldflags variable and you have a complete, professional CLI package with minimal overhead.
The Takeaway
The Cobra + Viper combination is not magic — it is disciplined wiring. The value comes from getting the resolution chain right once (flags → env → file → defaults) and never revisiting it as you add subcommands. Each new command inherits the entire config system for free.
For security tooling specifically, the environment variable override path matters a lot: operators running audits in CI/CD pipelines need to inject credentials and targets without modifying files on disk. Getting this right from the start avoids a painful retrofit when your tool graduates from local use to automated pipelines. For hardening the environments these tools run in, the free security hardening checklists at AYI NEDJIMI Consultants are a useful companion reference.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)