carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev df9e89efa71c5b4994cad11b51a370f0d72c6e41 parent 4167d5b
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.