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
- The
flagpackage parses command-line arguments os.ArgsRaw Argument Accessos.Stdin/os.Stdoutstandard streams- The cobra library and the subcommand pattern
- CLI and File I/O Integration
- Project: Password Manager CLI (Generate/Store/Retrieve/Export Passwords)
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:
# 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
// 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))
}
}
$ 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 |
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
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)
}
$ 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
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
}
$ 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
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)
}
}
$ 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)
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)
}
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
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)")
}
}
}
$ echo "hello world" | go run main.go
HELLO WORLD
$ go run main.go hello world
HELLO WORLD
▶ Example: Interactive Input (fmt.Scanf)
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)
}
▶ Example: Redirecting os.Stdout
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")
}
(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.
# 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
// 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)
}
}
// 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)
}
// main.go
package main
import "passgen/cmd"
func main() {
cmd.Execute()
}
$ 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:
// 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:
# 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
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]
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
flag and cobra?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.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.os.Args?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:].~/.config/passgen.json); (2) Specify a path using the --store parameter; (3) Environment variables (such as PASSGEN_STORE).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.crypto/rand instead of math/rand—crypto/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.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).os.Stderr (rather than os.Stdout) so that mytool 2>/dev/null runs silently. Use fmt.Fprintln(os.Stderr, "error").📖 Summary
- The
flagpackage parses command-line arguments:flag.String/flag.Int/flag.Bool os.Argsaccesses the raw arguments, allowing for flexible manual parsingos.Stdin/os.Stdout/os.Stderrstandard I/O streamsflag.NewFlagSetimplements a subcommand-style CLI- The cobra library provides a complete framework for subcommands
crypto/randgenerates cryptographically secure random numbers- Data persistence for CLI tools: JSON files + in-memory map cache
go buildcompiles into a single binary for cross-platform distribution
📝 Exercises
-
Basic Problem (Difficulty ⭐): Use the
flagpackage to write awcclone:mywcsupports three flags—--lines,--words, and--chars—reads input from stdin or a file, and outputs the statistics. -
Advanced Exercise (Difficulty ⭐⭐): Add support for the
--storeenvironment variable to the password manager in this lesson: If thePASSGEN_STOREenvironment variable is set, use it as the storage path first; otherwise, use the--storeparameter; and finally, use the default path. -
Challenge (Difficulty: ⭐⭐⭐): Add a
statscommand 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 usesort.Slice+strings.Count+time.Time.