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: