spec: console default resolution for the carlos CLI
One credential: never ask. Several with no default: always ask. Defaults settable globally (~/.carlos/config) and per-project (.carlos/config, committed), project overriding global; a set default is authoritative and never falls back to the single-credential pick. auth login alone gains a built-in flagship fallback at the end of the chain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxXEWoRXU8WVTXjTJypuJ5
1 file changed,
+162
−0
docs/superpowers/specs/2026-08-04-console-default-resolution-design.md+162 −0
diff --git a/docs/superpowers/specs/2026-08-04-console-default-resolution-design.md b/docs/superpowers/specs/2026-08-04-console-default-resolution-design.md| new file mode 100644 |
| index 000000000..05b164552 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-04-console-default-resolution-design.md |
| @@ -0,0 +1,162 @@ |
| +# Console default resolution for the carlos CLI |
| + |
| +**Date:** 2026-08-04 |
| +**Status:** Approved (Paul, 2026-08-04 session) |
| + |
| +## Problem |
| + |
| +`carlos auth login` with no `-console` and no `CARLOS_CONSOLE` refuses and |
| +makes you retype `https://platform.carlosframework.com` — a URL the error |
| +message itself already contains. Meanwhile every other verb resolves its |
| +console through `pick()` (auth.go): explicit flag → env → the only live |
| +credential → a refusal when there are several. There is no way to say "in |
| +this checkout, the console is the Tito stack" or "on this machine, the |
| +default is the flagship" — someone permanently multi-homed (flagship + a |
| +self-hosted deployment) types `-console` forever. |
| + |
| +## Rules (what the user asked for, verbatim in spirit) |
| + |
| +1. **One credential → never specify.** Unchanged from today. |
| +2. **Several credentials and no default set → always specify.** Unchanged |
| + refusal; the message additionally suggests setting a default. |
| +3. **A default is settable at two levels**: global (`~/.carlos/config`) for |
| + everything, and per-project (`.carlos/config` at the project root, |
| + committed to the repo) which overrides the global. The titogo checkout |
| + defaults to the Tito stack; everywhere else defaults to the flagship. |
| + |
| +## Resolution order |
| + |
| +For **every** verb that talks to a console (`ship`, `promote`, `accounts`, |
| +`auth whoami`, `auth logout`, …): |
| + |
| + -console flag |
| + → CARLOS_CONSOLE env |
| + → nearest .carlos/config walking up from cwd |
| + → ~/.carlos/config |
| + → the only live credential |
| + → refuse: "logged in to several consoles — say which with -console, |
| + or set a default with: carlos auth default <url>" |
| + |
| +`carlos auth login` uses the same chain but, having no credential to fall |
| +back on, ends differently: |
| + |
| + … → ~/.carlos/config |
| + → built-in https://platform.carlosframework.com |
| + |
| +The HEADLESS warning in the usage text stays: a default makes it easier to |
| +accidentally log in on a box, so the "don't log in on boxes — use -owner" |
| +note earns its keep more, not less. |
| + |
| +### A set default is authoritative — no fallback past it |
| + |
| +If a config default resolves, the single-credential auto-pick is **not** |
| +consulted. Holding only a flagship credential inside the titogo checkout |
| +(whose `.carlos/config` says the Tito stack), `carlos ship` errors |
| +"not logged in to <tito console> — run: carlos auth login …" rather than |
| +quietly shipping titogo to the flagship. A default is explicit intent; the |
| +single-credential rule exists only for the no-default world. |
| + |
| +The same holds for `CARLOS_CONSOLE` and `-console` today, so the invariant |
| +is uniform: **any explicitly named console resolves against credentials for |
| +that console or errors — it never silently substitutes another.** |
| + |
| +## The config file |
| + |
| +One layout at both levels: a `.carlos` **directory** containing a `config` |
| +file. `~/.carlos` already exists (it holds `credentials`); projects get the |
| +same shape. One lookup rule, no file-vs-directory branching, and room for a |
| +future gitignored `.carlos/local` without a migration. |
| + |
| +`config` is JSON: |
| + |
| + { |
| + "console": "https://carlos-tito-ie.example" |
| + } |
| + |
| +- Unknown fields are ignored (additive evolution, per the wire-shape rule — |
| + though this file is new, so nothing existing parses it). |
| +- A missing file, or a file without `"console"`, means "no default at this |
| + level" and resolution continues down the chain. |
| +- A **malformed** file (unparseable JSON) is an error, not a skip: silently |
| + ignoring a typo'd project config would ship to the wrong console — the |
| + exact mistake this feature exists to prevent. |
| +- The stored value is normalized on read the same way `-console` is |
| + (`normalizeConsole`: https, or http only for loopback; scheme+host only). |
| + A value that fails normalization is an error, same as malformed JSON. |
| + |
| +### Project lookup walk |
| + |
| +From cwd upward to the filesystem root, the first directory containing |
| +`.carlos/config` wins. If the walk passes through `$HOME`, it finds |
| +`~/.carlos/config` by the same rule — which is simply the global default |
| +arriving one step early; behavior is identical. If the walk finds nothing, |
| +`~/.carlos/config` is consulted explicitly (projects outside `$HOME`). |
| + |
| +## Setting and inspecting: `carlos auth default` |
| + |
| + carlos auth default print the effective default and |
| + where it came from (flag/env are |
| + not shown — this reports config) |
| + carlos auth default <url> write ~/.carlos/config |
| + carlos auth default -project <url> write ./.carlos/config (cwd) |
| + |
| +- URLs are normalized and validated on **write** (same `normalizeConsole` |
| + rule), so a bad default is caught when set, not at first use. |
| +- `-project` writes to `./.carlos/config` relative to cwd — deliberately not |
| + "the repo root", because the CLI has no VCS awareness and guessing a root |
| + is worse than the documented rule "run it where you want the file". |
| +- Writes create the `.carlos` directory (0755 for a project, `~/.carlos` |
| + stays 0700) and rewrite `config` wholesale via the same |
| + temp-file-then-rename pattern as `credentials.save()`. `config` holds no |
| + secret; 0644 is fine. |
| +- Removing a default: delete the file, or edit it — it is one line of JSON. |
| + No `-clear` flag until someone actually wants one (YAGNI). |
| +- With no config anywhere, `carlos auth default` prints that the built-in |
| + login default is the flagship and that other verbs use the |
| + single-credential rule. |
| + |
| +## Security notes (accepted, recorded) |
| + |
| +- **A committed `.carlos/config` is repo-controlled input.** A malicious PR |
| + could repoint a project at a *different console the operator is logged in |
| + to*. Per-origin credentials contain the blast radius — no token is ever |
| + sent to a console other than the one it was issued by — and every verb |
| + prints the console it is talking to. No extra ceremony (no trust prompt, |
| + no allowlist): the mitigation is per-origin credentials plus visibility. |
| +- **`config` is not a secret** and lives world-readable in repos by design. |
| + Nothing from `credentials` moves into it, ever. |
| +- The device-code phishing stance (bare URI + typed code, never the |
| + prefilled link) is untouched. |
| + |
| +## What changes in code |
| + |
| +- `auth.go pick()` grows the two config steps between env and |
| + single-credential, and the authoritative-default rule. |
| +- `auth.go authLogin()` replaces its "which console?" error with the chain |
| + plus the built-in flagship fallback. |
| +- New: config load/save (one small file, likely `config.go`), the walk, and |
| + the `default` sub-verb in `RunAuth` + `authUsage`. |
| +- `credentials` file: **untouched**. No wire-shape change anywhere; the |
| + console API is untouched. |
| + |
| +Consumer check (the "everything that PARSES the shape" rule): the config |
| +file is new — its only parser is the CLI itself. No other component reads |
| +`~/.carlos` except the CLI's own credentials code, which does not change. |
| + |
| +## Testing |
| + |
| +- Resolution order: each link in the chain wins over everything below it, |
| + for `login` and for `pick()`-based verbs (table test with temp dirs and |
| + `CARLOS_CREDENTIALS`/`HOME`/cwd control). |
| +- Authoritative default: project default set + only credential is for a |
| + different console → error naming the default console, not a silent pick. |
| +- Walk: nested cwd finds an ancestor's `.carlos/config`; nearer beats |
| + farther; `$HOME` reached mid-walk equals global. |
| +- Malformed config and non-normalizable URL → error, not skip. |
| +- `auth default` round-trip: write global, write `-project`, print shows |
| + value and source path. |
| +- Login with nothing configured anywhere → flagship URL in the printed |
| + output (no network in the test; assert on the start-request target via a |
| + fake server or on the printed base). |
| +- Existing multi-credential refusal still fires when no default is set, with |
| + the new set-a-default hint. |