CLI Applications

Why CLI Applications Matter

Consider a chef with a perfectly organized kitchen where every tool is exactly where they need it, works flawlessly, and can be packed into a single box to take anywhere. That's what Go brings to command-line tool development.

Real-world Impact: Every developer uses command-line tools daily - from git and docker to custom build scripts and deployment tools. When you master CLI development in Go, you can create the very tools that power modern software development, automation, and DevOps workflows. Companies like Docker, HashiCorp, and GitHub chose Go specifically for their flagship CLI tools because Go delivers the perfect combination of performance, reliability, and deployment simplicity.

Command-line interfaces are the backbone of automation, DevOps, and system administration. They provide:

  • Automation capabilities - Script complex workflows with reliable tools
  • Performance - Direct system access without GUI overhead
  • Composability - Chain tools together with pipes and scripts
  • Remote execution - Control systems over SSH without graphics
  • Repeatability - Execute the same command consistently across environments

Think about the tools you use every day: git, docker, kubectl, npm, cargo, terraform. These are all CLI applications, and they're all critical to modern software development. The best CLI tools are invisible in their complexity - they just work, providing exactly the interface users need without unnecessary friction.

Learning Objectives

By the end of this article, you'll be able to:

Design command-line interfaces with proper argument parsing and help systems
Use Go's flag package to build robust CLI tools with validation
Implement subcommands for complex multi-function applications
Handle I/O operations that work with Unix pipes and redirection
Apply best practices for error handling, exit codes, and user experience
Create interactive CLIs with prompts, progress bars, and colored output
Build production-ready tools that integrate well with existing ecosystems
Implement advanced patterns like configuration management and plugin systems

Core Concepts - Understanding Go's CLI Philosophy

Why Go Dominates CLI Development

Think about the last time you struggled with a Python script that needed different versions of libraries, or a Bash script that broke on a different system. Go eliminates these headaches entirely.

Go has become the de facto language for modern CLI tools, replacing Python and Bash for production tooling. But why? The answer lies in Go's unique combination of features that align perfectly with CLI requirements.

Why Go Excels at CLI:

  1. Single Binary Deployment - No dependencies, just copy and run anywhere

    • Python requires interpreter + dependencies
    • Node.js needs runtime + node_modules
    • Go produces one executable file
  2. Cross-Compilation - Build for all platforms from one machine: GOOS=linux go build

    • Build Windows binaries on macOS
    • Build ARM binaries on x86
    • No need for platform-specific build machines
  3. Lightning-Fast Startup - <1ms vs Python's ~100ms, crucial for frequent tool usage

    • Instant feedback for users
    • No interpreter loading time
    • Native machine code execution
  4. Compile-Time Type Safety - Catch errors before deployment, not in production

    • Find bugs during development
    • Refactor with confidence
    • No runtime type errors
  5. Memory Efficiency - No interpreter overhead, ideal for high-performance tools

    • Lower memory footprint
    • Predictable performance
    • No garbage collection pauses during critical operations
  6. Built-in Concurrency - Goroutines make parallel processing trivial

    • Process multiple files simultaneously
    • Handle background tasks efficiently
    • Scale to available CPU cores
  7. Standard Library - Everything you need built-in: networking, crypto, compression

    • JSON/XML parsing
    • HTTP clients and servers
    • File system operations
    • All without external dependencies

Production Examples: Docker, Kubernetes, Terraform, Hugo, GitHub CLI, AWS CLI v2

These aren't just successful CLI tools - they're industry-defining applications that millions of developers rely on daily. Each chose Go for specific technical reasons:

  • Docker: Single binary distribution, cross-platform support
  • Kubernetes: Performance, concurrency, network handling
  • Terraform: Deterministic execution, cross-compilation
  • Hugo: Blazing fast static site generation
  • GitHub CLI: Rapid development, easy distribution

💡 Key Insight: Go's single-binary deployment means you can build your tool once and distribute it anywhere without worrying about dependencies or runtime environments. This is why companies like Docker, HashiCorp, and GitHub chose Go for their flagship CLI tools. Users can download one file and immediately start using your tool - no installation steps, no dependency hell, no "it works on my machine" problems.

The Evolution of CLI Tools

Understanding the history of CLI development helps appreciate Go's advantages:

Generation 1: Shell Scripts (1970s-1990s)

  • Bash, sh, csh scripts
  • Pros: Quick to write, system integration
  • Cons: Platform-specific, poor error handling, limited data structures

Generation 2: Scripting Languages (1990s-2010s)

  • Python, Perl, Ruby
  • Pros: Rich libraries, better structure
  • Cons: Dependency management, slow startup, version conflicts

Generation 3: Compiled Languages (2010s-present)

  • Go, Rust
  • Pros: Performance, single binary, type safety
  • Cons: Longer compile times (not an issue for distribution)

Go represents the sweet spot: the ease of development closer to scripting languages with the performance and distribution simplicity of compiled languages.

CLI Tool Categories and Use Cases

Different types of CLI tools serve different purposes in the software development lifecycle. Understanding these categories helps you design better tools:

Tool Type Purpose Examples Go Advantages
System Utilities File management, text processing grep, sed, ripgrep Performance, memory efficiency
Build Tools Compilation, dependency management make, bazel, custom build scripts Cross-platform builds, parallel processing
DevOps Tools Infrastructure, deployment kubectl, terraform, docker Single binary, reliability, network handling
Developer Tools Code generation, testing, linting go generate, testing frameworks, golangci-lint Type safety, performance, easy distribution
Data Processing ETL, transformations, analysis Custom data pipelines, jq-like tools Concurrency, memory efficiency
Monitoring Tools Log analysis, metrics collection Custom monitoring scripts, exporters Low overhead, concurrent processing
Configuration Management System setup, provisioning Ansible alternatives, deployment tools Cross-platform, embedded templates

Choosing the Right Tool Type:

When building a CLI tool, consider:

  1. User Base: Developers vs. system administrators vs. end users
  2. Execution Frequency: One-off scripts vs. continuous monitoring
  3. Performance Requirements: Interactive vs. batch processing
  4. Integration Needs: Standalone vs. part of a larger ecosystem
  5. Distribution Model: Internal team vs. open source vs. commercial

⚠️ Decision Point: While Go excels at CLI tools, choose Go when you need performance, type safety, and cross-platform deployment. For quick one-off scripts that you'll run once and discard, Python or Bash might still be faster to write. But for anything you'll distribute, maintain, or run frequently, Go is the superior choice.

CLI Design Principles

Before diving into code, let's establish the principles that separate great CLI tools from mediocre ones:

1. Do One Thing Well (Unix Philosophy)

The Unix philosophy emphasizes small, focused tools that can be combined. Each tool should:

  • Have a clear, singular purpose
  • Accept input from stdin
  • Write output to stdout
  • Be composable with other tools via pipes

Example: Instead of building one tool that "processes data," build separate tools for filtering, transforming, and analyzing. Users can then combine them:

1$ fetch-data | filter --type=error | analyze --format=json

2. Provide Sensible Defaults

Users shouldn't need to specify everything:

  • Use common conventions (like port 8080 for servers)
  • Infer from environment (like detecting terminal width)
  • Make the common case simple

3. Be Consistent

Follow established patterns:

  • Use -v for verbose, -h for help
  • Return 0 for success, non-zero for errors
  • Support both short (-f) and long (--file) flags
  • Follow platform conventions (like XDG paths on Linux)

4. Fail Loudly and Early

When things go wrong:

  • Provide clear error messages
  • Include context about what failed and why
  • Suggest remediation steps
  • Exit with appropriate error codes

5. Respect the User's Environment

CLI tools should:

  • Support standard input/output redirection
  • Respect NO_COLOR environment variable
  • Check for terminal capabilities
  • Handle signals gracefully (SIGINT, SIGTERM)

6. Document Everything

Great CLI tools are self-documenting:

  • Built-in help with -h or --help
  • Man pages for detailed documentation
  • Examples in help output
  • Clear error messages that explain problems

The Flag Package - Foundation of Go CLI Tools

The flag package is like having a skilled receptionist who knows exactly how to handle different types of requests that come to your application. It parses command-line arguments, validates them, and presents them in a clean, organized way.

The flag package is Go's standard solution for command-line argument parsing. While there are more feature-rich third-party libraries, understanding flag is essential because:

  1. It's always available (part of the standard library)
  2. It has zero dependencies
  3. It's sufficient for many CLI tools
  4. Other libraries often build on its concepts

How Flag Parsing Works

When your program starts, the operating system provides an array of strings (command-line arguments). The flag package:

  1. Defines expected flags and their types
  2. Parses os.Args to extract flag values
  3. Validates types and constraints
  4. Provides structured access to values
  5. Generates help messages automatically

Let's see this in action:

Basic Flags

 1// run
 2package main
 3
 4import (
 5    "flag"
 6    "fmt"
 7)
 8
 9func main() {
10    // Define flags
11    name := flag.String("name", "World", "name to greet")
12    age := flag.Int("age", 0, "your age")
13    verbose := flag.Bool("verbose", false, "enable verbose output")
14
15    // Parse command line
16    flag.Parse()
17
18    fmt.Printf("Hello, %s!\n", *name)
19    if *age > 0 {
20        fmt.Printf("You are %d years old.\n", *age)
21    }
22    if *verbose {
23        fmt.Println("Verbose mode enabled")
24    }
25}

Breaking Down the Code:

  1. flag.String("name", "World", "name to greet") defines a string flag:

    • "name" is the flag name (used as -name or --name)
    • "World" is the default value
    • "name to greet" is the help description
    • Returns a *string pointer to the value
  2. flag.Parse() processes os.Args and populates flag values

  3. Access values by dereferencing pointers: *name, *age, *verbose

Usage Examples:

 1# Using default values
 2$ ./app
 3Hello, World!
 4
 5# Specifying flags
 6$ ./app -name=Alice -age=30 -verbose
 7Hello, Alice!
 8You are 30 years old.
 9Verbose mode enabled
10
11# Alternative flag syntax
12$ ./app -name Alice -age 30    # Space separator
13$ ./app --name Alice --age 30  # Double dash (also works)

🌍 Real-world Example: This pattern is used by tools like kubectl get pods -v=2 where -v controls verbosity level, or docker run --name myapp where --name specifies the container name.

💡 Key Insight: The flag package automatically generates help messages. Run your program with -help or -h to see all available options:

1$ ./app -help
2Usage of ./app:
3  -age int
4        your age
5  -name string
6        name to greet (default "World")
7  -verbose
8        enable verbose output

Flag Types and Their Use Cases

The flag package supports several built-in types:

 1// run
 2package main
 3
 4import (
 5    "flag"
 6    "fmt"
 7    "time"
 8)
 9
10func main() {
11    // String flags - text input
12    name := flag.String("name", "default", "string value")
13
14    // Integer flags - whole numbers
15    count := flag.Int("count", 1, "integer value")
16
17    // Boolean flags - true/false switches
18    verbose := flag.Bool("verbose", false, "boolean flag")
19
20    // Float flags - decimal numbers
21    threshold := flag.Float64("threshold", 0.5, "float value")
22
23    // Duration flags - time periods
24    timeout := flag.Duration("timeout", 30*time.Second, "timeout duration")
25
26    flag.Parse()
27
28    fmt.Printf("Name: %s\n", *name)
29    fmt.Printf("Count: %d\n", *count)
30    fmt.Printf("Verbose: %v\n", *verbose)
31    fmt.Printf("Threshold: %.2f\n", *threshold)
32    fmt.Printf("Timeout: %v\n", *timeout)
33}

Type-Specific Behavior:

  • Boolean flags: Can be used alone (-verbose) or with values (-verbose=true)
  • Duration flags: Accept strings like "30s", "5m", "2h30m"
  • Numeric flags: Validate input automatically (errors on non-numeric input)

Usage:

1$ ./app -name=prod -count=10 -verbose -threshold=0.75 -timeout=2m
2Name: prod
3Count: 10
4Verbose: true
5Threshold: 0.75
6Timeout: 2m0s

Flag Variables - Binding to Existing Variables

Sometimes you need to work with variables that are already defined in your program, like configuration structures or existing variables. This is where flag binding becomes incredibly useful. Instead of getting a pointer from the flag package, you can bind flags directly to your variables.

Why Use Flag Variables:

  1. Configuration structs: Populate config objects directly
  2. Global variables: Bind to package-level variables
  3. Type consistency: Keep your variable types consistent throughout code
  4. No pointer dereferencing: Cleaner code without * everywhere

Bind flags to existing variables:

 1// run
 2package main
 3
 4import (
 5    "flag"
 6    "fmt"
 7)
 8
 9func main() {
10    var (
11        host    string
12        port    int
13        debug   bool
14        timeout float64
15    )
16
17    flag.StringVar(&host, "host", "localhost", "server host")
18    flag.IntVar(&port, "port", 8080, "server port")
19    flag.BoolVar(&debug, "debug", false, "enable debug mode")
20    flag.Float64Var(&timeout, "timeout", 30.0, "timeout in seconds")
21
22    flag.Parse()
23
24    fmt.Printf("Server: %s:%d\n", host, port)
25    fmt.Printf("Debug: %v\n", debug)
26    fmt.Printf("Timeout: %.1fs\n", timeout)
27}

Advantages of Var Methods:

  • No pointer dereferencing needed
  • Works with struct fields
  • Cleaner code structure
  • Better for configuration objects

Advanced Pattern: Configuration Structs

 1// run
 2package main
 3
 4import (
 5    "flag"
 6    "fmt"
 7)
 8
 9type Config struct {
10    Host     string
11    Port     int
12    Debug    bool
13    MaxConns int
14    Timeout  float64
15}
16
17func (c *Config) RegisterFlags() {
18    flag.StringVar(&c.Host, "host", "localhost", "server host")
19    flag.IntVar(&c.Port, "port", 8080, "server port")
20    flag.BoolVar(&c.Debug, "debug", false, "debug mode")
21    flag.IntVar(&c.MaxConns, "max-conns", 100, "max connections")
22    flag.Float64Var(&c.Timeout, "timeout", 30.0, "timeout in seconds")
23}
24
25func main() {
26    config := &Config{}
27    config.RegisterFlags()
28    flag.Parse()
29
30    fmt.Printf("Configuration:\n")
31    fmt.Printf("  Host: %s\n", config.Host)
32    fmt.Printf("  Port: %d\n", config.Port)
33    fmt.Printf("  Debug: %v\n", config.Debug)
34    fmt.Printf("  Max Connections: %d\n", config.MaxConns)
35    fmt.Printf("  Timeout: %.1fs\n", config.Timeout)
36}

This pattern is excellent for larger applications where configuration comes from multiple sources (flags, environment variables, config files).

Positional Arguments

Beyond named flags, most CLI tools need to handle positional arguments - the extra items that appear after all the flags. Think of git add file.txt where file.txt is a positional argument, or cp source dest where both source and dest are positional arguments.

When to Use Positional Arguments:

  • File paths to process
  • Primary command targets
  • Required inputs that are obvious from context
  • Things that feel natural without flag names
 1// run
 2package main
 3
 4import (
 5    "flag"
 6    "fmt"
 7)
 8
 9func main() {
10    verbose := flag.Bool("v", false, "verbose output")
11
12    flag.Parse()
13
14    // Remaining arguments after flags
15    args := flag.Args()
16
17    if *verbose {
18        fmt.Printf("Processing %d files:\n", len(args))
19    }
20
21    for _, file := range args {
22        fmt.Println("File:", file)
23    }
24
25    // You can also access specific arguments by index
26    if len(args) > 0 {
27        fmt.Println("First file:", flag.Arg(0))
28    }
29}

Usage:

 1$ ./app -v file1.txt file2.txt file3.txt
 2Processing 3 files:
 3File: file1.txt
 4File: file2.txt
 5File: file3.txt
 6First file: file1.txt
 7
 8$ ./app file1.txt file2.txt
 9File: file1.txt
10File: file2.txt

Important Nuances:

  1. Flag parsing stops at first non-flag argument (unless you use --):

    1$ ./app -v file1.txt -d file2.txt  # -d is treated as positional arg
    
  2. Double dash (--) explicitly ends flag parsing:

    1$ ./app -v -- -file-with-dash.txt  # -file-with-dash.txt is arg, not flag
    
  3. Validation is your responsibility:

    1args := flag.Args()
    2if len(args) == 0 {
    3    fmt.Println("Error: at least one file required")
    4    os.Exit(1)
    5}
    

Custom Flag Types

Sometimes you need to handle more complex flag types, like comma-separated lists, URLs, or custom validation. The flag package allows you to create your own flag types by implementing the flag.Value interface.

The flag.Value interface requires two methods:

1type Value interface {
2    String() string
3    Set(string) error
4}

🌍 Real-world Example: This is similar to how docker run --label env=prod --label team=backend allows multiple labels to be specified, or how go test -run TestAPI -run TestDB can filter tests with multiple patterns.

Example: String Slice Flag

 1// run
 2package main
 3
 4import (
 5    "flag"
 6    "fmt"
 7    "strings"
 8)
 9
10type StringSlice []string
11
12func (s *StringSlice) String() string {
13    return strings.Join(*s, ",")
14}
15
16func (s *StringSlice) Set(value string) error {
17    *s = append(*s, value)
18    return nil
19}
20
21func main() {
22    var tags StringSlice
23    flag.Var(&tags, "tag", "tags (can be specified multiple times)")
24
25    flag.Parse()
26
27    fmt.Println("Tags:", tags)
28    fmt.Printf("Tag count: %d\n", len(tags))
29}

Usage:

1$ ./app -tag=dev -tag=backend -tag=api
2Tags: [dev backend api]
3Tag count: 3

Advanced Custom Type: URL Validation

 1// run
 2package main
 3
 4import (
 5    "flag"
 6    "fmt"
 7    "net/url"
 8)
 9
10type URLValue struct {
11    URL *url.URL
12}
13
14func (u *URLValue) String() string {
15    if u.URL != nil {
16        return u.URL.String()
17    }
18    return ""
19}
20
21func (u *URLValue) Set(value string) error {
22    parsed, err := url.Parse(value)
23    if err != nil {
24        return fmt.Errorf("invalid URL: %w", err)
25    }
26
27    if parsed.Scheme == "" {
28        return fmt.Errorf("URL must have a scheme (http:// or https://)")
29    }
30
31    u.URL = parsed
32    return nil
33}
34
35func main() {
36    urlFlag := &URLValue{}
37    flag.Var(urlFlag, "url", "URL to fetch (must include scheme)")
38
39    flag.Parse()
40
41    if urlFlag.URL != nil {
42        fmt.Printf("URL: %s\n", urlFlag.URL)
43        fmt.Printf("Scheme: %s\n", urlFlag.URL.Scheme)
44        fmt.Printf("Host: %s\n", urlFlag.URL.Host)
45        fmt.Printf("Path: %s\n", urlFlag.URL.Path)
46    }
47}

Usage:

1$ ./app -url=https://api.example.com/v1/users
2URL: https://api.example.com/v1/users
3Scheme: https
4Host: api.example.com
5Path: /v1/users
6
7$ ./app -url=example.com
8invalid URL: URL must have a scheme (http:// or https://)

More Custom Type Examples:

 1// Enum/Choice type
 2type EnvironmentType string
 3
 4const (
 5    EnvDevelopment EnvironmentType = "development"
 6    EnvStaging     EnvironmentType = "staging"
 7    EnvProduction  EnvironmentType = "production"
 8)
 9
10func (e *EnvironmentType) String() string {
11    return string(*e)
12}
13
14func (e *EnvironmentType) Set(value string) error {
15    switch value {
16    case "development", "staging", "production":
17        *e = EnvironmentType(value)
18        return nil
19    default:
20        return fmt.Errorf("invalid environment: %s (must be development, staging, or production)", value)
21    }
22}
23
24// IP address type
25type IPAddress net.IP
26
27func (i *IPAddress) String() string {
28    return net.IP(*i).String()
29}
30
31func (i *IPAddress) Set(value string) error {
32    ip := net.ParseIP(value)
33    if ip == nil {
34        return fmt.Errorf("invalid IP address: %s", value)
35    }
36    *i = IPAddress(ip)
37    return nil
38}

💡 Key Insight: Custom flag types enable you to handle complex input scenarios while maintaining the clean, consistent interface that users expect from well-designed CLI tools. They also centralize validation logic, making your code more maintainable.

Flag Sets - Multiple Command Support

For tools with subcommands (like git add, git commit), you need separate flag sets:

 1// run
 2package main
 3
 4import (
 5    "flag"
 6    "fmt"
 7    "os"
 8)
 9
10func main() {
11    // Create flag sets for different subcommands
12    addCmd := flag.NewFlagSet("add", flag.ExitOnError)
13    addName := addCmd.String("name", "", "item name")
14    addPriority := addCmd.Int("priority", 0, "priority level")
15
16    listCmd := flag.NewFlagSet("list", flag.ExitOnError)
17    listAll := listCmd.Bool("all", false, "show all items")
18
19    if len(os.Args) < 2 {
20        fmt.Println("expected 'add' or 'list' subcommands")
21        os.Exit(1)
22    }
23
24    switch os.Args[1] {
25    case "add":
26        addCmd.Parse(os.Args[2:])
27        fmt.Printf("Adding: %s (priority: %d)\n", *addName, *addPriority)
28    case "list":
29        listCmd.Parse(os.Args[2:])
30        fmt.Printf("Listing items (all: %v)\n", *listAll)
31    default:
32        fmt.Printf("unknown subcommand: %s\n", os.Args[1])
33        os.Exit(1)
34    }
35}

Usage:

1$ ./app add -name="Buy milk" -priority=1
2Adding: Buy milk (priority: 1)
3
4$ ./app list -all
5Listing items (all: true)

This pattern is fundamental for building sophisticated CLI tools with multiple commands, which we'll explore in depth later.

Building a Complete CLI App

Now let's put everything together and build a practical CLI application that demonstrates real-world patterns. This example shows how to structure a tool that users can actually deploy and use in production.

A complete CLI application needs more than just flag parsing. It needs:

  1. Clear structure: Separate concerns (parsing, validation, execution)
  2. Error handling: Graceful failures with helpful messages
  3. Configuration: Support multiple input sources
  4. Validation: Check inputs before processing
  5. User feedback: Progress indication and status messages

File Processor Example

This is similar to tools like grep, find, or ripgrep that search through files. We'll build a file finder that can search recursively and show verbose output. This demonstrates many important CLI patterns in one cohesive example.

  1// run
  2package main
  3
  4import (
  5    "flag"
  6    "fmt"
  7    "os"
  8    "path/filepath"
  9    "strings"
 10)
 11
 12type Config struct {
 13    Directory string
 14    Pattern   string
 15    Recursive bool
 16    Verbose   bool
 17    MaxDepth  int
 18}
 19
 20func main() {
 21    config := Config{}
 22
 23    flag.StringVar(&config.Directory, "dir", ".", "directory to search")
 24    flag.StringVar(&config.Pattern, "pattern", "*.txt", "file pattern")
 25    flag.BoolVar(&config.Recursive, "r", false, "search recursively")
 26    flag.BoolVar(&config.Verbose, "v", false, "verbose output")
 27    flag.IntVar(&config.MaxDepth, "max-depth", -1, "maximum recursion depth (-1 for unlimited)")
 28
 29    flag.Parse()
 30
 31    if err := run(config); err != nil {
 32        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
 33        os.Exit(1)
 34    }
 35}
 36
 37func run(config Config) error {
 38    if config.Verbose {
 39        fmt.Printf("Searching in: %s\n", config.Directory)
 40        fmt.Printf("Pattern: %s\n", config.Pattern)
 41        fmt.Printf("Recursive: %v\n", config.Recursive)
 42    }
 43
 44    // Validate directory exists
 45    info, err := os.Stat(config.Directory)
 46    if err != nil {
 47        return fmt.Errorf("cannot access directory: %w", err)
 48    }
 49    if !info.IsDir() {
 50        return fmt.Errorf("%s is not a directory", config.Directory)
 51    }
 52
 53    if config.Recursive {
 54        return searchRecursive(config)
 55    }
 56
 57    return searchDirectory(config.Directory, config.Pattern, config.Verbose)
 58}
 59
 60func searchDirectory(dir, pattern string, verbose bool) error {
 61    matches, err := filepath.Glob(filepath.Join(dir, pattern))
 62    if err != nil {
 63        return err
 64    }
 65
 66    if verbose {
 67        fmt.Printf("Found %d matches\n", len(matches))
 68    }
 69
 70    for _, match := range matches {
 71        fmt.Println(match)
 72    }
 73
 74    return nil
 75}
 76
 77func searchRecursive(config Config) error {
 78    count := 0
 79    depth := 0
 80
 81    err := filepath.Walk(config.Directory, func(path string, info os.FileInfo, err error) error {
 82        if err != nil {
 83            if config.Verbose {
 84                fmt.Fprintf(os.Stderr, "Warning: cannot access %s: %v\n", path, err)
 85            }
 86            return nil // Continue walking despite errors
 87        }
 88
 89        // Calculate current depth
 90        relPath, _ := filepath.Rel(config.Directory, path)
 91        currentDepth := strings.Count(relPath, string(os.PathSeparator))
 92
 93        // Check max depth
 94        if config.MaxDepth >= 0 && currentDepth > config.MaxDepth {
 95            if info.IsDir() {
 96                return filepath.SkipDir
 97            }
 98            return nil
 99        }
100
101        if info.IsDir() {
102            depth = max(depth, currentDepth)
103            return nil
104        }
105
106        matched, err := filepath.Match(config.Pattern, filepath.Base(path))
107        if err != nil {
108            return err
109        }
110
111        if matched {
112            count++
113            fmt.Println(path)
114        }
115
116        return nil
117    })
118
119    if config.Verbose {
120        fmt.Printf("\nSearch complete: %d files found (depth: %d)\n", count, depth)
121    }
122
123    return err
124}
125
126func max(a, b int) int {
127    if a > b {
128        return a
129    }
130    return b
131}

Key Patterns in This Example:

  1. Configuration struct: Centralizes all settings
  2. Separate run function: Main logic isolated from flag parsing
  3. Validation before execution: Check inputs early
  4. Error wrapping: Use %w for error context
  5. Graceful error handling: Continue on minor errors, fail on critical ones
  6. Progress feedback: Verbose mode for debugging

Usage Examples:

 1# Basic search in current directory
 2$ ./filesearch -pattern="*.go"
 3
 4# Recursive search with verbose output
 5$ ./filesearch -r -v -pattern="*.md" -dir=/docs
 6
 7# Limited depth search
 8$ ./filesearch -r -max-depth=2 -pattern="config.*"
 9
10# Search for text files recursively
11$ ./filesearch -r -pattern="*.txt" -dir=/project

This example demonstrates production-ready patterns:

  • Proper error handling with context
  • User-friendly verbose mode
  • Input validation
  • Clean separation of concerns
  • Configurable behavior

Subcommands - Building Complex CLI Tools

As your CLI tools grow more complex, you'll need to support different actions like git add, git commit, or docker run. These are called subcommands, and they allow your tool to have multiple distinct functionalities while maintaining a clean, organized interface.

Why Subcommands Matter:

Modern CLI tools are often suites of related functionality rather than single-purpose utilities. Consider:

  • git has 160+ subcommands (add, commit, push, pull, etc.)
  • docker organizes features into subcommands (run, build, ps, logs)
  • kubectl groups Kubernetes operations (get, apply, delete, describe)

Subcommands provide:

  1. Logical grouping of related features
  2. Discoverability - users can explore capabilities
  3. Extensibility - easy to add new commands
  4. Namespace isolation - different flags for different commands

Manual Subcommand Handling

Think of subcommands as different departments in a company. Each department has its own specific job but shares the same building entrance. Let's implement a task management CLI with multiple subcommands.

  1// run
  2package main
  3
  4import (
  5    "flag"
  6    "fmt"
  7    "os"
  8)
  9
 10func main() {
 11    if len(os.Args) < 2 {
 12        printUsage()
 13        os.Exit(1)
 14    }
 15
 16    switch os.Args[1] {
 17    case "add":
 18        addCmd()
 19    case "list":
 20        listCmd()
 21    case "remove":
 22        removeCmd()
 23    case "complete":
 24        completeCmd()
 25    case "help":
 26        printUsage()
 27    default:
 28        fmt.Printf("Unknown command: %s\n\n", os.Args[1])
 29        printUsage()
 30        os.Exit(1)
 31    }
 32}
 33
 34func printUsage() {
 35    fmt.Println("Task Manager - Manage your tasks from the command line")
 36    fmt.Println()
 37    fmt.Println("Usage: taskmgr <command> [options]")
 38    fmt.Println()
 39    fmt.Println("Commands:")
 40    fmt.Println("  add        Add a new task")
 41    fmt.Println("  list       List all tasks")
 42    fmt.Println("  remove     Remove a task")
 43    fmt.Println("  complete   Mark a task as complete")
 44    fmt.Println("  help       Show this help message")
 45    fmt.Println()
 46    fmt.Println("Use 'taskmgr <command> -h' for more information about a command")
 47}
 48
 49func addCmd() {
 50    addFlags := flag.NewFlagSet("add", flag.ExitOnError)
 51    name := addFlags.String("name", "", "task name (required)")
 52    priority := addFlags.Int("priority", 0, "priority level (0-5)")
 53    tags := addFlags.String("tags", "", "comma-separated tags")
 54
 55    addFlags.Usage = func() {
 56        fmt.Println("Usage: taskmgr add [options]")
 57        fmt.Println()
 58        fmt.Println("Options:")
 59        addFlags.PrintDefaults()
 60    }
 61
 62    addFlags.Parse(os.Args[2:])
 63
 64    if *name == "" {
 65        fmt.Println("Error: task name is required")
 66        addFlags.Usage()
 67        os.Exit(1)
 68    }
 69
 70    if *priority < 0 || *priority > 5 {
 71        fmt.Println("Error: priority must be between 0 and 5")
 72        os.Exit(1)
 73    }
 74
 75    fmt.Printf("Adding task: %s (priority: %d, tags: %s)\n", *name, *priority, *tags)
 76}
 77
 78func listCmd() {
 79    listFlags := flag.NewFlagSet("list", flag.ExitOnError)
 80    all := listFlags.Bool("all", false, "show completed tasks")
 81    filter := listFlags.String("filter", "", "filter by tag")
 82    sortBy := listFlags.String("sort", "priority", "sort by: priority, date, name")
 83
 84    listFlags.Parse(os.Args[2:])
 85
 86    fmt.Printf("Listing tasks (all: %v, filter: %s, sort: %s)\n", *all, *filter, *sortBy)
 87}
 88
 89func removeCmd() {
 90    removeFlags := flag.NewFlagSet("remove", flag.ExitOnError)
 91    id := removeFlags.Int("id", 0, "task ID (required)")
 92    force := removeFlags.Bool("force", false, "skip confirmation")
 93
 94    removeFlags.Parse(os.Args[2:])
 95
 96    if *id == 0 {
 97        fmt.Println("Error: task ID is required")
 98        os.Exit(1)
 99    }
100
101    if !*force {
102        fmt.Printf("Remove task %d? [y/N]: ", *id)
103        var response string
104        fmt.Scanln(&response)
105        if response != "y" && response != "Y" {
106            fmt.Println("Cancelled")
107            return
108        }
109    }
110
111    fmt.Printf("Removing task with ID: %d\n", *id)
112}
113
114func completeCmd() {
115    completeFlags := flag.NewFlagSet("complete", flag.ExitOnError)
116    id := completeFlags.Int("id", 0, "task ID (required)")
117
118    completeFlags.Parse(os.Args[2:])
119
120    if *id == 0 {
121        fmt.Println("Error: task ID is required")
122        os.Exit(1)
123    }
124
125    fmt.Printf("Marking task %d as complete\n", *id)
126}

Usage:

 1# Add a task
 2$ ./taskmgr add -name="Write documentation" -priority=3 -tags="docs,urgent"
 3Adding task: Write documentation (priority: 3, tags: docs,urgent)
 4
 5# List tasks
 6$ ./taskmgr list -all
 7Listing tasks (all: true, filter: , sort: priority)
 8
 9# Remove a task with confirmation
10$ ./taskmgr remove -id=5
11Remove task 5? [y/N]: y
12Removing task with ID: 5
13
14# Remove without confirmation
15$ ./taskmgr remove -id=5 -force
16Removing task with ID: 5
17
18# Complete a task
19$ ./taskmgr complete -id=3
20Marking task 3 as complete
21
22# Show help
23$ ./taskmgr help

Best Practices in This Example:

  1. Clear help messages: Both global and per-command
  2. Input validation: Check required fields
  3. Confirmation prompts: For destructive operations
  4. Custom flag sets: Isolated flags per command
  5. Consistent error handling: Exit codes and messages

Advanced Subcommand Architecture

For larger applications, a more structured approach helps:

 1// run
 2package main
 3
 4import (
 5    "flag"
 6    "fmt"
 7    "os"
 8)
 9
10// Command represents a subcommand
11type Command struct {
12    Name        string
13    Description string
14    Flags       *flag.FlagSet
15    Run         func() error
16}
17
18// CLI manages commands
19type CLI struct {
20    commands map[string]*Command
21}
22
23func NewCLI() *CLI {
24    return &CLI{
25        commands: make(map[string]*Command),
26    }
27}
28
29func (c *CLI) Register(cmd *Command) {
30    c.commands[cmd.Name] = cmd
31}
32
33func (c *CLI) Run(args []string) error {
34    if len(args) < 1 {
35        c.printUsage()
36        return fmt.Errorf("no command specified")
37    }
38
39    cmdName := args[0]
40    cmd, exists := c.commands[cmdName]
41    if !exists {
42        return fmt.Errorf("unknown command: %s", cmdName)
43    }
44
45    if err := cmd.Flags.Parse(args[1:]); err != nil {
46        return err
47    }
48
49    return cmd.Run()
50}
51
52func (c *CLI) printUsage() {
53    fmt.Println("Available commands:")
54    for name, cmd := range c.commands {
55        fmt.Printf("  %-12s %s\n", name, cmd.Description)
56    }
57}
58
59func main() {
60    cli := NewCLI()
61
62    // Register commands
63    addFlags := flag.NewFlagSet("add", flag.ExitOnError)
64    name := addFlags.String("name", "", "task name")
65
66    cli.Register(&Command{
67        Name:        "add",
68        Description: "Add a new task",
69        Flags:       addFlags,
70        Run: func() error {
71            if *name == "" {
72                return fmt.Errorf("name is required")
73            }
74            fmt.Printf("Adding: %s\n", *name)
75            return nil
76        },
77    })
78
79    listFlags := flag.NewFlagSet("list", flag.ExitOnError)
80    all := listFlags.Bool("all", false, "show all")
81
82    cli.Register(&Command{
83        Name:        "list",
84        Description: "List all tasks",
85        Flags:       listFlags,
86        Run: func() error {
87            fmt.Printf("Listing (all: %v)\n", *all)
88            return nil
89        },
90    })
91
92    // Run CLI
93    if err := cli.Run(os.Args[1:]); err != nil {
94        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
95        os.Exit(1)
96    }
97}

This architecture provides:

  • Extensibility: Easy to add new commands
  • Testability: Commands can be tested in isolation
  • Maintainability: Each command is self-contained
  • Plugin support: Commands could be loaded dynamically

Environment Variables

Environment variables are like the invisible settings that configure how your program behaves in different environments. They're perfect for configuration that shouldn't be exposed on the command line, like API keys or database passwords, or for settings that vary by deployment environment.

Why Environment Variables?

  1. Security: Sensitive data doesn't appear in command history or process lists
  2. Deployment flexibility: Different configs for dev/staging/prod without code changes
  3. Convention: Standard practice in cloud platforms (Heroku, AWS, Docker)
  4. Overridability: Users can customize behavior without changing code
  5. Platform integration: IDEs and CI/CD systems support them natively

Common Use Cases:

  • API keys and secrets
  • Database connection strings
  • Service endpoints
  • Feature flags
  • Debug/log levels
  • Temporary directories

Reading Environment Variables

🌍 Real-world Example: This pattern is used extensively in cloud applications. The PORT variable is used by Heroku, AWS Elastic Beanstalk, and Google Cloud Run to tell your application which port to listen on. Similarly, DATABASE_URL is commonly used to configure database connections. The twelve-factor app methodology explicitly recommends storing config in environment variables.

 1// run
 2package main
 3
 4import (
 5    "flag"
 6    "fmt"
 7    "os"
 8    "strconv"
 9)
10
11func main() {
12    // Default values from environment
13    defaultPort := 8080
14    if envPort := os.Getenv("PORT"); envPort != "" {
15        if port, err := strconv.Atoi(envPort); err == nil {
16            defaultPort = port
17        }
18    }
19
20    defaultHost := os.Getenv("HOST")
21    if defaultHost == "" {
22        defaultHost = "localhost"
23    }
24
25    // Environment variable for log level
26    logLevel := os.Getenv("LOG_LEVEL")
27    if logLevel == "" {
28        logLevel = "info"
29    }
30
31    // Flags override environment variables
32    host := flag.String("host", defaultHost, "server host")
33    port := flag.Int("port", defaultPort, "server port")
34    debug := flag.Bool("debug", false, "enable debug mode")
35
36    flag.Parse()
37
38    // Debug mode can also come from environment
39    if os.Getenv("DEBUG") == "true" {
40        *debug = true
41    }
42
43    fmt.Printf("Server will run on %s:%d\n", *host, *port)
44    fmt.Printf("Log level: %s\n", logLevel)
45    fmt.Printf("Debug mode: %v\n", *debug)
46}

Configuration Priority (highest to lowest):

  1. Command-line flags (explicit user intent)
  2. Environment variables (deployment-specific config)
  3. Configuration files (persistent settings)
  4. Default values (fallback)

Usage:

 1# Use defaults
 2$ ./server
 3Server will run on localhost:8080
 4Log level: info
 5Debug mode: false
 6
 7# Override with environment
 8$ PORT=3000 HOST=0.0.0.0 LOG_LEVEL=debug ./server
 9Server will run on 0.0.0.0:3000
10Log level: debug
11Debug mode: false
12
13# Override with flags (highest priority)
14$ PORT=3000 ./server -port=9000
15Server will run on localhost:9000
16Log level: info
17Debug mode: false
18
19# Combined environment and flags
20$ DEBUG=true ./server -host=api.example.com
21Server will run on api.example.com:8080
22Log level: info
23Debug mode: true

Advanced Environment Variable Patterns

Type-Safe Environment Variable Parsing:

 1// run
 2package main
 3
 4import (
 5    "fmt"
 6    "os"
 7    "strconv"
 8    "time"
 9)
10
11type Config struct {
12    Port            int
13    Host            string
14    DatabaseURL     string
15    CacheTTL        time.Duration
16    MaxConnections  int
17    EnableMetrics   bool
18}
19
20func LoadConfig() (*Config, error) {
21    config := &Config{
22        Port:           8080,
23        Host:           "localhost",
24        CacheTTL:       5 * time.Minute,
25        MaxConnections: 100,
26    }
27
28    // Port
29    if portStr := os.Getenv("PORT"); portStr != "" {
30        port, err := strconv.Atoi(portStr)
31        if err != nil {
32            return nil, fmt.Errorf("invalid PORT: %w", err)
33        }
34        config.Port = port
35    }
36
37    // Host
38    if host := os.Getenv("HOST"); host != "" {
39        config.Host = host
40    }
41
42    // Database URL (required)
43    config.DatabaseURL = os.Getenv("DATABASE_URL")
44    if config.DatabaseURL == "" {
45        return nil, fmt.Errorf("DATABASE_URL is required")
46    }
47
48    // Cache TTL
49    if ttlStr := os.Getenv("CACHE_TTL"); ttlStr != "" {
50        ttl, err := time.ParseDuration(ttlStr)
51        if err != nil {
52            return nil, fmt.Errorf("invalid CACHE_TTL: %w", err)
53        }
54        config.CacheTTL = ttl
55    }
56
57    // Max connections
58    if maxStr := os.Getenv("MAX_CONNECTIONS"); maxStr != "" {
59        max, err := strconv.Atoi(maxStr)
60        if err != nil {
61            return nil, fmt.Errorf("invalid MAX_CONNECTIONS: %w", err)
62        }
63        config.MaxConnections = max
64    }
65
66    // Boolean flag
67    config.EnableMetrics = os.Getenv("ENABLE_METRICS") == "true"
68
69    return config, nil
70}
71
72func main() {
73    config, err := LoadConfig()
74    if err != nil {
75        fmt.Fprintf(os.Stderr, "Configuration error: %v\n", err)
76        os.Exit(1)
77    }
78
79    fmt.Printf("Configuration loaded:\n")
80    fmt.Printf("  Host: %s\n", config.Host)
81    fmt.Printf("  Port: %d\n", config.Port)
82    fmt.Printf("  Database: %s\n", config.DatabaseURL)
83    fmt.Printf("  Cache TTL: %v\n", config.CacheTTL)
84    fmt.Printf("  Max Connections: %d\n", config.MaxConnections)
85    fmt.Printf("  Metrics: %v\n", config.EnableMetrics)
86}

Usage:

 1# Missing required variable
 2$ ./app
 3Configuration error: DATABASE_URL is required
 4
 5# Valid configuration
 6$ DATABASE_URL=postgres://localhost/mydb CACHE_TTL=10m ENABLE_METRICS=true ./app
 7Configuration loaded:
 8  Host: localhost
 9  Port: 8080
10  Database: postgres://localhost/mydb
11  Cache TTL: 10m0s
12  Max Connections: 100
13  Metrics: true

Loading from .env Files:

Many projects use .env files for local development:

 1// run
 2package main
 3
 4import (
 5    "bufio"
 6    "fmt"
 7    "os"
 8    "strings"
 9)
10
11// LoadEnvFile loads environment variables from a file
12func LoadEnvFile(filename string) error {
13    file, err := os.Open(filename)
14    if err != nil {
15        if os.IsNotExist(err) {
16            return nil // .env file is optional
17        }
18        return err
19    }
20    defer file.Close()
21
22    scanner := bufio.NewScanner(file)
23    for scanner.Scan() {
24        line := strings.TrimSpace(scanner.Text())
25
26        // Skip empty lines and comments
27        if line == "" || strings.HasPrefix(line, "#") {
28            continue
29        }
30
31        // Parse KEY=VALUE
32        parts := strings.SplitN(line, "=", 2)
33        if len(parts) != 2 {
34            continue
35        }
36
37        key := strings.TrimSpace(parts[0])
38        value := strings.TrimSpace(parts[1])
39
40        // Remove quotes if present
41        value = strings.Trim(value, "\"'")
42
43        // Only set if not already set
44        if os.Getenv(key) == "" {
45            os.Setenv(key, value)
46        }
47    }
48
49    return scanner.Err()
50}
51
52func main() {
53    // Load .env file if it exists
54    if err := LoadEnvFile(".env"); err != nil {
55        fmt.Fprintf(os.Stderr, "Error loading .env: %v\n", err)
56        os.Exit(1)
57    }
58
59    // Now use environment variables as normal
60    port := os.Getenv("PORT")
61    if port == "" {
62        port = "8080"
63    }
64
65    fmt.Printf("Port: %s\n", port)
66    fmt.Printf("Database: %s\n", os.Getenv("DATABASE_URL"))
67}

Example .env file:

 1# Server configuration
 2PORT=3000
 3HOST=localhost
 4
 5# Database
 6DATABASE_URL=postgres://user:pass@localhost/dbname
 7
 8# Features
 9ENABLE_METRICS=true
10LOG_LEVEL=debug

Security Best Practices:

  1. Never commit .env files - add to .gitignore
  2. Use secrets management - Vault, AWS Secrets Manager for production
  3. Validate all inputs - Never trust environment variables
  4. Document required variables - Include .env.example in your repo
  5. Use different variables per environment - Don't reuse production credentials in development

Input/Output Handling

Great CLI tools follow the Unix philosophy: do one thing well and work well with other tools. This means reading from stdin and writing to stdout, allowing your tool to be part of a pipeline like cat data.txt | ./app | grep "error". Proper I/O handling is what separates amateur CLI tools from professional ones that integrate seamlessly into Unix workflows.

Unix Philosophy Principles:

  1. Write programs that do one thing and do it well
  2. Write programs to work together
  3. Write programs to handle text streams

These principles, established in the 1970s, remain relevant because they enable composition and automation.

Reading from Stdin

Reading from stdin makes your tool composable with other Unix tools. Think of grep, sed, or awk - they all read from stdin and can be chained together. This pattern is fundamental to Unix tool design.

Why Stdin Matters:

  • Composition: Chain tools together (cmd1 | cmd2 | cmd3)
  • Flexibility: Works with pipes, redirects, and files
  • Testing: Easy to test with strings instead of files
  • Efficiency: Stream processing without temp files
 1// run
 2package main
 3
 4import (
 5    "bufio"
 6    "flag"
 7    "fmt"
 8    "io"
 9    "os"
10    "strings"
11)
12
13func main() {
14    uppercase := flag.Bool("u", false, "convert to uppercase")
15    lowercase := flag.Bool("l", false, "convert to lowercase")
16    trim := flag.Bool("t", false, "trim whitespace")
17    lineNumbers := flag.Bool("n", false, "show line numbers")
18
19    flag.Parse()
20
21    var reader io.Reader = os.Stdin
22
23    // If files are provided as arguments, read from them instead
24    if flag.NArg() > 0 {
25        file, err := os.Open(flag.Arg(0))
26        if err != nil {
27            fmt.Fprintf(os.Stderr, "Error opening file: %v\n", err)
28            os.Exit(1)
29        }
30        defer file.Close()
31        reader = file
32    }
33
34    scanner := bufio.NewScanner(reader)
35    lineNum := 1
36
37    for scanner.Scan() {
38        line := scanner.Text()
39
40        if *trim {
41            line = strings.TrimSpace(line)
42        }
43
44        if *uppercase {
45            line = strings.ToUpper(line)
46        } else if *lowercase {
47            line = strings.ToLower(line)
48        }
49
50        if *lineNumbers {
51            fmt.Printf("%4d: %s\n", lineNum, line)
52        } else {
53            fmt.Println(line)
54        }
55
56        lineNum++
57    }
58
59    if err := scanner.Err(); err != nil {
60        fmt.Fprintf(os.Stderr, "Error reading: %v\n", err)
61        os.Exit(1)
62    }
63}

Usage:

 1# From stdin
 2$ echo "hello world" | ./app -u
 3HELLO WORLD
 4
 5# From file
 6$ ./app -u input.txt
 7
 8# Chained with other commands
 9$ cat log.txt | grep ERROR | ./app -n
10   1: ERROR: Connection failed
11   2: ERROR: Timeout exceeded
12
13# Multiple transformations
14$ echo "  hello world  " | ./app -t -u
15HELLO WORLD

Advanced Pattern: Multiple Input Sources:

 1// run
 2package main
 3
 4import (
 5    "bufio"
 6    "flag"
 7    "fmt"
 8    "io"
 9    "os"
10)
11
12func processReader(r io.Reader, prefix string) error {
13    scanner := bufio.NewScanner(r)
14    for scanner.Scan() {
15        fmt.Printf("%s%s\n", prefix, scanner.Text())
16    }
17    return scanner.Err()
18}
19
20func main() {
21    flag.Parse()
22
23    if flag.NArg() == 0 {
24        // No files specified, read from stdin
25        if err := processReader(os.Stdin, ""); err != nil {
26            fmt.Fprintf(os.Stderr, "Error: %v\n", err)
27            os.Exit(1)
28        }
29    } else {
30        // Process each file
31        for _, filename := range flag.Args() {
32            file, err := os.Open(filename)
33            if err != nil {
34                fmt.Fprintf(os.Stderr, "Error opening %s: %v\n", filename, err)
35                continue
36            }
37
38            prefix := fmt.Sprintf("[%s] ", filename)
39            if err := processReader(file, prefix); err != nil {
40                fmt.Fprintf(os.Stderr, "Error reading %s: %v\n", filename, err)
41            }
42
43            file.Close()
44        }
45    }
46}

💡 Key Insight: Supporting both file input and stdin makes your tool flexible. Users can pipe data into it or specify files directly, following the Unix philosophy of small, composable tools. This pattern is used by nearly all standard Unix utilities.

Writing to Files and Stdout

Most tools should write to stdout by default, but support optional file output:

 1// run
 2package main
 3
 4import (
 5    "flag"
 6    "fmt"
 7    "io"
 8    "os"
 9    "time"
10)
11
12func main() {
13    output := flag.String("o", "", "output file (default: stdout)")
14    timestamp := flag.Bool("t", false, "add timestamp")
15
16    flag.Parse()
17
18    var writer io.Writer = os.Stdout
19
20    // If output file specified, write to it
21    if *output != "" {
22        file, err := os.Create(*output)
23        if err != nil {
24            fmt.Fprintf(os.Stderr, "Error creating file: %v\n", err)
25            os.Exit(1)
26        }
27        defer file.Close()
28        writer = file
29    }
30
31    message := "Hello from CLI app!"
32
33    if *timestamp {
34        message = fmt.Sprintf("[%s] %s", time.Now().Format(time.RFC3339), message)
35    }
36
37    fmt.Fprintln(writer, message)
38
39    // If we wrote to a file, also confirm to user
40    if *output != "" {
41        fmt.Fprintf(os.Stderr, "Output written to %s\n", *output)
42    }
43}

Usage:

 1# Write to stdout (default)
 2$ ./app
 3Hello from CLI app!
 4
 5# Write to file
 6$ ./app -o result.txt
 7Output written to result.txt
 8
 9# With timestamp to stdout
10$ ./app -t
11[2024-01-15T10:30:00Z] Hello from CLI app!
12
13# Redirect stdout manually
14$ ./app -t > output.txt

Best Practices:

  1. Default to stdout: Let users redirect output themselves
  2. Status messages to stderr: User-facing info goes to stderr, data to stdout
  3. Confirm file operations: Tell users when files are created
  4. Support - for stdin/stdout: Unix convention for "use standard stream"

Error Handling and Exit Codes

Professional CLI tools need to communicate success and failure clearly to other programs and scripts. Exit codes are the standard way to do this - 0 means success, non-zero means failure. This is critical for automation, CI/CD pipelines, and any scenario where your tool is called by another program.

🌍 Real-world Example: This is crucial for CI/CD pipelines. When go test fails, it exits with code 1, causing GitHub Actions or Jenkins to mark the build as failed. Similarly, curl exits with different codes to indicate network errors vs. HTTP errors, allowing scripts to handle failures appropriately.

Standard Exit Codes:

  • 0: Success
  • 1: General error
  • 2: Misuse of shell command (invalid arguments)
  • 126: Command cannot execute
  • 127: Command not found
  • 128+n: Fatal error signal "n"
  • 130: Terminated by Ctrl+C (SIGINT)

Many applications define custom exit codes for specific error types:

1const (
2    ExitSuccess       = 0
3    ExitGeneralError  = 1
4    ExitUsageError    = 2
5    ExitConfigError   = 3
6    ExitNetworkError  = 4
7    ExitFileError     = 5
8)

Proper Error Handling Pattern

 1// run
 2package main
 3
 4import (
 5    "errors"
 6    "flag"
 7    "fmt"
 8    "os"
 9)
10
11const (
12    ExitSuccess = 0
13    ExitError   = 1
14    ExitUsage   = 2
15)
16
17func main() {
18    // Run main logic and capture exit code
19    os.Exit(realMain())
20}
21
22func realMain() int {
23    var (
24        input  string
25        output string
26    )
27
28    flag.StringVar(&input, "i", "", "input file (required)")
29    flag.StringVar(&output, "o", "", "output file (required)")
30    flag.Parse()
31
32    // Validate arguments
33    if input == "" || output == "" {
34        fmt.Fprintln(os.Stderr, "Error: Both -i and -o flags are required")
35        flag.Usage()
36        return ExitUsage
37    }
38
39    // Process files
40    if err := processFiles(input, output); err != nil {
41        // Handle different error types
42        if errors.Is(err, os.ErrNotExist) {
43            fmt.Fprintf(os.Stderr, "Error: Input file not found: %s\n", input)
44        } else if errors.Is(err, os.ErrPermission) {
45            fmt.Fprintf(os.Stderr, "Error: Permission denied\n")
46        } else {
47            fmt.Fprintf(os.Stderr, "Error: %v\n", err)
48        }
49        return ExitError
50    }
51
52    fmt.Printf("Successfully processed %s -> %s\n", input, output)
53    return ExitSuccess
54}
55
56func processFiles(input, output string) error {
57    // Check input file exists
58    if _, err := os.Stat(input); os.IsNotExist(err) {
59        return fmt.Errorf("input file does not exist: %w", err)
60    }
61
62    // Simulate processing
63    fmt.Printf("Processing %s -> %s\n", input, output)
64
65    // In real implementation, do actual file processing here
66
67    return nil
68}

Why This Pattern Works:

  1. Separation of concerns: main() only handles exit codes
  2. Testability: realMain() returns int, easy to test
  3. Proper cleanup: defer works correctly (doesn't work with os.Exit)
  4. Clear error reporting: Different exit codes for different failures

Testing Exit Codes:

 1$ ./app -i input.txt -o output.txt
 2Processing input.txt -> output.txt
 3Successfully processed input.txt -> output.txt
 4$ echo $?
 50
 6
 7$ ./app -i nonexistent.txt -o output.txt
 8Error: Input file not found: nonexistent.txt
 9$ echo $?
101
11
12$ ./app
13Error: Both -i and -o flags are required
14Usage of ./app:
15  -i string
16        input file (required)
17  -o string
18        output file (required)
19$ echo $?
202

Advanced Error Handling

For larger applications, structured error handling helps:

 1// run
 2package main
 3
 4import (
 5    "errors"
 6    "fmt"
 7    "os"
 8)
 9
10// AppError represents a detailed application error
11type AppError struct {
12    Op      string // Operation that failed
13    Err     error  // Underlying error
14    Code    int    // Exit code
15    Message string // User-friendly message
16}
17
18func (e *AppError) Error() string {
19    if e.Message != "" {
20        return fmt.Sprintf("%s: %s (%v)", e.Op, e.Message, e.Err)
21    }
22    return fmt.Sprintf("%s: %v", e.Op, e.Err)
23}
24
25func (e *AppError) Unwrap() error {
26    return e.Err
27}
28
29// Error constructors
30func UsageError(op, message string) *AppError {
31    return &AppError{
32        Op:      op,
33        Code:    2,
34        Message: message,
35        Err:     errors.New("invalid usage"),
36    }
37}
38
39func FileError(op string, err error) *AppError {
40    return &AppError{
41        Op:   op,
42        Code: 1,
43        Err:  err,
44    }
45}
46
47func NetworkError(op string, err error) *AppError {
48    return &AppError{
49        Op:   op,
50        Code: 4,
51        Err:  err,
52    }
53}
54
55func processFile(filename string) error {
56    if filename == "" {
57        return UsageError("processFile", "filename cannot be empty")
58    }
59
60    _, err := os.Stat(filename)
61    if err != nil {
62        return FileError("processFile", err)
63    }
64
65    return nil
66}
67
68func main() {
69    err := processFile("")
70    if err != nil {
71        var appErr *AppError
72        if errors.As(err, &appErr) {
73            fmt.Fprintf(os.Stderr, "Error: %s\n", appErr.Message)
74            os.Exit(appErr.Code)
75        }
76
77        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
78        os.Exit(1)
79    }
80}

Benefits:

  • Structured error information
  • Consistent error handling
  • Easy to map errors to exit codes
  • Better debugging information

Best Practices

These best practices come from studying successful CLI tools like git, docker, kubectl, and go itself. Following them will make your tools professional and user-friendly.

1. Use flag package for simple CLIs - It's built-in and sufficient for most cases

Start with the standard library. Only reach for third-party libraries when you need features like:

  • Shell completion
  • Rich help formatting
  • Nested subcommands with inheritance
  • Built-in validation rules

2. Provide good help messages - Use flag descriptions effectively

Every flag should have:

  • Clear, concise description
  • Default value (if applicable)
  • Value type or format expected
  • Example usage
1flag.StringVar(&config, "config", "config.json",
2    "path to configuration file (JSON format)")

3. Handle errors gracefully - Print to stderr and use appropriate exit codes

1// Bad
2fmt.Println("Error:", err)
3
4// Good
5fmt.Fprintf(os.Stderr, "Error: %v\n", err)
6os.Exit(1)

4. Support stdin/stdout - Make tools pipeable

Every data processing tool should support:

  • Reading from stdin when no file specified
  • Writing to stdout by default
  • Optional file output with flags

5. Use environment variables for defaults - Allow configuration flexibility

Priority order:

  1. Command-line flags (highest)
  2. Environment variables
  3. Configuration files
  4. Built-in defaults (lowest)

6. Validate input early - Fail fast with clear error messages

 1func realMain() int {
 2    // Parse flags
 3    flag.Parse()
 4
 5    // Validate immediately
 6    if err := validateConfig(); err != nil {
 7        fmt.Fprintf(os.Stderr, "Configuration error: %v\n", err)
 8        return ExitUsage
 9    }
10
11    // Proceed with valid config
12    return run()
13}

7. Make CLIs composable - Follow Unix philosophy

Design tools that:

  • Do one thing well
  • Work with text streams
  • Can be chained with pipes
  • Use standard exit codes

8. Add version flag - Use -version or -v for version info

 1var version = "1.0.0" // Set via ldflags in build
 2
 3func main() {
 4    versionFlag := flag.Bool("version", false, "print version and exit")
 5    flag.Parse()
 6
 7    if *versionFlag {
 8        fmt.Printf("myapp version %s\n", version)
 9        os.Exit(0)
10    }
11
12    // Rest of application...
13}

Build with version info:

1$ go build -ldflags "-X main.version=1.2.3"

9. Support both short and long flags - -v and --verbose

The flag package supports both by default:

1$ ./app -v          # Works
2$ ./app -verbose    # Also works
3$ ./app --verbose   # Also works

10. Test with various inputs - Include edge cases and malformed input

Test scenarios:

  • Valid inputs
  • Missing required flags
  • Invalid flag values
  • Empty files
  • Large files
  • Stdin vs. file input
  • Permission errors
  • Disk full scenarios

When to Use Flag vs When to Use Frameworks

Think of this like choosing between a manual transmission and an automatic one in a car. The flag package is manual - more control, less convenience. CLI frameworks are automatic - more features, more dependencies.

Use flag package when:

  • Your tool has simple flags and arguments (< 10 flags, no subcommands)
  • You want zero dependencies (distributing internally, size matters)
  • Performance is critical (flag package is fast and lightweight)
  • You're building small, focused tools (following Unix philosophy)
  • You need maximum control over parsing behavior
  • You're learning CLI development (understand the basics first)

Use CLI frameworks when:

  • You have complex subcommands and nested flags (like git or docker)
  • You need auto-generated shell completions (bash, zsh, fish)
  • You want sophisticated help and validation (colorized output, examples)
  • You're building large, enterprise-level tools (many subcommands, rich UX)
  • You need middleware/hooks for common tasks (auth, logging, metrics)
  • You want to save development time (framework handles common patterns)

Popular Go CLI Frameworks:

  1. Cobra (github.com/spf13/cobra)

    • Used by: Kubernetes, Hugo, GitHub CLI
    • Pros: Rich features, great docs, shell completion
    • Cons: Larger dependency tree
  2. urfave/cli (github.com/urfave/cli)

    • Used by: Geth (Ethereum), many smaller tools
    • Pros: Simpler than Cobra, still feature-rich
    • Cons: Less community momentum than Cobra
  3. flag package

    • Used by: Go standard tools, simple utilities
    • Pros: Zero dependencies, part of stdlib
    • Cons: Limited features, manual work required

Decision Matrix:

Feature flag urfave/cli Cobra
Dependencies 0 2 10+
Subcommands Manual Built-in Built-in
Shell completion No Yes Yes
Help formatting Basic Good Excellent
Learning curve Easy Medium Medium
Binary size Minimal Small Medium

Recommendation:

  • Start with flag for your first CLI tools
  • Graduate to frameworks when you need their features
  • Don't prematurely optimize - features matter more than size for most tools
  • Consider your distribution method (internal vs. open source)

Common Pitfalls

Learning from others' mistakes is the fastest way to improve. Here are the most common issues developers encounter when building CLI tools:

1. Not calling flag.Parse() - Flags won't be processed

 1// Bad
 2func main() {
 3    verbose := flag.Bool("v", false, "verbose")
 4    // Missing: flag.Parse()
 5    if *verbose { // Always false!
 6        fmt.Println("Verbose mode")
 7    }
 8}
 9
10// Good
11func main() {
12    verbose := flag.Bool("v", false, "verbose")
13    flag.Parse() // Must call this!
14    if *verbose {
15        fmt.Println("Verbose mode")
16    }
17}

2. Parsing flags in wrong order - Call Parse() after defining all flags

 1// Bad
 2func main() {
 3    verbose := flag.Bool("v", false, "verbose")
 4    flag.Parse() // Too early!
 5    debug := flag.Bool("d", false, "debug") // Won't be recognized
 6}
 7
 8// Good
 9func main() {
10    verbose := flag.Bool("v", false, "verbose")
11    debug := flag.Bool("d", false, "debug")
12    flag.Parse() // After all flags defined
13}

3. Ignoring errors - Always check errors from file operations

 1// Bad
 2file, _ := os.Open("data.txt")
 3// What if file doesn't exist?
 4scanner := bufio.NewScanner(file) // Panics!
 5
 6// Good
 7file, err := os.Open("data.txt")
 8if err != nil {
 9    fmt.Fprintf(os.Stderr, "Error opening file: %v\n", err)
10    os.Exit(1)
11}
12defer file.Close()

4. Hardcoding file paths - Use flags or environment variables

 1// Bad
 2data, err := os.ReadFile("/tmp/config.json")
 3// Works on Unix, fails on Windows
 4
 5// Good
 6configPath := flag.String("config", defaultConfigPath(), "config file")
 7flag.Parse()
 8data, err := os.ReadFile(*configPath)
 9
10func defaultConfigPath() string {
11    if runtime.GOOS == "windows" {
12        return "C:\\ProgramData\\myapp\\config.json"
13    }
14    return "/etc/myapp/config.json"
15}

5. Poor error messages - Be specific about what went wrong

 1// Bad
 2if err != nil {
 3    fmt.Println("Error")
 4    os.Exit(1)
 5}
 6
 7// Good
 8if err != nil {
 9    fmt.Fprintf(os.Stderr, "Failed to connect to database at %s: %v\n",
10        dbURL, err)
11    fmt.Fprintf(os.Stderr, "Hint: Check that the database is running and the credentials are correct\n")
12    os.Exit(1)
13}

6. Not using os.Exit codes - Use proper exit codes

 1// Bad
 2func main() {
 3    if err := run(); err != nil {
 4        fmt.Println("Error:", err)
 5        // Exits with 0 (success)!
 6    }
 7}
 8
 9// Good
10func main() {
11    if err := run(); err != nil {
12        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
13        os.Exit(1) // Proper error code
14    }
15}

7. Blocking on stdin - Check if input is available before reading

 1// Bad
 2func main() {
 3    scanner := bufio.NewScanner(os.Stdin)
 4    for scanner.Scan() { // Hangs if no input!
 5        process(scanner.Text())
 6    }
 7}
 8
 9// Good
10func main() {
11    // Check if stdin has data
12    stat, _ := os.Stdin.Stat()
13    if (stat.Mode() & os.ModeCharDevice) != 0 {
14        fmt.Fprintf(os.Stderr, "No input provided. Use -h for help.\n")
15        os.Exit(1)
16    }
17
18    scanner := bufio.NewScanner(os.Stdin)
19    for scanner.Scan() {
20        process(scanner.Text())
21    }
22}

8. Not closing files - Use defer to ensure cleanup

 1// Bad
 2file, err := os.Open("data.txt")
 3if err != nil {
 4    return err
 5}
 6// If error occurs below, file never closes!
 7data := processFile(file)
 8file.Close()
 9
10// Good
11file, err := os.Open("data.txt")
12if err != nil {
13    return err
14}
15defer file.Close() // Always closes, even on error
16data := processFile(file)

9. Writing user messages to stdout - Use stderr for non-data output

1// Bad
2fmt.Println("Processing file...") // Pollutes stdout
3fmt.Println(result) // Actual output mixed with status
4
5// Good
6fmt.Fprintln(os.Stderr, "Processing file...") // To stderr
7fmt.Println(result) // Only data to stdout

10. Not handling SIGINT/SIGTERM - Allow graceful shutdown

 1// Bad
 2func main() {
 3    for {
 4        process() // No way to stop gracefully
 5    }
 6}
 7
 8// Good
 9func main() {
10    ctx, cancel := context.WithCancel(context.Background())
11    defer cancel()
12
13    sigChan := make(chan os.Signal, 1)
14    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
15
16    go func() {
17        <-sigChan
18        fmt.Fprintln(os.Stderr, "\nShutting down gracefully...")
19        cancel()
20    }()
21
22    for ctx.Err() == nil {
23        process()
24    }
25}

⚠️ Critical Reminder: Always test your CLI tool with invalid inputs, missing files, and interrupted operations. Professional tools handle edge cases gracefully rather than panicking or hanging. Test with:

  • Missing required arguments
  • Invalid flag values
  • Non-existent files
  • Permission errors
  • Disk full scenarios
  • Network timeouts
  • Interrupted operations (Ctrl+C)

Common Patterns

These are battle-tested patterns that appear in production CLI tools. Copy and adapt them for your own applications.

Version Flag

Every production tool should include version information:

 1// run
 2package main
 3
 4import (
 5    "flag"
 6    "fmt"
 7    "os"
 8    "runtime"
 9)
10
11var (
12    version   = "1.0.0"
13    commit    = "none"
14    buildTime = "unknown"
15)
16
17func main() {
18    versionFlag := flag.Bool("version", false, "print version and exit")
19    flag.Parse()
20
21    if *versionFlag {
22        printVersion()
23        os.Exit(0)
24    }
25
26    // Rest of application...
27    fmt.Println("Running application...")
28}
29
30func printVersion() {
31    fmt.Printf("myapp version %s\n", version)
32    fmt.Printf("  commit: %s\n", commit)
33    fmt.Printf("  built: %s\n", buildTime)
34    fmt.Printf("  go: %s\n", runtime.Version())
35    fmt.Printf("  platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
36}

Build with version information:

1#!/bin/bash
2VERSION="1.2.3"
3COMMIT=$(git rev-parse --short HEAD)
4BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S')
5
6go build -ldflags "\
7    -X main.version=${VERSION} \
8    -X main.commit=${COMMIT} \
9    -X main.buildTime=${BUILD_TIME}"

Output:

1$ ./myapp -version
2myapp version 1.2.3
3  commit: a1b2c3d
4  built: 2024-01-15_10:30:00
5  go: go1.21.5
6  platform: linux/amd64

Progress Bar

For long-running operations, show progress:

 1// run
 2package main
 3
 4import (
 5    "fmt"
 6    "strings"
 7    "time"
 8)
 9
10func showProgress(current, total int) {
11    percent := float64(current) / float64(total) * 100
12    bar := strings.Repeat("=", int(percent/2))
13    fmt.Printf("\r[%-50s] %.0f%% (%d/%d)", bar, percent, current, total)
14    if current == total {
15        fmt.Println()
16    }
17}
18
19func main() {
20    total := 100
21    for i := 0; i <= total; i++ {
22        showProgress(i, total)
23        time.Sleep(50 * time.Millisecond)
24    }
25}

Output:

[====================                              ] 40% (40/100)

Confirmation Prompt

For destructive operations, ask for confirmation:

 1// run
 2package main
 3
 4import (
 5    "bufio"
 6    "fmt"
 7    "os"
 8    "strings"
 9)
10
11func confirm(prompt string) bool {
12    fmt.Printf("%s [y/N]: ", prompt)
13    reader := bufio.NewReader(os.Stdin)
14    response, err := reader.ReadString('\n')
15    if err != nil {
16        return false
17    }
18
19    response = strings.ToLower(strings.TrimSpace(response))
20    return response == "y" || response == "yes"
21}
22
23func main() {
24    if confirm("Delete all data?") {
25        fmt.Println("Deleting...")
26    } else {
27        fmt.Println("Cancelled")
28    }
29}

Spinner for Long Operations

Show activity during operations:

 1// run
 2package main
 3
 4import (
 5    "fmt"
 6    "time"
 7)
 8
 9func spinner(done chan bool) {
10    chars := []rune{'|', '/', '-', '\\'}
11    i := 0
12    for {
13        select {
14        case <-done:
15            fmt.Print("\r \r") // Clear spinner
16            return
17        default:
18            fmt.Printf("\r%c Processing...", chars[i%len(chars)])
19            i++
20            time.Sleep(100 * time.Millisecond)
21        }
22    }
23}
24
25func main() {
26    done := make(chan bool)
27    go spinner(done)
28
29    // Simulate work
30    time.Sleep(5 * time.Second)
31
32    done <- true
33    fmt.Println("Complete!")
34}

Configuration File Support

Support multiple configuration sources:

 1// run
 2package main
 3
 4import (
 5    "encoding/json"
 6    "flag"
 7    "fmt"
 8    "os"
 9    "path/filepath"
10)
11
12type Config struct {
13    Host string `json:"host"`
14    Port int    `json:"port"`
15}
16
17func loadConfig() (*Config, error) {
18    config := &Config{
19        Host: "localhost",
20        Port: 8080,
21    }
22
23    // 1. Load from config file if exists
24    configPath := flag.String("config", "", "config file path")
25    flag.Parse()
26
27    if *configPath == "" {
28        // Try default locations
29        homeDir, _ := os.UserHomeDir()
30        possiblePaths := []string{
31            ".config.json",
32            filepath.Join(homeDir, ".myapp", "config.json"),
33            "/etc/myapp/config.json",
34        }
35
36        for _, path := range possiblePaths {
37            if _, err := os.Stat(path); err == nil {
38                *configPath = path
39                break
40            }
41        }
42    }
43
44    if *configPath != "" {
45        data, err := os.ReadFile(*configPath)
46        if err == nil {
47            json.Unmarshal(data, config)
48        }
49    }
50
51    // 2. Override with environment variables
52    if host := os.Getenv("APP_HOST"); host != "" {
53        config.Host = host
54    }
55    if port := os.Getenv("APP_PORT"); port != "" {
56        fmt.Sscanf(port, "%d", &config.Port)
57    }
58
59    // 3. Override with flags (highest priority)
60    host := flag.String("host", config.Host, "server host")
61    port := flag.Int("port", config.Port, "server port")
62    flag.Parse()
63
64    config.Host = *host
65    config.Port = *port
66
67    return config, nil
68}
69
70func main() {
71    config, err := loadConfig()
72    if err != nil {
73        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
74        os.Exit(1)
75    }
76
77    fmt.Printf("Config: %s:%d\n", config.Host, config.Port)
78}

This demonstrates the configuration precedence:

  1. Built-in defaults
  2. Configuration file
  3. Environment variables
  4. Command-line flags

Advanced CLI Patterns

Now let's explore advanced patterns used in production-grade CLI applications. These patterns elevate your tools from functional to professional.

Interactive Prompts

For production CLI applications, use libraries that provide rich interactive prompts with validation, default values, and user-friendly input handling. The most popular library is github.com/AlecAivazis/survey/v2.

Survey - Modern CLI Prompts

Install the survey library:

1go get github.com/AlecAivazis/survey/v2

Basic Prompts:

 1package main
 2
 3import (
 4    "fmt"
 5    "strings"
 6    "github.com/AlecAivazis/survey/v2"
 7)
 8
 9type UserInput struct {
10    Name     string
11    Email    string
12    Age      int
13    Role     string
14    Features []string
15    Confirm  bool
16}
17
18func main() {
19    var input UserInput
20
21    // Text input with validation
22    namePrompt := &survey.Input{
23        Message: "What is your name?",
24        Help:    "Enter your full name",
25    }
26    survey.AskOne(namePrompt, &input.Name, survey.WithValidator(survey.Required))
27
28    // Email input with validation
29    emailPrompt := &survey.Input{
30        Message: "Email address:",
31    }
32    survey.AskOne(emailPrompt, &input.Email,
33        survey.WithValidator(survey.Required),
34        survey.WithValidator(func(ans interface{}) error {
35            if str, ok := ans.(string); !ok || !strings.Contains(str, "@") {
36                return fmt.Errorf("invalid email format")
37            }
38            return nil
39        }),
40    )
41
42    // Select from options
43    rolePrompt := &survey.Select{
44        Message: "Choose a role:",
45        Options: []string{"Developer", "Designer", "Manager", "Other"},
46        Default: "Developer",
47    }
48    survey.AskOne(rolePrompt, &input.Role)
49
50    // Multi-select
51    featurePrompt := &survey.MultiSelect{
52        Message: "Select features to enable:",
53        Options: []string{"API Access", "Webhooks", "Analytics", "Export"},
54    }
55    survey.AskOne(featurePrompt, &input.Features)
56
57    // Confirmation
58    confirmPrompt := &survey.Confirm{
59        Message: "Proceed with these settings?",
60        Default: true,
61    }
62    survey.AskOne(confirmPrompt, &input.Confirm)
63
64    if input.Confirm {
65        fmt.Printf("\nCreating account for %s (%s)\n", input.Name, input.Email)
66        fmt.Printf("Role: %s\n", input.Role)
67        fmt.Printf("Features: %v\n", input.Features)
68    }
69}

Password Input:

 1var password string
 2prompt := &survey.Password{
 3    Message: "Enter password:",
 4    Help:    "Minimum 8 characters",
 5}
 6
 7survey.AskOne(prompt, &password, survey.WithValidator(func(ans interface{}) error {
 8    if str, ok := ans.(string); !ok || len(str) < 8 {
 9        return fmt.Errorf("password must be at least 8 characters")
10    }
11    return nil
12}))

Complete Questionnaire:

 1type Config struct {
 2    ProjectName string
 3    Framework   string
 4    Database    string
 5    Features    []string
 6}
 7
 8func collectConfig() (*Config, error) {
 9    var config Config
10
11    questions := []*survey.Question{
12        {
13            Name: "projectname",
14            Prompt: &survey.Input{
15                Message: "Project name:",
16            },
17            Validate: survey.Required,
18        },
19        {
20            Name: "framework",
21            Prompt: &survey.Select{
22                Message: "Choose framework:",
23                Options: []string{"Chi", "Gin", "Echo", "Fiber"},
24                Default: "Chi",
25            },
26        },
27        {
28            Name: "database",
29            Prompt: &survey.Select{
30                Message: "Choose database:",
31                Options: []string{"PostgreSQL", "MySQL", "SQLite", "MongoDB"},
32            },
33        },
34        {
35            Name: "features",
36            Prompt: &survey.MultiSelect{
37                Message: "Select features:",
38                Options: []string{
39                    "Authentication",
40                    "API Documentation",
41                    "Caching",
42                    "Rate Limiting",
43                    "Background Jobs",
44                },
45            },
46        },
47    }
48
49    err := survey.Ask(questions, &config)
50    return &config, err
51}

Progress Bars

For long-running operations, provide visual feedback with progress bars. Use github.com/schollz/progressbar/v3:

1go get github.com/schollz/progressbar/v3

Basic Progress Bar:

 1package main
 2
 3import (
 4    "time"
 5    "github.com/schollz/progressbar/v3"
 6)
 7
 8func main() {
 9    bar := progressbar.Default(100)
10
11    for i := 0; i < 100; i++ {
12        bar.Add(1)
13        time.Sleep(40 * time.Millisecond)
14    }
15}

Customized Progress Bar:

 1bar := progressbar.NewOptions(1000,
 2    progressbar.OptionEnableColorCodes(true),
 3    progressbar.OptionShowBytes(true),
 4    progressbar.OptionSetWidth(40),
 5    progressbar.OptionSetDescription("[cyan]Processing files...[reset]"),
 6    progressbar.OptionSetTheme(progressbar.Theme{
 7        Saucer:        "[green]=[reset]",
 8        SaucerHead:    "[green]>[reset]",
 9        SaucerPadding: " ",
10        BarStart:      "[",
11        BarEnd:        "]",
12    }),
13    progressbar.OptionShowCount(),
14    progressbar.OptionShowIts(),
15    progressbar.OptionOnCompletion(func() {
16        fmt.Println("\n✓ Complete!")
17    }),
18)
19
20for i := 0; i < 1000; i++ {
21    bar.Add(1)
22    time.Sleep(10 * time.Millisecond)
23}

File Download with Progress:

 1func downloadFile(url, filepath string) error {
 2    resp, err := http.Get(url)
 3    if err != nil {
 4        return err
 5    }
 6    defer resp.Body.Close()
 7
 8    f, err := os.Create(filepath)
 9    if err != nil {
10        return err
11    }
12    defer f.Close()
13
14    bar := progressbar.DefaultBytes(
15        resp.ContentLength,
16        "Downloading",
17    )
18
19    _, err = io.Copy(io.MultiWriter(f, bar), resp.Body)
20    return err
21}

Colored Output

Add color to your CLI output for better UX. Use github.com/fatih/color:

1go get github.com/fatih/color

Basic Colors:

 1package main
 2
 3import (
 4    "fmt"
 5    "github.com/fatih/color"
 6)
 7
 8func main() {
 9    // Basic colors
10    color.Red("Error: Connection failed")
11    color.Green("Success: File saved")
12    color.Yellow("Warning: Deprecated API")
13    color.Blue("Info: Processing started")
14
15    // Bold text
16    color.New(color.FgCyan, color.Bold).Println("Important Notice")
17
18    // Background colors
19    color.New(color.FgWhite, color.BgRed).Println("CRITICAL ERROR")
20
21    // Mix attributes
22    color.New(color.FgYellow, color.Bold, color.Underline).Println("Highlighted")
23}

Reusable Color Functions:

 1var (
 2    errorColor   = color.New(color.FgRed, color.Bold)
 3    successColor = color.New(color.FgGreen)
 4    warningColor = color.New(color.FgYellow)
 5    infoColor    = color.New(color.FgCyan)
 6)
 7
 8func printError(format string, args ...interface{}) {
 9    errorColor.Printf("✗ ERROR: "+format+"\n", args...)
10}
11
12func printSuccess(format string, args ...interface{}) {
13    successColor.Printf("✓ SUCCESS: "+format+"\n", args...)
14}
15
16func printWarning(format string, args ...interface{}) {
17    warningColor.Printf("⚠ WARNING: "+format+"\n", args...)
18}
19
20func printInfo(format string, args ...interface{}) {
21    infoColor.Printf("ℹ INFO: "+format+"\n", args...)
22}
23
24func main() {
25    printInfo("Starting application...")
26    printSuccess("Connected to database")
27    printWarning("API rate limit: 90%% used")
28    printError("Failed to load config file")
29}

Conditional Colors:

 1func setupColors() {
 2    // Respect NO_COLOR environment variable
 3    if os.Getenv("NO_COLOR") != "" {
 4        color.NoColor = true
 5    }
 6
 7    // Auto-detect terminal support
 8    if !isTerminal(os.Stdout) {
 9        color.NoColor = true
10    }
11}
12
13func isTerminal(f *os.File) bool {
14    fileInfo, err := f.Stat()
15    if err != nil {
16        return false
17    }
18    return (fileInfo.Mode() & os.ModeCharDevice) != 0
19}
20
21func main() {
22    setupColors()
23
24    color.Green("This respects NO_COLOR environment variable")
25}

Practice Exercises

Exercise 1: Word Count Tool

Learning Objectives: Master file I/O, text processing, and command-line flag handling while building a practical Unix-style tool.

Real-World Context: Word count tools like wc are essential for developers, writers, and data analysts who need to quickly analyze text files, count code lines, or process log data. This exercise teaches you the fundamentals of building efficient CLI utilities that follow Unix philosophy.

Difficulty: Intermediate | Time Estimate: 30 minutes

Build a wc-like tool that counts lines, words, and characters with support for multiple input sources and selective counting options.

Solution
 1// run
 2package main
 3
 4import (
 5    "bufio"
 6    "flag"
 7    "fmt"
 8    "io"
 9    "os"
10    "strings"
11)
12
13type Counts struct {
14    Lines      int
15    Words      int
16    Characters int
17}
18
19func count(reader io.Reader) (Counts, error) {
20    var counts Counts
21    scanner := bufio.NewScanner(reader)
22
23    for scanner.Scan() {
24        line := scanner.Text()
25        counts.Lines++
26        counts.Words += len(strings.Fields(line))
27        counts.Characters += len(line) + 1 // +1 for newline
28    }
29
30    if err := scanner.Err(); err != nil {
31        return counts, err
32    }
33
34    return counts, nil
35}
36
37func main() {
38    lines := flag.Bool("l", false, "count lines")
39    words := flag.Bool("w", false, "count words")
40    chars := flag.Bool("c", false, "count characters")
41
42    flag.Parse()
43
44    // If no flags set, show all
45    if !*lines && !*words && !*chars {
46        *lines = true
47        *words = true
48        *chars = true
49    }
50
51    var reader io.Reader = os.Stdin
52    filename := ""
53
54    if flag.NArg() > 0 {
55        filename = flag.Arg(0)
56        file, err := os.Open(filename)
57        if err != nil {
58            fmt.Fprintf(os.Stderr, "Error: %v\n", err)
59            os.Exit(1)
60        }
61        defer file.Close()
62        reader = file
63    }
64
65    counts, err := count(reader)
66    if err != nil {
67        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
68        os.Exit(1)
69    }
70
71    if *lines {
72        fmt.Printf("%8d", counts.Lines)
73    }
74    if *words {
75        fmt.Printf("%8d", counts.Words)
76    }
77    if *chars {
78        fmt.Printf("%8d", counts.Characters)
79    }
80
81    if filename != "" {
82        fmt.Printf(" %s", filename)
83    }
84    fmt.Println()
85}

Exercise 2: TODO CLI

Learning Objectives: Implement subcommands, manage persistent data storage, and handle user input validation in a command-line interface.

Real-World Context: Task management tools are ubiquitous in software development, from simple personal TODO apps to complex project management systems like Jira and Asana. This exercise introduces you to building data-driven CLI applications that maintain state across sessions.

Difficulty: Intermediate | Time Estimate: 45 minutes

Build a simple TODO list manager with add, list, and complete commands that persists data between sessions and provides a clean user experience.

Solution
  1// run
  2package main
  3
  4import (
  5    "encoding/json"
  6    "flag"
  7    "fmt"
  8    "os"
  9    "time"
 10)
 11
 12type Todo struct {
 13    ID        int       `json:"id"`
 14    Task      string    `json:"task"`
 15    Completed bool      `json:"completed"`
 16    CreatedAt time.Time `json:"created_at"`
 17}
 18
 19type TodoList struct {
 20    Todos  []Todo `json:"todos"`
 21    NextID int    `json:"next_id"`
 22}
 23
 24const todoFile = "todos.json"
 25
 26func (tl *TodoList) load() error {
 27    data, err := os.ReadFile(todoFile)
 28    if os.IsNotExist(err) {
 29        return nil
 30    }
 31    if err != nil {
 32        return err
 33    }
 34
 35    return json.Unmarshal(data, tl)
 36}
 37
 38func (tl *TodoList) save() error {
 39    data, err := json.MarshalIndent(tl, "", "  ")
 40    if err != nil {
 41        return err
 42    }
 43
 44    return os.WriteFile(todoFile, data, 0644)
 45}
 46
 47func (tl *TodoList) add(task string) {
 48    todo := Todo{
 49        ID:        tl.NextID,
 50        Task:      task,
 51        Completed: false,
 52        CreatedAt: time.Now(),
 53    }
 54    tl.Todos = append(tl.Todos, todo)
 55    tl.NextID++
 56}
 57
 58func (tl *TodoList) list(showAll bool) {
 59    for _, todo := range tl.Todos {
 60        if !showAll && todo.Completed {
 61            continue
 62        }
 63
 64        status := "[ ]"
 65        if todo.Completed {
 66            status = "[✓]"
 67        }
 68
 69        fmt.Printf("%s %d: %s\n", status, todo.ID, todo.Task)
 70    }
 71}
 72
 73func (tl *TodoList) complete(id int) bool {
 74    for i := range tl.Todos {
 75        if tl.Todos[i].ID == id {
 76            tl.Todos[i].Completed = true
 77            return true
 78        }
 79    }
 80    return false
 81}
 82
 83func main() {
 84    if len(os.Args) < 2 {
 85        fmt.Println("Usage: todo <command> [options]")
 86        fmt.Println("Commands: add, list, done")
 87        os.Exit(1)
 88    }
 89
 90    list := &TodoList{Todos: []Todo{}, NextID: 1}
 91    if err := list.load(); err != nil {
 92        fmt.Fprintf(os.Stderr, "Error loading todos: %v\n", err)
 93        os.Exit(1)
 94    }
 95
 96    switch os.Args[1] {
 97    case "add":
 98        addCmd := flag.NewFlagSet("add", flag.ExitOnError)
 99        addCmd.Parse(os.Args[2:])
100
101        if addCmd.NArg() == 0 {
102            fmt.Println("Usage: todo add <task>")
103            os.Exit(1)
104        }
105
106        task := addCmd.Arg(0)
107        list.add(task)
108        fmt.Println("Added:", task)
109
110    case "list":
111        listCmd := flag.NewFlagSet("list", flag.ExitOnError)
112        all := listCmd.Bool("a", false, "show all including completed")
113        listCmd.Parse(os.Args[2:])
114
115        list.list(*all)
116        return // No need to save
117
118    case "done":
119        doneCmd := flag.NewFlagSet("done", flag.ExitOnError)
120        doneCmd.Parse(os.Args[2:])
121
122        if doneCmd.NArg() == 0 {
123            fmt.Println("Usage: todo done <id>")
124            os.Exit(1)
125        }
126
127        var id int
128        fmt.Sscanf(doneCmd.Arg(0), "%d", &id)
129
130        if list.complete(id) {
131            fmt.Println("Marked as complete:", id)
132        } else {
133            fmt.Println("Todo not found:", id)
134            os.Exit(1)
135        }
136
137    default:
138        fmt.Printf("Unknown command: %s\n", os.Args[1])
139        os.Exit(1)
140    }
141
142    if err := list.save(); err != nil {
143        fmt.Fprintf(os.Stderr, "Error saving todos: %v\n", err)
144        os.Exit(1)
145    }
146}

Exercise 3: File Converter

Learning Objectives: Work with structured data formats, implement format detection, and handle file processing with proper error management.

Real-World Context: Data format conversion is a critical skill in modern software development. Whether you're processing configuration files, migrating data between systems, or working with APIs, the ability to convert between formats like JSON and CSV is essential. Tools like jq and csvkit solve these problems professionally.

Difficulty: Intermediate | Time Estimate: 40 minutes

Create a tool that converts between JSON and CSV formats with automatic format detection and robust error handling for malformed data.

Solution
  1// run
  2package main
  3
  4import (
  5    "encoding/csv"
  6    "encoding/json"
  7    "flag"
  8    "fmt"
  9    "os"
 10)
 11
 12func jsonToCSV(input, output string) error {
 13    // Read JSON
 14    data, err := os.ReadFile(input)
 15    if err != nil {
 16        return err
 17    }
 18
 19    var records []map[string]interface{}
 20    if err := json.Unmarshal(data, &records); err != nil {
 21        return err
 22    }
 23
 24    if len(records) == 0 {
 25        return fmt.Errorf("no records found")
 26    }
 27
 28    // Get headers from first record
 29    var headers []string
 30    for key := range records[0] {
 31        headers = append(headers, key)
 32    }
 33
 34    // Write CSV
 35    file, err := os.Create(output)
 36    if err != nil {
 37        return err
 38    }
 39    defer file.Close()
 40
 41    writer := csv.NewWriter(file)
 42    defer writer.Flush()
 43
 44    // Write headers
 45    writer.Write(headers)
 46
 47    // Write records
 48    for _, record := range records {
 49        var row []string
 50        for _, header := range headers {
 51            row = append(row, fmt.Sprintf("%v", record[header]))
 52        }
 53        writer.Write(row)
 54    }
 55
 56    return nil
 57}
 58
 59func csvToJSON(input, output string) error {
 60    // Read CSV
 61    file, err := os.Open(input)
 62    if err != nil {
 63        return err
 64    }
 65    defer file.Close()
 66
 67    reader := csv.NewReader(file)
 68    rows, err := reader.ReadAll()
 69    if err != nil {
 70        return err
 71    }
 72
 73    if len(rows) < 2 {
 74        return fmt.Errorf("CSV must have headers and at least one row")
 75    }
 76
 77    headers := rows[0]
 78    var records []map[string]string
 79
 80    for _, row := range rows[1:] {
 81        record := make(map[string]string)
 82        for i, value := range row {
 83            if i < len(headers) {
 84                record[headers[i]] = value
 85            }
 86        }
 87        records = append(records, record)
 88    }
 89
 90    // Write JSON
 91    data, err := json.MarshalIndent(records, "", "  ")
 92    if err != nil {
 93        return err
 94    }
 95
 96    return os.WriteFile(output, data, 0644)
 97}
 98
 99func main() {
100    input := flag.String("i", "", "input file")
101    output := flag.String("o", "", "output file")
102    format := flag.String("f", "", "output format (json or csv)")
103
104    flag.Parse()
105
106    if *input == "" || *output == "" || *format == "" {
107        fmt.Println("Usage: converter -i input -o output -f format")
108        fmt.Println("Format: json or csv")
109        os.Exit(1)
110    }
111
112    var err error
113    switch *format {
114    case "csv":
115        err = jsonToCSV(*input, *output)
116    case "json":
117        err = csvToJSON(*input, *output)
118    default:
119        fmt.Printf("Unknown format: %s\n", *format)
120        os.Exit(1)
121    }
122
123    if err != nil {
124        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
125        os.Exit(1)
126    }
127
128    fmt.Printf("Converted %s to %s\n", *input, *output)
129}

Exercise 4: Log Analyzer

Learning Objectives: Parse structured text, implement pattern matching with regular expressions, and generate analytical reports from log data.

Real-World Context: Log analysis is crucial for system monitoring, debugging, and security auditing. Professional tools like grep, awk, sed, and specialized log analyzers help developers and sysadmins understand system behavior, detect anomalies, and troubleshoot issues efficiently.

Difficulty: Advanced | Time Estimate: 60 minutes

Build a tool that analyzes log files and reports comprehensive statistics including error rates, status code distributions, and top IP addresses with filtering capabilities.

Solution
  1// run
  2package main
  3
  4import (
  5    "bufio"
  6    "flag"
  7    "fmt"
  8    "os"
  9    "regexp"
 10    "sort"
 11    "strings"
 12)
 13
 14type LogStats struct {
 15    TotalLines  int
 16    ErrorCount  int
 17    WarnCount   int
 18    InfoCount   int
 19    StatusCodes map[string]int
 20    IPAddresses map[string]int
 21}
 22
 23func analyzeLog(filename string, pattern string) (*LogStats, error) {
 24    file, err := os.Open(filename)
 25    if err != nil {
 26        return nil, err
 27    }
 28    defer file.Close()
 29
 30    stats := &LogStats{
 31        StatusCodes: make(map[string]int),
 32        IPAddresses: make(map[string]int),
 33    }
 34
 35    var filterRegex *regexp.Regexp
 36    if pattern != "" {
 37        filterRegex, err = regexp.Compile(pattern)
 38        if err != nil {
 39            return nil, fmt.Errorf("invalid regex pattern: %v", err)
 40        }
 41    }
 42
 43    ipRegex := regexp.MustCompile(`\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`)
 44    statusRegex := regexp.MustCompile(`\b[2-5]\d{2}\b`)
 45
 46    scanner := bufio.NewScanner(file)
 47    for scanner.Scan() {
 48        line := scanner.Text()
 49
 50        if filterRegex != nil && !filterRegex.MatchString(line) {
 51            continue
 52        }
 53
 54        stats.TotalLines++
 55
 56        lineLower := strings.ToLower(line)
 57        if strings.Contains(lineLower, "error") {
 58            stats.ErrorCount++
 59        }
 60        if strings.Contains(lineLower, "warn") {
 61            stats.WarnCount++
 62        }
 63        if strings.Contains(lineLower, "info") {
 64            stats.InfoCount++
 65        }
 66
 67        // Extract status codes
 68        if matches := statusRegex.FindAllString(line, -1); matches != nil {
 69            for _, code := range matches {
 70                stats.StatusCodes[code]++
 71            }
 72        }
 73
 74        // Extract IP addresses
 75        if matches := ipRegex.FindAllString(line, -1); matches != nil {
 76            for _, ip := range matches {
 77                stats.IPAddresses[ip]++
 78            }
 79        }
 80    }
 81
 82    if err := scanner.Err(); err != nil {
 83        return nil, err
 84    }
 85
 86    return stats, nil
 87}
 88
 89func printStats(stats *LogStats, topN int) {
 90    fmt.Println("Log Analysis Results")
 91    fmt.Println("====================")
 92    fmt.Printf("Total lines: %d\n", stats.TotalLines)
 93    fmt.Printf("Errors: %d\n", stats.ErrorCount)
 94    fmt.Printf("Warnings: %d\n", stats.WarnCount)
 95    fmt.Printf("Info: %d\n", stats.InfoCount)
 96
 97    if len(stats.StatusCodes) > 0 {
 98        fmt.Println("\nHTTP Status Codes:")
 99        for code, count := range stats.StatusCodes {
100            fmt.Printf("  %s: %d\n", code, count)
101        }
102    }
103
104    if len(stats.IPAddresses) > 0 {
105        fmt.Printf("\nTop %d IP Addresses:\n", topN)
106
107        type ipCount struct {
108            IP    string
109            Count int
110        }
111
112        var ips []ipCount
113        for ip, count := range stats.IPAddresses {
114            ips = append(ips, ipCount{ip, count})
115        }
116
117        sort.Slice(ips, func(i, j int) bool {
118            return ips[i].Count > ips[j].Count
119        })
120
121        for i := 0; i < topN && i < len(ips); i++ {
122            fmt.Printf("  %s: %d\n", ips[i].IP, ips[i].Count)
123        }
124    }
125}
126
127func main() {
128    file := flag.String("f", "", "log file to analyze")
129    pattern := flag.String("p", "", "filter pattern (regex)")
130    topN := flag.Int("top", 10, "number of top IPs to show")
131
132    flag.Parse()
133
134    if *file == "" {
135        fmt.Println("Usage: loganalyzer -f logfile [-p pattern] [-top N]")
136        os.Exit(1)
137    }
138
139    stats, err := analyzeLog(*file, *pattern)
140    if err != nil {
141        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
142        os.Exit(1)
143    }
144
145    printStats(stats, *topN)
146}

Exercise 5: Environment Configuration Manager

Learning Objectives: Manage environment-specific configurations, implement secure credential handling, and create flexible deployment utilities.

Real-World Context: Configuration management is fundamental to modern application deployment. Tools like dotenv, consul-template, and Kubernetes ConfigMaps solve the challenge of managing different configurations across development, staging, and production environments while keeping sensitive data secure.

Difficulty: Advanced | Time Estimate: 75 minutes

Build a comprehensive configuration management tool that handles environment variables, supports multiple deployment environments, encrypts sensitive data, and validates configuration completeness before application startup.

Solution
  1// run
  2package main
  3
  4import (
  5	"crypto/aes"
  6	"crypto/cipher"
  7	"crypto/rand"
  8	"encoding/base64"
  9	"encoding/json"
 10	"flag"
 11	"fmt"
 12	"io"
 13	"os"
 14	"path/filepath"
 15	"strings"
 16)
 17
 18type ConfigValue struct {
 19	Value     string `json:"value"`
 20	Encrypted bool   `json:"encrypted"`
 21	Required  bool   `json:"required"`
 22	Default   string `json:"default"`
 23	Env       string `json:"env"`
 24}
 25
 26type Environment struct {
 27	Name        string            `json:"name"`
 28	Values      map[string]string `json:"values"`
 29	Description string            `json:"description"`
 30}
 31
 32type ConfigManager struct {
 33	Environments  map[string]Environment  `json:"environments"`
 34	GlobalValues  map[string]ConfigValue  `json:"global_values"`
 35	EncryptionKey []byte                  `json:"-"`
 36	ConfigFile    string                  `json:"-"`
 37	CurrentEnv    string                  `json:"-"`
 38}
 39
 40func NewConfigManager(configFile string) *ConfigManager {
 41	return &ConfigManager{
 42		Environments: make(map[string]Environment),
 43		GlobalValues: make(map[string]ConfigValue),
 44		ConfigFile:   configFile,
 45	}
 46}
 47
 48func (cm *ConfigManager) Load() error {
 49	data, err := os.ReadFile(cm.ConfigFile)
 50	if os.IsNotExist(err) {
 51		return cm.createDefaultConfig()
 52	}
 53	if err != nil {
 54		return fmt.Errorf("failed to read config file: %w", err)
 55	}
 56
 57	if err := json.Unmarshal(data, cm); err != nil {
 58		return fmt.Errorf("failed to parse config file: %w", err)
 59	}
 60
 61	key := os.Getenv("CONFIG_ENCRYPTION_KEY")
 62	if key == "" {
 63		keyFile := filepath.Join(filepath.Dir(cm.ConfigFile), ".config_key")
 64		if keyData, err := os.ReadFile(keyFile); err == nil {
 65			key = string(keyData)
 66		}
 67	}
 68
 69	if key != "" {
 70		cm.EncryptionKey = []byte(key)
 71	}
 72
 73	return nil
 74}
 75
 76func (cm *ConfigManager) Save() error {
 77	data, err := json.MarshalIndent(cm, "", "  ")
 78	if err != nil {
 79		return fmt.Errorf("failed to marshal config: %w", err)
 80	}
 81
 82	return os.WriteFile(cm.ConfigFile, data, 0600)
 83}
 84
 85func (cm *ConfigManager) createDefaultConfig() error {
 86	cm.Environments = map[string]Environment{
 87		"development": {
 88			Name:        "development",
 89			Values:      map[string]string{},
 90			Description: "Local development environment",
 91		},
 92		"staging": {
 93			Name:        "staging",
 94			Values:      map[string]string{},
 95			Description: "Staging environment for testing",
 96		},
 97		"production": {
 98			Name:        "production",
 99			Values:      map[string]string{},
100			Description: "Production environment",
101		},
102	}
103
104	cm.GlobalValues = map[string]ConfigValue{
105		"DATABASE_URL": {
106			Value:     "localhost:5432",
107			Required:  true,
108			Env:       "DATABASE_URL",
109			Encrypted: false,
110		},
111		"API_KEY": {
112			Required:  true,
113			Env:       "API_KEY",
114			Encrypted: true,
115		},
116		"DEBUG": {
117			Value:     "false",
118			Required:  false,
119			Default:   "false",
120			Env:       "DEBUG",
121			Encrypted: false,
122		},
123	}
124
125	return cm.Save()
126}
127
128func (cm *ConfigManager) encrypt(plaintext string) (string, error) {
129	if len(cm.EncryptionKey) != 32 {
130		return "", fmt.Errorf("encryption key must be 32 bytes")
131	}
132
133	block, err := aes.NewCipher(cm.EncryptionKey)
134	if err != nil {
135		return "", err
136	}
137
138	gcm, err := cipher.NewGCM(block)
139	if err != nil {
140		return "", err
141	}
142
143	nonce := make([]byte, gcm.NonceSize())
144	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
145		return "", err
146	}
147
148	ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
149	return base64.StdEncoding.EncodeToString(ciphertext), nil
150}
151
152func (cm *ConfigManager) decrypt(ciphertext string) (string, error) {
153	if len(cm.EncryptionKey) != 32 {
154		return "", fmt.Errorf("encryption key must be 32 bytes")
155	}
156
157	data, err := base64.StdEncoding.DecodeString(ciphertext)
158	if err != nil {
159		return "", err
160	}
161
162	block, err := aes.NewCipher(cm.EncryptionKey)
163	if err != nil {
164		return "", err
165	}
166
167	gcm, err := cipher.NewGCM(block)
168	if err != nil {
169		return "", err
170	}
171
172	nonceSize := gcm.NonceSize()
173	if len(data) < nonceSize {
174		return "", fmt.Errorf("ciphertext too short")
175	}
176
177	nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
178	plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
179	if err != nil {
180		return "", err
181	}
182
183	return string(plaintext), nil
184}
185
186func (cm *ConfigManager) SetValue(env, key, value string, encrypt bool) error {
187	if env == "global" {
188		config := cm.GlobalValues[key]
189		config.Value = value
190		config.Encrypted = encrypt
191		cm.GlobalValues[key] = config
192	} else {
193		if _, exists := cm.Environments[env]; !exists {
194			return fmt.Errorf("environment %s does not exist", env)
195		}
196		cm.Environments[env].Values[key] = value
197	}
198	return nil
199}
200
201func (cm *ConfigManager) GetValue(env, key string) (string, error) {
202	if env != "global" {
203		if e, exists := cm.Environments[env]; exists {
204			if value, exists := e.Values[key]; exists {
205				return value, nil
206			}
207		}
208	}
209
210	if config, exists := cm.GlobalValues[key]; exists {
211		value := config.Value
212
213		if config.Env != "" {
214			if envValue := os.Getenv(config.Env); envValue != "" {
215				value = envValue
216			}
217		}
218
219		if value == "" && config.Default != "" {
220			value = config.Default
221		}
222
223		if config.Encrypted && value != "" {
224			return cm.decrypt(value)
225		}
226
227		return value, nil
228	}
229
230	return "", fmt.Errorf("key %s not found", key)
231}
232
233func (cm *ConfigManager) ValidateEnvironment(env string) []error {
234	var errors []error
235
236	if env != "global" {
237		if _, exists := cm.Environments[env]; !exists {
238			errors = append(errors, fmt.Errorf("environment %s does not exist", env))
239			return errors
240		}
241	}
242
243	for key, config := range cm.GlobalValues {
244		if config.Required {
245			value, err := cm.GetValue(env, key)
246			if err != nil || value == "" {
247				errors = append(errors, fmt.Errorf("required value %s is missing or empty", key))
248			}
249		}
250	}
251
252	return errors
253}
254
255func (cm *ConfigManager) ExportEnvironment(env string, format string) error {
256	output := make(map[string]string)
257
258	if env == "global" {
259		for key := range cm.GlobalValues {
260			if value, err := cm.GetValue(env, key); err == nil {
261				output[key] = value
262			}
263		}
264	} else {
265		if e, exists := cm.Environments[env]; exists {
266			output = e.Values
267		} else {
268			return fmt.Errorf("environment %s does not exist", env)
269		}
270	}
271
272	switch strings.ToLower(format) {
273	case "env":
274		for key, value := range output {
275			fmt.Printf("export %s=%q\n", key, value)
276		}
277	case "json":
278		data, _ := json.MarshalIndent(output, "", "  ")
279		fmt.Println(string(data))
280	case "dotenv":
281		for key, value := range output {
282			fmt.Printf("%s=%s\n", key, value)
283		}
284	default:
285		return fmt.Errorf("unsupported format: %s", format)
286	}
287
288	return nil
289}
290
291func main() {
292	configFile := flag.String("config", "config.json", "configuration file path")
293	env := flag.String("env", "development", "environment to use")
294	action := flag.String("action", "validate", "action: validate, set, get, export, list")
295	key := flag.String("key", "", "configuration key")
296	value := flag.String("value", "", "configuration value")
297	encrypt := flag.Bool("encrypt", false, "encrypt the value")
298	format := flag.String("format", "env", "export format: env, json, dotenv")
299	listEnvs := flag.Bool("list-envs", false, "list all environments")
300
301	flag.Parse()
302
303	cm := NewConfigManager(*configFile)
304	if err := cm.Load(); err != nil {
305		fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
306		os.Exit(1)
307	}
308
309	switch *action {
310	case "validate":
311		errors := cm.ValidateEnvironment(*env)
312		if len(errors) > 0 {
313			fmt.Printf("Configuration validation failed for %s:\n", *env)
314			for _, err := range errors {
315				fmt.Printf("  - %v\n", err)
316			}
317			os.Exit(1)
318		}
319		fmt.Printf("✓ Configuration is valid for %s\n", *env)
320
321	case "set":
322		if *key == "" || *value == "" {
323			fmt.Println("Both key and value are required for set action")
324			os.Exit(1)
325		}
326		if err := cm.SetValue(*env, *key, *value, *encrypt); err != nil {
327			fmt.Fprintf(os.Stderr, "Error setting value: %v\n", err)
328			os.Exit(1)
329		}
330		if err := cm.Save(); err != nil {
331			fmt.Fprintf(os.Stderr, "Error saving config: %v\n", err)
332			os.Exit(1)
333		}
334		fmt.Printf("Set %s.%s = %s\n", *env, *key, *value)
335
336	case "get":
337		if *key == "" {
338			fmt.Println("Key is required for get action")
339			os.Exit(1)
340		}
341		value, err := cm.GetValue(*env, *key)
342		if err != nil {
343			fmt.Fprintf(os.Stderr, "Error getting value: %v\n", err)
344			os.Exit(1)
345		}
346		fmt.Printf("%s.%s = %s\n", *env, *key, value)
347
348	case "export":
349		if err := cm.ExportEnvironment(*env, *format); err != nil {
350			fmt.Fprintf(os.Stderr, "Error exporting: %v\n", err)
351			os.Exit(1)
352		}
353
354	case "list":
355		if *listEnvs {
356			fmt.Println("Environments:")
357			for name, e := range cm.Environments {
358				fmt.Printf("  %s: %s\n", name, e.Description)
359			}
360		} else {
361			fmt.Printf("Configuration for %s:\n", *env)
362			if *env == "global" {
363				for key, config := range cm.GlobalValues {
364					status := "plaintext"
365					if config.Encrypted {
366						status = "encrypted"
367					}
368					fmt.Printf("  %s: (%s)\n", key, status)
369				}
370			} else {
371				if e, exists := cm.Environments[*env]; exists {
372					for key, value := range e.Values {
373						fmt.Printf("  %s: %s\n", key, value)
374					}
375				}
376			}
377		}
378
379	default:
380		fmt.Printf("Unknown action: %s\n", *action)
381		os.Exit(1)
382	}
383}

Summary

  • Use flag package for command-line argument parsing
  • flag.String(), flag.Int(), flag.Bool() for basic types
  • flag.StringVar() to bind to existing variables
  • flag.Args() for positional arguments after flags
  • Support subcommands with flag.NewFlagSet() for complex CLIs
  • Read from stdin/stdout for composability and Unix philosophy
  • Use proper exit codes (0 for success, non-zero for errors)
  • Combine flags with environment variables for flexible configuration
  • Provide clear help messages and comprehensive error output
  • Test with various inputs, edge cases, and failure scenarios
  • Consider CLI frameworks (Cobra, urfave/cli) for complex applications
  • Add interactive features (prompts, progress bars, colors) for better UX
  • Follow best practices: validate early, fail loudly, respect user environment

Build robust CLI tools that follow Unix philosophy, provide excellent user experience, and integrate well with other tools and automation systems!