Skip to content

CLI Without Frameworks: Subcommand Routing

CLI Without Frameworks: Subcommand Routing

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Sheep and sheepctl are built without cobra, urfave/cli, or any other frameworks. Just os.Args and flag from the standard library. Two CLIs, ~1170 lines between them, 30+ commands. Here's how it's organized.

Sheep CLI - Routing

func main() {
    if len(os.Args) < 2 {
        printUsage()
        os.Exit(1)
    }

    // Commands that don't need a Manager
    switch os.Args[1] {
    case "init":
        handleInit()
        return
    case "version":
        fmt.Println("sheep v0.1.0")
        return
    case "help", "--help", "-h":
        printUsage()
        return
    }

    // Initialize Manager for the rest
    dataDir := os.Getenv("SHEEP_DATA_DIR")
    mgr := container.NewManager(dataDir)
    if err := mgr.Init(); err != nil {
        fatal("init: %v", err)
    }

    switch os.Args[1] {
    case "run":       cmdRun(mgr)
    case "create":    cmdCreate(mgr)
    case "start":     cmdStart(mgr)
    case "stop":      cmdStop(mgr)
    case "rm":        cmdRemove(mgr)
    case "ps":        cmdPs(mgr)
    case "inspect":   cmdInspect(mgr)
    case "images":    cmdImages(mgr)
    case "pull":      cmdPull(mgr)
    case "push":      cmdPush(mgr)
    case "tag":       cmdTag(mgr)
    case "import":    cmdImport(mgr)
    case "bootstrap": cmdBootstrap(mgr)
    case "rmi":       cmdRemoveImage(mgr)
    case "logs":      cmdLogs(mgr)
    default:
        fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
        printUsage()
        os.Exit(1)
    }
}

Two switches: the first for commands that don't need a Manager (init, version, help), the second for everything else. init is handled separately because it's the re-exec from the child process - no Manager needed inside the namespace.

Flag Parsing for run

parseRunFlags processes os.Args[2:] manually:

func parseRunFlags(args []string) container.RunOpts {
    opts := container.RunOpts{}

    for i := 0; i < len(args); i++ {
        switch args[i] {
        case "--name":
            i++
            if i < len(args) { opts.Name = args[i] }
        case "-d", "--detach":
            opts.Detach = true
        case "-m", "--memory":
            i++
            if i < len(args) {
                opts.Config.Memory = parseMemory(args[i])
            }
        case "--cpu-shares":
            i++
            if i < len(args) {
                v, _ := strconv.ParseInt(args[i], 10, 64)
                opts.Config.CPUShares = v
            }
        case "--cpu-quota":
            i++
            if i < len(args) {
                v, _ := strconv.ParseInt(args[i], 10, 64)
                opts.Config.CPUQuota = v
            }
        case "--pids-limit":
            i++
            if i < len(args) {
                v, _ := strconv.ParseInt(args[i], 10, 64)
                opts.Config.PidsLimit = v
            }
        case "--hostname", "-h":
            i++
            if i < len(args) {
                opts.Config.Hostname = args[i]
            }
        case "-e", "--env":
            i++
            if i < len(args) {
                opts.Config.Env = append(opts.Config.Env, args[i])
            }
        case "-v", "--volume":
            i++
            if i < len(args) {
                parts := strings.SplitN(args[i], ":", 3)
                if len(parts) >= 2 {
                    m := container.Mount{
                        Source: parts[0],
                        Target: parts[1],
                    }
                    if len(parts) == 3 && parts[2] == "ro" {
                        m.ReadOnly = true
                    }
                    opts.Mounts = append(opts.Mounts, m)
                }
            }
        case "-w", "--workdir":
            i++
            if i < len(args) {
                opts.Config.WorkDir = args[i]
            }
        default:
            // First non-flag = image, rest = command
            if opts.Image == "" {
                opts.Image = args[i]
            } else {
                opts.Command = append(opts.Command, args[i])
            }
        }
    }

    if opts.Image == "" { fatal("image is required") }
    if len(opts.Command) == 0 {
        opts.Command = []string{"/bin/sh"}
    }
    return opts
}

The pattern: flags with -- take a value (next argument), boolean flags (-d) don't. Everything without -- is a positional argument: first = image, rest = command.

This works like Docker:

sheep run --name web -m 256m -d -e PORT=8080 \
    -v /data:/app/data:ro nginx nginx -g "daemon off;"

Sheepctl CLI - flag Package

Sheepctl (the kubectl analog) uses flag for global flags:

// In sheepctl:
var (
    apiAddr   = flag.String("api", "localhost:9876", "API server")
    namespace = flag.String("n", "default", "Namespace")
)

func main() {
    flag.Parse()
    args := flag.Args()

    if len(args) == 0 { printUsage(); os.Exit(1) }

    switch args[0] {
    case "apply":    cmdApply(args[1:])
    case "get":      cmdGet(args[1:])
    case "delete":   cmdDelete(args[1:])
    case "describe": cmdDescribe(args[1:])
    case "scale":    cmdScale(args[1:])
    case "logs":     cmdLogs(args[1:])
    case "nodes":    cmdNodes()
    case "events":   cmdEvents()
    case "info":     cmdInfo()
    }
}

flag.Parse() handles -api and -n, flag.Args() returns the rest. This combines the flag package for global flags with manual switching for subcommands.

Formatted Output

Sheep has its own cli package for tables:

// internal/cli/table.go
type Table struct {
    headers []string
    rows    [][]string
    widths  []int
}

func NewTable(headers ...string) *Table {
    t := &Table{headers: headers}
    t.widths = make([]int, len(headers))
    for i, h := range headers {
        t.widths[i] = len(h)
    }
    return t
}

func (t *Table) AddRow(cols ...string) {
    t.rows = append(t.rows, cols)
    for i, c := range cols {
        if i < len(t.widths) && len(c) > t.widths[i] {
            t.widths[i] = len(c)
        }
    }
}

Usage in sheep ps:

func cmdPs(mgr *container.Manager) {
    all := false
    for _, arg := range os.Args[2:] {
        if arg == "-a" || arg == "--all" { all = true }
    }

    containers := mgr.List(all)
    tbl := cli.NewTable("CONTAINER ID", "IMAGE",
        "COMMAND", "CREATED", "STATUS", "NAME")

    for _, c := range containers {
        tbl.AddRow(
            container.ShortID(c.ID),
            c.Image,
            cli.Truncate(strings.Join(c.Command, " "), 30),
            timeAgo(c.CreatedAt),
            formatStatus(c),
            c.Name,
        )
    }
    tbl.Render(os.Stdout)
}

Output:

CONTAINER ID  IMAGE    COMMAND     CREATED   STATUS          NAME
a1b2c3d4e5f6  nginx    nginx -g    5m ago    Up 5m ago       web
f6e5d4c3b2a1  minimal  /bin/sh     1h ago    Exited (0) 30m  test

Manual Parsing vs cobra

os.Args + flag cobra
Dependencies 0 ~3 MB binary size
Autocompletion No Bash/Zsh/Fish
Nested commands Manual Built-in
Help generation Manual Automatic
Testing Direct function call Through cobra.Command
Lines of code ~100 for routing ~50 for setup

For Sheep with 15 commands and Sheepctl with 9, the manual approach is simpler and more transparent. For a CLI with 100+ commands and nested groups (like kubectl get, kubectl create, kubectl config), cobra makes sense.

What's Missing

Manual parsing doesn't validate unknown flags. sheep run --typo value nginx silently ignores --typo and value becomes the image name. Cobra would print "unknown flag: --typo". For a production CLI, that matters.

Two more traps in our approach:

  • Flags that take a value (--name, -m) silently break if the value is missing at the end of the line: sheep run nginx --name just ignores --name because i++ runs off the end of the slice. Cobra would say "flag needs an argument".
  • Mixed style: sheep parses by hand, sheepctl uses flag. So in sheep flags are written --api, but in sheepctl -api (single dash, because that's how the flag package works). The user has to remember the difference between the two binaries.

💡 Fun facts

  • The flag package deliberately doesn't treat GNU-style --flag (double dash) as distinct semantics: to it -n and --n are the same. That's why in Go tools you often see a single dash even for long names (go test -count, -run).
  • The Docker CLI was also once "frameworkless", but later moved to cobra - the same cobra used by kubectl, Hugo, and the GitHub CLI. spf13/cobra became the de facto CLI standard in the Go ecosystem.
  • os.Args[0] isn't the command, it's the path to the binary itself. Some tools (like BusyBox, or our sheep init) look at Args[0] to behave differently depending on the name they were invoked under.
  • kubectl has over 60 commands and tens of thousands of lines just for flag parsing and validation. What fits in ~100 lines of switch in Sheep is a whole large subsystem in a "grown-up" CLI.

What I figured out while digging into this

I used to reach for cobra reflexively in every new tool. Here it turned out that for 15 commands the entire "framework" is two switches and a manual loop over the arguments that reads top to bottom with zero magic. What surprised me most was how much convenience costs: cobra adds megabytes to the binary and a layer of abstraction you have to dig through while debugging. For a learning project, transparency turned out to be worth more than autocompletion. But the moment commands hit 30+ with nested groups, I'd give up and go back to cobra without a second thought.

What could be improved

  • Add unknown-flag validation: any argument starting with - that doesn't match in the switch should fail with an error rather than becoming the image name.
  • Generate --help for each subcommand from a single flag-description struct, instead of writing usage by hand and risking it drifting from the actual parser.
  • Unify sheep and sheepctl on one parsing style, so the user doesn't confuse --api and -api.
  • Add shell autocompletion (at least via a static bash-completion file) - that's the biggest thing the user misses compared to cobra.

Try It Yourself

./sheep help
./sheep run --help  # shows all flags
./sheep ps -a
./sheep images
./sheepctl info
./sheepctl get pods -n default

Next up - JSON serialization: one format for files, databases, APIs, and OCI manifests.

Resources

Source code for the series: github.com/igorgorovoy/sheep-shepherd-meadow

Previous: Goroutines for Control Loops | Next: JSON Serialization