CLI Without Frameworks: Subcommand Routing¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
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 --namejust ignores--namebecausei++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 theflagpackage works). The user has to remember the difference between the two binaries.
💡 Fun facts¶
- The
flagpackage deliberately doesn't treat GNU-style--flag(double dash) as distinct semantics: to it-nand--nare 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 oursheep init) look atArgs[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
--helpfor 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
--apiand-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¶
- flag package: Go's built-in flag parser
- spf13/cobra: full-featured CLI framework, for comparison
Source code for the series: github.com/igorgorovoy/sheep-shepherd-meadow
Previous: Goroutines for Control Loops | Next: JSON Serialization
