Skip to main content

Building CLIs in Go with Cobra: Flags, Subcommands, and Shell Completion

·7 mins
Table of Contents
Cobra is the de-facto standard for serious Go CLIs. kubectl, the GitHub CLI, and Docker all use it. If you are building a command-line tool with multiple subcommands, typed flags, and shell completion, this is how you do it properly.

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.go
main.go
package main

import "github.com/example/fileorg/cmd"

func main() {
    cmd.Execute()
}

main.go is exactly three lines. All logic lives in cmd/.

Root Command
#

cmd/root.go
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
#

cmd/organize.go
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
}
Tip

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
#

cmd/stats.go
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.

cmd/completion.go
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.

cmd/stats_test.go
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:

.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.

Related