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:
-
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
-
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
-
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
-
Compile-Time Type Safety - Catch errors before deployment, not in production
- Find bugs during development
- Refactor with confidence
- No runtime type errors
-
Memory Efficiency - No interpreter overhead, ideal for high-performance tools
- Lower memory footprint
- Predictable performance
- No garbage collection pauses during critical operations
-
Built-in Concurrency - Goroutines make parallel processing trivial
- Process multiple files simultaneously
- Handle background tasks efficiently
- Scale to available CPU cores
-
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:
- User Base: Developers vs. system administrators vs. end users
- Execution Frequency: One-off scripts vs. continuous monitoring
- Performance Requirements: Interactive vs. batch processing
- Integration Needs: Standalone vs. part of a larger ecosystem
- 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
-vfor verbose,-hfor 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
-hor--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:
- It's always available (part of the standard library)
- It has zero dependencies
- It's sufficient for many CLI tools
- 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:
- Defines expected flags and their types
- Parses
os.Argsto extract flag values - Validates types and constraints
- Provides structured access to values
- 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:
-
flag.String("name", "World", "name to greet")defines a string flag:"name"is the flag name (used as-nameor--name)"World"is the default value"name to greet"is the help description- Returns a
*stringpointer to the value
-
flag.Parse()processesos.Argsand populates flag values -
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:
- Configuration structs: Populate config objects directly
- Global variables: Bind to package-level variables
- Type consistency: Keep your variable types consistent throughout code
- 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:
-
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 -
Double dash (
--) explicitly ends flag parsing:1$ ./app -v -- -file-with-dash.txt # -file-with-dash.txt is arg, not flag -
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:
- Clear structure: Separate concerns (parsing, validation, execution)
- Error handling: Graceful failures with helpful messages
- Configuration: Support multiple input sources
- Validation: Check inputs before processing
- 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:
- Configuration struct: Centralizes all settings
- Separate
runfunction: Main logic isolated from flag parsing - Validation before execution: Check inputs early
- Error wrapping: Use
%wfor error context - Graceful error handling: Continue on minor errors, fail on critical ones
- 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:
githas 160+ subcommands (add,commit,push,pull, etc.)dockerorganizes features into subcommands (run,build,ps,logs)kubectlgroups Kubernetes operations (get,apply,delete,describe)
Subcommands provide:
- Logical grouping of related features
- Discoverability - users can explore capabilities
- Extensibility - easy to add new commands
- 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:
- Clear help messages: Both global and per-command
- Input validation: Check required fields
- Confirmation prompts: For destructive operations
- Custom flag sets: Isolated flags per command
- 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?
- Security: Sensitive data doesn't appear in command history or process lists
- Deployment flexibility: Different configs for dev/staging/prod without code changes
- Convention: Standard practice in cloud platforms (Heroku, AWS, Docker)
- Overridability: Users can customize behavior without changing code
- 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):
- Command-line flags (explicit user intent)
- Environment variables (deployment-specific config)
- Configuration files (persistent settings)
- 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:
- Never commit .env files - add to
.gitignore - Use secrets management - Vault, AWS Secrets Manager for production
- Validate all inputs - Never trust environment variables
- Document required variables - Include .env.example in your repo
- 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:
- Write programs that do one thing and do it well
- Write programs to work together
- 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:
- Default to stdout: Let users redirect output themselves
- Status messages to stderr: User-facing info goes to stderr, data to stdout
- Confirm file operations: Tell users when files are created
- 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: Success1: General error2: Misuse of shell command (invalid arguments)126: Command cannot execute127: Command not found128+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:
- Separation of concerns:
main()only handles exit codes - Testability:
realMain()returns int, easy to test - Proper cleanup:
deferworks correctly (doesn't work withos.Exit) - 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:
- Command-line flags (highest)
- Environment variables
- Configuration files
- 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
gitordocker) - 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:
-
Cobra (github.com/spf13/cobra)
- Used by: Kubernetes, Hugo, GitHub CLI
- Pros: Rich features, great docs, shell completion
- Cons: Larger dependency tree
-
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
-
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
flagfor 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:
- Built-in defaults
- Configuration file
- Environment variables
- 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
flagpackage for command-line argument parsing flag.String(),flag.Int(),flag.Bool()for basic typesflag.StringVar()to bind to existing variablesflag.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!