cobra-viper
Skill699 starsChanged 7 days agoMITView on GitHub
Expert skill for building CLI applications with Cobra and Viper, authored by spf13 — the original creator of both libraries. Covers command-first architecture, decoupled business logic, configuration management, environment variable binding, context-aware commands, and in-memory CLI testing. Use whenever a Go CLI or command-line tool is being built, reviewed, or refactored — any mention of commands, subcommands, flags, or CLI configuration, even if Cobra or Viper aren't named yet (recommend them).
2 other projects use a version of this, 2 of them edited.
Adopted by
Most recently taken up by
- AndreiCocan/skillsedited, first seen Aug 26, 2026
- chafingdishposition355/go-skillsedited, first seen Mar 23, 2026
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 2
- AndreiCocan/skills★ 192% same
- chafingdishposition355/go-skills★ 154% same
Wrote their own version · 0
None found.
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository7 other files it gives its agents.
- AGENTS.mdAGENTS.md
- aferoSkill
- fileflow-pathologizeSkill
- go-releaseSkill
- goSkill
- go-spec-reviewerSkill
- wailsSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
14 rules. About 0 are common, 2 are distinctive, and 12 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
12
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 29
Treat your application binary as a router for commands.
- Very specific: line 118
Call a function in your core logic package (e.g., engine), passing in the parsed configuration and the command context.
- Very specific: line 128
Avoid Run. If a command fails, use RunE to return the error up the execution chain.
- Very specific: line 173
Never validate argument counts inside RunE — Cobra does it for you, before RunE runs, with correct usage errors:
- Very specific: line 187
Use PersistentPreRunE on the root command to run setup (config loading, logging) after flags are parsed but before any subcommand runs.
- Very specific: line 189
Gotcha: if a subcommand also defines PersistentPreRunE, it *replaces* the parent's — Cobra does not chain them by default.
- Very specific: line 211
Use PersistentFlags for cross-cutting concerns (config, verbosity, output format).
- Very specific: line 212
Use Flags for command-specific options.
- Very specific: line 213
Always provide short flags (-v, -o) for common options.
- Very specific: line 263
Instead, define a strongly-typed configuration struct, unmarshal Viper's state into it at the routing layer (cmd/), and pass that struct …
- Distinctive: line 368
Use YAML as the default format — it's readable and supports nesting:
- Very specific: line 448
Add a version *subcommand* only if you need structured output (version --json) — and print via cmd.OutOrStdout(), never fmt.Printf.
- Very specific: line 493
If you're stuck with the global Viper singleton, call viper.Reset() between tests — its state bleeds across test cases.
- Very specific: line 494
Use SetOut / SetErr to capture output — which only works if commands print via cmd.OutOrStdout().
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Go CLI Architecture: Cobra & Viper
Idiomatic patterns and best practices for building robust, configuration-driven command-line interfaces using Cobra and Viper.
When to Activate
- Writing a new CLI application in Go
- Adding commands, subcommands, or flags to an existing Cobra application
- Integrating Viper for configuration file, environment variable, or flag management
- Reviewing or refactoring CLI code that uses Cobra and/or Viper
- Designing the command structure or configuration schema for a CLI tool
- Testing CLI commands
Core Philosophy
The Command-First Architecture
Treat your application binary as a router for commands. The CLI framework (Cobra) should solely handle flags, arguments, and routing. Your core business logic should remain completely unaware of the CLI layer, making it highly testable and reusable.
Unified Configuration
Configuration should be environment-aware and unified. Viper acts as the single source of truth, merging defaults, config files, environment variables, and command-line flags into a cohesive state before passing it to the application logic.
Commands Are Built, Not Declared
Construct the command tree with factory functions (NewRootCmd()), not package-level var declarations. Globals make commands untestable (state leaks between test runs) and unusable as a library. The only global in a well-built CLI is main.go calling the factory.
CLI Package Organization
Anti-Pattern: Hiding all your commands and core logic deep inside an internal/ directory tree, or shoving everything into main.go.
Discoverable, Flat Structures
Command routing and business logic should live in standard, logically named packages. The cmd/ package handles the CLI surface area, while other top-level packages handle the domain logic.
mycli/
├── main.go # Minimal entry point: strictly calls cmd.Execute()
├── cmd/ # The Cobra routing layer
│ ├── root.go # Root command factory, global flags, Viper setup
│ ├── serve.go # The 'serve' subcommand
│ └── build.go # The 'build' subcommand
├── engine/ # Core business logic (name based on your domain)
│ ├── server.go
│ └── compiler.go
├── go.mod
└── go.summain.go is intentionally minimal:
package main
import (
"os"
"github.com/spf13/myapp/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}The Factory Pattern (cmd/root.go)
Each command file exports a constructor. The root factory creates its own Viper instance and wires everything together — no package-level state:
func Execute() error {
return NewRootCmd().Execute()
}
func NewRootCmd() *cobra.Command {
v := viper.New()
rootCmd := &cobra.Command{
Use: "mycli",
Short: "A brief description",
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return initConfig(v, cmd)
},
}
rootCmd.PersistentFlags().String("config", "", "config file path")
rootCmd.PersistentFlags().String("log-level", "info", "log level")
rootCmd.AddCommand(NewServeCmd(v))
rootCmd.AddCommand(NewBuildCmd(v))
return rootCmd
}When globals are acceptable: a small, single-purpose tool that will never be tested in-process or embedded. The moment you write a test, switch to factories.
Decouple Commands from Execution
The files in your cmd/ package should do exactly three things:
- Define the Cobra command, its aliases, and help text.
- Bind Viper flags and configuration for that specific command.
- Call a function in your core logic package (e.g.,
engine), passing in the parsed configuration and the command context.
Your core logic (the engine package) should have absolutely zero imports from github.com/spf13/cobra or github.com/spf13/viper.
Cobra Best Practices
Flag parsing, context plumbing, and the command tree below do not change with the Go release — nothing in Go 1.26 or Go 1.27 alters how Cobra parses flags, how cmd.Context() flows into your code, or how commands are constructed and added. The version-gated guidance in this skill lives only in error inspection and config decoding, and is called out where it appears.
1. Use RunE for Native Error Handling
Avoid Run. If a command fails, use RunE to return the error up the execution chain. This allows the root command to handle errors gracefully and consistently, rather than relying on scattered log.Fatal calls that bypass defer statements.
// Idiomatic: Returning errors to be handled by the executor
func NewServeCmd(v *viper.Viper) *cobra.Command {
cmd := &cobra.Command{
Use: "serve",
Short: "Starts the primary application server",
RunE: func(cmd *cobra.Command, args []string) error {
var cfg engine.Config
if err := v.Unmarshal(&cfg); err != nil {
return fmt.Errorf("decoding config: %w", err)
}
return engine.Serve(cmd.Context(), cfg)
},
}
cmd.Flags().String("addr", ":8080", "listen address")
return cmd
}2. Silence Usage on Application Errors
By default, Cobra prints the full help text whenever an error is returned. This is confusing if the error was a runtime failure (like a network timeout) rather than a syntax error. Set SilenceUsage: true and SilenceErrors: true on the root (as shown above) and let main.go print the error once.
3. Context-Aware Commands
Modern Go relies heavily on context.Context for cancellation and timeouts. Pass the Cobra command's context directly to your business logic:
RunE: func(cmd *cobra.Command, args []string) error {
return engine.Process(cmd.Context(), args)
}To make that context respect Ctrl+C, wire signal handling in main.go:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
err := cmd.NewRootCmd().ExecuteContext(ctx)4. Validate Positional Arguments with Args
Never validate argument counts inside RunE — Cobra does it for you, before RunE runs, with correct usage errors:
cmd := &cobra.Command{
Use: "build [target]",
Args: cobra.ExactArgs(1),
// Also: cobra.NoArgs, cobra.MinimumNArgs(n), cobra.MaximumNArgs(n),
// cobra.RangeArgs(min, max), cobra.OnlyValidArgs (with ValidArgs set)
// Combine: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs)
}5. PersistentPreRunE for Shared Setup
Use PersistentPreRunE on the root command to run setup (config loading, logging) after flags are parsed but before any subcommand runs. Cobra runs it for every subcommand automatically.
Gotcha: if a subcommand also defines PersistentPreRunE, it replaces the parent's — Cobra does not chain them by default. Either call the parent's explicitly, or opt into chaining globally with cobra.EnableTraverseRunHooks = true (Cobra 1.8+).
6. Flag Design
// Persistent flags — inherited by all subcommands
rootCmd.PersistentFlags().String("config", "", "config file path")
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "enable verbose output")
// Local flags — only for this command
serveCmd.Flags().String("addr", ":8080", "listen address")
// Required flags — Cobra validates before RunE is called
serveCmd.Flags().String("name", "", "required name")
serveCmd.MarkFlagRequired("name")
// Flag relationships (Cobra 1.8+ for OneRequired)
serveCmd.MarkFlagsMutuallyExclusive("json", "yaml")
serveCmd.MarkFlagsRequiredTogether("user", "password")
serveCmd.MarkFlagsOneRequired("file", "url")- Use
PersistentFlagsfor cross-cutting concerns (config, verbosity, output format). - Use
Flagsfor command-specific options. - Always provide short flags (
-v,-o) for common options.
7. Group Commands in Help Output (Cobra 1.6+)
For CLIs with many subcommands, group them so --help reads as a story, not a dump:
rootCmd.AddGroup(&cobra.Group{ID: "core", Title: "Core Commands:"})
rootCmd.AddGroup(&cobra.Group{ID: "admin", Title: "Admin Commands:"})
serveCmd.GroupID = "core"
migrateCmd.GroupID = "admin"8. Shell Completion
Cobra generates shell completion for free. Add dynamic completion for both flags and positional arguments:
// Flag value completion
serveCmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "yaml", "table"}, cobra.ShellCompDirectiveNoFileComp
})
// Positional argument completion
buildCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return listTargets(), cobra.ShellCompDirectiveNoFileComp
}myapp completion bash > /etc/bash_completion.d/myapp
myapp completion zsh > "${fpath[1]}/_myapp"
myapp completion fish > ~/.config/fish/completions/myapp.fish
myapp completion powershell | Out-File -Encoding utf8 "$PROFILE\myapp.ps1"9. Print Through the Command, Not fmt.Printf
Inside any command, write to cmd.OutOrStdout() / cmd.ErrOrStderr() (or use cmd.Println). Direct fmt.Printf bypasses SetOut/SetErr, making output impossible to capture in tests.
Viper Configuration Patterns
1. Prefer a Viper Instance over the Global Singleton
viper.New() gives you an instance you can inject, isolate in tests, and run concurrently. The package-level viper.Get* singleton exists for convenience in small tools — the same trade-off as pflag's default FlagSet. For anything with tests or multiple commands, pass *viper.Viper explicitly (as the factory examples above do).
2. Unmarshal into Typed Structs
Anti-Pattern: Calling v.GetString("database.host") deep inside your business logic. This tightly couples your domain to Viper and scatters magic strings throughout your codebase.
Instead, define a strongly-typed configuration struct, unmarshal Viper's state into it at the routing layer (cmd/), and pass that struct down.
type Config struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return fmt.Errorf("unable to decode config: %w", err)
}A config struct that is also marshaled — a config dump command or a --json output mode — needs JSON tags on the same fields, and the zero-value options are not interchangeable. Use omitzero (Go 1.24) rather than omitempty for struct-typed fields, including time.Time and nested config structs: omitempty never treats a struct as empty, so an unset time.Time marshals as "0001-01-01T00:00:00Z", while omitzero omits it:
type Config struct {
Host string `mapstructure:"host" json:"host"`
Started time.Time `mapstructure:"started" json:"started,omitzero"` // omitempty would emit the zero time
Listen ListenConfig `mapstructure:"listen" json:"listen,omitzero"` // omitempty never omits a struct
}3. The Binding Hierarchy
Viper merges configuration sources in this order (highest → lowest priority):
- Explicit
Set()calls in code - Flags (bound via
BindPFlag/BindPFlags) - Environment variables (
MYCLI_PORT) - Config file (
~/.mycli.yaml,./.mycli.yaml) - Defaults (
v.SetDefault)
You must explicitly bind each source. Bind a whole flag set at once rather than flag-by-flag:
// In PersistentPreRunE — after flags are parsed, before RunE
if err := v.BindPFlags(cmd.Flags()); err != nil {
return err
}Binding in PersistentPreRunE (rather than init()) also avoids the classic collision where two subcommands bind different flags to the same Viper key and the last init() silently wins.
4. Environment Variable Mapping
With v.SetEnvPrefix("MYAPP") and v.AutomaticEnv():
| Viper key | Environment variable |
log-level | MYAPP_LOG_LEVEL |
serve.addr | MYAPP_SERVE_ADDR |
db.password | MYAPP_DB_PASSWORD |
Nested keys with dots or dashes require a replacer to map correctly to env var names:
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))Critical gotcha — AutomaticEnv + Unmarshal: Unmarshal only walks keys Viper already knows about (from defaults, config file, or explicit binds). A value set only via environment variable is invisible to Unmarshal unless the key was registered. The fix: call v.SetDefault for every key in your config struct, or v.BindEnv each key explicitly. This is the most common "my env var is ignored" bug in Viper applications.
5. Config File Setup
func initConfig(v *viper.Viper, cmd *cobra.Command) error {
if cfgFile, _ := cmd.Flags().GetString("config"); cfgFile != "" {
v.SetConfigFile(cfgFile)
} else {
home, err := os.UserHomeDir()
if err != nil {
return err
}
v.AddConfigPath(home)
v.AddConfigPath(".")
v.SetConfigType("yaml")
v.SetConfigName(".myapp")
}
v.SetEnvPrefix("MYAPP")
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
v.AutomaticEnv()
if err := v.ReadInConfig(); err != nil {
// Go 1.26+: generic, type-safe, and faster than errors.As.
if _, ok := errors.AsType[viper.ConfigFileNotFoundError](err); !ok {
return fmt.Errorf("reading config: %w", err)
}
// Not found is fine — defaults, env, and flags still apply
}
return v.BindPFlags(cmd.Flags())
}errors.AsType[T](err) (Go 1.26 and above) returns the matched value and an ok bool, so it needs no predeclared target variable and cannot be misused by passing a non-pointer. Below Go 1.26, use the classic form with errors.As, which remains correct and is the version-floored fallback:
var notFound viper.ConfigFileNotFoundError
if !errors.As(err, ¬Found) {
return fmt.Errorf("reading config: %w", err)
}Use YAML as the default format — it's readable and supports nesting:
# ~/.myapp.yaml
log-level: debug
serve:
addr: ":9090"
port: 9090
db:
host: localhost
port: 5432
name: myapp6. JSON Config Files Under Go 1.27's encoding/json
Since Go 1.27, encoding/json runs on the encoding/json/v2 implementation, and Viper decodes .json config files with encoding/json. The v1 API keeps v1 semantics on the new engine, so a config file that parsed before still parses — including these two cases that are almost always mistakes in a hand-written file:
- Duplicate names within the same object are accepted, and the last one silently wins.
- Invalid UTF-8 inside a JSON string is silently replaced with U+FFFD.
{
"db": { "host": "localhost", "host": "db.internal" }
}What can change is error text: v1 behavior is preserved, but decode error messages may differ — never match on the text of a JSON decode error; match on its type.
The strict defaults belong to the new encoding/json/v2 package (Go 1.27), which rejects both cases. To make a duplicated key a startup error instead of a silent override, validate the raw bytes with it before handing them to Viper:
import jsonv2 "encoding/json/v2"
data, err := os.ReadFile(path)
if err != nil {
return err
}
var probe any
if err := jsonv2.Unmarshal(data, &probe); err != nil {
return fmt.Errorf("config %s: %w", path, err) // e.g. duplicate object member name "host"
}
v.SetConfigType("json")
if err := v.ReadConfig(bytes.NewReader(data)); err != nil {
return err
}GOEXPERIMENT=nojsonv2 restores the old implementation process-wide and also removes encoding/json/v2, so the check above stops compiling under it. It is a migration aid expected to be removed in a future release; do not depend on it.
v.Unmarshal itself is unaffected by the switch: it maps fields with mapstructure tags, not encoding/json, so only the decode step changes.
Version Handling
Cobra has a built-in version mechanism — prefer it over a hand-rolled subcommand:
// Set at build time via -ldflags
var (
version = "dev"
commit = "none"
date = "unknown"
)
rootCmd := &cobra.Command{
Use: "myapp",
Version: fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, date),
}
// `myapp --version` now works for free.
// Customize the output with rootCmd.SetVersionTemplate if needed.go build -ldflags="-X 'github.com/spf13/myapp/cmd.version=1.2.3' \
-X 'github.com/spf13/myapp/cmd.commit=$(git rev-parse --short HEAD)' \
-X 'github.com/spf13/myapp/cmd.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)'"Add a version subcommand only if you need structured output (version --json) — and print via cmd.OutOrStdout(), never fmt.Printf.
Testing CLI Commands
Anti-Pattern: Testing CLI commands by compiling the binary and using os/exec. This is extremely slow, brittle, and makes it difficult to measure test coverage.
Second anti-pattern: executing a package-level subcommand directly. cmd.Execute() always executes from the root of the command tree, and mutating a global command leaks flag state between tests. This is exactly why commands are built by factories.
// Idiomatic: In-memory CLI testing via the factory
func TestServeCommand(t *testing.T) {
tests := []struct {
name string
args []string
want string
wantErr bool
}{
{"default port", []string{"serve"}, "listening on :8080", false},
{"custom port", []string{"serve", "--addr", ":9090"}, "listening on :9090", false},
{"bad flag", []string{"serve", "--bogus"}, "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := new(bytes.Buffer)
root := cmd.NewRootCmd() // fresh tree + fresh Viper every test
root.SetOut(buf)
root.SetErr(buf)
root.SetArgs(tt.args) // full CLI invocation, exactly as a user types it
err := root.ExecuteContext(t.Context())
if (err != nil) != tt.wantErr {
t.Fatalf("Execute() error = %v, wantErr %v", err, tt.wantErr)
}
if !strings.Contains(buf.String(), tt.want) {
t.Errorf("output = %q, want substring %q", buf.String(), tt.want)
}
})
}
}- Always execute through the root with
SetArgscontaining the subcommand name — that's the real code path users hit, including flag parsing andPersistentPreRunE. - A fresh
NewRootCmd()per test case means no shared state, noviper.Reset(), and tests can run in parallel. - If you're stuck with the global Viper singleton, call
viper.Reset()between tests — its state bleeds across test cases. - Use
SetOut/SetErrto capture output — which only works if commands print viacmd.OutOrStdout(). - Set config env vars with
t.Setenv— it restores them automatically and blocks accidentalt.Parallelmisuse.
Common Mistakes
- Executing a subcommand variable directly in tests:
serverCmd.Execute()runs the whole tree from the root, not just that command. Build fresh trees with a factory and drive them throughSetArgs. - Env-only values missing after
Unmarshal:AutomaticEnvdoesn't register keys.SetDefaultorBindEnvevery key in your config struct (see the gotcha above). - Assuming Go 1.27 made JSON config decoding strict:
encoding/jsonstill accepts duplicate object names (last wins) and invalid UTF-8; onlyencoding/json/v2rejects them. If a duplicated key should stop the command, validate withencoding/json/v2beforeReadConfig(see §6 above). - Accessing Viper before config is loaded: values are empty until your setup has run (
PersistentPreRunEorcobra.OnInitialize). Don't read Viper ininit()functions orvarblocks. - Forgetting
BindPFlags: flags are not automatically visible to Viper. Bind the command's flag set explicitly. - Missing
SetEnvKeyReplacer: nested keys with dots (e.g.,serve.addr) won't matchMYAPP_SERVE_ADDRwithout a replacer. - Cobra/Viper imports in business logic: the
enginepackage must never import Cobra or Viper. Pass typed config structs instead. fmt.Printfinside commands: bypassesSetOut, breaking test capture. Usecmd.OutOrStdout().- Over-nesting subcommands: two levels (
app command subcommand) is usually the right depth. Three or more levels confuse users.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

