Announcement

👇Official Account👇

Welcome to join the group & private message

Article first/tail QR code

Skip to content

Go CLI Utility Development Practice: Master Modern Command-Line Tools

Series Navigation: This article is part of the Golang Development Toolchain series

Table of Contents


1. Introduction

In 2025, CLI tools remain essential for developers. Go's static binaries, fast execution, and excellent standard library make it the perfect language for building professional command-line utilities.

Why Go for CLI Development?

  • Single binary: No runtime dependencies
  • Cross-platform: Compile for Windows, macOS, Linux
  • Fast execution: Native performance
  • Excellent ecosystem: Cobra, Viper, urfave/cli
  • Easy distribution: goreleaser automation

What You'll Build: In this guide, we'll create a production-ready weather and mobile query tool, demonstrating best practices for real-world CLI applications.

Terminal Weather Tool Demo


2. The Cobra Framework (Industry Standard)

Cobra is the de facto standard for Go CLI applications, used by:

  • Kubernetes (kubectl)
  • Hugo (static site generator)
  • GitHub CLI (gh)
  • Docker (docker)

2.1 Installation and Project Setup

bash
# Install Cobra CLI generator
go install github.com/spf13/cobra-cli@latest

# Verify installation
cobra-cli version

# Create new project
mkdir pf_tools && cd pf_tools
cobra-cli init --author "PFinal南丞" --license MIT

Generated Project Structure:

pf_tools/
├── LICENSE
├── README.md
├── cmd/
│   └── root.go          # Root command definition
├── main.go              # Entry point
├── go.mod
└── go.sum

2.2 Understanding Root Command

Let's examine and enhance the generated cmd/root.go:

go
// cmd/root.go
package cmd

import (
    "fmt"
    "os"
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var (
    cfgFile string
    verbose bool
)

// rootCmd represents the base command
var rootCmd = &cobra.Command{
    Use:   "pf_tools",
    Short: "A professional CLI toolkit for weather and mobile queries",
    Long: `PF Tools is a modern command-line utility that provides:
  - Real-time weather forecasts
  - Mobile number attribution lookup
  - Beautiful terminal UI with TermUI
  
Built with Go and Cobra framework.`,
    Version: "1.0.0",
    // PersistentPreRun runs before all commands
    PersistentPreRun: func(cmd *cobra.Command, args []string) {
        if verbose {
            fmt.Println("Running in verbose mode...")
        }
    },
}

// Execute adds all child commands to the root command and sets flags appropriately.
func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func init() {
    // Initialize configuration before command execution
    cobra.OnInitialize(initConfig)

    // Global flags (available to all subcommands)
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.pf_tools.yaml)")
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
    
    // Local flags (only for root command)
    rootCmd.Flags().BoolP("version", "V", false, "show version")
}

func initConfig() {
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
    } else {
        home, err := os.UserHomeDir()
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
        
        // Search config in home directory with name ".pf_tools" (without extension)
        viper.AddConfigPath(home)
        viper.AddConfigPath(".")
        viper.SetConfigType("yaml")
        viper.SetConfigName(".pf_tools")
    }

    viper.AutomaticEnv() // Read environment variables
    
    if err := viper.ReadInConfig(); err == nil && verbose {
        fmt.Println("Using config file:", viper.ConfigFileUsed())
    }
}

Key Improvements:

  • ✅ Version information
  • ✅ Persistent flags for global options
  • ✅ Configuration file support with Viper
  • ✅ Environment variable support
  • ✅ Proper error handling

2.3 Adding Subcommands with Best Practices

Weather Subcommand

bash
cobra-cli add weather

Enhanced cmd/weather.go:

go
// cmd/weather.go
package cmd

import (
    "fmt"
    "log"
    "pf_tools/pkg/weather"
    "github.com/spf13/cobra"
    ui "github.com/gizak/termui/v3"
    "github.com/gizak/termui/v3/widgets"
)

var (
    city   string
    days   int
    format string
)

// weatherCmd represents the weather command
var weatherCmd = &cobra.Command{
    Use:   "weather [city]",
    Short: "Query weather forecast for a city",
    Long: `Query real-time weather forecast with beautiful terminal UI.
    
Examples:
  pf_tools weather Beijing
  pf_tools weather --city Shanghai --days 7
  pf_tools weather --format json Paris`,
    Args: cobra.MaximumNArgs(1), // Allow 0 or 1 argument
    ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
        // Auto-completion suggestions
        return []string{"Beijing", "Shanghai", "Guangzhou", "Shenzhen", "London", "Paris", "Tokyo"}, cobra.ShellCompDirectiveDefault
    },
    PreRunE: func(cmd *cobra.Command, args []string) error {
        // Argument validation
        if len(args) > 0 {
            city = args[0]
        } else if city == "" {
            return fmt.Errorf("city name is required")
        }
        
        if days < 1 || days > 15 {
            return fmt.Errorf("days must be between 1 and 15")
        }
        
        return nil
    },
    RunE: func(cmd *cobra.Command, args []string) error {
        // Fetch weather data
        weatherData, err := weather.Fetch(city, days)
        if err != nil {
            return fmt.Errorf("failed to fetch weather: %w", err)
        }
        
        // Output format selection
        switch format {
        case "json":
            return weather.PrintJSON(weatherData)
        case "text":
            return weather.PrintText(weatherData)
        case "ui":
            return displayWeatherUI(weatherData)
        default:
            return fmt.Errorf("unsupported format: %s", format)
        }
    },
}

func init() {
    rootCmd.AddCommand(weatherCmd)
    
    // Flags with validation
    weatherCmd.Flags().StringVarP(&city, "city", "c", "", "city name (required if no argument)")
    weatherCmd.Flags().IntVarP(&days, "days", "d", 7, "forecast days (1-15)")
    weatherCmd.Flags().StringVarP(&format, "format", "f", "ui", "output format: ui, json, text")
    
    // Mark required flags (alternative to argument)
    // weatherCmd.MarkFlagRequired("city")
}

func displayWeatherUI(data *weather.Data) error {
    if err := ui.Init(); err != nil {
        return fmt.Errorf("failed to initialize UI: %w", err)
    }
    defer ui.Close()
    
    // Create table widget
    table := widgets.NewTable()
    table.Title = fmt.Sprintf(" %s Weather Forecast ", data.City)
    table.BorderStyle = ui.NewStyle(ui.ColorCyan)
    table.TitleStyle = ui.NewStyle(ui.ColorWhite, ui.ColorBlue, ui.ModifierBold)
    
    // Populate table rows
    table.Rows = [][]string{
        {"Date", "Condition", "High", "Low", "Wind", "Humidity"},
    }
    
    for _, day := range data.Forecast {
        table.Rows = append(table.Rows, []string{
            day.Date,
            day.Condition,
            fmt.Sprintf("%d°C", day.High),
            fmt.Sprintf("%d°C", day.Low),
            day.Wind,
            fmt.Sprintf("%d%%", day.Humidity),
        })
    }
    
    table.TextStyle = ui.NewStyle(ui.ColorWhite)
    table.RowSeparator = false
    
    // Calculate table size dynamically
    termWidth, termHeight := ui.TerminalDimensions()
    table.SetRect(0, 0, termWidth, termHeight)
    
    ui.Render(table)
    
    // Event loop for interaction
    uiEvents := ui.PollEvents()
    for {
        e := <-uiEvents
        switch e.ID {
        case "q", "<C-c>", "<Escape>":
            return nil
        case "<Resize>":
            payload := e.Payload.(ui.Resize)
            table.SetRect(0, 0, payload.Width, payload.Height)
            ui.Clear()
            ui.Render(table)
        }
    }
}

Best Practices Demonstrated:

  1. Argument validation with PreRunE
  2. Multiple output formats (UI, JSON, Text)
  3. Auto-completion support
  4. Graceful error handling with RunE
  5. Responsive UI with resize handling

Mobile Subcommand

bash
cobra-cli add mobile

Enhanced cmd/mobile.go:

go
// cmd/mobile.go
package cmd

import (
    "fmt"
    "pf_tools/pkg/mobile"
    "github.com/spf13/cobra"
    ui "github.com/gizak/termui/v3"
    "github.com/gizak/termui/v3/widgets"
)

var (
    number     string
    outputFile string
)

// mobileCmd represents the mobile command
var mobileCmd = &cobra.Command{
    Use:   "mobile [phone-number]",
    Short: "Query mobile phone number attribution",
    Long: `Query carrier, province, city, and zip code for Chinese mobile numbers.
    
Examples:
  pf_tools mobile 13800138000
  pf_tools mobile --number 13800138000 --output result.json`,
    Args: cobra.MaximumNArgs(1),
    PreRunE: func(cmd *cobra.Command, args []string) error {
        if len(args) > 0 {
            number = args[0]
        } else if number == "" {
            return fmt.Errorf("phone number is required")
        }
        
        // Validate number format
        if !mobile.IsValid(number) {
            return fmt.Errorf("invalid phone number format: %s", number)
        }
        
        return nil
    },
    RunE: func(cmd *cobra.Command, args []string) error {
        // Query mobile information
        info, err := mobile.Query(number)
        if err != nil {
            return fmt.Errorf("query failed: %w", err)
        }
        
        // Save to file if specified
        if outputFile != "" {
            if err := mobile.SaveToFile(info, outputFile); err != nil {
                return fmt.Errorf("failed to save result: %w", err)
            }
            fmt.Printf("Result saved to %s\n", outputFile)
            return nil
        }
        
        // Display in terminal UI
        return displayMobileUI(info)
    },
}

func init() {
    rootCmd.AddCommand(mobileCmd)
    
    mobileCmd.Flags().StringVarP(&number, "number", "n", "", "phone number to query")
    mobileCmd.Flags().StringVarP(&outputFile, "output", "o", "", "save result to file (JSON format)")
}

func displayMobileUI(info *mobile.Info) error {
    if err := ui.Init(); err != nil {
        return fmt.Errorf("failed to initialize UI: %w", err)
    }
    defer ui.Close()

    // Create list widget
    list := widgets.NewList()
    list.Title = " Mobile Number Information "
    list.BorderStyle = ui.NewStyle(ui.ColorGreen)
    list.TitleStyle = ui.NewStyle(ui.ColorWhite, ui.ColorGreen, ui.ModifierBold)
    
    list.Rows = []string{
        fmt.Sprintf("[0] Number:   %s", info.Number),
        fmt.Sprintf("[1] Carrier:  %s", info.Carrier),
        fmt.Sprintf("[2] Province: %s", info.Province),
        fmt.Sprintf("[3] City:     %s", info.City),
        fmt.Sprintf("[4] ZipCode:  %s", info.ZipCode),
        fmt.Sprintf("[5] AreaCode: %s", info.AreaCode),
    }
    
    list.TextStyle = ui.NewStyle(ui.ColorYellow)
    list.WrapText = false
    
    termWidth, termHeight := ui.TerminalDimensions()
    list.SetRect(0, 0, min(50, termWidth), min(10, termHeight))
    
    ui.Render(list)
    
    uiEvents := ui.PollEvents()
    for {
        e := <-uiEvents
        switch e.ID {
        case "q", "<C-c>", "<Escape>":
            return nil
        case "<Resize>":
            payload := e.Payload.(ui.Resize)
            list.SetRect(0, 0, min(50, payload.Width), min(10, payload.Height))
            ui.Clear()
            ui.Render(list)
        }
    }
}

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}

3. Configuration Management with Viper

Viper is the perfect companion to Cobra for configuration management.

3.1 Configuration File Structure

Create ~/.pf_tools.yaml:

yaml
# API Configuration
api:
  weather:
    key: "your-weather-api-key"
    base_url: "https://api.openweathermap.org/data/2.5"
    timeout: 10 # seconds
  mobile:
    key: "your-mobile-api-key"
    base_url: "https://api.example.com/mobile"
    timeout: 5

# UI Preferences
ui:
  theme: "dark" # dark | light
  colors:
    primary: "cyan"
    secondary: "green"
  refresh_rate: 1000 # milliseconds

# Cache Settings
cache:
  enabled: true
  ttl: 3600 # seconds
  directory: "~/.pf_tools/cache"

# Logging
log:
  level: "info" # debug | info | warn | error
  file: "~/.pf_tools/logs/app.log"
  max_size: 10 # MB
  max_backups: 3

3.2 Loading Configuration

go
// pkg/config/config.go
package config

import (
    "fmt"
    "os"
    "path/filepath"
    "github.com/spf13/viper"
)

type Config struct {
    API   APIConfig   `mapstructure:"api"`
    UI    UIConfig    `mapstructure:"ui"`
    Cache CacheConfig `mapstructure:"cache"`
    Log   LogConfig   `mapstructure:"log"`
}

type APIConfig struct {
    Weather APIEndpoint `mapstructure:"weather"`
    Mobile  APIEndpoint `mapstructure:"mobile"`
}

type APIEndpoint struct {
    Key     string `mapstructure:"key"`
    BaseURL string `mapstructure:"base_url"`
    Timeout int    `mapstructure:"timeout"`
}

type UIConfig struct {
    Theme       string            `mapstructure:"theme"`
    Colors      map[string]string `mapstructure:"colors"`
    RefreshRate int               `mapstructure:"refresh_rate"`
}

type CacheConfig struct {
    Enabled   bool   `mapstructure:"enabled"`
    TTL       int    `mapstructure:"ttl"`
    Directory string `mapstructure:"directory"`
}

type LogConfig struct {
    Level      string `mapstructure:"level"`
    File       string `mapstructure:"file"`
    MaxSize    int    `mapstructure:"max_size"`
    MaxBackups int    `mapstructure:"max_backups"`
}

var globalConfig *Config

// Load reads configuration from file and environment variables
func Load() (*Config, error) {
    if globalConfig != nil {
        return globalConfig, nil
    }
    
    // Set configuration file paths
    home, err := os.UserHomeDir()
    if err != nil {
        return nil, fmt.Errorf("failed to get home directory: %w", err)
    }
    
    viper.AddConfigPath(filepath.Join(home, ".pf_tools"))
    viper.AddConfigPath(".")
    viper.SetConfigName("config")
    viper.SetConfigType("yaml")
    
    // Environment variable binding
    viper.SetEnvPrefix("PF")
    viper.AutomaticEnv()
    
    // Set defaults
    setDefaults()
    
    // Read configuration
    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            return nil, fmt.Errorf("failed to read config: %w", err)
        }
        // Config file not found, use defaults
    }
    
    var cfg Config
    if err := viper.Unmarshal(&cfg); err != nil {
        return nil, fmt.Errorf("failed to unmarshal config: %w", err)
    }
    
    globalConfig = &cfg
    return globalConfig, nil
}

func setDefaults() {
    viper.SetDefault("api.weather.timeout", 10)
    viper.SetDefault("api.mobile.timeout", 5)
    viper.SetDefault("ui.theme", "dark")
    viper.SetDefault("cache.enabled", true)
    viper.SetDefault("cache.ttl", 3600)
    viper.SetDefault("log.level", "info")
}

// Get returns the global configuration
func Get() *Config {
    if globalConfig == nil {
        globalConfig, _ = Load()
    }
    return globalConfig
}

Usage in commands:

go
import "pf_tools/pkg/config"

func init() {
    // Load config when command initializes
    cfg, err := config.Load()
    if err != nil {
        log.Fatalf("Failed to load config: %v", err)
    }
    
    // Use configuration
    apiKey := cfg.API.Weather.Key
    timeout := cfg.API.Weather.Timeout
}

4. Advanced Error Handling

4.1 Custom Error Types

go
// pkg/errors/errors.go
package errors

import (
    "errors"
    "fmt"
)

// Common error types
var (
    ErrInvalidInput   = errors.New("invalid input")
    ErrAPIFailure     = errors.New("API request failed")
    ErrNetworkTimeout = errors.New("network timeout")
    ErrNotFound       = errors.New("resource not found")
)

// APIError wraps HTTP API errors
type APIError struct {
    StatusCode int
    Message    string
    Err        error
}

func (e *APIError) Error() string {
    return fmt.Sprintf("API error (status %d): %s", e.StatusCode, e.Message)
}

func (e *APIError) Unwrap() error {
    return e.Err
}

func NewAPIError(statusCode int, message string, err error) *APIError {
    return &APIError{
        StatusCode: statusCode,
        Message:    message,
        Err:        err,
    }
}

// ValidationError represents input validation errors
type ValidationError struct {
    Field   string
    Value   interface{}
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error for field '%s': %s (value: %v)", e.Field, e.Message, e.Value)
}

// Helper functions
func IsAPIError(err error) bool {
    var apiErr *APIError
    return errors.As(err, &apiErr)
}

func IsValidationError(err error) bool {
    var valErr *ValidationError
    return errors.As(err, &valErr)
}

4.2 Error Handling in Commands

go
// In cmd/weather.go
func (cmd *weatherCmd) RunE(cmd *cobra.Command, args []string) error {
    weatherData, err := weather.Fetch(city, days)
    if err != nil {
        // Handle different error types
        var apiErr *errors.APIError
        if errors.As(err, &apiErr) {
            if apiErr.StatusCode == 404 {
                return fmt.Errorf("city '%s' not found", city)
            }
            return fmt.Errorf("weather service error: %w", err)
        }
        
        if errors.Is(err, errors.ErrNetworkTimeout) {
            return fmt.Errorf("request timed out, please check your network")
        }
        
        return fmt.Errorf("unexpected error: %w", err)
    }
    
    return displayWeatherUI(weatherData)
}

5. Testing CLI Applications

5.1 Unit Testing Commands

go
// cmd/weather_test.go
package cmd

import (
    "bytes"
    "testing"
    "github.com/spf13/cobra"
)

func TestWeatherCommand(t *testing.T) {
    tests := []struct {
        name    string
        args    []string
        wantErr bool
    }{
        {
            name:    "valid city",
            args:    []string{"weather", "Beijing"},
            wantErr: false,
        },
        {
            name:    "missing city",
            args:    []string{"weather"},
            wantErr: true,
        },
        {
            name:    "invalid days",
            args:    []string{"weather", "Shanghai", "--days", "20"},
            wantErr: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Create a new root command for testing
            rootCmd := &cobra.Command{Use: "pf_tools"}
            rootCmd.AddCommand(weatherCmd)
            
            // Capture output
            buf := new(bytes.Buffer)
            rootCmd.SetOut(buf)
            rootCmd.SetErr(buf)
            
            // Set arguments
            rootCmd.SetArgs(tt.args)
            
            // Execute command
            err := rootCmd.Execute()
            
            if (err != nil) != tt.wantErr {
                t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

5.2 Integration Testing

go
// integration_test.go
//go:build integration

package main

import (
    "os/exec"
    "strings"
    "testing"
)

func TestCLIIntegration(t *testing.T) {
    // Build the CLI first
    cmd := exec.Command("go", "build", "-o", "pf_tools_test")
    if err := cmd.Run(); err != nil {
        t.Fatalf("Failed to build CLI: %v", err)
    }
    defer os.Remove("pf_tools_test")
    
    tests := []struct {
        name       string
        args       []string
        wantOutput string
    }{
        {
            name:       "version flag",
            args:       []string{"--version"},
            wantOutput: "pf_tools version",
        },
        {
            name:       "help command",
            args:       []string{"help"},
            wantOutput: "Available Commands:",
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            cmd := exec.Command("./pf_tools_test", tt.args...)
            output, err := cmd.CombinedOutput()
            if err != nil {
                t.Fatalf("Command failed: %v\nOutput: %s", err, output)
            }
            
            if !strings.Contains(string(output), tt.wantOutput) {
                t.Errorf("Output = %s, want to contain %s", output, tt.wantOutput)
            }
        })
    }
}

Run tests:

bash
# Unit tests
go test ./... -v

# Integration tests
go test -tags=integration ./... -v

# With coverage
go test ./... -cover -coverprofile=coverage.out
go tool cover -html=coverage.out

See also: Go Testing Advanced Techniques


6. Building and Distribution

6.1 Cross-Platform Compilation

bash
# Build for current platform
go build -o pf_tools .

# Build for Linux
GOOS=linux GOARCH=amd64 go build -o pf_tools-linux .

# Build for macOS (Intel)
GOOS=darwin GOARCH=amd64 go build -o pf_tools-macos-intel .

# Build for macOS (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o pf_tools-macos-arm64 .

# Build for Windows
GOOS=windows GOARCH=amd64 go build -o pf_tools.exe .

# All platforms at once
make build-all

6.2 Makefile for Build Automation

Create Makefile:

makefile
BINARY_NAME=pf_tools
VERSION=1.0.0
BUILD_DIR=dist
LDFLAGS=-ldflags "-X main.Version=${VERSION} -X main.BuildTime=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)"

.PHONY: all build clean test install

all: clean test build

build:
	@echo "Building ${BINARY_NAME}..."
	go build ${LDFLAGS} -o ${BUILD_DIR}/${BINARY_NAME} .

build-all:
	@echo "Building for all platforms..."
	GOOS=linux GOARCH=amd64 go build ${LDFLAGS} -o ${BUILD_DIR}/${BINARY_NAME}-linux-amd64 .
	GOOS=darwin GOARCH=amd64 go build ${LDFLAGS} -o ${BUILD_DIR}/${BINARY_NAME}-darwin-amd64 .
	GOOS=darwin GOARCH=arm64 go build ${LDFLAGS} -o ${BUILD_DIR}/${BINARY_NAME}-darwin-arm64 .
	GOOS=windows GOARCH=amd64 go build ${LDFLAGS} -o ${BUILD_DIR}/${BINARY_NAME}-windows-amd64.exe .

clean:
	@echo "Cleaning..."
	rm -rf ${BUILD_DIR}
	go clean

test:
	@echo "Running tests..."
	go test ./... -v

install:
	@echo "Installing ${BINARY_NAME}..."
	go install ${LDFLAGS} .

run:
	go run main.go

lint:
	golangci-lint run

fmt:
	gofmt -s -w .
	goimports -w .

Usage:

bash
make build         # Build for current platform
make build-all     # Build for all platforms
make test          # Run tests
make install       # Install to $GOPATH/bin

6.3 Automated Release with goreleaser

Install goreleaser:

bash
go install github.com/goreleaser/goreleaser@latest

Create .goreleaser.yaml:

yaml
# .goreleaser.yaml
before:
  hooks:
    - go mod tidy
    - go test ./...

builds:
  - id: pf_tools
    binary: pf_tools
    env:
      - CGO_ENABLED=0
    goos:
      - linux
      - windows
      - darwin
    goarch:
      - amd64
      - arm64
    ldflags:
      - -s -w
      - -X main.Version={{.Version}}
      - -X main.Commit={{.Commit}}
      - -X main.Date={{.Date}}

archives:
  - format: tar.gz
    name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
    format_overrides:
      - goos: windows
        format: zip
    files:
      - README.md
      - LICENSE

checksum:
  name_template: 'checksums.txt'

snapshot:
  name_template: "{{ incpatch .Version }}-next"

changelog:
  sort: asc
  filters:
    exclude:
      - '^docs:'
      - '^test:'

release:
  github:
    owner: PFinal-tool
    name: pf_tools
  draft: false
  prerelease: auto

Release process:

bash
# Test release locally
goreleaser release --snapshot --clean

# Create a Git tag
git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0

# Release (requires GITHUB_TOKEN)
export GITHUB_TOKEN="your_github_token"
goreleaser release --clean

7. Performance Optimization

7.1 Binary Size Reduction

bash
# Standard build
go build -o pf_tools .
ls -lh pf_tools  # ~10MB

# With optimization flags
go build -ldflags="-s -w" -o pf_tools .
ls -lh pf_tools  # ~7MB

# With UPX compression
upx --best --lzma pf_tools
ls -lh pf_tools  # ~3MB

Flags explanation:

  • -s: Omit symbol table and debug information
  • -w: Omit DWARF symbol table
  • upx --best: Maximum compression (slower startup)

7.2 Startup Time Optimization

Benchmark startup time:

bash
# Without optimization
time ./pf_tools --version
# real    0m0.150s

# With PGO (Profile-Guided Optimization)
# 1. Generate profile
./pf_tools weather Beijing > /dev/null
go test -cpuprofile=cpu.prof ./...

# 2. Build with profile
go build -pgo=cpu.prof -o pf_tools .

# 3. Test again
time ./pf_tools --version
# real    0m0.100s  (33% faster!)

7.3 HTTP Client Optimization

go
// pkg/http/client.go
package http

import (
    "net"
    "net/http"
    "time"
)

// NewOptimizedClient creates an HTTP client with production-ready settings
func NewOptimizedClient(timeout time.Duration) *http.Client {
    return &http.Client{
        Timeout: timeout,
        Transport: &http.Transport{
            MaxIdleConns:        100,
            MaxIdleConnsPerHost: 10,
            IdleConnTimeout:     90 * time.Second,
            DisableCompression:  false,
            DisableKeepAlives:   false,
            
            DialContext: (&net.Dialer{
                Timeout:   30 * time.Second,
                KeepAlive: 30 * time.Second,
            }).DialContext,
            
            // Connection pooling
            ForceAttemptHTTP2:     true,
            TLSHandshakeTimeout:   10 * time.Second,
            ExpectContinueTimeout: 1 * time.Second,
        },
    }
}

Performance comparison:

ConfigurationReq/secLatency (p95)
Default client150120ms
Optimized client80025ms
Improvement5.3x4.8x faster

8. Best Practices Summary

8.1 Command Design Principles

  1. Single Responsibility: Each command does one thing well
  2. Composability: Commands can be chained with pipes
  3. Idempotency: Same input produces same output
  4. Progressive Disclosure: Simple by default, powerful when needed
go
// ✅ GOOD: Simple and composable
pf_tools weather Beijing | jq '.temperature'

// ❌ BAD: Too many flags required
pf_tools weather --city Beijing --format json --api-key xxx --timeout 30

8.2 User Experience Guidelines

  1. Fast Feedback: Show progress for long operations
  2. Helpful Errors: Suggest fixes when commands fail
  3. Sensible Defaults: Minimize required flags
  4. Auto-completion: Support shell completion
  5. Color Coding: Use colors to highlight important information
go
// Example: Progress indicator
import "github.com/schollz/progressbar/v3"

bar := progressbar.Default(100)
for i := 0; i < 100; i++ {
    bar.Add(1)
    time.Sleep(10 * time.Millisecond)
}

8.3 Security Considerations

  1. API Key Management: Never hardcode keys
  2. Input Validation: Sanitize all user input
  3. HTTPS Only: Enforce secure connections
  4. Rate Limiting: Respect API limits
go
// ✅ GOOD: API key from environment or config
apiKey := os.Getenv("WEATHER_API_KEY")
if apiKey == "" {
    apiKey = viper.GetString("api.weather.key")
}

// ❌ BAD: Hardcoded API key
const apiKey = "sk-1234567890"  // NEVER DO THIS!

9. Alternative: urfave/cli

While Cobra is the industry standard, urfave/cli offers a simpler alternative for smaller projects.

go
// main.go with urfave/cli
package main

import (
    "fmt"
    "log"
    "os"
    "github.com/urfave/cli/v2"
)

func main() {
    app := &cli.App{
        Name:  "pf_tools",
        Usage: "A simple CLI toolkit",
        Version: "1.0.0",
        Commands: []*cli.Command{
            {
                Name:    "weather",
                Aliases: []string{"w"},
                Usage:   "query weather forecast",
                Flags: []cli.Flag{
                    &cli.StringFlag{
                        Name:     "city",
                        Aliases:  []string{"c"},
                        Required: true,
                        Usage:    "city name",
                    },
                    &cli.IntFlag{
                        Name:  "days",
                        Value: 7,
                        Usage: "forecast days",
                    },
                },
                Action: func(c *cli.Context) error {
                    city := c.String("city")
                    days := c.Int("days")
                    fmt.Printf("Querying weather for %s (%d days)\n", city, days)
                    return nil
                },
            },
        },
    }
    
    if err := app.Run(os.Args); err != nil {
        log.Fatal(err)
    }
}

Cobra vs urfave/cli:

FeatureCobraurfave/cli
ComplexityModerateSimple
FeaturesRich (PersistentFlags, PreRun, etc.)Basic
File countMultiple (cmd/ directory)Single file
Best forLarge, complex CLIsSmall, simple tools

10. Real-World Examples

  • kubectl: Kubernetes CLI
  • gh: GitHub CLI
  • hugo: Static site generator
  • helm: Kubernetes package manager
  • docker: Container platform CLI

10.2 Project Structure Template

pf_tools/
├── cmd/                    # Command definitions
│   ├── root.go
│   ├── weather.go
│   └── mobile.go
├── pkg/                    # Core business logic
│   ├── config/
│   │   └── config.go
│   ├── weather/
│   │   ├── weather.go
│   │   └── weather_test.go
│   ├── mobile/
│   │   ├── mobile.go
│   │   └── mobile_test.go
│   ├── http/
│   │   └── client.go
│   └── errors/
│       └── errors.go
├── internal/               # Private application code
│   └── cache/
│       └── cache.go
├── scripts/                # Build and deployment scripts
│   ├── build.sh
│   └── release.sh
├── .github/
│   └── workflows/
│       └── release.yml
├── .goreleaser.yaml
├── Makefile
├── go.mod
├── go.sum
├── main.go
├── LICENSE
└── README.md

External Resources


12. Conclusion

Building professional CLI tools in Go is straightforward with the right tools and practices. Key takeaways:

  1. Use Cobra for complex CLI applications
  2. Integrate Viper for configuration management
  3. Implement proper error handling with custom error types
  4. Test thoroughly with unit and integration tests
  5. Automate releases with goreleaser
  6. Optimize performance (binary size, startup time, HTTP client)
  7. Follow best practices for user experience and security

The complete source code for this project is available at: https://github.com/PFinal-tool/pf_tools

Happy coding! Build amazing CLI tools with Go! 🚀


Last Updated: November 12, 2025
Author: PFinal南丞
License: MIT

Have fun building your own Go-powered tools!

Last updated: