carlos / platform Public

cli: accounts list works signed-in (#157)

Mode split by registry-file existence, not resolveMode — the verb must
keep working on a box with a registry and no credentials, and now works
on a laptop with neither registry nor box access.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 2e804415c2f3baad694664a1cdea343659accd20 parent aca4bea
3 files changed, +145 −8
  • accounts.go +58 −3
  • accounts_list_test.go +81 −0
  • docs/superpowers/specs/2026-08-12-account-default-resolution-design.md +6 −5
diff --git a/accounts.go b/accounts.go
index b398cc583..135eb33df 100644
--- a/accounts.go
+++ b/accounts.go
@@ -16,6 +16,7 @@ import (
"errors"
"flag"
"fmt"
+ "io"
"log"
"net/http"
"os"
@@ -39,7 +40,13 @@ const accountsUsage = `usage: carlos accounts <create|list|migrate>
over any logged-in one.
[-console <url>] which logged-in console to mint through
(only needed with several)
- list sqid, name, and app count
+ list your accounts. Signed-in (the usual case):
+ name, sqid and your role, from the console.
+ On a box with a registry: sqid, name and
+ app count from the registry itself.
+ [-console <url>] list through a console even where a
+ registry exists (or: which logged-in
+ console, with several)
migrate -app <a> -to <sqid> copy an app's objects to the account-
qualified prefix and re-stamp its routes
[-from <sqid>] source account (default: the legacy,
@@ -203,9 +210,57 @@ func createAccountViaConsole(cred *credential, regPath, name string) {
func runAccountsList(args []string) {
fs := flag.NewFlagSet("accounts list", flag.ExitOnError)
- regPath := fs.String("registry", defaultRegistry(), "registry database")
+ regPath := fs.String("registry", defaultRegistry(), "registry database (box mode)")
+ consoleFlag := fs.String("console", "", "list through a console instead (default: the box registry when one exists, else your logged-in console)")
fs.Parse(args)
- r, err := registry.Open(*regPath)
+ // Mode split by what exists, not by resolveMode: this verb predates the
+ // console API and must keep working on a box that has a registry and no
+ // bucket env, no credentials — while a laptop with neither registry nor
+ // box access (the #157 case) asks its console. An explicit -console
+ // skips the registry even where one exists.
+ if *consoleFlag == "" {
+ if _, err := os.Stat(*regPath); err == nil {
+ runAccountsListRegistry(*regPath)
+ return
+ }
+ }
+ if err := accountsListAPI(os.Stdout, *consoleFlag); err != nil {
+ log.Fatal(err)
+ }
+}
+
+// accountsListAPI is the signed-in form: the caller's own memberships from
+// GET /api/cli/apps, as a NAME/SQID/ROLE table. The one 404 this path can
+// see is a console too old to have the endpoint (no account/app segment
+// here for the membership gate to refuse), so it gets a named refusal
+// instead of notFoundErr's check-your-flags riddle.
+func accountsListAPI(out io.Writer, consoleFlag string) error {
+ cred, err := loggedInCredential(consoleFlag)
+ if err != nil {
+ return err
+ }
+ var acc struct {
+ Accounts []apiAccountEntry `json:"accounts"`
+ }
+ status, err := newConsoleClient(cred.Console, cred.Token).
+ do(context.Background(), http.MethodGet, "/api/cli/apps", nil, &acc)
+ if status == http.StatusNotFound {
+ return fmt.Errorf("%s predates account listing — upgrade the console, or run this on a box with -registry", cred.Console)
+ }
+ if err != nil {
+ return err
+ }
+ w := newTable(out)
+ fmt.Fprintln(w, "NAME\tSQID\tROLE")
+ for _, a := range acc.Accounts {
+ fmt.Fprintf(w, "%s\t%s\t%s\n", a.Name, a.Sqid, a.Role)
+ }
+ w.Flush()
+ return nil
+}
+
+func runAccountsListRegistry(regPath string) {
+ r, err := registry.Open(regPath)
if err != nil {
log.Fatal(err)
}
diff --git a/accounts_list_test.go b/accounts_list_test.go
new file mode 100644
index 000000000..3bb1a8610
--- /dev/null
+++ b/accounts_list_test.go
@@ -0,0 +1,81 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+// signedInConsole plants a credential for an httptest console that serves
+// GET /api/cli/apps, and returns that console's URL.
+func signedInConsole(t *testing.T, handler http.HandlerFunc) string {
+ t.Helper()
+ ts := httptest.NewServer(handler)
+ t.Cleanup(ts.Close)
+ f, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.put(credential{Console: ts.URL, Address: "tom@keymail.dev", Token: "tok",
+ CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+ return ts.URL
+}
+
+// accounts list, signed-in: no registry on this machine, a credential and a
+// console that answers — the no-infra path #157 was filed for.
+func TestAccountsListSignedIn(t *testing.T) {
+ useTempCredentials(t)
+ signedInConsole(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps" || r.Header.Get("Authorization") != "Bearer tok" {
+ http.NotFound(w, r)
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{
+ {"sqid": "bdk", "name": "tom@keymail.dev", "role": "owner", "apps": []string{}},
+ {"sqid": "bce", "name": "acme", "role": "member", "apps": []string{"shop"}},
+ }})
+ })
+
+ var out bytes.Buffer
+ if err := accountsListAPI(&out, ""); err != nil {
+ t.Fatalf("accountsListAPI: %v", err)
+ }
+ for _, want := range []string{"NAME", "SQID", "ROLE",
+ "tom@keymail.dev", "bdk", "owner", "acme", "bce", "member"} {
+ if !strings.Contains(out.String(), want) {
+ t.Fatalf("table missing %q:\n%s", want, out.String())
+ }
+ }
+}
+
+// A console from before the listing endpoint gets a plain named refusal,
+// not the membership-404 riddle.
+func TestAccountsListOldConsole(t *testing.T) {
+ useTempCredentials(t)
+ url := signedInConsole(t, func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ })
+
+ var out bytes.Buffer
+ err := accountsListAPI(&out, "")
+ if err == nil || !strings.Contains(err.Error(), url) || !strings.Contains(err.Error(), "predates") {
+ t.Fatalf("old console = %v, want a refusal naming the console", err)
+ }
+}
+
+// Not logged in and no registry: the error names the fix, exactly pick()'s.
+func TestAccountsListNeitherMode(t *testing.T) {
+ useTempCredentials(t)
+ var out bytes.Buffer
+ if err := accountsListAPI(&out, ""); err == nil ||
+ !strings.Contains(err.Error(), "carlos auth login") {
+ t.Fatalf("no credential = %v, want the login hint", err)
+ }
+}
diff --git a/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md b/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md
index 8db27311c..b41ccdd1b 100644
--- a/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md
+++ b/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md
@@ -120,11 +120,12 @@ file, so the fix is evident.
## Discovery verbs
-- **`carlos accounts list`**: signed-in mode (no `CARLOS_DEPLOYMENT_BUCKET`
- / `CARLOS_DEPLOYMENT_DIR`, credential present) calls the endpoint and
- prints a NAME/SQID table. The registry form stays for boxes, keyed off
- the same `resolveMode` split every other dual-mode verb uses; gains
- `-console`.
+- **`carlos accounts list`**: signed-in mode calls the endpoint and prints
+ a NAME/SQID/ROLE table (role rides free on the existing wire shape). The
+ registry form stays for boxes; the split keys off **registry-file
+ existence, not `resolveMode`** — the verb must keep working on a box
+ with a registry and neither bucket env nor credentials. `-console` skips
+ the registry even where one exists.
- **`carlos auth whoami`**: after identity, fetches the endpoint and prints
`accounts: tom@keymail.dev (bdk), acme (bce)`. Degrades **silently** on
404/error — whoami's job is identity; the membership line is a bonus, and