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
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)
}
}
}
# Run test
$ go run main.go hello world
Number of arguments: 3
Program name: /tmp/go-build.../main
Passed arguments:
[0] hello
[1] world
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
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())
}
$ 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
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
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)
}
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
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)
}
}
$ 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
--help and -h, no need to implement manually. It also auto-generates usage hints when arguments are incorrect.
5. cobra Flag Binding
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)
}
}
$ go run main.go -n Alice -c 3
Hello, Alice! (1/3)
Hello, Alice! (2/3)
Hello, Alice! (3/3)
6. Interactive Input
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)
}
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)
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))
}
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 ⭐)
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)
}
$ 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 ⭐⭐)
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
}
$ 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 ⭐⭐⭐)
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]) + ".."
}
$ 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
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)
}
}
$ 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
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)
}
}
$ 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?
// 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)?
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()
},
}
// ...
}
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()?
// 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?
# 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:
- The
flagpackage is sufficient for simple tools, usecobrafor complex tools cobra'sPersistentFlagsapply to all subcommands,Flagsapply only to the current command- Argument validation should be handled uniformly in
PreRunE, keeping theRunfunction clean - Use JSON files for data persistence, stored in the user's home directory
- Progress bars and colored output can significantly improve CLI tool user experience
📝 Exercises
Exercise 1: Environment Variable Viewer
Write a CLI tool envtool that supports the following features:
# 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:
# 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:
- Implement using the
flagpackage - Password strength must be evaluated and displayed (weak/medium/strong)
- Generated results can be copied to clipboard (optional)
Exercise 3: Batch File Processing Tool
Write a batch tool supporting batch file operations:
# 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.



