Go has become a popular choice for building command-line interface (CLI) applications due to its simplicity, performance, and rich ecosystem. I’ve worked with numerous CLI tools in production environments and found certain patterns consistently lead to better user experiences and maintainable codebases.
Command Argument Parsing
The foundation of any CLI application is proper argument parsing. While Go’s standard library includes the flag
package, more sophisticated applications often benefit from third-party libraries.
package main
import (
"fmt"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "fileutil",
Usage: "file manipulation utilities",
Commands: []*cli.Command{
{
Name: "compress",
Aliases: []string{"c"},
Usage: "compress a file",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "algorithm",
Aliases: []string{"a"},
Value: "gzip",
Usage: "compression algorithm to use",
Required: false,
},
},
Action: func(c *cli.Context) error {
filename := c.Args().First()
algorithm := c.String("algorithm")
fmt.Printf("Compressing %s using %s\n", filename, algorithm)
return nil
},
},
},
}
if err := app.Run(os.Args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
os.Exit(1)
}
}
Cobra is another excellent choice for complex command hierarchies:
package main
import (
"fmt"
"github.com/spf13/cobra"
)
func main() {
var algorithm string
compressCmd := &cobra.Command{
Use: "compress [file]",
Short: "Compress a file",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Compressing %s using %s\n", args[0], algorithm)
},
}
compressCmd.Flags().StringVarP(&algorithm, "algorithm", "a", "gzip", "compression algorithm to use")
rootCmd := &cobra.Command{Use: "fileutil"}
rootCmd.AddCommand(compressCmd)
rootCmd.Execute()
}
Input Validation
Validating user input prevents runtime errors and improves user experience. I always implement validation logic early in the command processing chain.
func validateFilePath(path string) error {
if path == "" {
return errors.New("file path cannot be empty")
}
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("file does not exist: %s", path)
}
return fmt.Errorf("error accessing file: %w", err)
}
if info.IsDir() {
return fmt.Errorf("path is a directory, not a file: %s", path)
}
return nil
}
func compressFile(c *cli.Context) error {
filePath := c.Args().First()
if err := validateFilePath(filePath); err != nil {
return err
}
// Proceed with compression...
return nil
}
For sensitive inputs like passwords, use terminal packages to mask input:
import "golang.org/x/term"
func getPassword() (string, error) {
fmt.Print("Enter password: ")
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println() // Add newline after password input
if err != nil {
return "", err
}
return string(passwordBytes), nil
}
Progress Indicators
Long-running operations benefit from progress indicators to assure users the application hasn’t stalled.
package main
import (
"time"
"github.com/schollz/progressbar/v3"
)
func main() {
totalItems := 1000
bar := progressbar.Default(int64(totalItems))
for i := 0; i < totalItems; i++ {
bar.Add(1)
time.Sleep(5 * time.Millisecond)
}
}
For indeterminate operations, spinners provide feedback without showing percentage:
package main
import (
"time"
"github.com/briandowns/spinner"
)
func main() {
s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
s.Prefix = "Processing "
s.Start()
// Simulate work
time.Sleep(3 * time.Second)
s.Stop()
fmt.Println("Done!")
}
Graceful Shutdown Handling
A well-designed CLI responds properly to termination signals:
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
// Set up signal handling
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-signalChan
fmt.Println("\nReceived termination signal")
cancel() // Cancel the context
}()
if err := runLongOperation(ctx); err != nil {
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
os.Exit(1)
}
}
func runLongOperation(ctx context.Context) error {
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
fmt.Println("Performing cleanup...")
time.Sleep(500 * time.Millisecond)
fmt.Println("Cleanup complete")
return nil
default:
fmt.Printf("Working... step %d/10\n", i+1)
time.Sleep(1 * time.Second)
}
}
return nil
}
Configuration Management
Supporting multiple configuration sources makes applications flexible across environments:
package main
import (
"fmt"
"github.com/spf13/viper"
)
func main() {
// Set defaults
viper.SetDefault("port", 8080)
viper.SetDefault("database.timeout", 30)
// Config file support
viper.SetConfigName("config") // name of config file (without extension)
viper.SetConfigType("yaml") // type of config file
viper.AddConfigPath("/etc/myapp/") // system-wide config
viper.AddConfigPath("$HOME/.myapp") // user-specific config
viper.AddConfigPath(".") // current directory
// Read environment variables
viper.AutomaticEnv()
viper.SetEnvPrefix("MYAPP") // will be uppercased automatically
// Read config file
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
fmt.Printf("Error reading config file: %s\n", err)
}
}
// Display configuration
fmt.Printf("Port: %d\n", viper.GetInt("port"))
fmt.Printf("Database Timeout: %d seconds\n", viper.GetInt("database.timeout"))
}
A sample config.yaml file might look like:
port: 9090
database:
timeout: 60
host: localhost
username: appuser
Interactive Prompts
For complex operations, interactive prompts guide users through choices:
package main
import (
"fmt"
"github.com/AlecAivazis/survey/v2"
)
func main() {
var deployEnv string
var features []string
var confirm bool
// Ask for environment
prompt := &survey.Select{
Message: "Choose deployment environment:",
Options: []string{"development", "staging", "production"},
}
survey.AskOne(prompt, &deployEnv)
// Multiple selection
multiPrompt := &survey.MultiSelect{
Message: "Select features to enable:",
Options: []string{"analytics", "authentication", "notifications", "reporting"},
}
survey.AskOne(multiPrompt, &features)
// Confirmation
confirmPrompt := &survey.Confirm{
Message: fmt.Sprintf("Deploy to %s with %d features?", deployEnv, len(features)),
}
survey.AskOne(confirmPrompt, &confirm)
if confirm {
fmt.Println("Deploying...")
} else {
fmt.Println("Deployment canceled")
}
}
Proper Exit Codes
Using meaningful exit codes helps automation tools understand command outcomes:
package main
import (
"errors"
"fmt"
"os"
)
const (
ExitSuccess = 0
ExitFailure = 1
ExitInvalidInput = 2
ExitFileError = 3
ExitNetworkError = 4
ExitPermissionDenied = 5
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "Error: Missing required argument")
os.Exit(ExitInvalidInput)
}
if err := processFile(os.Args[1]); err != nil {
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
var exitCode int
switch {
case errors.Is(err, os.ErrNotExist):
exitCode = ExitFileError
case errors.Is(err, os.ErrPermission):
exitCode = ExitPermissionDenied
default:
exitCode = ExitFailure
}
os.Exit(exitCode)
}
os.Exit(ExitSuccess)
}
func processFile(path string) error {
// Implementation
return nil
}
Output Formatting
Supporting multiple output formats helps both humans and automated tools consume your CLI’s output:
package main
import (
"encoding/json"
"fmt"
"os"
"text/tabwriter"
"github.com/urfave/cli/v2"
"gopkg.in/yaml.v3"
)
type ServerInfo struct {
Name string `json:"name" yaml:"name"`
IP string `json:"ip" yaml:"ip"`
Status string `json:"status" yaml:"status"`
Uptime int `json:"uptime" yaml:"uptime"`
CPU float64 `json:"cpu" yaml:"cpu"`
Memory float64 `json:"memory" yaml:"memory"`
}
func getServers() []ServerInfo {
return []ServerInfo{
{"web-01", "192.168.1.10", "running", 15, 45.2, 60.5},
{"db-01", "192.168.1.11", "running", 20, 78.3, 82.1},
{"cache-01", "192.168.1.12", "stopped", 0, 0.0, 0.0},
}
}
func main() {
var outputFormat string
app := &cli.App{
Name: "serverctl",
Usage: "server management utility",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Value: "table",
Usage: "Output format (table, json, yaml)",
Destination: &outputFormat,
},
},
Commands: []*cli.Command{
{
Name: "list",
Usage: "list all servers",
Action: func(c *cli.Context) error {
servers := getServers()
switch c.String("output") {
case "json":
return outputJSON(servers)
case "yaml":
return outputYAML(servers)
default:
return outputTable(servers)
}
},
},
},
}
app.Run(os.Args)
}
func outputTable(servers []ServerInfo) error {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, "NAME\tIP\tSTATUS\tUPTIME (h)\tCPU (%)\tMEMORY (%)")
for _, server := range servers {
fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%.1f\t%.1f\n",
server.Name, server.IP, server.Status, server.Uptime, server.CPU, server.Memory)
}
return w.Flush()
}
func outputJSON(servers []ServerInfo) error {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(servers)
}
func outputYAML(servers []ServerInfo) error {
encoder := yaml.NewEncoder(os.Stdout)
return encoder.Encode(servers)
}
Comprehensive Example
Let’s tie these patterns together in a more complete example:
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"os/signal"
"syscall"
"text/tabwriter"
"time"
"github.com/briandowns/spinner"
"github.com/schollz/progressbar/v3"
"github.com/spf13/viper"
"github.com/urfave/cli/v2"
"gopkg.in/yaml.v3"
)
// Exit codes
const (
ExitSuccess = 0
ExitFailure = 1
ExitInvalidInput = 2
ExitFileError = 3
)
// Data structures
type Task struct {
ID string `json:"id" yaml:"id"`
Title string `json:"title" yaml:"title"`
Description string `json:"description" yaml:"description"`
Status string `json:"status" yaml:"status"`
CreatedAt time.Time `json:"created_at" yaml:"created_at"`
}
// Global variables
var tasks = []Task{
{
ID: "task-001",
Title: "Implement user authentication",
Description: "Add OAuth2 support for user login",
Status: "in_progress",
CreatedAt: time.Now().Add(-72 * time.Hour),
},
{
ID: "task-002",
Title: "Optimize database queries",
Description: "Add indices and rewrite slow queries",
Status: "todo",
CreatedAt: time.Now().Add(-48 * time.Hour),
},
{
ID: "task-003",
Title: "Write documentation",
Description: "Create user and developer guides",
Status: "completed",
CreatedAt: time.Now().Add(-24 * time.Hour),
},
}
func main() {
// Setup configuration
viper.SetDefault("storage.path", "./tasks.json")
viper.SetConfigName("taskctl")
viper.SetConfigType("yaml")
viper.AddConfigPath("$HOME/.taskctl")
viper.AddConfigPath(".")
viper.AutomaticEnv()
viper.SetEnvPrefix("TASKCTL")
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
fmt.Printf("Error reading config file: %s\n", err)
}
}
// Create CLI app
app := &cli.App{
Name: "taskctl",
Usage: "task management utility",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Value: "table",
Usage: "Output format (table, json, yaml)",
},
},
Commands: []*cli.Command{
{
Name: "list",
Aliases: []string{"ls"},
Usage: "list all tasks",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "status",
Aliases: []string{"s"},
Usage: "Filter by status (todo, in_progress, completed)",
},
},
Action: listTasks,
},
{
Name: "add",
Usage: "add a new task",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "title",
Aliases: []string{"t"},
Usage: "Task title",
Required: true,
},
&cli.StringFlag{
Name: "description",
Aliases: []string{"d"},
Usage: "Task description",
},
&cli.StringFlag{
Name: "status",
Aliases: []string{"s"},
Value: "todo",
Usage: "Task status (todo, in_progress, completed)",
},
},
Action: addTask,
},
{
Name: "export",
Usage: "export tasks to a file",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "file",
Aliases: []string{"f"},
Usage: "File to export to",
Required: true,
},
&cli.StringFlag{
Name: "format",
Value: "json",
Usage: "Export format (json, yaml)",
},
},
Action: exportTasks,
},
},
}
// Handle interrupts
ctx, cancel := context.WithCancel(context.Background())
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-signalChan
fmt.Println("\nReceived termination signal")
cancel()
}()
// Run the application
if err := app.RunContext(ctx, os.Args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
os.Exit(ExitFailure)
}
}
func listTasks(c *cli.Context) error {
statusFilter := c.String("status")
var filteredTasks []Task
if statusFilter != "" {
for _, task := range tasks {
if task.Status == statusFilter {
filteredTasks = append(filteredTasks, task)
}
}
} else {
filteredTasks = tasks
}
switch c.String("output") {
case "json":
return outputJSON(filteredTasks)
case "yaml":
return outputYAML(filteredTasks)
default:
return outputTaskTable(filteredTasks)
}
}
func addTask(c *cli.Context) error {
title := c.String("title")
description := c.String("description")
status := c.String("status")
// Validate input
if title == "" {
return fmt.Errorf("title cannot be empty")
}
if status != "todo" && status != "in_progress" && status != "completed" {
return fmt.Errorf("invalid status: %s", status)
}
// Show progress
s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
s.Prefix = "Adding task "
s.Start()
// Simulate work
time.Sleep(1 * time.Second)
// Create task
task := Task{
ID: fmt.Sprintf("task-%03d", len(tasks)+1),
Title: title,
Description: description,
Status: status,
CreatedAt: time.Now(),
}
tasks = append(tasks, task)
s.Stop()
fmt.Printf("Task added with ID: %s\n", task.ID)
return nil
}
func exportTasks(c *cli.Context) error {
filename := c.String("file")
format := c.String("format")
// Validate input
if filename == "" {
return fmt.Errorf("filename cannot be empty")
}
if format != "json" && format != "yaml" {
return fmt.Errorf("unsupported format: %s", format)
}
// Create progress bar
bar := progressbar.Default(100)
// Simulate export progress
for i := 0; i < 100; i++ {
bar.Add(1)
time.Sleep(20 * time.Millisecond)
}
// Write file
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("could not create file: %w", err)
}
defer file.Close()
switch format {
case "json":
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(tasks); err != nil {
return fmt.Errorf("could not encode tasks to JSON: %w", err)
}
case "yaml":
encoder := yaml.NewEncoder(file)
if err := encoder.Encode(tasks); err != nil {
return fmt.Errorf("could not encode tasks to YAML: %w", err)
}
}
fmt.Printf("\nExported %d tasks to %s in %s format\n", len(tasks), filename, format)
return nil
}
func outputTaskTable(tasks []Task) error {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, "ID\tTITLE\tSTATUS\tCREATED")
for _, task := range tasks {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
task.ID, task.Title, task.Status, task.CreatedAt.Format("2006-01-02"))
}
return w.Flush()
}
func outputJSON(data interface{}) error {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(data)
}
func outputYAML(data interface{}) error {
encoder := yaml.NewEncoder(os.Stdout)
return encoder.Encode(data)
}
In my experience, following these patterns creates CLI applications that are intuitive, maintainable, and robust. The Go ecosystem offers excellent tooling for command-line application development, balancing simplicity with power.
When I build CLI tools, I focus on making them feel like Unix tools users are already familiar with. This means supporting standard flags like --help
and --version
, using stdin/stdout for piping data between commands, and providing clear, concise error messages.
These patterns have served me well in production environments where reliability and ease of use are paramount. Command-line tools are often the first interface users have with your software, so making a good impression with a thoughtful CLI design is invaluable.