golang

Building Robust CLI Applications in Go: Best Practices and Patterns

Learn to build professional-grade CLI apps in Go with best practices for argument parsing, validation, and UX. This practical guide covers command handling, progress indicators, config management, and output formatting to create tools users will love.

Building Robust CLI Applications in Go: Best Practices and Patterns

Go has become a popular choice for building command-line interface (CLI) applications due to its simplicity, performance, and rich ecosystem. I’ve worked with numerous CLI tools in production environments and found certain patterns consistently lead to better user experiences and maintainable codebases.

Command Argument Parsing

The foundation of any CLI application is proper argument parsing. While Go’s standard library includes the flag package, more sophisticated applications often benefit from third-party libraries.

package main

import (
    "fmt"
    "os"

    "github.com/urfave/cli/v2"
)

func main() {
    app := &cli.App{
        Name:  "fileutil",
        Usage: "file manipulation utilities",
        Commands: []*cli.Command{
            {
                Name:    "compress",
                Aliases: []string{"c"},
                Usage:   "compress a file",
                Flags: []cli.Flag{
                    &cli.StringFlag{
                        Name:     "algorithm",
                        Aliases:  []string{"a"},
                        Value:    "gzip",
                        Usage:    "compression algorithm to use",
                        Required: false,
                    },
                },
                Action: func(c *cli.Context) error {
                    filename := c.Args().First()
                    algorithm := c.String("algorithm")
                    fmt.Printf("Compressing %s using %s\n", filename, algorithm)
                    return nil
                },
            },
        },
    }

    if err := app.Run(os.Args); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %s\n", err)
        os.Exit(1)
    }
}

Cobra is another excellent choice for complex command hierarchies:

package main

import (
    "fmt"

    "github.com/spf13/cobra"
)

func main() {
    var algorithm string

    compressCmd := &cobra.Command{
        Use:   "compress [file]",
        Short: "Compress a file",
        Args:  cobra.ExactArgs(1),
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Printf("Compressing %s using %s\n", args[0], algorithm)
        },
    }

    compressCmd.Flags().StringVarP(&algorithm, "algorithm", "a", "gzip", "compression algorithm to use")

    rootCmd := &cobra.Command{Use: "fileutil"}
    rootCmd.AddCommand(compressCmd)
    rootCmd.Execute()
}

Input Validation

Validating user input prevents runtime errors and improves user experience. I always implement validation logic early in the command processing chain.

func validateFilePath(path string) error {
    if path == "" {
        return errors.New("file path cannot be empty")
    }
    
    info, err := os.Stat(path)
    if err != nil {
        if os.IsNotExist(err) {
            return fmt.Errorf("file does not exist: %s", path)
        }
        return fmt.Errorf("error accessing file: %w", err)
    }
    
    if info.IsDir() {
        return fmt.Errorf("path is a directory, not a file: %s", path)
    }
    
    return nil
}

func compressFile(c *cli.Context) error {
    filePath := c.Args().First()
    if err := validateFilePath(filePath); err != nil {
        return err
    }
    
    // Proceed with compression...
    return nil
}

For sensitive inputs like passwords, use terminal packages to mask input:

import "golang.org/x/term"

func getPassword() (string, error) {
    fmt.Print("Enter password: ")
    passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
    fmt.Println() // Add newline after password input
    
    if err != nil {
        return "", err
    }
    
    return string(passwordBytes), nil
}

Progress Indicators

Long-running operations benefit from progress indicators to assure users the application hasn’t stalled.

package main

import (
    "time"

    "github.com/schollz/progressbar/v3"
)

func main() {
    totalItems := 1000
    bar := progressbar.Default(int64(totalItems))
    
    for i := 0; i < totalItems; i++ {
        bar.Add(1)
        time.Sleep(5 * time.Millisecond)
    }
}

For indeterminate operations, spinners provide feedback without showing percentage:

package main

import (
    "time"

    "github.com/briandowns/spinner"
)

func main() {
    s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
    s.Prefix = "Processing "
    s.Start()
    
    // Simulate work
    time.Sleep(3 * time.Second)
    
    s.Stop()
    fmt.Println("Done!")
}

Graceful Shutdown Handling

A well-designed CLI responds properly to termination signals:

package main

import (
    "context"
    "fmt"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    
    // Set up signal handling
    signalChan := make(chan os.Signal, 1)
    signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
    
    go func() {
        <-signalChan
        fmt.Println("\nReceived termination signal")
        cancel() // Cancel the context
    }()
    
    if err := runLongOperation(ctx); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %s\n", err)
        os.Exit(1)
    }
}

func runLongOperation(ctx context.Context) error {
    for i := 0; i < 10; i++ {
        select {
        case <-ctx.Done():
            fmt.Println("Performing cleanup...")
            time.Sleep(500 * time.Millisecond)
            fmt.Println("Cleanup complete")
            return nil
        default:
            fmt.Printf("Working... step %d/10\n", i+1)
            time.Sleep(1 * time.Second)
        }
    }
    return nil
}

Configuration Management

Supporting multiple configuration sources makes applications flexible across environments:

package main

import (
    "fmt"

    "github.com/spf13/viper"
)

func main() {
    // Set defaults
    viper.SetDefault("port", 8080)
    viper.SetDefault("database.timeout", 30)
    
    // Config file support
    viper.SetConfigName("config")           // name of config file (without extension)
    viper.SetConfigType("yaml")             // type of config file
    viper.AddConfigPath("/etc/myapp/")      // system-wide config
    viper.AddConfigPath("$HOME/.myapp")     // user-specific config
    viper.AddConfigPath(".")                // current directory
    
    // Read environment variables
    viper.AutomaticEnv()
    viper.SetEnvPrefix("MYAPP")             // will be uppercased automatically
    
    // Read config file
    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            fmt.Printf("Error reading config file: %s\n", err)
        }
    }
    
    // Display configuration
    fmt.Printf("Port: %d\n", viper.GetInt("port"))
    fmt.Printf("Database Timeout: %d seconds\n", viper.GetInt("database.timeout"))
}

A sample config.yaml file might look like:

port: 9090
database:
  timeout: 60
  host: localhost
  username: appuser

Interactive Prompts

For complex operations, interactive prompts guide users through choices:

package main

import (
    "fmt"

    "github.com/AlecAivazis/survey/v2"
)

func main() {
    var deployEnv string
    var features []string
    var confirm bool
    
    // Ask for environment
    prompt := &survey.Select{
        Message: "Choose deployment environment:",
        Options: []string{"development", "staging", "production"},
    }
    survey.AskOne(prompt, &deployEnv)
    
    // Multiple selection
    multiPrompt := &survey.MultiSelect{
        Message: "Select features to enable:",
        Options: []string{"analytics", "authentication", "notifications", "reporting"},
    }
    survey.AskOne(multiPrompt, &features)
    
    // Confirmation
    confirmPrompt := &survey.Confirm{
        Message: fmt.Sprintf("Deploy to %s with %d features?", deployEnv, len(features)),
    }
    survey.AskOne(confirmPrompt, &confirm)
    
    if confirm {
        fmt.Println("Deploying...")
    } else {
        fmt.Println("Deployment canceled")
    }
}

Proper Exit Codes

Using meaningful exit codes helps automation tools understand command outcomes:

package main

import (
    "errors"
    "fmt"
    "os"
)

const (
    ExitSuccess          = 0
    ExitFailure          = 1
    ExitInvalidInput     = 2
    ExitFileError        = 3
    ExitNetworkError     = 4
    ExitPermissionDenied = 5
)

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "Error: Missing required argument")
        os.Exit(ExitInvalidInput)
    }
    
    if err := processFile(os.Args[1]); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %s\n", err)
        
        var exitCode int
        switch {
        case errors.Is(err, os.ErrNotExist):
            exitCode = ExitFileError
        case errors.Is(err, os.ErrPermission):
            exitCode = ExitPermissionDenied
        default:
            exitCode = ExitFailure
        }
        
        os.Exit(exitCode)
    }
    
    os.Exit(ExitSuccess)
}

func processFile(path string) error {
    // Implementation
    return nil
}

Output Formatting

Supporting multiple output formats helps both humans and automated tools consume your CLI’s output:

package main

import (
    "encoding/json"
    "fmt"
    "os"
    "text/tabwriter"

    "github.com/urfave/cli/v2"
    "gopkg.in/yaml.v3"
)

type ServerInfo struct {
    Name     string `json:"name" yaml:"name"`
    IP       string `json:"ip" yaml:"ip"`
    Status   string `json:"status" yaml:"status"`
    Uptime   int    `json:"uptime" yaml:"uptime"`
    CPU      float64 `json:"cpu" yaml:"cpu"`
    Memory   float64 `json:"memory" yaml:"memory"`
}

func getServers() []ServerInfo {
    return []ServerInfo{
        {"web-01", "192.168.1.10", "running", 15, 45.2, 60.5},
        {"db-01", "192.168.1.11", "running", 20, 78.3, 82.1},
        {"cache-01", "192.168.1.12", "stopped", 0, 0.0, 0.0},
    }
}

func main() {
    var outputFormat string
    
    app := &cli.App{
        Name:  "serverctl",
        Usage: "server management utility",
        Flags: []cli.Flag{
            &cli.StringFlag{
                Name:        "output",
                Aliases:     []string{"o"},
                Value:       "table",
                Usage:       "Output format (table, json, yaml)",
                Destination: &outputFormat,
            },
        },
        Commands: []*cli.Command{
            {
                Name:  "list",
                Usage: "list all servers",
                Action: func(c *cli.Context) error {
                    servers := getServers()
                    
                    switch c.String("output") {
                    case "json":
                        return outputJSON(servers)
                    case "yaml":
                        return outputYAML(servers)
                    default:
                        return outputTable(servers)
                    }
                },
            },
        },
    }
    
    app.Run(os.Args)
}

func outputTable(servers []ServerInfo) error {
    w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
    fmt.Fprintln(w, "NAME\tIP\tSTATUS\tUPTIME (h)\tCPU (%)\tMEMORY (%)")
    
    for _, server := range servers {
        fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%.1f\t%.1f\n",
            server.Name, server.IP, server.Status, server.Uptime, server.CPU, server.Memory)
    }
    
    return w.Flush()
}

func outputJSON(servers []ServerInfo) error {
    encoder := json.NewEncoder(os.Stdout)
    encoder.SetIndent("", "  ")
    return encoder.Encode(servers)
}

func outputYAML(servers []ServerInfo) error {
    encoder := yaml.NewEncoder(os.Stdout)
    return encoder.Encode(servers)
}

Comprehensive Example

Let’s tie these patterns together in a more complete example:

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "os"
    "os/signal"
    "syscall"
    "text/tabwriter"
    "time"

    "github.com/briandowns/spinner"
    "github.com/schollz/progressbar/v3"
    "github.com/spf13/viper"
    "github.com/urfave/cli/v2"
    "gopkg.in/yaml.v3"
)

// Exit codes
const (
    ExitSuccess      = 0
    ExitFailure      = 1
    ExitInvalidInput = 2
    ExitFileError    = 3
)

// Data structures
type Task struct {
    ID          string    `json:"id" yaml:"id"`
    Title       string    `json:"title" yaml:"title"`
    Description string    `json:"description" yaml:"description"`
    Status      string    `json:"status" yaml:"status"`
    CreatedAt   time.Time `json:"created_at" yaml:"created_at"`
}

// Global variables
var tasks = []Task{
    {
        ID:          "task-001",
        Title:       "Implement user authentication",
        Description: "Add OAuth2 support for user login",
        Status:      "in_progress",
        CreatedAt:   time.Now().Add(-72 * time.Hour),
    },
    {
        ID:          "task-002",
        Title:       "Optimize database queries",
        Description: "Add indices and rewrite slow queries",
        Status:      "todo",
        CreatedAt:   time.Now().Add(-48 * time.Hour),
    },
    {
        ID:          "task-003",
        Title:       "Write documentation",
        Description: "Create user and developer guides",
        Status:      "completed",
        CreatedAt:   time.Now().Add(-24 * time.Hour),
    },
}

func main() {
    // Setup configuration
    viper.SetDefault("storage.path", "./tasks.json")
    viper.SetConfigName("taskctl")
    viper.SetConfigType("yaml")
    viper.AddConfigPath("$HOME/.taskctl")
    viper.AddConfigPath(".")
    viper.AutomaticEnv()
    viper.SetEnvPrefix("TASKCTL")
    
    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            fmt.Printf("Error reading config file: %s\n", err)
        }
    }

    // Create CLI app
    app := &cli.App{
        Name:  "taskctl",
        Usage: "task management utility",
        Flags: []cli.Flag{
            &cli.StringFlag{
                Name:    "output",
                Aliases: []string{"o"},
                Value:   "table",
                Usage:   "Output format (table, json, yaml)",
            },
        },
        Commands: []*cli.Command{
            {
                Name:    "list",
                Aliases: []string{"ls"},
                Usage:   "list all tasks",
                Flags: []cli.Flag{
                    &cli.StringFlag{
                        Name:    "status",
                        Aliases: []string{"s"},
                        Usage:   "Filter by status (todo, in_progress, completed)",
                    },
                },
                Action: listTasks,
            },
            {
                Name:  "add",
                Usage: "add a new task",
                Flags: []cli.Flag{
                    &cli.StringFlag{
                        Name:     "title",
                        Aliases:  []string{"t"},
                        Usage:    "Task title",
                        Required: true,
                    },
                    &cli.StringFlag{
                        Name:    "description",
                        Aliases: []string{"d"},
                        Usage:   "Task description",
                    },
                    &cli.StringFlag{
                        Name:    "status",
                        Aliases: []string{"s"},
                        Value:   "todo",
                        Usage:   "Task status (todo, in_progress, completed)",
                    },
                },
                Action: addTask,
            },
            {
                Name:  "export",
                Usage: "export tasks to a file",
                Flags: []cli.Flag{
                    &cli.StringFlag{
                        Name:     "file",
                        Aliases:  []string{"f"},
                        Usage:    "File to export to",
                        Required: true,
                    },
                    &cli.StringFlag{
                        Name:    "format",
                        Value:   "json",
                        Usage:   "Export format (json, yaml)",
                    },
                },
                Action: exportTasks,
            },
        },
    }

    // Handle interrupts
    ctx, cancel := context.WithCancel(context.Background())
    signalChan := make(chan os.Signal, 1)
    signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
    
    go func() {
        <-signalChan
        fmt.Println("\nReceived termination signal")
        cancel()
    }()

    // Run the application
    if err := app.RunContext(ctx, os.Args); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %s\n", err)
        os.Exit(ExitFailure)
    }
}

func listTasks(c *cli.Context) error {
    statusFilter := c.String("status")
    var filteredTasks []Task
    
    if statusFilter != "" {
        for _, task := range tasks {
            if task.Status == statusFilter {
                filteredTasks = append(filteredTasks, task)
            }
        }
    } else {
        filteredTasks = tasks
    }
    
    switch c.String("output") {
    case "json":
        return outputJSON(filteredTasks)
    case "yaml":
        return outputYAML(filteredTasks)
    default:
        return outputTaskTable(filteredTasks)
    }
}

func addTask(c *cli.Context) error {
    title := c.String("title")
    description := c.String("description")
    status := c.String("status")
    
    // Validate input
    if title == "" {
        return fmt.Errorf("title cannot be empty")
    }
    
    if status != "todo" && status != "in_progress" && status != "completed" {
        return fmt.Errorf("invalid status: %s", status)
    }
    
    // Show progress
    s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
    s.Prefix = "Adding task "
    s.Start()
    
    // Simulate work
    time.Sleep(1 * time.Second)
    
    // Create task
    task := Task{
        ID:          fmt.Sprintf("task-%03d", len(tasks)+1),
        Title:       title,
        Description: description,
        Status:      status,
        CreatedAt:   time.Now(),
    }
    
    tasks = append(tasks, task)
    
    s.Stop()
    fmt.Printf("Task added with ID: %s\n", task.ID)
    
    return nil
}

func exportTasks(c *cli.Context) error {
    filename := c.String("file")
    format := c.String("format")
    
    // Validate input
    if filename == "" {
        return fmt.Errorf("filename cannot be empty")
    }
    
    if format != "json" && format != "yaml" {
        return fmt.Errorf("unsupported format: %s", format)
    }
    
    // Create progress bar
    bar := progressbar.Default(100)
    
    // Simulate export progress
    for i := 0; i < 100; i++ {
        bar.Add(1)
        time.Sleep(20 * time.Millisecond)
    }
    
    // Write file
    file, err := os.Create(filename)
    if err != nil {
        return fmt.Errorf("could not create file: %w", err)
    }
    defer file.Close()
    
    switch format {
    case "json":
        encoder := json.NewEncoder(file)
        encoder.SetIndent("", "  ")
        if err := encoder.Encode(tasks); err != nil {
            return fmt.Errorf("could not encode tasks to JSON: %w", err)
        }
    case "yaml":
        encoder := yaml.NewEncoder(file)
        if err := encoder.Encode(tasks); err != nil {
            return fmt.Errorf("could not encode tasks to YAML: %w", err)
        }
    }
    
    fmt.Printf("\nExported %d tasks to %s in %s format\n", len(tasks), filename, format)
    return nil
}

func outputTaskTable(tasks []Task) error {
    w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
    fmt.Fprintln(w, "ID\tTITLE\tSTATUS\tCREATED")
    
    for _, task := range tasks {
        fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
            task.ID, task.Title, task.Status, task.CreatedAt.Format("2006-01-02"))
    }
    
    return w.Flush()
}

func outputJSON(data interface{}) error {
    encoder := json.NewEncoder(os.Stdout)
    encoder.SetIndent("", "  ")
    return encoder.Encode(data)
}

func outputYAML(data interface{}) error {
    encoder := yaml.NewEncoder(os.Stdout)
    return encoder.Encode(data)
}

In my experience, following these patterns creates CLI applications that are intuitive, maintainable, and robust. The Go ecosystem offers excellent tooling for command-line application development, balancing simplicity with power.

When I build CLI tools, I focus on making them feel like Unix tools users are already familiar with. This means supporting standard flags like --help and --version, using stdin/stdout for piping data between commands, and providing clear, concise error messages.

These patterns have served me well in production environments where reliability and ease of use are paramount. Command-line tools are often the first interface users have with your software, so making a good impression with a thoughtful CLI design is invaluable.

Keywords: Go CLI development keywords, Go command-line interface, CLI application development, Go CLI libraries, Go Cobra library, Go Urfave CLI, terminal application Go, CLI argument parsing, Go user input validation, CLI progress indicators, graceful shutdown handling, Go configuration management, Viper configuration, interactive CLI prompts, Survey prompts Go, proper exit codes, CLI output formatting, Go CLI best practices, building command-line tools, Go CLI tools, Go tabwriter, CLI signal handling, Go error handling, Go context cancellation, Go CLI examples, JSON YAML output CLI, Go CLI patterns, production-grade CLI, cross-platform CLI applications, Go CLI spinners, Go CLI progress bars, command hierarchies Go



Similar Posts
Blog Image
Creating a Custom Kubernetes Operator in Golang: A Complete Tutorial

Kubernetes operators: Custom software extensions managing complex apps via custom resources. Created with Go for tailored needs, automating deployment and scaling. Powerful tool simplifying application management in Kubernetes ecosystems.

Blog Image
10 Critical Go Performance Bottlenecks: Essential Optimization Techniques for Developers

Learn Go's top 10 performance bottlenecks and their solutions. Optimize string concatenation, slice management, goroutines, and more with practical code examples from a seasoned developer. Make your Go apps faster today.

Blog Image
Why Should You Use Timeout Middleware in Your Golang Gin Web Applications?

Dodging the Dreaded Bottleneck: Mastering Timeout Middleware in Gin

Blog Image
Is Your Golang App with Gin Framework Safe Without HMAC Security?

Guarding Golang Apps: The Magic of HMAC Middleware and the Gin Framework

Blog Image
Go Microservices Architecture: Scaling Your Applications with gRPC and Protobuf

Go microservices with gRPC and Protobuf offer scalable, efficient architecture. Enables independent service scaling, efficient communication, and flexible deployment. Challenges include complexity, testing, and monitoring, but tools like Kubernetes and service meshes help manage these issues.

Blog Image
Supercharge Web Apps: Unleash WebAssembly's Relaxed SIMD for Lightning-Fast Performance

WebAssembly's Relaxed SIMD: Boost browser performance with parallel processing. Learn how to optimize computationally intensive tasks for faster web apps. Code examples included.