plan: account default resolution (#157)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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`. |