Raw os.Args parsing works for a single command with one positional argument. The moment you add a second subcommand or a --dry-run flag, you are reinventing the wheel. Cobra handles argument parsing, flag validation, help text generation, and shell completion out of the box.
We will build a fileorg tool: an organizer that sorts files by extension and reports statistics. The example is simple on purpose – the patterns apply to any CLI.
Project Structure#
The cmd/ directory pattern keeps main.go tiny and makes each subcommand independently testable.
fileorg/
cmd/
root.go # root command, persistent flags
organize.go # organize subcommand
stats.go # stats subcommand
internal/
fs/
scanner.go # file scanning logic
main.gopackage main
import "github.com/example/fileorg/cmd"
func main() {
cmd.Execute()
}main.go is exactly three lines. All logic lives in cmd/.
Root Command#
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"go.uber.org/zap"
)
var (
verbose bool
logger *zap.SugaredLogger
)
var rootCmd = &cobra.Command{
Use: "fileorg",
Short: "Organize and analyze files by extension",
Long: `fileorg organizes files into subdirectories by extension
and provides statistics about file distribution.`,
Version: "1.0.0",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Common setup that runs before every subcommand
cfg := zap.NewProductionConfig()
if verbose {
cfg.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
}
log, err := cfg.Build()
if err != nil {
return fmt.Errorf("initializing logger: %w", err)
}
logger = log.Sugar()
return nil
},
}
func Execute() {
cobra.CheckErr(rootCmd.Execute())
}
func init() {
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable debug logging")
}PersistentPreRunE runs before every subcommand. Use it for common initialization – loggers, config files, database connections. If it returns an error, Cobra prints the error and exits non-zero automatically.
The organize Subcommand#
package cmd
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/spf13/cobra"
)
var (
source string
dryRun bool
)
var organizeCmd = &cobra.Command{
Use: "organize",
Short: "Move files into subdirectories by extension",
Example: ` fileorg organize --source /tmp/downloads
fileorg organize --source /tmp/downloads --dry-run`,
RunE: func(cmd *cobra.Command, args []string) error {
return runOrganize(source, dryRun)
},
}
func init() {
organizeCmd.Flags().StringVar(&source, "source", "", "directory to organize")
organizeCmd.Flags().BoolVar(&dryRun, "dry-run", false, "print actions without executing")
// required flags fail early with a clear error message
cobra.CheckErr(organizeCmd.MarkFlagRequired("source"))
rootCmd.AddCommand(organizeCmd)
}
func runOrganize(dir string, dryRun bool) error {
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("reading directory %s: %w", dir, err)
}
moved := 0
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := filepath.Ext(entry.Name())
if ext == "" {
ext = "no-extension"
} else {
ext = ext[1:] // strip the leading dot
}
destDir := filepath.Join(dir, ext)
src := filepath.Join(dir, entry.Name())
dst := filepath.Join(destDir, entry.Name())
if dryRun {
fmt.Printf("would move: %s -> %s\n", src, dst)
continue
}
if err := os.MkdirAll(destDir, fs.ModePerm); err != nil {
return fmt.Errorf("creating directory %s: %w", destDir, err)
}
if err := os.Rename(src, dst); err != nil {
logger.Warnw("failed to move file", "src", src, "dst", dst, "error", err)
continue
}
moved++
}
if !dryRun {
fmt.Printf("moved %d files\n", moved)
}
return nil
}Use RunE rather than Run for all subcommands. RunE lets you return errors naturally; Cobra handles printing them and setting the exit code. Run forces you to call os.Exit manually, which prevents deferred cleanup from running.
The stats Subcommand#
package cmd
import (
"fmt"
"os"
"path/filepath"
"sort"
"text/tabwriter"
"github.com/spf13/cobra"
)
var statsCmd = &cobra.Command{
Use: "stats",
Short: "Show file extension distribution in a directory",
Args: cobra.ExactArgs(1), // positional argument validation
RunE: func(cmd *cobra.Command, args []string) error {
return runStats(args[0])
},
}
func init() {
rootCmd.AddCommand(statsCmd)
}
func runStats(dir string) error {
counts := make(map[string]int)
total := 0
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("reading directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := filepath.Ext(entry.Name())
if ext == "" {
ext = "(none)"
}
counts[ext]++
total++
}
// Sort extensions by count descending
type row struct {
ext string
count int
}
rows := make([]row, 0, len(counts))
for ext, n := range counts {
rows = append(rows, row{ext, n})
}
sort.Slice(rows, func(i, j int) bool {
return rows[i].count > rows[j].count
})
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, "EXTENSION\tCOUNT\tPERCENT")
fmt.Fprintln(w, "---------\t-----\t-------")
for _, r := range rows {
pct := float64(r.count) / float64(total) * 100
fmt.Fprintf(w, "%s\t%d\t%.1f%%\n", r.ext, r.count, pct)
}
w.Flush()
fmt.Printf("\ntotal: %d files\n", total)
return nil
}Persistent Flags vs Local Flags#
Persistent flags (added via PersistentFlags()) are inherited by all subcommands. Local flags (added via Flags()) apply only to the command they are defined on.
// Available to all subcommands
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "...")
// Only available to organize
organizeCmd.Flags().BoolVar(&dryRun, "dry-run", false, "...")Use MarkFlagRequired to enforce required flags at the Cobra layer rather than in your business logic:
cobra.CheckErr(organizeCmd.MarkFlagRequired("source"))When --source is omitted, Cobra prints required flag(s) "source" not set and exits 1 before RunE is ever called.
Shell Completion#
Cobra generates completion scripts for bash, zsh, fish, and PowerShell automatically.
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion script",
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.ExactValidArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return rootCmd.GenBashCompletion(os.Stdout)
case "zsh":
return rootCmd.GenZshCompletion(os.Stdout)
case "fish":
return rootCmd.GenFishCompletion(os.Stdout, true)
case "powershell":
return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
}
return nil
},
}
func init() {
rootCmd.AddCommand(completionCmd)
}Install for zsh:
fileorg completion zsh > "${fpath[1]}/_fileorg"Testing CLI Commands#
The key to testable CLI commands is writing output to a configurable io.Writer rather than always using os.Stdout. Set the command’s output to a bytes.Buffer in tests.
package cmd
import (
"bytes"
"os"
"path/filepath"
"testing"
)
func TestStatsCommand(t *testing.T) {
// Create a temp dir with known files
dir := t.TempDir()
for _, name := range []string{"a.go", "b.go", "c.py", "d.txt"} {
f, _ := os.Create(filepath.Join(dir, name))
f.Close()
}
buf := new(bytes.Buffer)
statsCmd.SetOut(buf)
statsCmd.SetErr(buf)
statsCmd.SetArgs([]string{dir})
err := statsCmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := buf.String()
if !bytes.Contains([]byte(out), []byte(".go")) {
t.Errorf("expected .go in output, got:\n%s", out)
}
}Building and Distributing#
# Single binary for the current platform
go build -ldflags="-s -w" -o fileorg ./...
# Cross-compile for Linux amd64
GOOS=linux GOARCH=amd64 go build -o fileorg-linux-amd64 ./...For automated multi-platform releases, GoReleaser handles cross-compilation, checksums, and GitHub release creation from a single .goreleaser.yaml:
builds:
- env: [CGO_ENABLED=0]
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
archives:
- format: tar.gz
format_overrides:
- goos: windows
format: zip
checksum:
name_template: "checksums.txt"Common mistakes and how to avoid them
Global state in commands
Declaring flag variables as package-level vars works for small tools but causes test pollution when multiple tests run in the same process. Prefer passing flags as function arguments or wrapping commands in a struct with a NewRootCmd() *cobra.Command constructor.
Not using PersistentPreRunE
Repeating logger initialization or config loading in every subcommand’s RunE is fragile. Put shared setup in PersistentPreRunE on the root command. If a subcommand overrides PreRunE, it must call parent.PersistentPreRunE explicitly, or the parent’s setup is skipped.
Ignoring exit codes
cmd.Execute() returns an error. If you call it without cobra.CheckErr or your own exit-code handling, your binary exits 0 even when a subcommand fails. Callers in shell scripts silently treat failures as success.
No shell completion Users who install your tool via Homebrew or a package manager expect tab completion to work. It takes about 20 lines to add (shown above). Skip it and your tool feels unpolished next to kubectl or git.
Using ioutil.ReadDir (deprecated)
ioutil.ReadDir was deprecated in Go 1.16. Use os.ReadDir instead – it returns []os.DirEntry which is more efficient because it avoids a Stat call per file.
If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.