Go in Practice: Developing CLI Tools

Building CLI tools is one of Go's strengths—they compile into a single binary, are cross-platform, and have zero dependencies. Writing CLIs in Go has become the standard in the cloud-native era.

From docker to kubectl to gh, almost all mainstream CLI tools are written in Go. In this lesson, you'll master all the core skills of CLI development and build a complete password manager.

1. You will learn



2. A True Story of an Operations Engineer

(1) Pain Point: Having to manually generate a password every time

Bob is an operations engineer who often needs to generate complex passwords for various services:

"Today I generate a password for PostgreSQL, tomorrow for Redis, and the day after tomorrow for an admin account. Every time, I have to open a password generator website, copy and paste it into 1Password—five times a day. It's so annoying. Plus, different websites have different requirements: 12 characters with special characters, 16 characters with no ambiguous characters..."

Bob is looking for a CLI tool like this:

BASH
# Desired CLI
$ passgen generate --length 16 --special
Generated: Kd9#mP2$xL7qR!vB

$ passgen save mydb -p Kd9#mP2$xL7qR!vB
Saved: mydb

$ passgen list
mydb    (created: 2026-07-08)
admin   (created: 2026-07-05)
api-key (created: 2026-07-01)

$ passgen get mydb
mydb: Kd9#mP2$xL7qR!vB

But there were no tools on the market that fully met his needs. So he decided to write one himself using Go.

(2) Go Solution: flag package + file I/O

GO
// passgen.go — Version 1: Basic password generation
package main

import (
    "crypto/rand"
    "flag"
    "fmt"
    "math/big"
)

var (
    length  = flag.Int("length", 12, "password length")
    special = flag.Bool("special", false, "include special characters")
    count   = flag.Int("count", 1, "number of passwords to generate")
)

const (
    lowerChars   = "abcdefghijklmnopqrstuvwxyz"
    upperChars   = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    digitChars   = "0123456789"
    specialChars = "!@#$%^&*()-_=+[]{}|;:,.<>?"
)

func generatePassword(length int, includeSpecial bool) string {
    charset := lowerChars + upperChars + digitChars
    if includeSpecial {
        charset += specialChars
    }

    pwd := make([]byte, length)
    for i := range pwd {
        n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
        pwd[i] = charset[n.Int64()]
    }
    return string(pwd)
}

func main() {
    flag.Parse()

    for i := 0; i < *count; i++ {
        fmt.Println(generatePassword(*length, *special))
    }
}
BASH
$ go run passgen.go --length 16 --special
Kd9#mP2$xL7qR!vB

$ go run passgen.go --length 8
aB3xK9mP

(3) Performance: Manual vs. CLI

Method Speed Repeatability CI/CD Integration Security
Website Generation 30s/time ❌ (Privacy Risk)
Create manually 60 seconds per instance ✅ Can be duplicated
Go CLI 0.5s per operation ✅ Scriptable ✅ Integratable ✅ Runs locally
💡 Tip: A Go binary is approximately 10 MB (without the runtime), and after running go build, you can simply copy it to any Linux, Mac, or Windows server to run it. This is the core advantage of using Go for CLI applications.



3. flag package: Command-line argument parsing

(1) Flag Basics

GO
package main

import (
    "flag"
    "fmt"
)

func main() {
    // Register parameters (returns pointers)
    name := flag.String("name", "World", "name to greet")
    age := flag.Int("age", 0, "age")
    verbose := flag.Bool("verbose", false, "verbose output")

    // Parse (must be called before reading values)
    flag.Parse()

    if *verbose {
        fmt.Printf("name=%s, age=%d\n", *name, *age)
    }
    fmt.Printf("Hello, %s!\n", *name)
}
BASH
$ go run main.go --name Alice --age 28 --verbose
name=Alice, age=28
Hello, Alice!

$ go run main.go
Hello, World!

(2) Quick Reference for the flag Type

Function Type Example
flag.String(name, default, usage) *string --name Alice
flag.Int(name, default, usage) *int --port 8080
flag.Float64(name, default, usage) *float64 --ratio 0.5
flag.Bool(name, default, usage) *bool --verbose
flag.Duration(name, default, usage) *time.Duration --timeout 5s

▶ Example: flag short parameter + non-flag parameter

GO
package main

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

func main() {
    // Custom Usage message
    flag.Usage = func() {
        fmt.Fprintf(os.Stderr, "Usage: mytool [options] <file path>\n\n")
        fmt.Fprintf(os.Stderr, "Options:\n")
        flag.PrintDefaults()
    }

    // Register parameters
    output := flag.String("o", "output.txt", "output file path")
    limit := flag.Int("n", 0, "lines to process (0=all)")
    debug := flag.Bool("debug", false, "enable debug mode")

    // Parse
    flag.Parse()

    // Access non-flag arguments (remaining args)
    args := flag.Args()
    if len(args) < 1 {
        flag.Usage()
        os.Exit(1)
    }

    filePath := args[0]
    fmt.Printf("Processing file: %s\n", filePath)
    fmt.Printf("Output: %s\n", *output)
    fmt.Printf("Lines: %d\n", *limit)
    fmt.Printf("Debug: %v\n", *debug)

    // Supports both -o and --o forms
    // Supports both -o=file and -o file syntax
}
▶ Try it Yourself
BASH
$ go run main.go -o result.txt -n 100 data.csv
Processing file: data.csv
Output: result.txt
Lines: 100
Debug: false


4. os.Args and os.Stdin/Stdout

(1) os.Args: Raw arguments

GO
package main

import (
    "fmt"
    "os"
)

func main() {
    fmt.Printf("Program: %s\n", os.Args[0])
    fmt.Printf("Arg count: %d\n", len(os.Args)-1)

    for i, arg := range os.Args[1:] {
        fmt.Printf("Arg %d: %s\n", i+1, arg)
    }
}
BASH
$ go run main.go hello world --debug
Program: /tmp/main
Arg count: 3
Arg 1: hello
Arg 2: world
Arg 3: --debug

▶ Example: Binding the flag variable (IntVar / StringVar)

GO
package main

import (
    "flag"
    "fmt"
)

func main() {
    // flag.Int returns a pointer vs flag.IntVar binds to an existing variable
    var (
        name   string
        port   int
        debug  bool
    )

    flag.StringVar(&name, "name", "World", "name")
    flag.IntVar(&port, "port", 8080, "port")
    flag.BoolVar(&debug, "debug", false, "debug mode")

    flag.Parse()

    fmt.Printf("name=%s, port=%d, debug=%v\n", name, port, debug)
}
▶ Try it Yourself
💡 Tip: flag.IntVar(&dest, ...) binds to an existing variable, while flag.Int(...) returns a pointer. Both are completely equivalent—choose whichever style you prefer. IntVar is better suited for declaring variables in a centralized location.

(3) os.Stdin: Pipe input

GO
package main

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

func main() {
    stat, _ := os.Stdin.Stat()
    hasStdin := (stat.Mode() & os.ModeCharDevice) == 0

    if hasStdin {
        // Read from pipe (echo "hello" | go run main.go)
        scanner := bufio.NewScanner(os.Stdin)
        for scanner.Scan() {
            line := strings.ToUpper(scanner.Text())
            fmt.Println(line)
        }
    } else {
        // Read from arguments (go run main.go hello)
        if len(os.Args) > 1 {
            fmt.Println(strings.ToUpper(strings.Join(os.Args[1:], " ")))
        } else {
            fmt.Println("Please provide input (pipe or arguments)")
        }
    }
}
BASH
$ echo "hello world" | go run main.go
HELLO WORLD

$ go run main.go hello world
HELLO WORLD

▶ Example: Interactive Input (fmt.Scanf)

GO
package main

import "fmt"

func main() {
    var name string
    var age int

    fmt.Print("Enter name: ")
    fmt.Scanf("%s", &name)

    fmt.Print("Enter age: ")
    fmt.Scanf("%d", &age)

    fmt.Printf("Hello %s, you are %d years old\n", name, age)
}
▶ Try it Yourself

▶ Example: Redirecting os.Stdout

GO
package main

import (
    "fmt"
    "os"
)

func main() {
    // Write directly to stdout
    fmt.Fprintln(os.Stdout, "Standard output")
    fmt.Fprintln(os.Stderr, "Error output")

    // Redirect to file
    file, _ := os.Create("output.log")
    defer file.Close()

    // fmt.Fprintln can write to any io.Writer
    fmt.Fprintln(file, "This line was written to the file")
    fmt.Fprintln(file, "Second line")
}
▶ Try it Yourself

(6) Comparison of Input Sources

Input Method Command Example Use Case
Command-line arguments mytool --name Alice Configuration options, filename
stdin pipe cat data | mytool Large amounts of data, chained processing
Interactive Input mytool waits for user input Input requiring guidance
Environment Variable MYTOOL_DEBUG=1 mytool Sensitive Configuration


5. cobra library: subcommand mode

(1) Introduction to Cobra

cobra is the most popular CLI framework for Go (written by the creator of Kubernetes), and supports subcommands, help messages, and autocompletion.

BASH
# Install cobra-cli
$ go install github.com/spf13/cobra-cli@latest

# Initialize project
$ cobra-cli init passgen
$ cobra-cli add generate
$ cobra-cli add save
$ cobra-cli add list

▶ Example: Structure of the "cobra" subcommand

GO
// cmd/root.go
package cmd

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "passgen",
    Short: "Password Manager",
    Long:  `A CLI tool for generating, storing, and managing passwords.`,
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
▶ Try it Yourself
GO
// cmd/generate.go
package cmd

import (
    "fmt"
    "crypto/rand"
    "math/big"

    "github.com/spf13/cobra"
)

var (
    genLength  int
    genSpecial bool
    genCount   int
)

var generateCmd = &cobra.Command{
    Use:   "generate",
    Short: "Generate a random password",
    Long:  `Generate a random password with specified length and complexity.`,
    Run: func(cmd *cobra.Command, args []string) {
        for i := 0; i < genCount; i++ {
            fmt.Println(generatePassword(genLength, genSpecial))
        }
    },
}

func init() {
    rootCmd.AddCommand(generateCmd)
    generateCmd.Flags().IntVarP(&genLength, "length", "l", 12, "password length")
    generateCmd.Flags().BoolVarP(&genSpecial, "special", "s", false, "include special characters")
    generateCmd.Flags().IntVarP(&genCount, "count", "c", 1, "number to generate")
}

func generatePassword(length int, special bool) string {
    charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
    if special {
        charset += "!@#$%^&*()-_=+[]{}|;:,.<>?"
    }
    pwd := make([]byte, length)
    for i := range pwd {
        n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
        pwd[i] = charset[n.Int64()]
    }
    return string(pwd)
}
GO
// main.go
package main

import "passgen/cmd"

func main() {
    cmd.Execute()
}
BASH
$ go build -o passgen
$ ./passgen generate --length 16 --special
Kd9#mP2$xL7qR!vB
$ ./passgen generate -l 8
aB3xK9mP

(3) flag vs cobra

Dimension flag package cobra
Built-in ✅ Standard Library ❌ Third-Party
Subcommand ❌ Not supported ✅ Natively supported
Auto Help ✅ Basics ✅ Aesthetics
Auto-completion ✅ bash/zsh/fish
Use Cases Simple Scripts Complex Multi-Command Tools


6. Complete Example: Password Manager CLI

Combine all this knowledge into a comprehensive password manager:

GO
// main.go
package main

import (
    "bufio"
    "crypto/rand"
    "encoding/json"
    "flag"
    "fmt"
    "math/big"
    "os"
    "strings"
    "time"
)

// ---------- Data Structures ----------

type PasswordEntry struct {
    Name      سلسلة    `json:"name"`
    Password  سلسلة    `json:"password"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at,omitempty"`
}

type PasswordStore struct {
    entries  map[سلسلة]PasswordEntry
    filePath سلسلة
}

const defaultStoreFile = "passwords.json"

// ---------- Storage Layer ----------

func NewPasswordStore(filePath سلسلة) *PasswordStore {
    store := &PasswordStore{
        entries:  make(map[سلسلة]PasswordEntry),
        filePath: filePath,
    }
    store.load()
    return store
}

func (s *PasswordStore) load() {
    data, err := os.ReadFile(s.filePath)
    if err != nil {
        return
    }
    json.Unmarshal(data, &s.entries)
}

func (s *PasswordStore) save() خطأ {
    data, err := json.MarshalIndent(s.entries, "", "  ")
    if err != nil {
        return err
    }
    return os.WriteFile(s.filePath, data, 0600)
}

func (s *PasswordStore) Add(name, password سلسلة) {
    now := time.Now()
    if existing, ok := s.entries[name]; ok {
        existing.Password = password
        existing.UpdatedAt = now
        s.entries[name] = existing
    } else {
        s.entries[name] = PasswordEntry{
            Name:      name,
            Password:  password,
            CreatedAt: now,
        }
    }
    s.save()
}

func (s *PasswordStore) Get(name سلسلة) (PasswordEntry, bool) {
    entry, ok := s.entries[name]
    return entry, ok
}

func (s *PasswordStore) Delete(name سلسلة) bool {
    _, ok := s.entries[name]
    if ok {
        delete(s.entries, name)
        s.save()
    }
    return ok
}

func (s *PasswordStore) List() []PasswordEntry {
    result := make([]PasswordEntry, 0, len(s.entries))
    for _, entry := range s.entries {
        result = append(result, entry)
    }
    return result
}

// ---------- Password Generation ----------

const (
    letters  = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
    digits   = "0123456789"
    specials = "!@#$%^&*()-_=+[]{}|;:,.<>?"
    noAmbigu = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"
)

func generatePassword(length int, useSpecial bool) (سلسلة, خطأ) {
    if length < 4 {
        return "", fmt.Errorf("password length must be at least 4")
    }

    charset := letters + digits
    if useSpecial {
        charset += specials
    }

    pwd := make([]byte, length)
    for i := range pwd {
        n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
        if err != nil {
            return "", err
        }
        pwd[i] = charset[n.Int64()]
    }
    return سلسلة(pwd), nil
}

// ---------- CLI Commands ----------

func cmdGenerate(args []سلسلة) {
    cmd := flag.NewFlagSet("generate", flag.ExitOnError)
    length := cmd.Int("length", 12, "password length")
    special := cmd.Bool("special", false, "include special characters")
    count := cmd.Int("count", 1, "number to generate")
    _ = cmd.Bool("no-ambiguous", false, "exclude ambiguous characters (0OIl1)")
    cmd.Parse(args)

    for i := 0; i < *count; i++ {
        pwd, err := generatePassword(*length, *special)
        if err != nil {
            fmt.Fprintf(os.Stderr, "Error: %v\n", err)
            os.Exit(1)
        }
        fmt.Println(pwd)
    }
}

func cmdSave(args []سلسلة, store *PasswordStore) {
    cmd := flag.NewFlagSet("save", flag.ExitOnError)
    password := cmd.String("password", "", "password (leave empty to auto-generate)")
    length := cmd.Int("length", 16, "auto-generated password length")
    cmd.Parse(args)

    name := cmd.Arg(0)
    if name == "" {
        fmt.Fprintln(os.Stderr, "Usage: passgen save <name> [--password <pwd>]")
        os.Exit(1)
    }

    pwd := *password
    if pwd == "" {
        var err خطأ
        pwd, err = generatePassword(*length, true)
        if err != nil {
            fmt.Fprintf(os.Stderr, "Error: %v\n", err)
            os.Exit(1)
        }
    }

    store.Add(name, pwd)
    fmt.Printf("Saved: %s = %s\n", name, pwd)
}

func cmdGet(args []سلسلة, store *PasswordStore) {
    if len(args) < 1 {
        fmt.Fprintln(os.Stderr, "Usage: passgen get <name>")
        os.Exit(1)
    }

    entry, ok := store.Get(args[0])
    if !ok {
        fmt.Fprintf(os.Stderr, "Not found: %s\n", args[0])
        os.Exit(1)
    }
    fmt.Printf("%s: %s\n", entry.Name, entry.Password)
}

func cmdList(args []سلسلة, store *PasswordStore) {
    entries := store.List()
    if len(entries) == 0 {
        fmt.Println("(no saved passwords)")
        return
    }

    fmt.Printf("%-20s %-20s %s\n", "Name", "Password", "Created")
    fmt.Println(strings.Repeat("-", 60))
    for _, e := range entries {
        pwd := e.Password
        if len(pwd) > 16 {
            pwd = pwd[:16] + "..."
        }
        fmt.Printf("%-20s %-20s %s\n",
            e.Name, pwd, e.CreatedAt.Format("2006-01-02"))
    }
}

func cmdDelete(args []سلسلة, store *PasswordStore) {
    if len(args) < 1 {
        fmt.Fprintln(os.Stderr, "Usage: passgen delete <name>")
        os.Exit(1)
    }

    if store.Delete(args[0]) {
        fmt.Printf("Deleted: %s\n", args[0])
    } else {
        fmt.Fprintf(os.Stderr, "Not found: %s\n", args[0])
    }
}

func cmdExport(args []سلسلة, store *PasswordStore) {
    output := "passwords_export.json"
    if len(args) > 0 {
        output = args[0]
    }

    data, _ := json.MarshalIndent(store.List(), "", "  ")
    os.WriteFile(output, data, 0600)
    fmt.Printf("Exported to: %s\n", output)
}

// ---------- Main Function ----------

func printUsage() {
    fmt.Println(`Password Manager CLI

Usage:
  passgen <command> [options]

Commands:
  generate    Generate a random password
  save        Save a password
  get         Retrieve a password
  list        List all passwords
  delete      Delete a password
  export      Export passwords to a file

Options:
  --store     Specify storage file path (default: passwords.json)

Examples:
  passgen generate --length 16 --special
  passgen save mydb --length 20
  passgen get mydb
  passgen list
  passgen delete mydb`)
}

func main() {
    // Global --store parameter
    storeFile := defaultStoreFile
    if len(os.Args) > 1 {
        for i := 1; i < len(os.Args); i++ {
            if os.Args[i] == "--store" && i+1 < len(os.Args) {
                storeFile = os.Args[i+1]
                // Remove the store parameter
                os.Args = append(os.Args[:i], os.Args[i+2:]...)
                break
            }
        }
    }

    store := NewPasswordStore(storeFile)

    if len(os.Args) < 2 {
        printUsage()
        return
    }

    command := os.Args[1]
    args := os.Args[2:]

    switch command {
    case "generate":
        cmdGenerate(args)
    case "save":
        cmdSave(args, store)
    case "get":
        cmdGet(args, store)
    case "list":
        cmdList(args, store)
    case "delete":
        cmdDelete(args, store)
    case "export":
        cmdExport(args, store)
    case "help", "--help", "-h":
        printUsage()
    default:
        fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
        fmt.Fprintf(os.Stderr, "Run 'passgen help' to see available commands\n")
        os.Exit(1)
    }
}

Intended Use:

BASH
# Generate password
$ go run main.go generate --length 16 --special
Kd9#mP2$xL7qR!vB

# Save password (auto-generated)
$ go run main.go save mydb --length 20
Saved: mydb = aB3xK9mP$L7qR!vD2nFg

# Save password (manually specified)
$ go run main.go save admin --password "MyStr0ng!Pass"
Saved: admin = MyStr0ng!Pass

# Retrieve password
$ go run main.go get mydb
mydb: aB3xK9mP$L7qR!vD2nFg

# List
$ go run main.go list
Name                 Password              Created
------------------------------------------------------------
mydb                 aB3xK9mP$L7qR!v...   2026-07-08
admin                MyStr0ng!Pass         2026-07-08

# Export
$ go run main.go export
Exported to: passwords_export.json

# Specify storage file
$ go run main.go --store vault.json generate -l 10
100%
flowchart TD
    A[passgen command] --> B{switch command}
    B -->|generate| C[flag.NewFlagSet parse<br/>--length --special --count]
    B -->|save| D[Parse name + --password<br/>Auto-generate or specify password]
    B -->|get| E[Look up by name from map]
    B -->|list| F[Iterate map, output table]
    B -->|delete| G[delete(map, name)]
    B -->|export| H[json.Marshal + WriteFile]
    C --> I[rand.Int secure random]
    D --> J[store.Add → json → save]
    E --> K[store.Get → output]
    F --> L[store.List → format]
    G --> M[store.Delete → json → save]
    H --> N[store.List → json.Marshal]
🔥 Common Mistake: flag.NewFlagSet is used for subcommands, and each subcommand has its own flag set. flag.FlagSet.Parse only parses its own arguments and does not modify os.Args. The main command must manually assign values using os.Args.


❓ FAQ

Q How do I choose between flag and cobra?
A Simple scripts (1 command, a few parameters) → flag; multi-command tools (git-style: tool <command> [options]) → cobra. flag is part of the standard library, while cobra offers more features but requires adding a dependency.
Q How do I parse subcommands?
A Manual method: Use os.Args[1] to identify the command, and flag.NewFlagSet("sub", ...) to parse the subcommand arguments. Cobra method: Use rootCmd.AddCommand(subCmd), and the framework handles it automatically.
Q How do I iterate through os.Args?
A os.Args[0] is the program path, and os.Args[1:] are the arguments. You can iterate through them using os.Args[i] or range os.Args[1:].
Q How is file I/O used in the CLI?
A There are three modes: (1) Save to a fixed path (~/.config/passgen.json); (2) Specify a path using the --store parameter; (3) Environment variables (such as PASSGEN_STORE).
Q How do I publish a CLI to GitHub?
A (1) Compile with go build -o mytool; (2) Upload to GitHub Releases; (3) Install using go install github.com/user/mytool@latest. Go's static binaries make publishing extremely simple.
Q How does the password generator ensure security?
A Use crypto/rand instead of math/randcrypto/rand uses the operating system's CSPRNG (such as /dev/urandom), which is unpredictable. math/rand is pseudo-random and not suitable for cryptographic purposes.
Q How can a CLI tool handle simple user input?
A Use bufio.NewScanner(os.Stdin) to read line by line, or fmt.Scanf to read formatted input. For production tools, we recommend the gopass library (which does not echo passwords).
Q How should error messages be handled in the CLI?
A Error messages should be written to os.Stderr (rather than os.Stdout) so that mytool 2>/dev/null runs silently. Use fmt.Fprintln(os.Stderr, "error").

📖 Summary


📝 Exercises

  1. Basic Problem (Difficulty ⭐): Use the flag package to write a wc clone: mywc supports three flags—--lines, --words, and --chars—reads input from stdin or a file, and outputs the statistics.

  2. Advanced Exercise (Difficulty ⭐⭐): Add support for the --store environment variable to the password manager in this lesson: If the PASSGEN_STORE environment variable is set, use it as the storage path first; otherwise, use the --store parameter; and finally, use the default path.

  3. Challenge (Difficulty: ⭐⭐⭐): Add a stats command to the password manager: count the total number of passwords, the longest and shortest passwords, the most frequently used characters, and statistics grouped by creation time (today/this week/this month/earlier). You must use sort.Slice + strings.Count + time.Time.

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%

🙏 帮我们做得更好

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

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