spec: CLI flag conventions — --app canonical, four short aliases, one usage printer
James asked why carlos uses -app when the world writes --app. The answer is stdlib flag, and --app already parses today; what is missing is the short aliases and a documented face. Claims four letters (-a app, -c channel, -e environment, -v version), reserves -vv for a verbose mode that does not exist yet, and leaves -console long-only. Registration goes through four helpers following the accountFlags precedent, because an alias re-registration assigns through the shared pointer and must repeat the primary's default. Two traps recorded from measurement rather than assumption: fs.Visit reports the name typed, so flagWasSet(fs,"app") is false when -a was given; and PrintDefaults can neither hide a flag nor emit --, so the stock printer is replaced rather than fought. Interspersed flags (carlos deploy ./dist --app blah) stay out of scope and get their own spec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+211
−0
docs/superpowers/specs/2026-08-19-cli-flag-conventions-design.md+211 −0
diff --git a/docs/superpowers/specs/2026-08-19-cli-flag-conventions-design.md b/docs/superpowers/specs/2026-08-19-cli-flag-conventions-design.md| new file mode 100644 |
| index 000000000..7e30530f9 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-19-cli-flag-conventions-design.md |
| @@ -0,0 +1,211 @@ |
| +# CLI flag conventions: `--app` canonical, short aliases, one usage printer |
| + |
| +**Date:** 2026-08-19 · **Status:** approved (Paul, in-session; letters |
| +ruled: `-a` app, `-c` channel, `-e` environment, `-v` version, `-vv` |
| +reserved for verbose, console stays long-only; approach A — a shared |
| +usage printer — chosen over hand-written help) |
| + |
| +James asked why `carlos deploy -app blah` uses one dash when the world |
| +outside writes `--app blah`, and whether we could take Heroku's shape or |
| +accept several shapes at once. The answer to the first is that the CLI has |
| +no CLI framework — every command is a stdlib `flag.NewFlagSet` (80 of |
| +them across 24 files) and Go's `flag` prints one dash — and the answer to |
| +the second is that **`--app` already parses today**: Go treats one and two |
| +minus signs as equivalent. What is missing is not the parser. It is the |
| +short aliases people reach for, and a documented face that says `--app`. |
| + |
| +This spec makes `--app` the canonical documented form, keeps `-app` |
| +working forever, claims four letters, and replaces the stock help printer |
| +so that all three of those can be true on the same screen. |
| + |
| +Counts here were taken with `git grep` on `main` at 4167d5b. Plain |
| +recursive grep is not trustworthy in this checkout: it walks the nested |
| +worktrees under `.claude/worktrees/` and `.worktrees/` and counts the |
| +same declaration many times over, off by more than 2x on some figures. |
| + |
| +## What is already true (measured, not assumed) |
| + |
| +A probe against a flag set shaped like `carlos deploy`'s: |
| + |
| + -app blah → app="blah" --app blah → app="blah" |
| + -app=blah → app="blah" --app=blah → app="blah" |
| + -a blah → error: flag provided but not defined: -a |
| + |
| +So double-dash needs no parser work, and Go matches flag names exactly — |
| +there is no prefix matching, so `-a` can mean `app` while `-account` |
| +keeps its own meaning with no ambiguity. |
| + |
| +Heroku's actual shape is worth stating, because the question framed it as |
| +`app=blah`: Heroku uses `--app` / `-a`, and reserves `KEY=value` for |
| +config vars (`heroku config:set`). We already match the second half — |
| +`carlos env set KEY=value` and `carlos secrets set KEY=value` parse |
| +through `parseKV` (`appconfig_cli.go:184`). This spec closes the first. |
| + |
| +## The claimed namespace |
| + |
| +Four letters, and nothing else. Each is registered in exactly one place |
| +(below), and this table is the record of what is spent: |
| + |
| +| Alias | Primary | Sites | Why this letter | |
| +|-------|------------------|-------|-----------------| |
| +| `-a` | `-app` | 46 | Heroku muscle memory; on nearly every member verb | |
| +| `-c` | `-channel` | 5 | Reads as channel; `console` does not get it | |
| +| `-e` | `-environment` | 8 | Universal (docker, fly, heroku) | |
| +| `-v` | `-version` | 3 | The value people type on `ship`/`deploy` | |
| + |
| +**`-vv` is reserved for verbose and is NOT built by this spec.** No |
| +verbose mode exists anywhere in the binary today. The reservation exists |
| +so that a future verbose flag cannot quietly claim `-v` and collide with |
| +`-version`. |
| + |
| +**`-console` stays long-only.** It is the most-defined flag in the tree |
| +(57 sites) and among the least-typed: it defaults to the only logged-in |
| +console, so it is an escape hatch, reached for when driving two |
| +deployments from one terminal, and typed into scripts where `--console` |
| +is the clearer word. `-x` was considered and rejected; the letter stays |
| +free for something typed daily. |
| + |
| +Already-claimed single characters, untouched: `-f` (`logs.go:68`, follow) |
| +and `-y` (`update.go:216`, skip prompt). |
| + |
| +## Registration: four helpers, following `accountFlags` |
| + |
| +`accountFlags(fs)` (`cli.go:1625`) is the existing precedent for a flag |
| +defined once and reused across commands. Four siblings join it: |
| + |
| + func appFlag(fs *flag.FlagSet, usage string) *string // -app, -a |
| + func channelFlag(fs *flag.FlagSet, usage string) *string // -channel, -c |
| + func environmentFlag(fs *flag.FlagSet, usage string) *string // -environment, -e |
| + func versionFlag(fs *flag.FlagSet, usage string) *string // -version, -v |
| + |
| +Each takes the per-command help text because it genuinely varies — 23 of |
| +the 46 `-app` sites say something bespoke ("app the instance belongs to", |
| +"repoint every non-platform route of this app", "bind the credential to |
| +one app") and the other 23 say "app name". An empty `usage` argument |
| +takes the common default for that flag, so the 23 plain sites read |
| +`appFlag(fs, "")`. |
| + |
| +Registering through a helper rather than two inline calls is what makes |
| +an alias impossible to forget on a new command, and it keeps the claimed |
| +namespace in one readable table instead of scattered across 62 sites. |
| + |
| +### The default-value trap |
| + |
| +An alias is registered by pointing a second name at the same variable: |
| + |
| + app := fs.String("app", "", usage) |
| + fs.StringVar(app, "a", "", "") |
| + |
| +`StringVar` **assigns through the pointer**, so the alias registration |
| +resets the variable to the alias's own default. The two defaults must |
| +therefore be identical. Harmless where the default is `""`; silently |
| +destructive for a flag like `-registry`, whose default is |
| +`defaultRegistry()`. The helpers each take their default once and pass |
| +the same value to both registrations, which is the structural reason to |
| +have helpers at all rather than open-coding the pair. |
| + |
| +`-registry` is not aliased by this spec, but the rule is written down |
| +here because the next person to add an alias will meet it. |
| + |
| +## `flagWasSet` must become alias-aware |
| + |
| +`flagWasSet` (`deploy.go:333`) answers "did the user type this flag?" by |
| +walking `fs.Visit`, and the deploy wizard and the saved-defaults block |
| +both rely on it to avoid overriding an explicit choice. |
| + |
| +`fs.Visit` reports **the name the user typed**, not the primary. Measured: |
| + |
| + -a blah → visited=[a] |
| + --app blah → visited=[app] |
| + |
| +So `flagWasSet(fs, "app")` is *false* when someone typed `-a`. Today this |
| +is unreachable — its five call sites (`deploy.go:126`–`161`) ask only |
| +about `kind` and `target`, neither of which is aliased — but it is a |
| +loaded gun: the first person to alias `-kind` gets a wizard that |
| +re-prompts for an artifact kind the user just named. |
| + |
| +`flagWasSet` therefore takes the primary name and returns true if the |
| +primary **or any of its aliases** was visited, resolved through the same |
| +alias table the helpers register from. A test covers `-a`, `--app` and |
| +`-app` all reporting set. |
| + |
| +## Help output: one printer, `--` canonical |
| + |
| +Adding aliases to the stock printer produces this, on every command: |
| + |
| + -a string |
| + alias for -app |
| + -app string |
| + app name |
| + |
| +Two entries per flag, alphabetically separated, and both rendered with a |
| +single dash — Go's `PrintDefaults` can neither hide a flag nor emit `--`. |
| +That output contradicts the documentation this spec is writing, so the |
| +printer is replaced. |
| + |
| +A shared `flagUsage` helper renders a flag set with aliases folded into |
| +their primary and `--` leading: |
| + |
| + --app, -a <string> app name |
| + --channel, -c <string> release channel to follow |
| + --console <origin> console to work through (default: the only |
| + logged-in one) |
| + |
| +Each of the 80 flag sets wires it once (`fs.Usage = flagUsage(fs, |
| +header)`). `logs.go:72` already overrides `fs.Usage` by hand and is |
| +converted to the shared printer with the rest. |
| + |
| +The printer derives its output from the live flag set, so help cannot |
| +drift from what the parser actually accepts. That is the reason to |
| +generate rather than hand-write: the alternative — extending the |
| +`logs.go` pattern to every command — was considered and rejected, because |
| +it doubles the drift surface at 75 sites where adding a flag and |
| +forgetting its help text produces a screen that lies. |
| + |
| +## Conflicting values stay last-wins |
| + |
| +`-a x --app y` resolves to `y`, silently. Measured, and left alone. |
| + |
| +It is stdlib behaviour, it matches every other Go CLI, and detecting it |
| +means intercepting `Visit` on every command to catch a mistake nobody |
| +makes twice. Recorded here as a decision rather than an oversight. |
| + |
| +## The usage strings and the docs |
| + |
| +The hand-written `usage :=` strings (75 across 17 files) and the |
| +top-level `carlos help` text (`main.go:138`) move to `--` form, as do the |
| +143 `carlos <verb> -flag` occurrences across 45 files under `docs/`, |
| +`site/` and `README.md`. Aliases are shown only where they earn the |
| +space — in the four helpers' generated help, and in `carlos help`'s |
| +per-verb lines for `-a` alone. Usage strings name the long form, because |
| +a usage string is read when something has already gone wrong and the |
| +unambiguous word helps more than the letter. |
| + |
| +99 test assertions across 29 files reference `-app`-style text. They keep |
| +passing unchanged wherever they exercise the parser (both dash counts |
| +parse), and are updated where they assert on printed help or usage text. |
| + |
| +## What this spec does not do |
| + |
| +**Interspersed flags are still not supported.** Go's `flag` stops parsing |
| +at the first non-flag argument, so: |
| + |
| + carlos deploy --app blah ./dist ✅ |
| + carlos deploy ./dist --app blah ❌ --app and blah read as filenames |
| + |
| +That is the genuine Heroku-muscle-memory papercut, it is independent of |
| +dash count, and it is deliberately out of scope here — a pre-pass that |
| +hoists flags ahead of positionals before `fs.Parse`, with its own |
| +decisions about `--` handling and about verbs whose positionals may |
| +legitimately begin with a dash. It gets its own spec. |
| + |
| +No short aliases beyond the four above. No `-console` alias. No verbose |
| +mode. |
| + |
| +## Compatibility |
| + |
| +Every change is additive at the parser. `-app` keeps working, forever and |
| +undocumented; `--app` already worked and is now the documented face; the |
| +four letters are new surface that could not previously parse. No script, |
| +runbook, CI job, or muscle memory breaks — which is the property that |
| +lets this land as one change rather than a deprecation. |