carlos / platform Public

plan: account default resolution (#157)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 46272e2dd8653c60c77f32af793509f8223d3e76 parent 8808e29
1 file changed, +154 −0
  • docs/superpowers/plans/2026-08-12-account-default-resolution.md +154 −0
diff --git a/docs/superpowers/plans/2026-08-12-account-default-resolution.md b/docs/superpowers/plans/2026-08-12-account-default-resolution.md
new file mode 100644
index 000000000..dfa3b4c78
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-12-account-default-resolution.md
@@ -0,0 +1,154 @@
+# Account Default Resolution Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** When `-account` is omitted in API mode, resolve it from config or the caller's memberships (one → use it; several → interactive chooser with a save-to-project prompt), and expose memberships via `GET /api/cli/accounts`, `carlos accounts list`, and `carlos auth whoami`. Closes #157.
+
+**Architecture:** One new token-gated console endpoint returning `Store.AccountsFor(addr)`. CLI-side, a single resolution seam on `apiSession` (`defaultAccount`) called through a `resolvedAccount(sess, raw, rawID)` wrapper that replaces `apiAccount(...)` at every API-mode callsite; flag defaults flip `"ops"` → `""` (direct mode already reads `""` as ops, so boxes are untouched).
+
+**Tech Stack:** Go stdlib only; existing test fixtures (`internal/console` fx, CLI httptest patterns, `carlosTestEnv`).
+
+## Global Constraints
+
+- `go build ./...`, `go vet ./...`, `go test ./... -count=1` clean; `gofmt -l` clean for touched files; `GOOS=darwin` compile passes (CLI runs on Macs — no Linux-only assumptions).
+- Sandbox env for go commands: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`.
+- Wire changes additive only; membership 404 opacity byte-identical; direct/bucket mode behavior unchanged.
+- Registry-mode verbs (`carlos route`, `carlos add`, `accounts list` on a box) keep the `"ops"` default and local resolution.
+- Commits per task; spec: `docs/superpowers/specs/2026-08-12-account-default-resolution-design.md`.
+
+---
+
+### Task 1: Console endpoint `GET /api/cli/accounts`
+
+**Files:**
+- Modify: `internal/console/authcli.go` (route next to `GET /auth/cli/whoami` at :213; handler next to `handleCLIWhoami` at :787)
+- Test: `internal/console/authcli_test.go`
+
+**Interfaces:**
+- Consumes: `s.cliUser(w, r)` gate, `s.store.AccountsFor(addr)` (`[]Account` with `.Name`/`.Sqid`, deleted excluded, creation order), `writeJSON`, `jsonError`.
+- Produces: `200 {"accounts":[{"name":…,"sqid":…}]}` — always an array, never null; `401` bare/wrong token (from `cliUser`).
+
+- [ ] **Step 1: failing test** — in `authcli_test.go`, mimic the whoami test at :152 (fixture + `getJSON`): signed-up user with a personal account gets `200` and their account's name+sqid; a second minted account (use the fixture's account-create path) appears in creation order; no/wrong token → 401. Assert the JSON shape exactly (`accounts` key, array of `{name,sqid}`).
+- [ ] **Step 2: run** — `go test ./internal/console/ -run CLIAccounts -count=1` → FAIL (404).
+- [ ] **Step 3: implement**
+
+```go
+s.mux.HandleFunc("GET /api/cli/accounts", s.handleCLIAccounts)
+
+// handleCLIAccounts answers `carlos accounts list` (signed-in mode) and the
+// omitted--account default: the caller's OWN live memberships, nothing else
+// — resolved from the token's address, so there is no enumeration surface.
+func (s *Server) handleCLIAccounts(w http.ResponseWriter, r *http.Request) {
+ addr, ok := s.cliUser(w, r)
+ if !ok {
+ return
+ }
+ accts, err := s.store.AccountsFor(addr)
+ if err != nil {
+ log.Printf("console: cli accounts: %v", err)
+ jsonError(w, http.StatusInternalServerError, "could not list accounts")
+ return
+ }
+ out := make([]map[string]string, 0, len(accts))
+ for _, a := range accts {
+ out = append(out, map[string]string{"name": a.Name, "sqid": a.Sqid})
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"accounts": out})
+}
+```
+
+- [ ] **Step 4: test passes**; **Step 5: commit** `console: GET /api/cli/accounts — the caller's own memberships (#157)`.
+
+### Task 2: Config `account` key + lossless rewrites
+
+**Files:**
+- Modify: `config.go` (`cliConfig` :39, `loadConfigFile` :50, `saveConfigFile` :124)
+- Test: `config_test.go`
+
+**Interfaces:**
+- Produces: `cliConfig.Account`; `configAccount() (name, source, fileConsole string, err error)` — nearest `.carlos/config` walking up (then global) **whose `account` is set**; `fileConsole` is that same file's normalized console ("" if unset). `saveConfigFile(path, console, perm)` and new `saveConfigAccount(path, console, account string, dirPerm os.FileMode)` both preserve unknown JSON fields on rewrite.
+
+- [ ] **Step 1: failing tests** — `account` key round-trips; console-only files return `"" ` from `configAccount` at that level and the walk continues; nearest account-bearing file wins and reports ITS console; rewrite of a file containing `{"console":…,"future_key":42}` keeps `future_key`; `saveConfigAccount` writes both keys with the file's dir at the given perm.
+- [ ] **Step 2: run** `go test . -run Config -count=1` → FAIL.
+- [ ] **Step 3: implement** — `Account string \`json:"account"\`` on `cliConfig`; refactor the read path to return the whole `cliConfig` (keep `loadConfigFile`'s console-normalization errors verbatim); `configAccount` mirrors `configDefault`'s walk but keys off `Account != ""`; rewrites read the existing file into `map[string]any` (missing file → empty map), set `"console"`/`"account"`, `json.MarshalIndent`, write with the existing perms logic.
+- [ ] **Step 4: pass**; **Step 5: commit** `cli: .carlos/config account key; rewrites preserve unknown fields`.
+
+### Task 3: Interactive chooser
+
+**Files:**
+- Create: `account_choose.go`, `account_choose_test.go`
+
+**Interfaces:**
+- Consumes: `charDevice` (color.go :93 — read its exact signature first and adapt).
+- Produces: `type apiAccountEntry struct{ Name, Sqid string }` (JSON tags `name`/`sqid` — Task 4 decodes into it); `chooseAccount(in io.Reader, out io.Writer, console string, accts []apiAccountEntry) (picked apiAccountEntry, save bool, err error)`; `interactiveTerminal() bool` (stdin AND stdout are char devices); `severalAccountsErr(accts []apiAccountEntry) error` producing exactly: `you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)`.
+
+- [ ] **Step 1: failing tests** — pick "2\n" returns accts[1]; junk then "1\n" re-prompts once; junk twice → `severalAccountsErr` text; EOF → same; save prompt: "y\n" → save=true, "\n"/"n\n" → false; output contains the numbered list and `account [1-2]:`.
+- [ ] **Step 2: run** → FAIL. **Step 3: implement** with `bufio.NewScanner(in)`, prompt loop (2 attempts), then `save <name> as this project's default account? [y/N]` reading one more line, `strings.EqualFold(answer, "y")`.
+- [ ] **Step 4: pass**; **Step 5: commit** `cli: interactive account chooser`.
+
+### Task 4: Resolution seam on apiSession
+
+**Files:**
+- Modify: `apimode.go` (near `apiAccount` :202)
+- Test: `apimode_test.go`
+
+**Interfaces:**
+- Consumes: `s.getJSON`, `apiErrorFromBody`, Task 2 `configAccount`, Task 3 chooser/entry/refusal, `saveConfigAccount`, `mutedf`, package `stdout`.
+- Produces:
+
+```go
+func (s *apiSession) listAccounts(ctx context.Context) (accts []apiAccountEntry, supported bool, err error)
+// 404 → (nil, false, nil): console predates the endpoint. Non-200/404 → apiErrorFromBody.
+
+func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error)
+// order: configAccount (honored iff fileConsole=="" || fileConsole==s.console;
+// prints muted `account: <name> (the default from <source>)`) → listAccounts:
+// !supported → apiAcct{Seg:"ops"}; 0 → error naming s.console; 1 → muted
+// `account: <name> (<sqid>)`, apiAcct{Seg: sqid, Exact: true}; several →
+// interactiveTerminal() ? chooser (on save=true saveConfigAccount(
+// filepath.Join(".carlos","config"), s.console, picked.Name, 0o755) + muted
+// confirmation) : severalAccountsErr. Chosen entry returns Seg=sqid, Exact.
+
+func resolvedAccount(sess *apiSession, raw, rawID *string) apiAcct
+// apiAccount(raw, rawID); if Seg=="" && sess!=nil → sess.defaultAccount
+// (context.Background()), log.Fatal on error. sess==nil callers never reach
+// it (verbs split modes before touching it).
+```
+
+- [ ] **Step 1: failing tests** — httptest console serving `/api/cli/accounts`: one account → Seg=sqid+Exact; several (non-TTY test env) → `severalAccountsErr` text; 404 → `{Seg:"ops"}`; explicit `-account`/`-account-id` never call the endpoint (assert via handler hit-counter); config account + matching console → its name, endpoint untouched; config console mismatch → falls through to memberships; expired-token 401 surfaces as an error, not a fallback.
+- [ ] **Step 2: run** → FAIL. **Step 3: implement.** **Step 4: pass.** **Step 5: commit** `cli: resolve omitted -account from config or memberships`.
+
+### Task 5: Flag-default flip + callsite sweep
+
+**Files:**
+- Modify: `cli.go` (:1555 accountFlags, :2447 ledgerAccountFlags, ship :1697, promote/rollback/channels/versions/tags sites :1961–:2326, ledger sites :2484–:2636), `apps.go` (:202, :322, :385, :418, :478, :522), `appconfig_cli.go` (14 sites), `deploy.go` :62, `restart.go` :41, `store.go` (4), `retention.go` :54, `instances_cli.go` (:244, :267, :307 + sites), `fleets.go` (6 defaults + 6 sites), `logs.go` (:70, :95)
+- **Leave alone:** `cli.go:72` and `cli.go:403` (registry-mode `route`/list verbs — genuine ops default).
+
+**Interfaces:** consumes Task 4's `resolvedAccount` only.
+
+- [ ] **Step 1:** flip every listed `fs.String("account", "ops", …)` to `fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")` — matching each file's existing help-string wording style; same for `accountFlags`/`ledgerAccountFlags`.
+- [ ] **Step 2:** replace every listed `apiAccount(acctRaw, acctIDRaw)` with `resolvedAccount(sess, acctRaw, acctIDRaw)`. Dual-mode verbs (ship, promote, etc. via `accountFlags`) only hit this on their `sess != nil` branch; their direct branch keeps `acctOf()` → `resolveAccount` (`""` → ops, unchanged).
+- [ ] **Step 3:** `go build ./... && go vet ./... && go test ./... -count=1` full-suite — existing tests that shipped with `-account` omitted against httptest consoles will exercise the new fallback; fix any that asserted the literal `ops` segment by adding the endpoint 404 (fallback keeps them green) or an explicit `-account ops`.
+- [ ] **Step 4: commit** `cli: -account defaults to your own account in API mode`.
+
+### Task 6: whoami memberships
+
+**Files:** modify `auth.go` (`authWhoami` :702), test in `auth_e2e_test.go` or the httptest pattern used by whoami today.
+
+- [ ] **Step 1: failing test** — console serving both endpoints: output contains `accounts: tom@keymail.dev (bdk), acme (bce)`; console without the endpoint → whoami output unchanged, exit 0.
+- [ ] **Step 2:** after the identity print, `newConsoleClient(c.Console, c.Token).do(ctx, GET, "/api/cli/accounts", nil, &struct{ Accounts []apiAccountEntry }{…})`; on ANY error print nothing; else one muted line `accounts: n1 (s1), n2 (s2)`.
+- [ ] **Step 3: pass; commit** `cli: whoami prints account memberships`.
+
+### Task 7: accounts list signed-in mode
+
+**Files:** modify `accounts.go` (`runAccountsList` :204), test in `accounts` CLI test file (follow existing patterns; shell-outs use `carlosTestEnv` + scratch `cmd.Dir`).
+
+- [ ] **Step 1: failing test** — with no registry file at `-registry` and a credential for an httptest console: NAME/SQID table of the caller's accounts; with a registry file present: today's registry table (existing behavior test stays green); old console (404) → error naming the console as predating account listing.
+- [ ] **Step 2:** add `-console` flag; if `*consoleFlag == ""` and `os.Stat(*regPath)` succeeds → existing registry path verbatim; else `loggedInCredential(*consoleFlag)` (fatal on err), GET `/api/cli/accounts`, on 404 fatal `"%s does not support account listing — it predates this CLI"`, else `newTable` NAME/SQID.
+- [ ] **Step 3: pass; commit** `cli: accounts list works signed-in (#157)`.
+
+### Task 8: Gate + PR
+
+- [ ] Full gate: build, vet, `go test ./... -count=1`, `gofmt -l` on touched files, `GOOS=darwin go build ./...`.
+- [ ] Opus review of the console endpoint + resolution seam (security-touching per delegation policy); fix findings.
+- [ ] Push branch, PR titled `CLI: default -account to your own account; membership discovery (#157)`, body maps spec sections to tasks, `Closes #157`.