carlos / platform Public

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
Paul Campbell 02728bdd8d31f766006724613f529ab2c8cd575c parent 3b8e323
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.