CLI Tool Development

Lesson 25: CLI Tool Development

Real-World Analogy

Imagine you're standing in front of a vending machine. You press a button to select a drink (passing arguments), the machine dispenses based on your choice (executing the corresponding function), and if you press the wrong button it displays "Invalid selection" (argument validation). A CLI tool is like that vending machine — users enter commands and arguments via the command line, the program executes the corresponding operations and outputs results. A good CLI tool is like a well-designed vending machine: clear button layout, explicit operation prompts, and friendly error feedback.


Core Concepts

Go is naturally suited for building CLI tools — it compiles to a single binary, supports cross-platform development, and starts quickly. Here are the core concepts:

Concept Description
os.Args Raw command-line argument slice, [0] is the program name
flag package Argument parsing tool provided by the standard library
cobra library Third-party CLI framework supporting subcommands, auto-help, and shell completion
Subcommands Like git commit, docker run — the program executes different logic based on the subcommand
Argument validation Verifying user input is valid and providing friendly error messages
Interactive input Reading user input at runtime to build interactive CLI experiences

os.Args and the flag Package

os.Args is the most primitive way to get arguments, suitable for simple scenarios; the flag package provides typed flag parsing:

Command-line structure:
program [flags] [args]
  ↑         ↑       ↑
program name  flags  positional args

Example:
go build -o myapp ./cmd
  ↑       ↑     ↑
go     -o myapp  ./cmd

The cobra Library

cobra is the most popular CLI framework in the Go community, used by projects like kubectl, docker, and hugo:

cobra core components:
- RootCmd: root command, program entry point
- SubCmd: subcommands, such as "add", "list", "delete"
- Run/RunE: command execution functions
- Flags: flag arguments bound to commands

Basic Syntax and Usage

1. os.Args Basics

GO
package main

import (
    "fmt"
    "os"
)

func main() {
    // os.Args is a string slice, the first element is the program name
    args := os.Args
    fmt.Printf("Number of arguments: %d\n", len(args))
    fmt.Printf("Program name: %s\n", args[0])

    // Iterate over all arguments
    if len(args) > 1 {
        fmt.Println("Passed arguments:")
        for i, arg := range args[1:] {
            fmt.Printf("  [%d] %s\n", i, arg)
        }
    }
}
BASH
# Run test
$ go run main.go hello world
Number of arguments: 3
Program name: /tmp/go-build.../main
Passed arguments:
  [0] hello
  [1] world
💡 Tip: os.Args[0] is not necessarily the program name you typed — it's the executable file path passed by the operating system. Be aware of this difference when working cross-platform.

2. Parsing Flags with the flag Package

GO
package main

import (
    "flag"
    "fmt"
)

func main() {
    // Define flag arguments
    name := flag.String("name", "World", "Your name")
    age := flag.Int("age", 18, "Your age")
    verbose := flag.Bool("v", false, "Verbose output")

    // Parse command-line arguments
    flag.Parse()

    // Use parsed values (note: they are pointers, need dereferencing)
    if *verbose {
        fmt.Printf("[Debug] name=%s, age=%d\n", *name, *age)
    }
    fmt.Printf("Hello, %s! You are %d years old.\n", *name, *age)

    // Get non-flag arguments (positional arguments)
    fmt.Printf("Remaining args: %v\n", flag.Args())
}
BASH
$ go run main.go -name=Alice -age=25 -v
[Debug] name=Alice, age=25
Hello, Alice! You are 25 years old.
Remaining args: []

$ go run main.go --help
Usage of main:
  -age int
        Your age (default 18)
  -name string
        Your name (default "World")
  -v    Verbose output
💡 Tip: flag supports two assignment styles: -flag=value and -flag value. However, with the -flag value style, -flag must be immediately followed by the value and cannot be combined with other flags.

3. Using Variable Binding

GO
package main

import (
    "flag"
    "fmt"
)

func main() {
    // Use variable binding to directly operate on variables instead of pointers
    var host string
    var port int
    var debug bool

    flag.StringVar(&host, "host", "localhost", "Server address")
    flag.IntVar(&port, "port", 8080, "Port number")
    flag.BoolVar(&debug, "debug", false, "Enable debug mode")

    flag.Parse()

    fmt.Printf("Connecting to %s:%d (debug: %v)\n", host, port, debug)
}
💡 Tip: StringVar/IntVar/BoolVar bind to existing variables, suitable for scenarios where configuration needs to be shared across multiple places. The corresponding String/Int/Bool return pointers, suitable for local use.

4. Basic cobra Structure

GO
package main

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

func main() {
    // Root command
    rootCmd := &cobra.Command{
        Use:   "myapp",
        Short: "An example CLI application",
        Long:  "This is an example CLI application built with cobra.",
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Println("Welcome to myapp!")
        },
    }

    // Subcommand
    helloCmd := &cobra.Command{
        Use:   "hello [name]",
        Short: "Say hello",
        Args:  cobra.MinimumNArgs(1), // At least 1 argument required
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Printf("Hello, %s!\n", args[0])
        },
    }

    // Add subcommand to root command
    rootCmd.AddCommand(helloCmd)

    // Execute
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
BASH
$ go run main.go
Welcome to myapp!

$ go run main.go hello Go
Hello, Go!

$ go run main.go --help
An example CLI application

Usage:
  myapp [command]

Available Commands:
  hello       Say hello
  help        Help about any command

Flags:
  -h, --help   help for myapp
💡 Tip: cobra automatically handles --help and -h, no need to implement manually. It also auto-generates usage hints when arguments are incorrect.

5. cobra Flag Binding

GO
package main

import (
    "fmt"
    "os"
    "strings"

    "github.com/spf13/cobra"
)

func main() {
    var name string
    var count int

    rootCmd := &cobra.Command{
        Use:   "greeter",
        Short: "Repeated greeting tool",
        Run: func(cmd *cobra.Command, args []string) {
            for i := 0; i < count; i++ {
                fmt.Printf("Hello, %s! (%d/%d)\n", name, i+1, count)
            }
        },
    }

    // Persistent flags (apply to all subcommands)
    rootCmd.PersistentFlags().StringVarP(&name, "name", "n", "World", "Greeting target")

    // Local flags (apply only to the current command)
    rootCmd.Flags().IntVarP(&count, "count", "c", 1, "Repeat count")

    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
BASH
$ go run main.go -n Alice -c 3
Hello, Alice! (1/3)
Hello, Alice! (2/3)
Hello, Alice! (3/3)

6. Interactive Input

GO
package main

import (
    "bufio"
    "fmt"
    "os"
    "strings"
)

func main() {
    reader := bufio.NewReader(os.Stdin)

    // Read single line input
    fmt.Print("Enter your name: ")
    name, _ := reader.ReadString('\n')
    name = strings.TrimSpace(name) // Remove newline character

    // Read input with default value
    fmt.Print("Enter your age (default 18): ")
    ageInput, _ := reader.ReadString('\n')
    ageInput = strings.TrimSpace(ageInput)

    if ageInput == "" {
        ageInput = "18"
    }

    fmt.Printf("Hello, %s! You are %s years old.\n", name, ageInput)
}
💡 Tip: bufio.NewReader is more reliable than fmt.Scanln — it correctly handles input containing spaces and won't truncate early due to newline characters.

7. Password Input (Hidden Characters)

GO
package main

import (
    "fmt"
    "os"
    "syscall"

    "golang.org/x/term"
)

func main() {
    fmt.Print("Enter password: ")

    // Read password (characters not echoed)
    password, err := term.ReadPassword(int(syscall.Stdin))
    if err != nil {
        fmt.Fprintf(os.Stderr, "Failed to read password: %v\n", err)
        return
    }
    fmt.Println() // Newline after reading

    fmt.Printf("Password length: %d\n", len(password))
}
💡 Tip: golang.org/x/term is Go's official extension library, providing cross-platform terminal operations including password reading, cursor control, and more.


Example Code

Example: Basic Command-Line Calculator (Difficulty ⭐)

GO
package main

import (
    "flag"
    "fmt"
    "os"
    "strconv"
)

func main() {
    // Define flags
    op := flag.String("op", "add", "Operation type: add, sub, mul, div")
    verbose := flag.Bool("v", false, "Show detailed information")

    flag.Parse()

    // Check positional argument count
    args := flag.Args()
    if len(args) < 2 {
        fmt.Fprintln(os.Stderr, "Error: At least two numbers are required as arguments")
        fmt.Fprintln(os.Stderr, "Usage: calc -op=add 10 20")
        flag.Usage()
        os.Exit(1)
    }

    // Parse numbers
    a, err := strconv.ParseFloat(args[0], 64)
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error: Cannot parse number %q: %v\n", args[0], err)
        os.Exit(1)
    }
    b, err := strconv.ParseFloat(args[1], 64)
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error: Cannot parse number %q: %v\n", args[1], err)
        os.Exit(1)
    }

    // Execute operation
    var result float64
    var opName string

    switch *op {
    case "add":
        result = a + b
        opName = "Addition"
    case "sub":
        result = a - b
        opName = "Subtraction"
    case "mul":
        result = a * b
        opName = "Multiplication"
    case "div":
        if b == 0 {
            fmt.Fprintln(os.Stderr, "Error: Divisor cannot be zero")
            os.Exit(1)
        }
        result = a / b
        opName = "Division"
    default:
        fmt.Fprintf(os.Stderr, "Error: Unsupported operation type %q\n", *op)
        os.Exit(1)
    }

    // Output result
    if *verbose {
        fmt.Printf("Operation: %s\n", opName)
        fmt.Printf("Expression: %g %s %g\n", a, map[string]string{
            "add": "+", "sub": "-", "mul": "*", "div": "/",
        }[*op], b)
    }
    fmt.Printf("Result: %g\n", result)
}
▶ Try it Yourself
BASH
$ go run main.go -op=add 10 20
Result: 30

$ go run main.go -op=mul -v 3.5 4
Operation: Multiplication
Expression: 3.5 * 4
Result: 14

$ go run main.go -op=div 10 0
Error: Divisor cannot be zero

$ go run main.go 10
Error: At least two numbers are required as arguments

Example: cobra Multi-Subcommand Tool — File Manager (Difficulty ⭐⭐)

GO
package main

import (
    "fmt"
    "os"
    "path/filepath"
    "strings"
    "time"

    "github.com/spf13/cobra"
)

var verbose bool

func main() {
    rootCmd := &cobra.Command{
        Use:   "filetool",
        Short: "Simple file management tool",
        Long:  "filetool is a CLI tool for viewing and managing files.",
    }

    // Persistent flags
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output")

    // Add subcommands
    rootCmd.AddCommand(infoCmd())
    rootCmd.AddCommand(listCmd())
    rootCmd.AddCommand(searchCmd())

    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

// info subcommand: display file details
func infoCmd() *cobra.Command {
    return &cobra.Command{
        Use:   "info <file path>",
        Short: "Display file details",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            path := args[0]
            info, err := os.Stat(path)
            if err != nil {
                return fmt.Errorf("cannot access %q: %w", path, err)
            }

            fmt.Printf("File name:    %s\n", info.Name())
            fmt.Printf("Size:         %d bytes\n", info.Size())
            fmt.Printf("Modified:     %s\n", info.ModTime().Format("2006-01-02 15:04:05"))
            fmt.Printf("Is directory: %v\n", info.IsDir())
            fmt.Printf("Permissions:  %s\n", info.Mode())

            if verbose {
                fmt.Printf("Absolute path: ")
                abs, err := filepath.Abs(path)
                if err == nil {
                    fmt.Println(abs)
                }
                fmt.Printf("Extension:     %s\n", filepath.Ext(path))
            }
            return nil
        },
    }
}

// list subcommand: list directory contents
func listCmd() *cobra.Command {
    var showHidden bool

    cmd := &cobra.Command{
        Use:   "list [directory path]",
        Short: "List directory contents",
        Args:  cobra.MaximumNArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            dir := "."
            if len(args) > 0 {
                dir = args[0]
            }

            entries, err := os.ReadDir(dir)
            if err != nil {
                return fmt.Errorf("cannot read directory %q: %w", dir, err)
            }

            fmt.Printf("Directory: %s\n", dir)
            fmt.Println(strings.Repeat("─", 50))

            count := 0
            for _, entry := range entries {
                // Skip hidden files (unless -a is specified)
                if !showHidden && strings.HasPrefix(entry.Name(), ".") {
                    continue
                }

                info, err := entry.Info()
                if err != nil {
                    continue
                }

                // Add / suffix to directories
                name := entry.Name()
                if entry.IsDir() {
                    name += "/"
                }

                fmt.Printf("  %-30s %8d  %s\n",
                    name,
                    info.Size(),
                    info.ModTime().Format("2006-01-02 15:04"))
                count++
            }

            fmt.Printf("\nTotal %d items\n", count)
            return nil
        },
    }

    cmd.Flags().BoolVarP(&showHidden, "all", "a", false, "Show hidden files")
    return cmd
}

// search subcommand: search files by extension
func searchCmd() *cobra.Command {
    var maxDepth int

    cmd := &cobra.Command{
        Use:   "search <extension>",
        Short: "Search files by extension",
        Long:  "Search for files with the specified extension in the current directory and subdirectories. Example: filetool search .go",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            ext := args[0]
            if !strings.HasPrefix(ext, ".") {
                ext = "." + ext
            }

            root := "."
            found := 0

            err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
                if err != nil {
                    return nil // Skip inaccessible files
                }

                // Check depth
                if maxDepth > 0 {
                    depth := strings.Count(filepath.Clean(path), string(os.PathSeparator)) -
                        strings.Count(filepath.Clean(root), string(os.PathSeparator))
                    if depth > maxDepth {
                        if info.IsDir() {
                            return filepath.SkipDir
                        }
                        return nil
                    }
                }

                if !info.IsDir() && filepath.Ext(path) == ext {
                    fmt.Printf("  %s (%d bytes, %s)\n",
                        path,
                        info.Size(),
                        info.ModTime().Format("2006-01-02"))
                    found++
                }
                return nil
            })

            if err != nil {
                return fmt.Errorf("search error: %w", err)
            }

            fmt.Printf("\nFound %d %s files\n", found, ext)
            return nil
        },
    }

    cmd.Flags().IntVarP(&maxDepth, "depth", "d", 0, "Maximum search depth (0 means unlimited)")
    return cmd
}
▶ Try it Yourself
BASH
$ go run main.go info main.go
File name:    main.go
Size:         2048 bytes
Modified:     2025-06-27 14:30:00
Is directory: false
Permissions:  -rw-r--r--

$ go run main.go list -a
Directory: .
──────────────────────────────────────────────────
  .git/                              4096  2025-06-27 14:00
  main.go                            2048  2025-06-27 14:30
  go.mod                              128  2025-06-27 14:00

Total 3 items

$ go run main.go search .go -d 2
  ./main.go (2048 bytes, 2025-06-27)
  ./internal/handler.go (1024 bytes, 2025-06-26)

Found 2 .go files

Example: Complete Todo CLI Application (Difficulty ⭐⭐⭐)

GO
package main

import (
    "encoding/json"
    "fmt"
    "os"
    "path/filepath"
    "strconv"
    "strings"
    "time"

    "github.com/spf13/cobra"
)

// Todo item structure
type Todo struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"created_at"`
    DoneAt    *time.Time `json:"done_at,omitempty"`
}

// TodoList supports persistence
type TodoList struct {
    Todos    []Todo `json:"todos"`
    NextID   int    `json:"next_id"`
    filePath string
}

// NewTodoList creates or loads a todo list
func NewTodoList(filePath string) (*TodoList, error) {
    list := &TodoList{
        Todos:  []Todo{},
        NextID: 1,
        filePath: filePath,
    }

    // If file exists, load data
    if _, err := os.Stat(filePath); err == nil {
        data, err := os.ReadFile(filePath)
        if err != nil {
            return nil, fmt.Errorf("failed to read data file: %w", err)
        }
        if len(data) > 0 {
            if err := json.Unmarshal(data, list); err != nil {
                return nil, fmt.Errorf("failed to parse data file: %w", err)
            }
        }
    }

    return list, nil
}

// Save saves to file
func (tl *TodoList) Save() error {
    // Ensure directory exists
    dir := filepath.Dir(tl.filePath)
    if err := os.MkdirAll(dir, 0755); err != nil {
        return fmt.Errorf("failed to create directory: %w", err)
    }

    data, err := json.MarshalIndent(tl, "", "  ")
    if err != nil {
        return fmt.Errorf("failed to serialize data: %w", err)
    }

    return os.WriteFile(tl.filePath, data, 0644)
}

// Add adds a new todo
func (tl *TodoList) Add(title string) Todo {
    todo := Todo{
        ID:        tl.NextID,
        Title:     title,
        Done:      false,
        CreatedAt: time.Now(),
    }
    tl.Todos = append(tl.Todos, todo)
    tl.NextID++
    return todo
}

// Complete marks as done
func (tl *TodoList) Complete(id int) error {
    for i := range tl.Todos {
        if tl.Todos[i].ID == id {
            if tl.Todos[i].Done {
                return fmt.Errorf("task #%d is already completed", id)
            }
            tl.Todos[i].Done = true
            now := time.Now()
            tl.Todos[i].DoneAt = &now
            return nil
        }
    }
    return fmt.Errorf("task #%d not found", id)
}

// Delete deletes a todo
func (tl *TodoList) Delete(id int) error {
    for i, todo := range tl.Todos {
        if todo.ID == id {
            tl.Todos = append(tl.Todos[:i], tl.Todos[i+1:]...)
            return nil
        }
    }
    return fmt.Errorf("task #%d not found", id)
}

// Filter returns a filtered list
func (tl *TodoList) Filter(showDone bool) []Todo {
    var result []Todo
    for _, todo := range tl.Todos {
        if showDone || !todo.Done {
            result = append(result, todo)
        }
    }
    return result
}

// dataFilePath returns the default data file path
func dataFilePath() string {
    home, err := os.UserHomeDir()
    if err != nil {
        return ".todo.json"
    }
    return filepath.Join(home, ".todo.json")
}

func main() {
    dataFile := dataFilePath()

    rootCmd := &cobra.Command{
        Use:   "todo",
        Short: "Command-line todo manager",
        Long: `todo is a simple command-line todo manager.

Data is stored in ~/.todo.json.`,
    }

    // add subcommand
    var addCmd = &cobra.Command{
        Use:   "add <task title>",
        Short: "Add a new task",
        Args:  cobra.MinimumNArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            title := strings.Join(args, " ")
            list, err := NewTodoList(dataFile)
            if err != nil {
                return err
            }
            todo := list.Add(title)
            if err := list.Save(); err != nil {
                return err
            }
            fmt.Printf("✓ Task #%d added: %s\n", todo.ID, todo.Title)
            return nil
        },
    }

    // done subcommand
    var doneCmd = &cobra.Command{
        Use:   "done <task ID>",
        Short: "Mark task as completed",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            id, err := strconv.Atoi(args[0])
            if err != nil {
                return fmt.Errorf("invalid task ID: %q", args[0])
            }
            list, err := NewTodoList(dataFile)
            if err != nil {
                return err
            }
            if err := list.Complete(id); err != nil {
                return err
            }
            if err := list.Save(); err != nil {
                return err
            }
            fmt.Printf("✓ Task #%d completed!\n", id)
            return nil
        },
    }

    // list subcommand
    var showAll bool
    var listCmd = &cobra.Command{
        Use:   "list",
        Short: "View task list",
        RunE: func(cmd *cobra.Command, args []string) error {
            list, err := NewTodoList(dataFile)
            if err != nil {
                return err
            }

            todos := list.Filter(showAll)
            if len(todos) == 0 {
                if showAll {
                    fmt.Println("No tasks yet.")
                } else {
                    fmt.Println("No pending tasks! Use -a to see all tasks.")
                }
                return nil
            }

            fmt.Printf("%-4s %-8s %-30s %s\n", "ID", "Status", "Title", "Created")
            fmt.Println(strings.Repeat("─", 60))

            for _, todo := range todos {
                status := "○"
                if todo.Done {
                    status = "✓"
                }
                fmt.Printf("%-4d %-8s %-30s %s\n",
                    todo.ID,
                    status,
                    truncate(todo.Title, 28),
                    todo.CreatedAt.Format("01-02 15:04"))
            }

            // Statistics
            total := len(list.Todos)
            done := 0
            for _, t := range list.Todos {
                if t.Done {
                    done++
                }
            }
            fmt.Printf("\nTotal %d tasks, %d completed, %d pending\n", total, done, total-done)
            return nil
        },
    }
    listCmd.Flags().BoolVarP(&showAll, "all", "a", false, "Show all tasks (including completed)")

    // delete subcommand
    var deleteCmd = &cobra.Command{
        Use:   "delete <task ID>",
        Short: "Delete a task",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            id, err := strconv.Atoi(args[0])
            if err != nil {
                return fmt.Errorf("invalid task ID: %q", args[0])
            }
            list, err := NewTodoList(dataFile)
            if err != nil {
                return err
            }
            if err := list.Delete(id); err != nil {
                return err
            }
            if err := list.Save(); err != nil {
                return err
            }
            fmt.Printf("✓ Task #%d deleted\n", id)
            return nil
        },
    }

    // clear subcommand
    var clearCmd = &cobra.Command{
        Use:   "clear",
        Short: "Clear all completed tasks",
        RunE: func(cmd *cobra.Command, args []string) error {
            list, err := NewTodoList(dataFile)
            if err != nil {
                return err
            }

            before := len(list.Todos)
            var remaining []Todo
            for _, todo := range list.Todos {
                if !todo.Done {
                    remaining = append(remaining, todo)
                }
            }
            list.Todos = remaining

            removed := before - len(list.Todos)
            if removed == 0 {
                fmt.Println("No completed tasks to clear.")
                return nil
            }

            if err := list.Save(); err != nil {
                return err
            }
            fmt.Printf("✓ Cleared %d completed tasks\n", removed)
            return nil
        },
    }

    rootCmd.AddCommand(addCmd, doneCmd, listCmd, deleteCmd, clearCmd)

    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

// truncate truncates a string
func truncate(s string, maxLen int) string {
    runes := []rune(s)
    if len(runes) <= maxLen {
        return s
    }
    return string(runes[:maxLen-2]) + ".."
}
▶ Try it Yourself
BASH
$ todo add Learn Go CLI development
✓ Task #1 added: Learn Go CLI development

$ todo add Complete exercises
✓ Task #2 added: Complete exercises

$ todo add Write tech blog
✓ Task #3 added: Write tech blog

$ todo list
ID   Status   Title                          Created
────────────────────────────────────────────────────────────
1    ○        Learn Go CLI development        06-27 14:30
2    ○        Complete exercises              06-27 14:31
3    ○        Write tech blog                 06-27 14:32

Total 3 tasks, 0 completed, 3 pending

$ todo done 1
✓ Task #1 completed!

$ todo list -a
ID   Status   Title                          Created
────────────────────────────────────────────────────────────
1    ✓        Learn Go CLI development        06-27 14:30
2    ○        Complete exercises              06-27 14:31
3    ○        Write tech blog                 06-27 14:32

Total 3 tasks, 1 completed, 2 pending

$ todo clear
✓ Cleared 1 completed tasks

Real-World Scenarios

Scenario 1: Argument Validation and Custom Validation

GO
package main

import (
    "fmt"
    "net"
    "os"
    "strconv"
    "strings"

    "github.com/spf13/cobra"
)

// PortValidator validates port numbers
func PortValidator(s string) error {
    port, err := strconv.Atoi(s)
    if err != nil {
        return fmt.Errorf("port must be a number, received: %q", s)
    }
    if port < 1 || port > 65535 {
        return fmt.Errorf("port must be between 1-65535, received: %d", port)
    }
    return nil
}

// EmailValidator simple email format validation
func EmailValidator(email string) error {
    if !strings.Contains(email, "@") {
        return fmt.Errorf("invalid email format, missing @ symbol: %q", email)
    }
    parts := strings.SplitN(email, "@", 2)
    if len(parts[0]) == 0 || len(parts[1]) == 0 {
        return fmt.Errorf("invalid email format: %q", email)
    }
    if !strings.Contains(parts[1], ".") {
        return fmt.Errorf("invalid email domain format: %q", email)
    }
    return nil
}

// HostPortValidator validates host:port format
func HostPortValidator(addr string) error {
    host, port, err := net.SplitHostPort(addr)
    if err != nil {
        return fmt.Errorf("format should be host:port, received: %q, error: %v", addr, err)
    }
    if host == "" {
        return fmt.Errorf("hostname cannot be empty: %q", addr)
    }
    return PortValidator(port)
}

func main() {
    var email string
    var port int
    var serverAddr string

    rootCmd := &cobra.Command{
        Use:   "server",
        Short: "Start the server",
        PreRunE: func(cmd *cobra.Command, args []string) error {
            // Validate all arguments before execution
            if err := EmailValidator(email); err != nil {
                return fmt.Errorf("admin email: %w", err)
            }
            if err := HostPortValidator(serverAddr); err != nil {
                return fmt.Errorf("server address: %w", err)
            }
            return nil
        },
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Printf("Starting server...\n")
            fmt.Printf("  Address: %s\n", serverAddr)
            fmt.Printf("  Admin: %s\n", email)
            fmt.Printf("  Port: %d\n", port)
        },
    }

    rootCmd.Flags().StringVarP(&email, "email", "e", "admin@example.com", "Admin email")
    rootCmd.Flags().IntVarP(&port, "port", "p", 8080, "Port number")
    rootCmd.Flags().StringVarP(&serverAddr, "addr", "a", "localhost:8080", "Server address (host:port)")

    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}
BASH
$ go run main.go --addr "invalid"
Error: server address: format should be host:port, received: "invalid", error: address invalid: missing port in address

$ go run main.go --email "not-email"
Error: admin email: invalid email format, missing @ symbol: "not-email"

$ go run main.go -a "0.0.0.0:9090" -e "admin@mysite.com"
Starting server...
  Address: 0.0.0.0:9090
  Admin: admin@mysite.com
  Port: 8080

Scenario 2: Progress Bar and Colored Output

GO
package main

import (
    "fmt"
    "math"
    "os"
    "strings"
    "time"

    "github.com/spf13/cobra"
)

// ANSI color constants
const (
    ColorReset  = "\033[0m"
    ColorRed    = "\033[31m"
    ColorGreen  = "\033[32m"
    ColorYellow = "\033[33m"
    ColorBlue   = "\033[34m"
    ColorCyan   = "\033[36m"
    ColorBold   = "\033[1m"
)

// ProgressBar progress bar
type ProgressBar struct {
    total   int
    current int
    width   int
    label   string
}

// NewProgressBar creates a progress bar
func NewProgressBar(total int, label string) *ProgressBar {
    return &ProgressBar{
        total: total,
        width: 40,
        label: label,
    }
}

// Update updates progress and displays
func (p *ProgressBar) Update(current int) {
    p.current = current
    percent := float64(p.current) / float64(p.total)
    filled := int(math.Round(percent * float64(p.width)))

    bar := strings.Repeat("█", filled) + strings.Repeat("░", p.width-filled)

    // Color changes with progress
    color := ColorYellow
    if percent >= 0.8 {
        color = ColorGreen
    } else if percent >= 0.5 {
        color = ColorCyan
    }

    fmt.Fprintf(os.Stderr, "\r%s %s[%s%s%s] %s%d/%d%s (%.0f%%)",
        p.label,
        ColorBold,
        color, bar, ColorReset,
        ColorBold, p.current, p.total, ColorReset,
        percent*100)

    if p.current >= p.total {
        fmt.Fprintf(os.Stderr, "\n")
    }
}

func main() {
    rootCmd := &cobra.Command{
        Use:   "downloader",
        Short: "Simulated file downloader (progress bar demo)",
        Run: func(cmd *cobra.Command, args []string) {
            files := []string{
                "go1.21.0.linux-amd64.tar.gz",
                "docs.tar.gz",
                "examples.zip",
            }

            totalSteps := 100

            for _, file := range files {
                fmt.Printf("\n%sDownloading: %s%s\n", ColorBlue, file, ColorReset)

                bar := NewProgressBar(totalSteps, "  Progress")

                for i := 0; i <= totalSteps; i++ {
                    bar.Update(i)
                    // Simulate download delay
                    time.Sleep(20 * time.Millisecond)
                }

                fmt.Printf("  %s✓ Download complete%s\n", ColorGreen, ColorReset)
            }

            fmt.Printf("\n%s%sAll downloads complete!%s\n", ColorBold, ColorGreen, ColorReset)
        },
    }

    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}
BASH
$ go run main.go

Downloading: go1.21.0.linux-amd64.tar.gz
  Progress [████████████████████████████████████████] 100/100 (100%)
  ✓ Download complete

Downloading: docs.tar.gz
  Progress [████████████████████████████████████████] 100/100 (100%)
  ✓ Download complete

All downloads complete!

❓ FAQ

Q1: How does the flag package handle duplicate flag names?

GO
// The flag package does not allow duplicate flag name definitions, it will panic
// But you can define same-named flags in different subcommands (if implementing subcommand logic yourself)

// When using cobra, each command's Flags are independent and won't conflict
cmd1.Flags().StringVar(&name, "name", "", "command1's name")
cmd2.Flags().StringVar(&name, "name", "", "command2's name") // Completely OK

Q2: How to make cobra support persistent configuration files (like ~/.config/myapp.yaml)?

GO
import (
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

func initConfig() {
    viper.SetConfigName("config")  // Config file name (without extension)
    viper.SetConfigType("yaml")
    viper.AddConfigPath("$HOME/.config/myapp")
    viper.AddConfigPath(".")

    // Environment variable override
    viper.AutomaticEnv()

    // Read config file (allow non-existence)
    if err := viper.ReadInConfig(); err == nil {
        fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
    }
}

func main() {
    rootCmd := &cobra.Command{
        Use: "myapp",
        PersistentPreRun: func(cmd *cobra.Command, args []string) {
            initConfig()
        },
    }
    // ...
}
💡 Tip: viper is cobra's best companion, providing unified reading of config files, environment variables, and command-line flags. Priority: command-line flags > environment variables > config file > defaults.

Q3: What's the difference between os.Args and flag.Args()?

GO
// os.Args contains all raw arguments, including the program name
os.Args     // ["myapp", "-v", "hello", "world"]

// After flag.Parse():
// flag.Args() only contains non-flag arguments (positional arguments)
flag.Args() // ["hello", "world"]

// flag has consumed -v, it won't appear in Args()

Q4: How to handle subcommand Tab completion?

BASH
# cobra has built-in shell completion generation
$ todo completion bash > /etc/bash_completion.d/todo
$ todo completion zsh > "${fpath[1]}/_todo"
$ todo completion fish > ~/.config/fish/completions/todo.fish

# For custom flags, you can register completion functions
cmd.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
    return []string{"json", "yaml", "table"}, cobra.ShellCompDirectiveNoFileComp
})

📖 Summary

In this lesson we learned the core content of Go CLI tool development:

Topic Tool Key Points
Raw arguments os.Args String slice, [0] is the program name
Flag parsing flag package Typed flags, auto-help, positional arguments
CLI framework cobra Subcommands, persistent flags, auto-completion
Argument validation Custom functions PreRunE/Args validators
Interactive input bufio/term Line input reading, hidden password input

Key Takeaways:


📝 Exercises

Exercise 1: Environment Variable Viewer

Write a CLI tool envtool that supports the following features:

BASH
# List all environment variables
$ envtool list

# Search environment variables containing a keyword
$ envtool search PATH

# Get the value of a specific environment variable
$ envtool get GOPATH

# Set an environment variable (current process only)
$ envtool set MY_VAR=hello

Requirement: implement using cobra, support --format=json|table flag to control output format.

Exercise 2: Password Generator

Write a CLI password generator passgen:

BASH
# Generate default password (16 characters, including uppercase, lowercase letters and numbers)
$ passgen

# Specify length and character set
$ passgen -l 32 -s "abc123!@#"

# Batch generate
$ passgen -n 5

# Exclude ambiguous characters (0/O, 1/l/I)
$ passgen --no-ambiguous

Requirements:

Exercise 3: Batch File Processing Tool

Write a batch tool supporting batch file operations:

BASH
# Batch rename: add prefix
$ batch rename --prefix "2025_" *.jpg

# Batch case conversion
$ batch case --to lower *.TXT

# Batch statistics
$ batch stats ./docs

# Batch find and replace (simulated)
$ batch replace --old "foo" --new "bar" *.go

Requirement: implement using cobra subcommands, each operation as an independent subcommand.


Next Lesson

In the next lesson we'll learn about REST API Development, covering how to build RESTful API services with Go, including route design, middleware, JSON handling, database integration, and other practical skills.

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏