Building CLI Apps in Go with Cobra & Viper

CLI development in Go with Cobra and Viper frameworks

Page content

Command-line interface (CLI) applications are essential tools for developers, system administrators, and DevOps professionals. Two Go libraries have become the de facto standard for CLI development in Go: Cobra for command structure and Viper for configuration management.

Go has emerged as an excellent language for building CLI tools due to its performance, simple deployment, and cross-platform support.

Tetris

Why Choose Go for CLI Applications

Go offers compelling advantages for CLI development:

  • Single Binary Distribution: No runtime dependencies or package managers required
  • Fast Execution: Native compilation provides excellent performance
  • Cross-Platform Support: Easy compilation for Linux, macOS, Windows, and more
  • Strong Standard Library: Rich tooling for file I/O, networking, and text processing
  • Concurrency: Built-in goroutines for parallel operations
  • Static Typing: Catch errors at compile time

Popular CLI tools built with Go include Docker, Kubernetes (kubectl), Hugo, Terraform, and GitHub CLI. If you’re new to Go or need a quick reference, check out our Go Cheat Sheet for essential Go syntax and patterns.

Introduction to Cobra

Cobra is a library that provides a simple interface for creating powerful modern CLI applications. Created by Steve Francia (spf13), the same author behind Hugo and Viper, Cobra is used in many of the most popular Go projects.

Key Cobra Features

Command Structure: Cobra implements the command pattern, allowing you to create applications with commands and subcommands (like git commit or docker run).

Flag Handling: Both local and persistent flags with automatic parsing and type conversion.

Automatic Help: Generates help text and usage information automatically.

Intelligent Suggestions: Provides suggestions when users make typos (“Did you mean ‘status’?”).

Shell Completions: Generate completion scripts for bash, zsh, fish, and PowerShell.

Flexible Output: Works seamlessly with custom formatters and output styles.

Introduction to Viper

Viper is a complete configuration solution for Go applications, designed to work seamlessly with Cobra. It handles configuration from multiple sources with a clear precedence order.

Key Viper Features

Multiple Configuration Sources:

  • Config files (JSON, YAML, TOML, HCL, INI, envfile, Java properties)
  • Environment variables
  • Command-line flags
  • Remote config systems (etcd, Consul)
  • Default values

Configuration Priority Order:

  1. Explicit calls to Set
  2. Command-line flags
  3. Environment variables
  4. Configuration file
  5. Key/value store
  6. Default values

Live Watching: Monitor configuration files and reload automatically when changes occur.

Type Conversion: Automatic conversion to various Go types (string, int, bool, duration, etc.).

Getting Started: Installation

First, initialize a new Go module and install both libraries:

go mod init myapp
go get -u github.com/spf13/cobra@latest
go get -u github.com/spf13/viper@latest

Optionally, install the Cobra CLI generator for scaffolding:

go install github.com/spf13/cobra-cli@latest

Building Your First CLI Application

Let’s build a practical example: a task management CLI tool with configuration support.

Project Structure

mytasks/
├── cmd/
│   ├── root.go
│   ├── add.go
│   ├── list.go
│   └── complete.go
├── config/
│   └── config.go
├── main.go
└── config.yaml

Main Entry Point

// main.go
package main

import "mytasks/cmd"

func main() {
    cmd.Execute()
}

Root Command with Viper Integration

// cmd/root.go
package cmd

import (
    "fmt"
    "os"
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var cfgFile string

var rootCmd = &cobra.Command{
    Use:   "mytasks",
    Short: "A simple task management CLI",
    Long: `MyTasks is a CLI task manager that helps you organize 
your daily tasks with ease. Built with Cobra and Viper.`,
}

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 is $HOME/.mytasks.yaml)")
    rootCmd.PersistentFlags().String("db", "", 
        "database file location")
    
    viper.BindPFlag("database", rootCmd.PersistentFlags().Lookup("db"))
}

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(".mytasks")
    }
    
    viper.SetEnvPrefix("MYTASKS")
    viper.AutomaticEnv()
    
    if err := viper.ReadInConfig(); err == nil {
        fmt.Println("Using config file:", viper.ConfigFileUsed())
    }
}

Adding Subcommands

// cmd/add.go
package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var priority string

var addCmd = &cobra.Command{
    Use:   "add [task description]",
    Short: "Add a new task",
    Args:  cobra.MinimumNArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        task := args[0]
        db := viper.GetString("database")
        
        fmt.Printf("Adding task: %s\n", task)
        fmt.Printf("Priority: %s\n", priority)
        fmt.Printf("Database: %s\n", db)
        
        // Here you would implement actual task storage
    },
}

func init() {
    rootCmd.AddCommand(addCmd)
    
    addCmd.Flags().StringVarP(&priority, "priority", "p", "medium", 
        "Task priority (low, medium, high)")
}

List Command

// cmd/list.go
package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var showCompleted bool

var listCmd = &cobra.Command{
    Use:   "list",
    Short: "List all tasks",
    Run: func(cmd *cobra.Command, args []string) {
        db := viper.GetString("database")
        
        fmt.Printf("Listing tasks from: %s\n", db)
        fmt.Printf("Show completed: %v\n", showCompleted)
        
        // Here you would implement actual task listing
    },
}

func init() {
    rootCmd.AddCommand(listCmd)
    
    listCmd.Flags().BoolVarP(&showCompleted, "completed", "c", false, 
        "Show completed tasks")
}

Implementing Data Persistence

For a production task management CLI, you’ll need to implement actual data storage. While we’re using a simple database path configuration here, you have several options for persisting data:

  • SQLite: Lightweight, serverless database perfect for CLI tools
  • PostgreSQL/MySQL: Full-featured databases for more complex applications
  • JSON/YAML files: Simple file-based storage for lightweight needs
  • Embedded databases: BoltDB, BadgerDB for key-value storage

If you’re working with relational databases like PostgreSQL, you’ll want to use an ORM or query builder. For a comprehensive comparison of Go database libraries, see our guide on Comparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlc.

Advanced Configuration with Viper

Configuration File Example

# .mytasks.yaml
database: ~/.mytasks.db
format: table
colors: true

notifications:
  enabled: true
  sound: true

priorities:
  high: red
  medium: yellow
  low: green

Reading Nested Configuration

func getNotificationSettings() {
    enabled := viper.GetBool("notifications.enabled")
    sound := viper.GetBool("notifications.sound")
    
    fmt.Printf("Notifications enabled: %v\n", enabled)
    fmt.Printf("Sound enabled: %v\n", sound)
}

func getPriorityColors() map[string]string {
    return viper.GetStringMapString("priorities")
}

Environment Variables

Viper automatically reads environment variables with the configured prefix:

export MYTASKS_DATABASE=/tmp/tasks.db
export MYTASKS_NOTIFICATIONS_ENABLED=false
mytasks list

Live Configuration Reload

func watchConfig() {
    viper.WatchConfig()
    viper.OnConfigChange(func(e fsnotify.Event) {
        fmt.Println("Config file changed:", e.Name)
        // Reload configuration-dependent components
    })
}

Best Practices

1. Use Cobra Generator for Consistency

cobra-cli init
cobra-cli add serve
cobra-cli add create

2. Organize Commands in Separate Files

Keep each command in its own file under the cmd/ directory for maintainability.

3. Leverage Persistent Flags

Use persistent flags for options that apply to all subcommands:

rootCmd.PersistentFlags().StringP("output", "o", "text", 
    "Output format (text, json, yaml)")

4. Implement Proper Error Handling

var rootCmd = &cobra.Command{
    Use:   "myapp",
    Short: "My application",
    RunE: func(cmd *cobra.Command, args []string) error {
        if err := doSomething(); err != nil {
            return fmt.Errorf("failed to do something: %w", err)
        }
        return nil
    },
}

5. Provide Meaningful Help Text

var cmd = &cobra.Command{
    Use:   "deploy [environment]",
    Short: "Deploy application to specified environment",
    Long: `Deploy builds and deploys your application to the 
specified environment. Supported environments are:
  - development (dev)
  - staging
  - production (prod)`,
    Example: `  myapp deploy staging
  myapp deploy production --version=1.2.3`,
}

6. Set Sensible Defaults

func init() {
    viper.SetDefault("port", 8080)
    viper.SetDefault("timeout", "30s")
    viper.SetDefault("retries", 3)
}

7. Validate Configuration

func validateConfig() error {
    port := viper.GetInt("port")
    if port < 1024 || port > 65535 {
        return fmt.Errorf("invalid port: %d", port)
    }
    return nil
}

Testing CLI Applications

Testing Commands

// cmd/root_test.go
package cmd

import (
    "bytes"
    "testing"
)

func TestRootCommand(t *testing.T) {
    cmd := rootCmd
    b := bytes.NewBufferString("")
    cmd.SetOut(b)
    cmd.SetArgs([]string{"--help"})
    
    if err := cmd.Execute(); err != nil {
        t.Fatalf("command execution failed: %v", err)
    }
}

Testing with Different Configurations

func TestWithConfig(t *testing.T) {
    viper.Set("database", "/tmp/test.db")
    viper.Set("debug", true)
    
    // Run your tests
    
    viper.Reset() // Clean up
}

Generating Shell Completions

Cobra can generate completion scripts for various shells:

// cmd/completion.go
package cmd

import (
    "os"
    "github.com/spf13/cobra"
)

var completionCmd = &cobra.Command{
    Use:   "completion [bash|zsh|fish|powershell]",
    Short: "Generate completion script",
    Long: `To load completions:

Bash:
  $ source <(mytasks completion bash)

Zsh:
  $ source <(mytasks completion zsh)

Fish:
  $ mytasks completion fish | source

PowerShell:
  PS> mytasks completion powershell | Out-String | Invoke-Expression
`,
    DisableFlagsInUseLine: true,
    ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
    Args: cobra.ExactValidArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        switch args[0] {
        case "bash":
            cmd.Root().GenBashCompletion(os.Stdout)
        case "zsh":
            cmd.Root().GenZshCompletion(os.Stdout)
        case "fish":
            cmd.Root().GenFishCompletion(os.Stdout, true)
        case "powershell":
            cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
        }
    },
}

func init() {
    rootCmd.AddCommand(completionCmd)
}

Building and Distribution

Build for Multiple Platforms

# Linux
GOOS=linux GOARCH=amd64 go build -o mytasks-linux-amd64

# macOS
GOOS=darwin GOARCH=amd64 go build -o mytasks-darwin-amd64
GOOS=darwin GOARCH=arm64 go build -o mytasks-darwin-arm64

# Windows
GOOS=windows GOARCH=amd64 go build -o mytasks-windows-amd64.exe

Reduce Binary Size

go build -ldflags="-s -w" -o mytasks

Add Version Information

// cmd/version.go
package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
)

var (
    Version   = "dev"
    Commit    = "none"
    BuildTime = "unknown"
)

var versionCmd = &cobra.Command{
    Use:   "version",
    Short: "Print version information",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Printf("Version: %s\n", Version)
        fmt.Printf("Commit: %s\n", Commit)
        fmt.Printf("Built: %s\n", BuildTime)
    },
}

func init() {
    rootCmd.AddCommand(versionCmd)
}

Build with version information:

go build -ldflags="-X 'mytasks/cmd.Version=1.0.0' \
    -X 'mytasks/cmd.Commit=$(git rev-parse HEAD)' \
    -X 'mytasks/cmd.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)'" \
    -o mytasks

Real-World Examples

Popular open-source tools built with Cobra and Viper:

  • kubectl: Kubernetes command-line tool
  • Hugo: Static site generator
  • GitHub CLI (gh): GitHub’s official CLI
  • Docker CLI: Container management
  • Helm: Kubernetes package manager
  • Skaffold: Kubernetes development workflow tool
  • Cobra CLI: Self-hosting - Cobra uses itself!

Beyond traditional DevOps tools, Go’s CLI capabilities extend to AI and machine learning applications. If you’re interested in building CLI tools that interact with large language models, check out our guide on Constraining LLMs with Structured Output using Ollama and Go, which shows how to build Go applications that work with AI models.

Useful Resources

Conclusion

Cobra and Viper together provide a powerful foundation for building professional CLI applications in Go. Cobra handles the command structure, flag parsing, and help generation, while Viper manages configuration from multiple sources with intelligent precedence.

This combination allows you to create CLI tools that are:

  • Easy to use with intuitive commands and automatic help
  • Flexible with multiple configuration sources
  • Professional with shell completions and proper error handling
  • Maintainable with clean code organization
  • Portable across different platforms

Whether you’re building developer tools, system utilities, or DevOps automation, Cobra and Viper provide the solid foundation you need to create CLI applications that users will love.