CLI Design in Go: Six Projects Compared#

Summary#

Six Go projects — cobra (a library), fzf (a single-command tool), gh (a large multi-command CLI), rclone (a plugin-registry CLI with a daemon API), restic (a strict monolith with no public API), and crush (a TUI agent with an optional REST split) — demonstrate the full spectrum of CLI design in Go. The clearest through-line is that cobra has become the de facto standard for command dispatch, but each project diverges sharply in how it handles flags, configuration layering, machine-readable output, and extensibility. The differences are not accidental: they reflect deliberate trade-offs driven by each project’s user base, security constraints, and operational context.


Comparison dimensions#

CLI framework choice#

ProjectFrameworkRationale
cobraN/A — is the frameworkDefines the standard
fzfCustom hand-rolled parserBinary size; exotic flag syntax (--no-*, --1, env-var layering)
ghCobra (deep integration)Large command surface; first-class help and completion
rcloneCobra + init()-based self-registrationPlugin architecture; commands register themselves
resticCobra + pflagClean separation; each command is a standalone constructor
crushCobra + charm.land/fangAdds Ctrl-C handling and color-profile-aware output

Cobra as commodity. Four of the six projects use Cobra. It handles flag parsing, help generation, shell completion, and subcommand routing. Once a project reaches more than 3-4 commands with distinct flag sets, the cost of a custom flag parser exceeds the binary-size savings.

fzf’s custom parser is a deliberate exception. fzf’s 177 flags include syntactic features (--no-<flag>, +1/-1 numeric flags, FZF_DEFAULT_OPTS_FILE layering) that no standard parser supports. The hand-rolled parser also contributes to a measured 2.5MB binary size advantage over what net/http + cobra would cost. This is a defensible choice for a tool installed on millions of machines, but would be inappropriate for a team maintaining a project long-term.

charm.land/fang (crush) is a thin wrapper — it adds Ctrl-C signal handling and colorprofile detection to Cobra. It’s worth noting as an emerging pattern: thin Cobra wrappers that add project-specific lifecycle concerns without forking cobra itself.


Command structure: flat vs hierarchical#

ProjectTop-level commandsDepthStrategy
fzf1 (the binary itself)0 subcommandsEverything is a flag
restic282 (e.g. key list, repair index)One file per command
rclone60+3 (e.g. serve http remote:path)Self-registered via init()
gh35 top-level, 150+ leaf3 (e.g. gh pr review --approve)Factory-injected constructors
crush122 (e.g. session list)Small surface, REST API beneath
cobra3 built-in (help, completion, __complete)1Injected into every consumer app

Command count correlates with problem domain breadth, not code quality. rclone’s 60+ commands reflect the breadth of cloud storage operations, not architectural sprawl. restic’s 28 commands reflect a complete backup lifecycle. crush’s 12 commands are minimal because the primary UX surface is the TUI, not the CLI.

Self-registration (rclone) vs central registry (gh, restic). rclone commands call cmd.Root.AddCommand(...) in their own init() functions — the root command never enumerates its children explicitly. This enables compile-time subsetting (build without certain commands by omitting their blank imports). gh and restic register all commands in a single location (root.NewCmdRoot() / newRootCommand()), which is more readable and easier to audit but requires the registry to know about every command package.


Flag organization patterns#

ProjectPatternGlobal flagsPer-command pattern
cobraN/A (framework)Package-level booleansConsumer-defined
fzfAll flags on root177 flags, no persistentSingle Options struct
ghCobra persistent + per-command structs--repo, --helpOptions struct + AddJSONFlags()
rcloneGlobal ConfigInfo struct bound to pflag~30 globalBackend-specific as --backend-flag
resticglobal.Options passed by pointer~24 globalPer-command <Cmd>Options.AddFlags()
crushCobra persistent--cwd, --data-dir, --debug, --hostPer-command Flags() inline

The global-options-as-struct pattern (rclone, restic) is the cleanest pattern for security-sensitive tools. Both rclone and restic define a ConfigInfo / global.Options struct that holds all global configuration. This struct is passed explicitly to every command (restic) or accessed via context injection (fs.GetConfig(ctx) in rclone). The advantages: configuration is auditable in one place, there are no hidden globals, and the struct can be serialized for logging. The disadvantage: the struct grows large (~24 fields in restic’s case) and requires discipline to avoid becoming a kitchen sink.

gh’s cmdutil.AddJSONFlags() is a reuse pattern worth naming. Because 50+ gh commands share the --json/--jq/--template output flags, gh extracts these into a single utility function cmdutil.AddJSONFlags(cmd, &opts.Exporter, fieldList). This ensures every list/view command is machine-readable without per-command implementation effort. Restic achieves the same goal with a single --json global flag; the approaches differ in granularity (field selection vs all-or-nothing).

Backend-specific flags via fs.Option (rclone) eliminates flag namespace collisions. rclone backends declare their options as []fs.Option in their RegInfo. The config system automatically namespaces them as --backendname-optionname. No command package needs to know about backend-specific flags; backends never call cmd.Flags().StringVar() directly. This is a sophisticated solution to the “flag explosion” problem in extensible systems.


Machine-readable output#

ProjectStrategyFormatGranularity
fzfNot applicable (interactive tool)
cobraNot applicable (library)
gh--json fields --jq expr --template tmplJSON (field-selectable)Per-command, 50+ commands
rclone--use-json-log, JSON-RPC daemon APIJSON for daemon; text for CLIPartial
restic--json global flagJSON (typed by message_type)All 20+ data commands
crush--json on session subcommands; SSE events in server modeJSON + SSESession management only

Restic’s message_type discriminator is the most systematic approach. Each command emits a stream of typed JSON objects ({"message_type":"status", ...}, {"message_type":"summary", ...}). This allows a single json.Decoder to multiplex different event types without versioned schemas. The result: third-party tools like resticprofile and Prometheus exporters parse restic output without a Go SDK.

gh’s --json/--jq/--template pattern is more powerful but harder to implement. Field selection (--json name,body,state) avoids transmitting unused data and makes the output self-documenting. The --jq and --template post-processors make simple transformations expressible without a separate jq process. But the pattern requires per-command field manifests, and maintaining field lists for 50+ commands is ongoing work.

Crush’s explicit --json on session subcommands signals agent-as-caller awareness: the session list --json pattern is designed for agents that call crush session list to enumerate sessions, parse JSON, and take action. This is a forward-looking design in the context of AI-assisted workflows.


Extension / plugin mechanisms#

ProjectMechanismRegistrationRuntime loading
fzfShell execute(CMD), HTTP action API, transform(CMD) DSLNoneVia shell
ghgh-* executables discovered at startupFilesystem scanSubprocess exec
rclonefs.Register() in init() for backends; rc.Add() for RC endpointsCompile-time blank importNo
resticlocation.Factory registryCompile-time backend/allNo
crushMCP (Model Context Protocol) over stdio/HTTPConfig-driven at startupAt startup (not hot)
cobraCommand.AddCommand() — tree compositionConsumer codeN/A

gh’s extension system is the most user-visible. gh-* executables installed under ~/.local/share/gh/extensions/ are discovered at startup and registered as first-class Cobra subcommands — complete with --help routing and tab completion. The extension manager injects GH_TOKEN and GH_HOST automatically, so extensions get authenticated GitHub access for free. This transforms gh from a CLI into a platform.

rclone and restic both use compile-time blank-import registration, but for different purposes. In rclone, this enables a build without certain backends (reduced binary). In restic, it is less about configurability and more about organizational clarity — all backends are always included.

fzf’s transform(CMD) action is unique. The ability to POST a shell command whose stdout is interpreted as more fzf actions creates a Turing-complete extension mechanism without a plugin API. This is “configuration as code” taken to an extreme: the extension point is a shell escape hatch that speaks the same action language as key bindings and the HTTP control plane. Its power comes from uniformity — one action vocabulary, three invocation contexts.

crush’s MCP integration is notable as the only project in the set that uses an external protocol (MCP JSON-RPC over stdio) for extensibility. This reflects crush’s domain — an LLM agent — where the relevant extension point is the tool/resource surface available to the model, not the CLI command surface.


Configuration hierarchy#

ProjectSources (highest to lowest priority)Config file formatEnv-var prefix
fzfCLI flags → FZF_DEFAULT_OPTS_FILEFZF_DEFAULT_OPTS → defaultsPlain text (options file)FZF_
ghGH_* env → config YAML → host YAML → compiled defaultsYAMLGH_
rcloneRCLONE_* env → CLI flags → config INI → backend defaultsINIRCLONE_
resticCLI flags → RESTIC_* env → defaultsNone (by design)RESTIC_
crushFlag overrides → env templates → project JSON → global JSON → built-in defaultsJSON (w/ schema)Custom ($VAR)
cobraPackage-level booleans (no layering)N/AN/A

restic’s deliberate no-config-file policy stands out. For a security-critical backup tool, the rationale is compelling: every invocation must be explicit and auditable. Environment variables are the compromise — they allow scripted invocations without visible config files — but there is no ~/.resticrc. This is a principled stance that constrains the user experience to enforce operational clarity.

crush’s variable resolver ($ANTHROPIC_API_KEY, $(command)) in config values is a pragmatic approach to secret injection. API keys are kept in env vars or a command output (e.g., from a secrets manager), not in config files. The JSON schema (schema.json) provides editor validation — a rare feature in CLI config systems.

The RCLONE_* env override pattern (all backends, all flags) is the most comprehensive. Every backend option that can be set in rclone.conf can be overridden with RCLONE_<SECTION>_<KEY>. This is critical for containerized deployments where config files are awkward but env vars are natural.


Output modes and exit codes#

ProjectExit codesHuman/machine splitNotable design
fzf0 match, 1 no-match, 2 error, 130 abortN/A (interactive)Exit code = match result
gh0 OK, 1 error, 2 cancel, 4 auth, 8 pending--json/--jq/--templateAuth failure distinguishable from errors
rclone0 OK, 1 error (generic)--use-json-log for machineLess systematic
restic0 OK, 1 error, 3 incomplete, 10/11/12 repo-specific, 130 SIGINT--json globalMost granular repo-specific codes
crushStandard 0/1 + special handling--json on session commandsInherits cobra defaults
cobraConsumer-definedConsumer-definedErrHelp → 0

Restic’s exit code taxonomy is the most useful for automation. The distinction between codes 10 (no repository), 11 (locked), and 12 (wrong password) lets backup automation scripts take different remediation actions without parsing stderr. gh’s exitAuth=4 serves the same purpose in CI pipelines: “retry after re-auth” is different from “fail the build.”

fzf’s exit codes encode the result, not just success/failure. exit 0 = match found; exit 1 = no match. This makes fzf usable in shell conditionals (if fzf < list; then...) without parsing stdout — a clean design for a filter tool.


Common patterns#

All projects share: cobra for dispatch (5/6), pflag-compatible flag sets, --help by convention, shell completion (cobra provides it for free; fzf ships its own), and at least one machine-readable output mode.

All Cobra users share: lazy initialization of help, __complete, and completion subcommands; SilenceErrors/SilenceUsage for error handling; PersistentPreRunE for cross-cutting concerns (auth check in gh, options validation in restic).

The Options struct + AddFlags() pattern (used explicitly in restic; implicitly in gh’s per-command structs) is the idiomatic approach for testable Cobra commands: the struct is populated by flag parsing, then passed to a run(opts) function. Tests populate the struct directly, bypassing Cobra entirely. This pattern appears in 4/5 Cobra users.

Manual dependency injection is universal. Zero projects use wire, dig, or fx. The complexity of DI frameworks is not justified for CLIs where the dependency graph is shallow and the composition happens once at startup.


Divergent choices#

Framework vs. no framework. fzf’s custom parser is the clearest divergence. It enables exotic syntax and saves binary size, but at the cost of maintenance burden and non-standard behavior. For a project of fzf’s longevity (authored and maintained by one developer), this is a justified trade-off. For teams, it would be a liability.

Cobra-is-the-framework vs. Cobra-is-a-library. gh treats Cobra as a full framework: it relies on Cobra’s help formatting, completion engine, and group support, and customizes cobra internals (custom usage template, --cobra env var to opt out of overrides). Restic and rclone treat Cobra as a thin dispatch layer: they override nothing and rely on the default behavior. The gh approach gives a richer user experience but couples the codebase more tightly to cobra’s internal API.

Binary as library vs. binary only. Rclone provides a C shared library (librclone) using the same JSON-RPC protocol as its HTTP daemon — zero additional API to maintain. Cobra provides a documented Go library with strong backward-compatibility guarantees. Restic explicitly refuses to provide a library API, citing correctness concerns (callers might misuse the encryption/locking invariants). gh exposes pkg/ packages as an ecosystem affordance, without formal versioning. These represent four distinct positions on the “should CLIs be libraries?” question.

Process model. Crush’s client/server mode is the only project with an optional process split. All others are single-process tools. This distinction reflects crush’s domain: an LLM session agent that may outlive the terminal — a use case where process isolation and multi-client access matter.


Recommendations for practitioners#

Choose Cobra unless you have fzf’s specific constraints (exotic flag syntax, binary size budget, single-command interface). For any multi-command CLI, the help generation, shell completion, and flag inheritance Cobra provides are worth the dependency.

Adopt the Options struct pattern for testable commands: define type CmdOptions struct {...}, register flags against it, pass the populated struct to a runCmd(opts) function. This is the universal pattern across gh, restic, and rclone.

Use a per-env prefix for env-var overrides (GH_, RESTIC_, RCLONE_). It prevents collision with other tools and makes variable provenance clear in CI logs.

Consider exit code taxonomy early. Adding meaningful exit codes after the fact is a breaking change. Plan at least: 0=success, 1=error, and one project-specific code for the most common failure mode that automation needs to handle differently.

For machine-readable output: --json as a global flag (restic model) is simpler to implement; --json fields with field selection (gh model) is more powerful but requires per-command maintenance. Choose based on whether callers need partial data or always consume everything.

For extensibility: gh’s subprocess extension model (any gh-* binary is a command) is the most user-friendly. rclone’s compile-time blank-import model is the most robust. MCP (crush) is the right pattern when the extension surface is tool use in an LLM context rather than CLI commands.


Book angle#

These six projects tell a coherent story about CLI maturity in Go.

Cobra is the gravity well. Once a project reaches multi-command territory, cobra’s surface gravity is nearly unavoidable. The question is not “cobra or not” but “how much of cobra do you embrace?” fzf shows the rare case where resisting makes sense; gh shows the maximum benefit of deep adoption.

The real design work is in what surrounds cobra: how flags are organized, how configuration is layered, what the machine-readable output looks like, and how extension works. These are the decisions that separate an accidentally good CLI from a deliberately designed one.

Restic is the archetype of “principled constraint.” No config file. No library API. No optional features. Every constraint is justified by the domain (backup tools must be auditable and tamper-evident). The result is a CLI where users can read the man page, understand everything, and trust the tool with their data.

gh is the archetype of “platform thinking.” The extension system, the go-gh SDK, the structured exit codes, the --json/--jq/--template uniformity — these are not features for individual users, they are investments in an ecosystem. The lesson: a CLI becomes a platform when its design anticipates that callers will be scripts, other CLIs, and eventually agents.

crush represents the next evolution. Its --json session flags exist explicitly because agents will call crush session list. Its SSE event stream exists because IDE integrations need real-time updates. Its Workspace interface abstraction exists because the same logic must be reachable from a TUI, a CLI, and eventually an editor plugin. Crush is the first CLI in this set designed with the assumption that its primary callers may not be humans.