Build professional command-line tools in Go using Cobra and Viper. Complete guide with copy-paste commands, cheat sheets, testing patterns, and production-ready examples.
Why Go for CLI Tools?
Go is the language of choice for modern CLI tools. Kubernetes (kubectl), Docker, GitHub CLI (gh), Terraform, and Hugo are all built with Go and Cobra.
This guide takes you from zero to production-ready CLI. By the end, you'll understand how to structure commands, handle configuration from multiple sources, validate input, write tests, and ship cross-platform binaries.
What we'll build: A task management CLI called "taskmaster" with commands for adding, listing, and completing tasks.
Prerequisites Checklist
Before starting, ensure you have everything set up. Don't worry if you're missing something - the installation tabs below will help you get started.
Quick Reference Cheat Sheet
If you just need to get Go and Cobra installed quickly, use these platform-specific commands. We'll explain everything in detail as we build.
Installation Commands
# Install via Homebrew
brew install go
# Verify installation
go versionInstall Cobra CLI
The cobra-cli tool generates boilerplate code for new projects and commands. It's optional but saves significant time when scaffolding.
Part 1: Project Setup
Let's create our taskmaster CLI from scratch. Cobra uses a specific project structure that keeps commands organized and makes the codebase easy to navigate as it grows.
The key insight is that every command is a separate file in the cmd/ directory. This keeps things modular - you can add new commands without touching existing code.
1Create Project Directory
Create a new directory and initialize a Go module.
mkdir taskmaster && cd taskmaster
go mod init github.com/yourusername/taskmasterGenerated Project Structure
After running cobra-cli init, you'll have a minimal but complete CLI structure. The main.go file is intentionally simple - it just calls cmd.Execute(). All the interesting logic lives in the cmd/ package.
Understanding the Root Command
The root command is the foundation of your CLI. When users run taskmaster without any subcommand, this is what executes. It's also where you define global flags that should be available to all subcommands.
1package cmd23import (4 "os"5 "github.com/spf13/cobra"6)78var rootCmd = &cobra.Command{9 Use: "taskmaster",10 Short: "A task management CLI tool",11 Long: "Taskmaster helps you manage tasks...",12}1314func Execute() {15 err := rootCmd.Execute()16 if err != nil {17 os.Exit(1)18 }19}2021func init() {22 rootCmd.PersistentFlags().StringP("config", "c", "", "config file")23 rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output")24}
Add Commands
Now let's add the commands that make our CLI useful. Each cobra-cli add creates a new file with boilerplate code that you'll customize.
Part 2: Building Commands
With our project structure in place, let's implement actual functionality. Each command follows a similar pattern: define the command struct, implement the handler logic, and register it with its parent command.
The most important decision is choosing between Run and RunE. Always prefer `RunE` - it lets you return errors that Cobra handles gracefully, setting appropriate exit codes and printing error messages.
Basic Command Structure
Here's a complete example of the add command. Notice how we define flags in init() but read their values inside RunE. This is important because flag parsing happens between these two phases.
1package cmd23import (4 "fmt"5 "github.com/spf13/cobra"6)78var addCmd = &cobra.Command{9 Use: "add [task name]",10 Short: "Add a new task",11 Long: "Add a new task with optional priority and due date.",12 Args: cobra.MinimumNArgs(1),13 RunE: func(cmd *cobra.Command, args []string) error {14 priority, _ := cmd.Flags().GetString("priority")15 dueDate, _ := cmd.Flags().GetString("due")16 taskName := args[0]1718 fmt.Printf("Adding task: %s\n", taskName)19 fmt.Printf("Priority: %s\n", priority)20 if dueDate != "" {21 fmt.Printf("Due: %s\n", dueDate)22 }23 return nil24 },25}2627func init() {28 rootCmd.AddCommand(addCmd)29 addCmd.Flags().StringP("priority", "p", "medium", "task priority")30 addCmd.Flags().StringP("due", "d", "", "due date (YYYY-MM-DD)")31}
Cobra supports many flag types out of the box. The pattern is always the same: declare the flag in init(), then retrieve its value in your command handler. Here's a quick reference for the most common types:
Flag Types Cheat Sheet
Common Cobra/Viper flag patterns
cmd.Flags().StringP("name", "n", "default", "description")viper.GetString("name")cmd.Flags().BoolP("verbose", "v", false, "description")viper.GetBool("verbose")cmd.Flags().IntP("count", "c", 10, "description")viper.GetInt("count")cmd.Flags().DurationP("timeout", "t", 30*time.Second, "desc")viper.GetDuration("timeout")cmd.Flags().StringSliceP("tags", "t", []string{}, "desc")viper.GetStringSlice("tags")cmd.MarkFlagRequired("name")cmd.PersistentFlags().String("config", "", "desc")Part 3: Viper Configuration
While Cobra handles command structure and flags beautifully, real-world CLIs need more sophisticated configuration. Users want to set defaults in config files, override them with environment variables, and override those with command-line flags.
This is where Viper shines. It's the standard companion to Cobra, handling the complexity of merging configuration from multiple sources.
Setup Viper in Root Command
The magic of Viper is in the initConfig() function. This runs before any command executes, loading configuration from files, binding environment variables, and setting defaults. The key function is viper.BindPFlag() - it connects your Cobra flags to Viper so that viper.GetString() returns the right value regardless of where it came from.
1package cmd23import (4 "fmt"5 "os"6 "github.com/spf13/cobra"7 "github.com/spf13/viper"8)910var cfgFile string1112func init() {13 cobra.OnInitialize(initConfig)14 rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file")15 rootCmd.PersistentFlags().StringP("output", "o", "text", "output format")16 viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))17}1819func initConfig() {20 if cfgFile != "" {21 viper.SetConfigFile(cfgFile)22 } else {23 home, _ := os.UserHomeDir()24 viper.AddConfigPath(home)25 viper.AddConfigPath(".")26 viper.SetConfigType("yaml")27 viper.SetConfigName(".taskmaster")28 }2930 viper.SetEnvPrefix("TASKMASTER")31 viper.AutomaticEnv()32 viper.SetDefault("output", "text")3334 if err := viper.ReadInConfig(); err == nil {35 fmt.Fprintln(os.Stderr, "Using config:", viper.ConfigFileUsed())36 }37}
Example Config File
Users can create a config file to set their preferences. Viper automatically searches common locations like the home directory and current directory. YAML is the most popular format, but Viper also supports JSON, TOML, and even Java properties files.
1# ~/.taskmaster.yaml2output: json3verbose: true45database:6 path: ~/.taskmaster.db78defaults:9 priority: medium10 tags:11 - work1213notifications:14 enabled: true15 email: you@example.comPart 4: Configuration Priority
Understanding how Viper resolves conflicting values is crucial. If a user sets output: json in their config file but passes --output=text on the command line, which wins?
Viper resolves configuration in this order (highest to lowest priority):
Priority Quick Reference
Part 5: Argument Validation
Good CLIs fail fast with helpful error messages. Cobra's argument validators let you enforce constraints declaratively rather than writing boilerplate validation code.
The difference between flags and arguments is important: flags are named (--priority high) while arguments are positional (taskmaster add "Buy milk"). Validators help ensure users provide the right number and type of arguments.
Argument Validators
Built-in Cobra argument validation
Args: cobra.NoArgsArgs: cobra.ExactArgs(2)Args: cobra.MinimumNArgs(1)Args: cobra.MaximumNArgs(3)Args: cobra.RangeArgs(1, 3)Args: cobra.OnlyValidArgsCustom Validator Example
Sometimes built-in validators aren't enough. You might need to validate that an argument is a valid email, a positive number, or matches a specific pattern. Custom validators give you full control:
1var completeCmd = &cobra.Command{2 Use: "complete [task-id]",3 Short: "Mark a task as complete",4 Args: func(cmd *cobra.Command, args []string) error {5 if len(args) != 1 {6 return fmt.Errorf("requires exactly 1 task ID")7 }8 // Validate it's a number9 if _, err := strconv.Atoi(args[0]); err != nil {10 return fmt.Errorf("task ID must be a number: %s", args[0])11 }12 return nil13 },14 RunE: func(cmd *cobra.Command, args []string) error {15 taskID, _ := strconv.Atoi(args[0])16 fmt.Printf("Completing task #%d\n", taskID)17 return nil18 },19}Part 6: Testing Your CLI
Testing CLI applications can be tricky. You need to capture output, simulate user input, and verify exit codes. The key is designing your commands for testability from the start.
The biggest mistake is writing directly to os.Stdout. Instead, accept an io.Writer that your code writes to. In production, pass os.Stdout. In tests, pass a bytes.Buffer you can inspect.
Testing Pattern: Constructor Function
Instead of defining commands as global variables, use constructor functions. This makes dependency injection possible and your commands testable in isolation.
1// cmd/add.go2func NewAddCmd(out io.Writer) *cobra.Command {3 cmd := &cobra.Command{4 Use: "add [task]",5 Short: "Add a new task",6 Args: cobra.MinimumNArgs(1),7 RunE: func(cmd *cobra.Command, args []string) error {8 priority, _ := cmd.Flags().GetString("priority")9 fmt.Fprintf(out, "Added: %s (%s)\n", args[0], priority)10 return nil11 },12 }13 cmd.Flags().StringP("priority", "p", "medium", "priority")14 return cmd15}1617func init() {18 rootCmd.AddCommand(NewAddCmd(os.Stdout))19}
Test File
Now writing tests is straightforward. Use table-driven tests (Go's idiomatic pattern) to cover multiple scenarios. Each test case creates its own buffer, executes the command, and verifies the output.
1// cmd/add_test.go2func TestAddCmd(t *testing.T) {3 tests := []struct {4 name string5 args []string6 expected string7 wantErr bool8 }{9 {10 name: "add with default priority",11 args: []string{"Buy groceries"},12 expected: "Added: Buy groceries (medium)\n",13 },14 {15 name: "add with high priority",16 args: []string{"--priority", "high", "Fix bug"},17 expected: "Added: Fix bug (high)\n",18 },19 {20 name: "missing task name",21 args: []string{},22 wantErr: true,23 },24 }2526 for _, tt := range tests {27 t.Run(tt.name, func(t *testing.T) {28 buf := new(bytes.Buffer)29 cmd := NewAddCmd(buf)30 cmd.SetArgs(tt.args)3132 err := cmd.Execute()3334 if (err != nil) != tt.wantErr {35 t.Errorf("error = %v, wantErr %v", err, tt.wantErr)36 }37 if !tt.wantErr && buf.String() != tt.expected {38 t.Errorf("got %q, want %q", buf.String(), tt.expected)39 }40 })41 }42}Run Tests
Go's testing is fast - you can run the entire test suite in milliseconds. Use -v for verbose output when debugging, and -cover to track code coverage.
Part 7: Production Features
Before shipping your CLI, you'll want a few production essentials: version information, proper build automation, and cross-platform binaries. Let's add these finishing touches.
Version Command
Every CLI should have a version command. The trick is embedding build information at compile time using Go's linker flags (-ldflags). This way, the version is baked into the binary.
1// cmd/version.go2var (3 version = "dev"4 commit = "none"5 buildDate = "unknown"6)78var versionCmd = &cobra.Command{9 Use: "version",10 Short: "Print version information",11 Run: func(cmd *cobra.Command, args []string) {12 fmt.Printf("taskmaster %s\n", version)13 fmt.Printf(" Commit: %s\n", commit)14 fmt.Printf(" Built: %s\n", buildDate)15 fmt.Printf(" Go: %s\n", runtime.Version())16 },17}Build with Version Info
Here's how to inject version information at build time. These commands set shell variables and pass them to the Go linker. In a real project, you'd put this in a Makefile or CI script.
Cross-Compilation
One of Go's killer features is effortless cross-compilation. Set two environment variables (GOOS and GOARCH) and Go builds a binary for that platform - no virtual machines or complex toolchains required.
GOOS=linux GOARCH=amd64 go build -o taskmaster-linuxComplete Project Structure
Here's what a production-ready Cobra CLI looks like. Notice the internal/ directory - this is a Go convention for code that shouldn't be imported by other packages. Keep your business logic here, separate from command definitions.
Quick Commands Reference
Keep this handy while developing. These are the commands you'll use daily when building and testing your CLI.
Go CLI Quick Reference
Essential commands for Cobra development
go mod init && cobra-cli initcobra-cli add commandNamego run main.gogo build -o mycligo test -v ./...go test -cover ./...go installgo fmt ./...golangci-lint runKnowledge Check
You've covered a lot of ground. Test your understanding with these questions - they focus on the concepts that trip up most developers.
What is the correct way to read a configuration value that respects flags, env vars, and config files?
Glossary
New to Go or CLI development? Here are the key terms you'll encounter in this guide and beyond.
Resources
Ready to go deeper? These resources will help you master CLI development in Go.
Cobra Documentation
Official Cobra documentation with comprehensive guides and API reference.
Viper GitHub
Complete Go configuration solution supporting JSON, TOML, YAML, env vars, and flags.
cobra-cli Generator
CLI tool to scaffold Cobra applications and add commands quickly.
Go CLI Best Practices
Community-driven best practices for building production CLI tools in Go.
Build great CLIs. Ship single binaries. Make users happy.
Need help building Go CLI tools?
Topics
Comments
Sign in to join the conversation
LoginNo comments yet. Be the first to share your thoughts!
Found an issue with this article?

