carlos / platform Public

cli: resolve omitted -account from config or memberships

listAccounts reads GET /api/cli/apps (live since 5856dc5); defaultAccount
orders config-pair, single membership (sqid, exact), chooser/refusal;
404 keeps the pre-discovery ops default so old consoles regress nothing.
resolvedAccount is the verb-facing seam.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b59bd97bea95026f47788e36baa32ef3bc903390 parent 903edbd
2 files changed, +248 −0
  • apimode.go +104 −0
  • apimode_test.go +144 −0
diff --git a/apimode.go b/apimode.go
index a26cd81bb..c71597cf4 100644
--- a/apimode.go
+++ b/apimode.go
@@ -22,6 +22,7 @@ import (
"errors"
"fmt"
"io"
+ "log"
"mime/multipart"
"net/http"
"net/url"
@@ -226,6 +227,109 @@ func apiPath(a apiAcct, app, tail string) string {
return p
}
+// listAccounts fetches the caller's own memberships: GET /api/cli/apps'
+// "accounts" array (handleAPIApps — live since main 5856dc5, 2026-08-03).
+// supported=false means the console predates the endpoint: the bare mux
+// 404, which cannot be the membership gate's 404 because this path carries
+// no account or app segment for a gate to refuse. Every other non-200 is
+// a real answer (401 expired token, 500) and surfaces via apiErrorFromBody
+// — never swallowed into a fallback.
+func (s *apiSession) listAccounts(ctx context.Context) (accts []apiAccountEntry, supported bool, err error) {
+ status, body, err := s.getJSON(ctx, "/api/cli/apps")
+ if err != nil {
+ return nil, false, err
+ }
+ if status == http.StatusNotFound {
+ return nil, false, nil
+ }
+ if status != http.StatusOK {
+ return nil, false, apiErrorFromBody(status, body)
+ }
+ var out struct {
+ Accounts []apiAccountEntry `json:"accounts"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return nil, false, fmt.Errorf("unreadable accounts response from %s: %w", s.console, err)
+ }
+ return out.Accounts, true, nil
+}
+
+// defaultAccount answers an omitted -account in API mode (spec:
+// docs/superpowers/specs/2026-08-12-account-default-resolution-design.md):
+// the config default when its file's console pairs with this session, else
+// the caller's one membership, else the chooser (terminal) or the naming
+// refusal (script). Whatever is decided for the caller is printed — a
+// guessed account that stayed invisible would be the wrong-deployment
+// mistake this file already refuses everywhere else.
+func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error) {
+ name, src, fileConsole, err := configAccount()
+ if err != nil {
+ return apiAcct{}, err
+ }
+ if name != "" && (fileConsole == "" || fileConsole == s.console) {
+ // The name travels verbatim, like a typed -account: the console
+ // resolves it against the caller's memberships, so a stale config
+ // value fails with the same membership 404 a typo would.
+ mutedf(stdout, "account: %s (the default from %s)", name, src)
+ return apiAcct{Seg: name}, nil
+ }
+ accts, supported, err := s.listAccounts(ctx)
+ if err != nil {
+ return apiAcct{}, err
+ }
+ if !supported {
+ // A console from before the listing endpoint cannot say whose
+ // token this is — keep the pre-discovery default so nothing
+ // regresses against an un-upgraded console.
+ return apiAcct{Seg: "ops"}, nil
+ }
+ switch len(accts) {
+ case 0:
+ return apiAcct{}, fmt.Errorf("your address belongs to no account on %s — create one with: carlos accounts create -name <name>", s.console)
+ case 1:
+ mutedf(stdout, "account: %s (%s)", accts[0].Name, accts[0].Sqid)
+ return apiAcct{Seg: accts[0].Sqid, Exact: true}, nil
+ }
+ if !interactiveTerminal() {
+ return apiAcct{}, severalAccountsErr(accts)
+ }
+ picked, save, err := chooseAccount(os.Stdin, stdout, s.console, accts)
+ if err != nil {
+ return apiAcct{}, err
+ }
+ if save {
+ // ./.carlos/config in the working directory — the same "run it
+ // where you want the file" rule as `auth default -project`. The
+ // console is pinned alongside: configAccount reads the pair as one
+ // fact, and an account without its console would silently stop
+ // applying the day the directory's console default changed.
+ path := filepath.Join(".carlos", "config")
+ if err := saveConfigAccount(path, s.console, picked.Name, 0o755); err != nil {
+ return apiAcct{}, err
+ }
+ mutedf(stdout, "saved: %s is now this directory's default account (%s)", picked.Name, path)
+ }
+ return apiAcct{Seg: picked.Sqid, Exact: true}, nil
+}
+
+// resolvedAccount is apiAccount plus the omitted-flag default: the seam
+// every API-mode verb reaches through. Explicit flags travel verbatim and
+// never touch the network; only an empty pair asks defaultAccount. Fatal
+// on refusal because every caller is a verb body whose next line would be
+// the request itself — there is nothing for it to do with the error but
+// print it and stop.
+func resolvedAccount(sess *apiSession, raw, rawID *string) apiAcct {
+ a := apiAccount(raw, rawID)
+ if a.Seg != "" || sess == nil {
+ return a
+ }
+ r, err := sess.defaultAccount(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+ return r
+}
+
// notFoundErr is what every call here returns for a 404 whose body carries
// no JSON "error" field. apiAccountApp (internal/console/apicli.go) answers
// an unknown account, an unknown app, and someone else's account with the
diff --git a/apimode_test.go b/apimode_test.go
index 3c3f98f69..fb7579a7a 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -2778,3 +2778,147 @@ func TestAPISessionCreateAppSendsPlace(t *testing.T) {
t.Fatalf("empty place must not travel as a non-empty value; got %q", gotPlace)
}
}
+
+// accountsConsole is a console stub for the omitted--account resolution
+// tests: it serves GET /api/cli/apps with the given status and body, and
+// counts how often the endpoint is consulted.
+func accountsConsole(t *testing.T, status int, body string) (*apiSession, *int) {
+ t.Helper()
+ hits := 0
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps" || r.Method != http.MethodGet {
+ t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
+ http.NotFound(w, r)
+ return
+ }
+ hits++
+ w.WriteHeader(status)
+ io.WriteString(w, body)
+ }))
+ t.Cleanup(ts.Close)
+ return newTestSession(ts.URL), &hits
+}
+
+func TestDefaultAccountSingleMembership(t *testing.T) {
+ useTempCredentials(t)
+ out := captureStdout(t)
+ s, hits := accountsConsole(t, http.StatusOK,
+ `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","role":"owner","apps":[]}]}`)
+
+ a, err := s.defaultAccount(context.Background())
+ if err != nil || a.Seg != "bdk" || !a.Exact {
+ t.Fatalf("one membership = %+v, %v; want its sqid, exact", a, err)
+ }
+ if *hits != 1 {
+ t.Fatalf("endpoint hits = %d, want 1", *hits)
+ }
+ if !strings.Contains(out.String(), "account: tom@keymail.dev (bdk)") {
+ t.Fatalf("the resolved account must be printed; got:\n%s", out.String())
+ }
+}
+
+func TestDefaultAccountSeveralNonTTY(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ s, _ := accountsConsole(t, http.StatusOK,
+ `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`)
+
+ // Under go test stdin is not a terminal, so several memberships take
+ // the non-interactive refusal — which names every account.
+ _, err := s.defaultAccount(context.Background())
+ want := "you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)"
+ if err == nil || err.Error() != want {
+ t.Fatalf("several = %v\nwant %q", err, want)
+ }
+}
+
+func TestDefaultAccountOldConsoleFallsBackToOps(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ s, _ := accountsConsole(t, http.StatusNotFound, "404 page not found\n")
+
+ a, err := s.defaultAccount(context.Background())
+ if err != nil || a.Seg != "ops" || a.Exact {
+ t.Fatalf("old console = %+v, %v; want the pre-discovery ops default", a, err)
+ }
+}
+
+func TestDefaultAccountZeroMemberships(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ s, _ := accountsConsole(t, http.StatusOK, `{"accounts":[]}`)
+
+ if _, err := s.defaultAccount(context.Background()); err == nil ||
+ !strings.Contains(err.Error(), s.console) {
+ t.Fatalf("zero memberships = %v, want an error naming the console", err)
+ }
+}
+
+func TestDefaultAccountAuthErrorSurfaces(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ s, _ := accountsConsole(t, http.StatusUnauthorized, `{"error":"token expired"}`)
+
+ // A 401 is an answer about the TOKEN, not about memberships — it must
+ // surface, never turn into an ops fallback that then 404s confusingly.
+ if _, err := s.defaultAccount(context.Background()); err == nil ||
+ !strings.Contains(err.Error(), "token expired") {
+ t.Fatalf("401 = %v, want the console's own diagnosis", err)
+ }
+}
+
+func TestDefaultAccountConfigDefault(t *testing.T) {
+ useTempCredentials(t)
+ out := captureStdout(t)
+ s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[]}`)
+
+ // A config account paired with THIS console is honored without asking
+ // the console anything.
+ cfgPath := writeConfig(t, ".", `{"console": "`+s.console+`", "account": "acme"}`)
+ a, err := s.defaultAccount(context.Background())
+ if err != nil || a.Seg != "acme" || a.Exact {
+ t.Fatalf("config account = %+v, %v; want the name, resolved server-side", a, err)
+ }
+ if *hits != 0 {
+ t.Fatalf("endpoint hits = %d, want 0 — config answered it", *hits)
+ }
+ if !strings.Contains(out.String(), "acme") || !strings.Contains(out.String(), cfgPath) {
+ t.Fatalf("the config-sourced account and its file must be printed; got:\n%s", out.String())
+ }
+
+ // A config account with NO console is a pure default: honored too.
+ writeConfig(t, ".", `{"account": "acme"}`)
+ if a, err = s.defaultAccount(context.Background()); err != nil || a.Seg != "acme" {
+ t.Fatalf("consoleless config account = %+v, %v", a, err)
+ }
+
+ // Paired with a DIFFERENT console it is ignored — and with zero
+ // memberships behind it, resolution reports those, proving the walk
+ // fell through to the endpoint.
+ writeConfig(t, ".", `{"console": "https://other.example", "account": "acme"}`)
+ if _, err = s.defaultAccount(context.Background()); err == nil ||
+ !strings.Contains(err.Error(), "no account") {
+ t.Fatalf("mismatched config console = %v, want fall-through to memberships", err)
+ }
+ if *hits != 1 {
+ t.Fatalf("endpoint hits = %d, want exactly the fall-through call", *hits)
+ }
+}
+
+func TestResolvedAccountExplicitFlagsBypass(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[]}`)
+
+ raw, rawID := "acme", ""
+ if a := resolvedAccount(s, &raw, &rawID); a.Seg != "acme" || a.Exact {
+ t.Fatalf("-account acme = %+v", a)
+ }
+ raw, rawID = "", "bdk"
+ if a := resolvedAccount(s, &raw, &rawID); a.Seg != "bdk" || !a.Exact {
+ t.Fatalf("-account-id bdk = %+v", a)
+ }
+ if *hits != 0 {
+ t.Fatalf("explicit flags must never consult the endpoint; hits = %d", *hits)
+ }
+}