The command line interface (CLI) serves as the primary interaction point for many Go applications. Creating an intuitive and robust CLI can significantly improve user experience and adoption rates. I’ve implemented numerous CLI applications in Go and found that mastering command-line parsing is essential for building professional tools. Let me share the most effective techniques I’ve discovered.
The Standard Library Approach: flag Package
Go’s standard library provides the flag package, which offers a straightforward approach to command-line parsing. This package handles the basics well and is sufficient for many applications.
package main
import (
"flag"
"fmt"
)
func main() {
// Define flags with name, default value, and usage description
port := flag.Int("port", 8080, "Port to serve on")
host := flag.String("host", "localhost", "Host address to bind to")
verbose := flag.Bool("verbose", false, "Enable verbose logging")
// Parse the command line into the defined flags
flag.Parse()
// Access the values via dereferencing the pointers
fmt.Printf("Server will start at %s:%d\n", *host, *port)
if *verbose {
fmt.Println("Verbose logging enabled")
}
// Any remaining arguments are available through flag.Args()
if len(flag.Args()) > 0 {
fmt.Println("Additional arguments:", flag.Args())
}
}
The flag package supports variable binding for more readable code. This approach is particularly useful when you have many flags:
package main
import (
"flag"
"fmt"
)
func main() {
var (
port int
host string
verbose bool
)
// Bind flags to variables
flag.IntVar(&port, "port", 8080, "Port to serve on")
flag.StringVar(&host, "host", "localhost", "Host address to bind to")
flag.BoolVar(&verbose, "verbose", false, "Enable verbose logging")
flag.Parse()
fmt.Printf("Server will start at %s:%d\n", host, port)
if verbose {
fmt.Println("Verbose logging enabled")
}
}
Implementing Subcommands
Many modern CLI tools use subcommands to organize functionality. Git and Docker are prime examples with commands like git commit
or docker run
. While the standard flag package doesn’t directly support subcommands, they can be implemented with a bit of extra code:
package main
import (
"flag"
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Expected subcommand")
os.Exit(1)
}
// First argument is the subcommand
switch os.Args[1] {
case "serve":
serveCmd := flag.NewFlagSet("serve", flag.ExitOnError)
port := serveCmd.Int("port", 8080, "Port to serve on")
serveCmd.Parse(os.Args[2:])
fmt.Printf("Starting server on port %d\n", *port)
case "config":
configCmd := flag.NewFlagSet("config", flag.ExitOnError)
showAll := configCmd.Bool("all", false, "Show all configurations")
configCmd.Parse(os.Args[2:])
fmt.Printf("Showing config (all=%v)\n", *showAll)
default:
fmt.Printf("Unknown subcommand: %s\n", os.Args[1])
os.Exit(1)
}
}
Enhanced Flag Management with Environment Variables
In containerized environments, configuration through environment variables is common. A robust CLI should check command line flags first, then fall back to environment variables:
package main
import (
"flag"
"fmt"
"os"
"strconv"
)
func main() {
// Define flags
portFlag := flag.Int("port", 0, "Port to serve on")
// Parse command line
flag.Parse()
// Determine the port with precedence:
// 1. Command line flag
// 2. Environment variable
// 3. Default value
port := *portFlag
if port == 0 {
if envPort := os.Getenv("APP_PORT"); envPort != "" {
if p, err := strconv.Atoi(envPort); err == nil {
port = p
}
}
}
// If still not set, use default
if port == 0 {
port = 8080
}
fmt.Printf("Starting server on port %d\n", port)
}
Custom Flag Types for Domain-Specific Parsing
The flag package supports custom types through the flag.Value interface. This is particularly useful for parsing complex values directly into application-specific types:
package main
import (
"flag"
"fmt"
"strings"
"time"
)
// Duration list type that implements flag.Value
type durationList []time.Duration
func (d *durationList) String() string {
return fmt.Sprint(*d)
}
func (d *durationList) Set(value string) error {
duration, err := time.ParseDuration(value)
if err != nil {
return err
}
*d = append(*d, duration)
return nil
}
// Key-value map type that implements flag.Value
type keyValueMap map[string]string
func (kv keyValueMap) String() string {
pairs := make([]string, 0, len(kv))
for k, v := range kv {
pairs = append(pairs, fmt.Sprintf("%s=%s", k, v))
}
return strings.Join(pairs, ",")
}
func (kv keyValueMap) Set(value string) error {
parts := strings.SplitN(value, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("expected key=value format")
}
kv[parts[0]] = parts[1]
return nil
}
func main() {
var intervals durationList
var metadata = make(keyValueMap)
flag.Var(&intervals, "interval", "Add time intervals (can be specified multiple times)")
flag.Var(metadata, "meta", "Add metadata key-value pairs (can be specified multiple times)")
flag.Parse()
fmt.Println("Intervals:", intervals)
fmt.Println("Metadata:", metadata)
}
Argument Validation and Error Handling
Robust CLI applications validate arguments early and provide meaningful error messages:
package main
import (
"errors"
"flag"
"fmt"
"os"
)
func main() {
var (
port int
dataDir string
)
flag.IntVar(&port, "port", 8080, "Port to serve on (1024-65535)")
flag.StringVar(&dataDir, "data-dir", "./data", "Directory to store data")
flag.Parse()
// Validate arguments after parsing
if err := validateArgs(port, dataDir); err != nil {
fmt.Fprintf(os.Stderr, "Error: %s\n\n", err)
flag.Usage()
os.Exit(1)
}
fmt.Printf("Starting server on port %d using data directory %s\n", port, dataDir)
}
func validateArgs(port int, dataDir string) error {
if port < 1024 || port > 65535 {
return errors.New("port must be between 1024 and 65535")
}
// Check if data directory exists
info, err := os.Stat(dataDir)
if err != nil {
return fmt.Errorf("data directory error: %w", err)
}
if !info.IsDir() {
return errors.New("data-dir must be a directory")
}
return nil
}
Interactive Prompting for Missing Arguments
CLI tools become more user-friendly when they can prompt for missing information instead of failing:
package main
import (
"bufio"
"flag"
"fmt"
"os"
"strings"
)
func main() {
var (
username string
password string
)
flag.StringVar(&username, "user", "", "Username for authentication")
flag.StringVar(&password, "pass", "", "Password for authentication")
flag.Parse()
// Prompt for username if not provided
if username == "" {
fmt.Print("Enter username: ")
reader := bufio.NewReader(os.Stdin)
username, _ = reader.ReadString('\n')
username = strings.TrimSpace(username)
}
// Prompt for password if not provided
if password == "" {
fmt.Print("Enter password: ")
reader := bufio.NewReader(os.Stdin)
password, _ = reader.ReadString('\n')
password = strings.TrimSpace(password)
}
fmt.Printf("Authenticating as %s...\n", username)
}
Comprehensive Usage Documentation
Professional CLIs provide clear documentation. Customize the flag package’s usage output for better help messages:
package main
import (
"flag"
"fmt"
"os"
)
func main() {
// Customize usage output
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [options] [arguments]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "A tool for processing data files.\n\n")
fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " %s --input=data.json --output=result.csv\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s --format=yaml data.json\n", os.Args[0])
}
var (
input string
output string
format string
)
flag.StringVar(&input, "input", "", "Input file to process")
flag.StringVar(&output, "output", "", "Output file (defaults to stdout)")
flag.StringVar(&format, "format", "json", "Format of the output (json, csv, yaml)")
flag.Parse()
// Logic would go here
fmt.Printf("Processing %s to %s in %s format\n", input, output, format)
}
Exit Code Management
Exit codes communicate program status to calling processes and scripts. Establish consistent exit codes for different scenarios:
package main
import (
"errors"
"flag"
"fmt"
"os"
)
// Define exit codes
const (
ExitSuccess = 0
ExitInvalidArgs = 1
ExitFileAccessError = 2
ExitProcessingError = 3
ExitNetworkError = 4
)
func main() {
var filename string
flag.StringVar(&filename, "file", "", "File to process")
flag.Parse()
if filename == "" {
fmt.Fprintln(os.Stderr, "Error: file argument is required")
flag.Usage()
os.Exit(ExitInvalidArgs)
}
if err := processFile(filename); err != nil {
var exitCode int
// Determine appropriate exit code based on error type
switch {
case errors.Is(err, os.ErrNotExist):
fmt.Fprintf(os.Stderr, "Error: file %s does not exist\n", filename)
exitCode = ExitFileAccessError
case errors.Is(err, os.ErrPermission):
fmt.Fprintf(os.Stderr, "Error: permission denied for file %s\n", filename)
exitCode = ExitFileAccessError
default:
fmt.Fprintf(os.Stderr, "Error processing file: %v\n", err)
exitCode = ExitProcessingError
}
os.Exit(exitCode)
}
os.Exit(ExitSuccess)
}
func processFile(filename string) error {
// File processing logic would go here
// This is a dummy implementation that checks if file exists
_, err := os.Stat(filename)
return err
}
Popular Third-Party Libraries
While the standard library’s flag package works well for simple cases, several third-party libraries offer more sophisticated features:
Cobra
Cobra is a complete CLI framework that provides subcommands, automatic help generation, and shell completions:
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func main() {
var port int
var verbose bool
// Root command
rootCmd := &cobra.Command{
Use: "myapp",
Short: "A brief description of your application",
Long: "A longer description that spans multiple lines",
}
// Serve subcommand
serveCmd := &cobra.Command{
Use: "serve",
Short: "Start the server",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Starting server on port %d\n", port)
if verbose {
fmt.Println("Verbose mode enabled")
}
},
}
serveCmd.Flags().IntVarP(&port, "port", "p", 8080, "Port to serve on")
serveCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output")
rootCmd.AddCommand(serveCmd)
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
urfave/cli
This library offers a balance of simplicity and power:
package main
import (
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "myapp",
Usage: "fight the loneliness!",
Action: func(c *cli.Context) error {
fmt.Println("Hello friend!")
return nil
},
Commands: []*cli.Command{
{
Name: "serve",
Aliases: []string{"s"},
Usage: "start the server",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "port",
Aliases: []string{"p"},
Value: 8080,
Usage: "port to serve on",
},
},
Action: func(c *cli.Context) error {
port := c.Int("port")
fmt.Printf("Starting server on port %d\n", port)
return nil
},
},
},
}
err := app.Run(os.Args)
if err != nil {
log.Fatal(err)
}
}
Practical Tips from Personal Experience
After building numerous CLI applications in Go, I’ve learned several practical lessons:
-
Start with the standard library flag package for simple applications. Only move to third-party libraries when you need more advanced features.
-
Always provide sensible defaults for optional flags. Users should be able to run your application with minimal configuration.
-
Use consistent flag naming conventions. For example, prefer kebab-case (
--output-file
) over camelCase (--outputFile
). -
Support both short and long flag forms for common operations (e.g.,
-v
and--verbose
). -
When prompting for input, consider security implications. Use a password reader for sensitive information.
-
Include versions in your CLI applications. This helps with troubleshooting and ensures users are running the expected version.
-
Consider implementing shell completions for complex CLIs. Libraries like cobra can generate completions for bash, zsh, and other shells.
-
Test your CLI with different input combinations, including edge cases and invalid inputs.
The command line remains an essential interface for many Go applications, especially in server, DevOps, and data processing contexts. By implementing these parsing techniques, you can create professional-grade CLIs that users find intuitive and powerful. The investment in a well-designed command-line interface pays dividends in user adoption and satisfaction.