carlos / platform Public

plan: console default resolution — 5 TDD tasks

config.go (file+walk), pick() chain, login fallback, auth default verb,
whole-tree gate. Pins apimode.go's exact-string noCredentialsMsg match as
a wire shape and hardens useTempCredentials against the developer's real
~/.carlos/config.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxXEWoRXU8WVTXjTJypuJ5
Paul Campbell fd5f6f347a46155fc8efdb2514756bdd3c766a1e parent 02728bd
1 file changed, +939 −0
  • docs/superpowers/plans/2026-08-04-console-default-resolution.md +939 −0
diff --git a/docs/superpowers/plans/2026-08-04-console-default-resolution.md b/docs/superpowers/plans/2026-08-04-console-default-resolution.md
new file mode 100644
index 000000000..edca8e3c6
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-04-console-default-resolution.md
@@ -0,0 +1,939 @@
+# Console 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:** `carlos` resolves its target console through settable defaults — global `~/.carlos/config`, per-project `.carlos/config` — with `auth login` alone falling through to the built-in flagship, per `docs/superpowers/specs/2026-08-04-console-default-resolution-design.md`.
+
+**Architecture:** One new file (`config.go`) owns the config file format, the upward directory walk, and the global path; `auth.go`'s `pick()` and `authLogin()` gain the two config steps in their resolution chains; a new `auth default` sub-verb sets and shows the default. Everything else (`ship`, `promote`, `accounts`, apimode) already resolves through `pick()`/`loggedInCredential` and picks the behavior up for free.
+
+**Tech Stack:** Go 1.26, stdlib only, tests in `package main` alongside the code (repo convention).
+
+## Global Constraints
+
+- Build/test env (sandbox): `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; if go refuses with "read-only file system", rerun with the sandbox disabled.
+- Before each commit: `go build ./...`, `go vet ./...`, `go test ./... -count=1` all clean; `gofmt -l` clean for touched files.
+- **`noCredentialsMsg` is a wire shape.** `apimode.go` matches `pick()`'s no-credentials refusal by exact string: `"not logged in — run: carlos auth login -console <url>"`. That wording must stay byte-identical in `pick()`.
+- The multi-credential refusal and the HEADLESS warning in `authUsage` are kept (the latter verbatim).
+- Built-in login fallback is exactly `https://platform.carlosframework.com`.
+- Config file is JSON `{"console": "https://…"}`; missing file or missing/empty `console` field = "no default at this level" (continue resolution); malformed JSON or a URL `normalizeConsole` rejects = error naming the file (never a silent skip).
+- A resolved config default is authoritative: never fall back past it to the single-credential pick.
+- Follow the repo's comment style: comments explain constraints and reasoning, not what the next line does.
+
+---
+
+### Task 1: config.go — file format, walk, global path
+
+**Files:**
+- Create: `config.go`
+- Create: `config_test.go`
+- Modify: `auth_test.go:16-23` (`useTempCredentials` — isolate HOME/cwd/env too)
+
+**Interfaces:**
+- Consumes: `normalizeConsole(string) (string, error)` from `auth.go`.
+- Produces (later tasks rely on these exact signatures):
+ - `const defaultConsole = "https://platform.carlosframework.com"`
+ - `func loadConfigFile(path string) (string, error)` — normalized console or `""`; error only for malformed/invalid.
+ - `func configDefault() (console, source string, err error)` — the effective configured default and the file it came from; `("", "", nil)` when none.
+ - `func globalConfigPath() (string, error)` — `~/.carlos/config`.
+ - `func saveConfigFile(path, console string, dirPerm os.FileMode) error` — validate, mkdir, write via temp+rename, 0644.
+
+- [ ] **Step 1: Harden the test helper.** Config resolution reads `$HOME`, cwd, and `CARLOS_CONSOLE`, so every test must point all of them away from the developer's real machine — a stray `~/.carlos/config` on the box running the tests must never change a test's answer. Replace `useTempCredentials` in `auth_test.go`:
+
+```go
+// useTempCredentials points everything the CLI's console resolution reads —
+// the credentials file, $HOME, the working directory, CARLOS_CONSOLE — at
+// scratch space for the length of one test. Config resolution (config.go)
+// consults all four; a developer's real ~/.carlos/config must never change
+// a test's answer.
+func useTempCredentials(t *testing.T) string {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("CARLOS_CONSOLE", "")
+ t.Chdir(t.TempDir())
+ path := filepath.Join(t.TempDir(), "carlos", "credentials")
+ t.Setenv("CARLOS_CREDENTIALS", path)
+ return path
+}
+```
+
+- [ ] **Step 2: Write the failing tests** in a new `config_test.go`:
+
+```go
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// writeConfig plants a .carlos/config under dir, the way a checked-in
+// project file or `carlos auth default` would.
+func writeConfig(t *testing.T, dir, body string) string {
+ t.Helper()
+ path := filepath.Join(dir, ".carlos", "config")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func TestLoadConfigFile(t *testing.T) {
+ useTempCredentials(t)
+ dir := t.TempDir()
+
+ // Missing file: no default at this level, not an error.
+ if c, err := loadConfigFile(filepath.Join(dir, ".carlos", "config")); err != nil || c != "" {
+ t.Fatalf("missing config = %q, %v; want empty, nil", c, err)
+ }
+
+ // A well-formed file: the console comes back normalized.
+ p := writeConfig(t, dir, `{"console": "https://tito.example/"}`+"\n")
+ if c, err := loadConfigFile(p); err != nil || c != "https://tito.example" {
+ t.Fatalf("loadConfigFile = %q, %v; want the normalized console", c, err)
+ }
+
+ // Unknown fields are ignored (additive evolution), and a file WITHOUT
+ // a console field is "no default at this level", not an error.
+ os.WriteFile(p, []byte(`{"future": true}`), 0o644)
+ if c, err := loadConfigFile(p); err != nil || c != "" {
+ t.Fatalf("config without console = %q, %v; want empty, nil", c, err)
+ }
+
+ // Malformed JSON is an error naming the file — silently skipping a
+ // typo'd config would ship to the wrong console.
+ os.WriteFile(p, []byte(`{`), 0o644)
+ if _, err := loadConfigFile(p); err == nil || !strings.Contains(err.Error(), p) {
+ t.Fatalf("malformed config = %v, want an error naming %s", err, p)
+ }
+
+ // A console normalizeConsole rejects is an error too, same reasoning.
+ os.WriteFile(p, []byte(`{"console": "ftp://nope"}`), 0o644)
+ if _, err := loadConfigFile(p); err == nil || !strings.Contains(err.Error(), p) {
+ t.Fatalf("bad-URL config = %v, want an error naming %s", err, p)
+ }
+}
+
+func TestConfigDefaultWalk(t *testing.T) {
+ useTempCredentials(t)
+ home := os.Getenv("HOME")
+
+ // Nothing anywhere: no default, no error.
+ if c, src, err := configDefault(); err != nil || c != "" || src != "" {
+ t.Fatalf("bare configDefault = %q %q %v, want empty", c, src, err)
+ }
+
+ // Only the global: the global wins, and the source names the file.
+ globalPath := writeConfig(t, home, `{"console": "https://global.example"}`)
+ c, src, err := configDefault()
+ if err != nil || c != "https://global.example" || src != globalPath {
+ t.Fatalf("global-only = %q %q %v", c, src, err)
+ }
+
+ // A project config anywhere up the cwd walk beats the global, and the
+ // NEAREST project config beats a farther one.
+ root := t.TempDir()
+ writeConfig(t, root, `{"console": "https://outer.example"}`)
+ nested := filepath.Join(root, "cmd", "deep")
+ if err := os.MkdirAll(nested, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Chdir(nested)
+ if c, _, err = configDefault(); err != nil || c != "https://outer.example" {
+ t.Fatalf("walk from nested = %q %v, want the ancestor's config", c, err)
+ }
+ nearest := writeConfig(t, nested, `{"console": "https://inner.example"}`)
+ if c, src, err = configDefault(); err != nil || c != "https://inner.example" || src != nearest {
+ t.Fatalf("nearest = %q %q %v", c, src, err)
+ }
+
+ // A level whose config has no console does not stop the walk.
+ os.WriteFile(nearest, []byte(`{"future": true}`), 0o644)
+ if c, _, err = configDefault(); err != nil || c != "https://outer.example" {
+ t.Fatalf("empty-console level = %q %v, want the walk to continue", c, err)
+ }
+
+ // A malformed config mid-walk is an error, not a skip.
+ os.WriteFile(nearest, []byte(`not json`), 0o644)
+ if _, _, err = configDefault(); err == nil {
+ t.Fatal("malformed project config must be an error, not a skip")
+ }
+}
+
+func TestSaveConfigFile(t *testing.T) {
+ useTempCredentials(t)
+ dir := t.TempDir()
+ path := filepath.Join(dir, ".carlos", "config")
+
+ // The URL is validated and normalized on WRITE, so a bad default is
+ // caught when set, not at first use.
+ if err := saveConfigFile(path, "ftp://nope", 0o755); err == nil {
+ t.Fatal("saving an invalid console must fail")
+ }
+ if err := saveConfigFile(path, "https://tito.example/", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if c, err := loadConfigFile(path); err != nil || c != "https://tito.example" {
+ t.Fatalf("round-trip = %q, %v", c, err)
+ }
+ // Not a secret: world-readable is fine and expected in a repo.
+ fi, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fi.Mode().Perm() != 0o644 {
+ t.Fatalf("config mode = %v, want 0644", fi.Mode().Perm())
+ }
+}
+```
+
+- [ ] **Step 3: Run the tests to verify they fail.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestLoadConfigFile|TestConfigDefaultWalk|TestSaveConfigFile' -count=1`
+Expected: compile FAILURE — `loadConfigFile`, `configDefault`, `saveConfigFile` undefined.
+
+- [ ] **Step 4: Implement `config.go`:**
+
+```go
+// The .carlos/config file: a settable default console.
+//
+// Two levels, one shape. ~/.carlos/config is the machine's default;
+// .carlos/config at a project root — committed to the repo — pins that
+// project to a deployment (the titogo checkout says the Tito stack; a
+// checkout with no opinion falls to the global). The nearest .carlos/config
+// walking up from the working directory wins.
+//
+// A resolved default is authoritative: resolution never falls past it to
+// "the only credential on this machine". Holding only a flagship credential
+// inside a checkout pinned to another stack is an error naming that stack —
+// never a quiet ship to the flagship. The single-credential convenience
+// exists only for the no-default world.
+//
+// The file is deliberately not a secret and never holds one: nothing from
+// ~/.carlos/credentials moves here, ever. It IS repo-controlled input when
+// committed, which a malicious PR could repoint — accepted in the spec
+// (docs/superpowers/specs/2026-08-04-console-default-resolution-design.md)
+// because per-origin credentials mean no token ever crosses consoles, and
+// every verb prints the console it is talking to.
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// defaultConsole is where `carlos auth login` lands when nothing — flag,
+// env, project config, global config — names a console: the flagship. Login
+// only; no other verb ever assumes it.
+const defaultConsole = "https://platform.carlosframework.com"
+
+// cliConfig is the whole file. Unknown fields are ignored on read and
+// dropped on rewrite: the file is additive, and the CLI is its only parser.
+type cliConfig struct {
+ Console string `json:"console"`
+}
+
+// loadConfigFile reads one .carlos/config. A missing file, or one without a
+// console, is ("", nil): "no default at this level", and resolution
+// continues. A file that cannot be parsed, or names a console
+// normalizeConsole refuses, is an ERROR — silently ignoring a typo'd
+// config would ship to the wrong console, the exact mistake this file
+// exists to prevent.
+func loadConfigFile(path string) (string, error) {
+ raw, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return "", nil
+ }
+ if err != nil {
+ return "", err
+ }
+ var cfg cliConfig
+ if err := json.Unmarshal(raw, &cfg); err != nil {
+ return "", fmt.Errorf("%s is not readable as a carlos config: %w", path, err)
+ }
+ if strings.TrimSpace(cfg.Console) == "" {
+ return "", nil
+ }
+ n, err := normalizeConsole(cfg.Console)
+ if err != nil {
+ return "", fmt.Errorf("%s: %w", path, err)
+ }
+ return n, nil
+}
+
+// configDefault resolves the configured default console: the nearest
+// .carlos/config walking up from the working directory, else
+// ~/.carlos/config. Returns the normalized console and the file it came
+// from — the source goes into error messages, because "not logged in to X"
+// is only actionable if you know WHICH file said X.
+//
+// If the walk passes through $HOME it finds ~/.carlos/config by the same
+// rule — the global default arriving one step early, same answer. The
+// explicit global check after the walk is for working directories outside
+// $HOME entirely.
+func configDefault() (console, source string, err error) {
+ if dir, werr := os.Getwd(); werr == nil {
+ for {
+ path := filepath.Join(dir, ".carlos", "config")
+ c, err := loadConfigFile(path)
+ if err != nil {
+ return "", "", err
+ }
+ if c != "" {
+ return c, path, nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
+ }
+ dir = parent
+ }
+ }
+ path, perr := globalConfigPath()
+ if perr != nil {
+ // No home directory means no global config — a state, not an error.
+ return "", "", nil
+ }
+ c, err := loadConfigFile(path)
+ if err != nil {
+ return "", "", err
+ }
+ if c == "" {
+ return "", "", nil
+ }
+ return c, path, nil
+}
+
+// globalConfigPath is ~/.carlos/config — the same directory that holds
+// credentials, though this file is not a secret and never holds one.
+func globalConfigPath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("no home directory for ~/.carlos/config: %w", err)
+ }
+ return filepath.Join(home, ".carlos", "config"), nil
+}
+
+// saveConfigFile validates the console and writes {"console": …} at path,
+// creating the .carlos directory (dirPerm: 0700 for ~/.carlos, which
+// MkdirAll leaves alone if it already exists; 0755 for a project directory
+// that is meant to be committed). Temp-file-then-rename like
+// credentialFile.save, so a crash cannot leave a truncated config — but
+// 0644, not 0600: this file is not a secret.
+func saveConfigFile(path, console string, dirPerm os.FileMode) error {
+ n, err := normalizeConsole(console)
+ if err != nil {
+ return err
+ }
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, dirPerm); err != nil {
+ return err
+ }
+ body, err := json.MarshalIndent(cliConfig{Console: n}, "", " ")
+ if err != nil {
+ return err
+ }
+ body = append(body, '\n')
+ tmp, err := os.CreateTemp(dir, ".config-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+ if err := tmp.Chmod(0o644); err != nil {
+ tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(body); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmp.Name(), path)
+}
+```
+
+- [ ] **Step 5: Run the tests to verify they pass.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestLoadConfigFile|TestConfigDefaultWalk|TestSaveConfigFile' -count=1 -v`
+Expected: PASS (3 tests).
+
+- [ ] **Step 6: Full check and commit.**
+
+Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l config.go config_test.go auth_test.go` (empty gofmt output)
+
+```bash
+git add config.go config_test.go auth_test.go
+git commit -m "auth: .carlos/config default-console file and lookup walk"
+```
+
+---
+
+### Task 2: pick() honors the configured default, authoritatively
+
+**Files:**
+- Modify: `auth.go:239-281` (`pick`)
+- Modify: `config_test.go` (add tests)
+
+**Interfaces:**
+- Consumes: `configDefault()` from Task 1.
+- Produces: `pick`'s signature is unchanged (`func (f *credentialFile) pick(console string) (*credential, error)`); every existing caller (`loggedInCredential`, and through it apimode/accounts/promoteActor) picks the new chain up with no change.
+
+**Hard constraint:** the no-credentials message must remain byte-identical — `"not logged in — run: carlos auth login -console <url>"` — because `apimode.go`'s `noCredentialsMsg` matches it exactly. It is only reachable when no default is configured, so it needs no new wording.
+
+- [ ] **Step 1: Write the failing tests** (append to `config_test.go`):
+
+```go
+func TestPickHonorsConfigDefault(t *testing.T) {
+ useTempCredentials(t)
+ home := os.Getenv("HOME")
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now().UTC()
+ f, _ := loadCredentials()
+ f.put(credential{Console: "https://flagship.example", Address: "p@keymail.dev",
+ Token: "t-flag", ExpiresAt: now.Add(time.Hour)})
+ f.put(credential{Console: "https://tito.example", Address: "p@keymail.dev",
+ Token: "t-tito", ExpiresAt: now.Add(time.Hour)})
+
+ // Two credentials, no default: today's refusal — and it now also says
+ // how to stop having to choose.
+ if _, err := f.pick(""); err == nil || !strings.Contains(err.Error(), "carlos auth default") {
+ t.Fatalf("multi-credential refusal = %v, want the set-a-default hint", err)
+ }
+
+ // A global default resolves the ambiguity.
+ writeConfig(t, home, `{"console": "https://tito.example"}`)
+ c, err := f.pick("")
+ if err != nil || c.Token != "t-tito" {
+ t.Fatalf("pick with global default = %+v %v, want the tito credential", c, err)
+ }
+
+ // A project default overrides the global.
+ writeConfig(t, cwd, `{"console": "https://flagship.example"}`)
+ if c, err = f.pick(""); err != nil || c.Token != "t-flag" {
+ t.Fatalf("pick with project default = %+v %v, want the flagship credential", c, err)
+ }
+
+ // CARLOS_CONSOLE still beats any config.
+ t.Setenv("CARLOS_CONSOLE", "https://tito.example")
+ if c, err = f.pick(""); err != nil || c.Token != "t-tito" {
+ t.Fatalf("env over config = %+v %v", c, err)
+ }
+ t.Setenv("CARLOS_CONSOLE", "")
+
+ // An explicit flag still beats everything.
+ if c, err = f.pick("https://tito.example"); err != nil || c.Token != "t-tito" {
+ t.Fatalf("flag over config = %+v %v", c, err)
+ }
+}
+
+func TestConfigDefaultIsAuthoritative(t *testing.T) {
+ useTempCredentials(t)
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The titogo case: the checkout is pinned to a stack this machine holds
+ // no credential for, and the only credential is for a DIFFERENT console.
+ // Falling back to it would quietly ship the project to the wrong
+ // deployment — the exact mistake the default exists to prevent.
+ f, _ := loadCredentials()
+ f.put(credential{Console: "https://flagship.example", Address: "p@keymail.dev",
+ Token: "t-flag", ExpiresAt: time.Now().Add(time.Hour)})
+ src := writeConfig(t, cwd, `{"console": "https://tito.example"}`)
+
+ _, err = f.pick("")
+ if err == nil {
+ t.Fatal("a set default with no credential must refuse, not fall back")
+ }
+ for _, want := range []string{"https://tito.example", src} {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("authoritative refusal %q does not name %q", err, want)
+ }
+ }
+
+ // A malformed config is an error even though a usable credential exists.
+ os.WriteFile(src, []byte(`{`), 0o644)
+ if _, err := f.pick(""); err == nil {
+ t.Fatal("malformed config must be an error, not a skip to the credential")
+ }
+}
+```
+
+Also add `"time"` to `config_test.go`'s imports.
+
+- [ ] **Step 2: Run the tests to verify they fail.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestPickHonorsConfigDefault|TestConfigDefaultIsAuthoritative' -count=1`
+Expected: FAIL — pick ignores config (wrong credential / missing hint / no refusal).
+
+- [ ] **Step 3: Replace `pick` in `auth.go`** (the whole function, currently auth.go:239-281):
+
+```go
+// pick resolves which credential a verb should use: an explicit -console,
+// else CARLOS_CONSOLE, else the configured default (.carlos/config walking
+// up from cwd, then ~/.carlos/config — config.go), else the only live
+// credential. Several with no default is a refusal rather than a guess —
+// shipping to the wrong deployment because the CLI picked one for you is
+// not a mistake worth being convenient about.
+//
+// Any explicitly named console — flag, env, or config — is authoritative:
+// it resolves against credentials for THAT console or errors. It never
+// falls back to "the only credential", because inside a checkout pinned to
+// one stack, quietly using a credential for a different stack is the wrong
+// deployment with extra steps.
+func (f *credentialFile) pick(console string) (*credential, error) {
+ if console == "" {
+ console = os.Getenv("CARLOS_CONSOLE")
+ }
+ from := ""
+ if console == "" {
+ var src string
+ var err error
+ console, src, err = configDefault()
+ if err != nil {
+ return nil, err
+ }
+ if console != "" {
+ // Only a config-sourced console gets the clause: whoever typed
+ // -console or exported CARLOS_CONSOLE knows where it came from,
+ // but a default set weeks ago in a file needs naming.
+ from = fmt.Sprintf(" (the default from %s)", src)
+ }
+ }
+ if console != "" {
+ want, err := normalizeConsole(console)
+ if err != nil {
+ return nil, err
+ }
+ c := f.find(want)
+ if c == nil {
+ return nil, fmt.Errorf("not logged in to %s%s — run: carlos auth login -console %s", want, from, want)
+ }
+ if c.Expired(time.Now()) {
+ return nil, fmt.Errorf("the credential for %s%s expired %s — run: carlos auth login -console %s",
+ want, from, c.ExpiresAt.Format(time.RFC3339), want)
+ }
+ return c, nil
+ }
+ var live []*credential
+ for i := range f.Credentials {
+ if !f.Credentials[i].Expired(time.Now()) {
+ live = append(live, &f.Credentials[i])
+ }
+ }
+ switch len(live) {
+ case 1:
+ return live[0], nil
+ case 0:
+ // EXACT wording — apimode.go's noCredentialsMsg matches this string.
+ return nil, errors.New("not logged in — run: carlos auth login -console <url>")
+ default:
+ var names []string
+ for _, c := range live {
+ names = append(names, c.Console)
+ }
+ return nil, fmt.Errorf("logged in to several consoles — say which with -console, "+
+ "or set a default with `carlos auth default <url>`: %s",
+ strings.Join(names, ", "))
+ }
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass — including the pre-existing ones.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -count=1`
+Expected: PASS. `TestCredentialSelection` (asserts the multi refusal contains `-console`) and the apimode tests (exact `noCredentialsMsg`) must still pass untouched — if either fails, the wording drifted; fix the wording, not the test.
+
+- [ ] **Step 5: Full check and commit.**
+
+Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l auth.go config_test.go`
+
+```bash
+git add auth.go config_test.go
+git commit -m "auth: pick() honors the configured default console, authoritatively"
+```
+
+---
+
+### Task 3: auth login resolves through the chain to the flagship
+
+**Files:**
+- Modify: `auth.go:536-548` (`authLogin` — flag definition and the "which console?" refusal)
+- Modify: `auth_test.go:336-342` (`TestAuthLoginNeedsAConsole` — replaced)
+- Modify: `config_test.go` (add login tests)
+
+**Interfaces:**
+- Consumes: `configDefault()`, `defaultConsole` from Task 1.
+- Produces: `func loginConsole(flagValue string) (string, error)` — the login-only resolution chain, factored out so the flagship fallback is testable without any network. `authLogin` calls it; nothing else does.
+
+- [ ] **Step 1: Write the failing tests.** Delete `TestAuthLoginNeedsAConsole` from `auth_test.go` (its premise — refusing when no `-console` — is the behavior being removed). Append to `config_test.go`:
+
+```go
+// The login chain: flag → CARLOS_CONSOLE → project config → global config →
+// the flagship. Login is the one verb with a built-in landing place; there
+// may be no credential yet, and the flagship is the product answer.
+func TestLoginConsoleChain(t *testing.T) {
+ useTempCredentials(t)
+ home := os.Getenv("HOME")
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if got, err := loginConsole(""); err != nil || got != defaultConsole {
+ t.Fatalf("bare loginConsole = %q %v, want the flagship", got, err)
+ }
+ writeConfig(t, home, `{"console": "https://global.example"}`)
+ if got, err := loginConsole(""); err != nil || got != "https://global.example" {
+ t.Fatalf("global config = %q %v", got, err)
+ }
+ writeConfig(t, cwd, `{"console": "https://project.example"}`)
+ if got, err := loginConsole(""); err != nil || got != "https://project.example" {
+ t.Fatalf("project config = %q %v", got, err)
+ }
+ t.Setenv("CARLOS_CONSOLE", "https://env.example")
+ if got, err := loginConsole(""); err != nil || got != "https://env.example" {
+ t.Fatalf("env = %q %v", got, err)
+ }
+ if got, err := loginConsole("https://flag.example"); err != nil || got != "https://flag.example" {
+ t.Fatalf("flag = %q %v", got, err)
+ }
+ // A malformed config fails the login rather than silently logging in
+ // to the flagship.
+ t.Setenv("CARLOS_CONSOLE", "")
+ os.WriteFile(filepath.Join(cwd, ".carlos", "config"), []byte(`{`), 0o644)
+ if _, err := loginConsole(""); err == nil {
+ t.Fatal("malformed config must fail the login, not skip to the flagship")
+ }
+}
+
+// End to end: with a configured default and no -console at all, login goes
+// where the config points.
+func TestAuthLoginUsesConfiguredDefault(t *testing.T) {
+ useTempCredentials(t)
+ fc := newFakeConsole(t)
+ writeConfig(t, os.Getenv("HOME"), `{"console": "`+fc.ts.URL+`"}`)
+ var out bytes.Buffer
+ if err := authLogin(&out, nil); err != nil {
+ t.Fatalf("authLogin via config default: %v\n%s", err, out.String())
+ }
+ f, _ := loadCredentials()
+ if c := f.find(fc.ts.URL); c == nil || c.Token != "cli-token" {
+ t.Fatalf("credential stored = %+v, want one for the configured console", c)
+ }
+}
+```
+
+Add `"bytes"` to `config_test.go`'s imports. (`newFakeConsole` serves plain `http://127.0.0.1:…`, which `normalizeConsole` allows as loopback — that is why the end-to-end test works unchanged.)
+
+- [ ] **Step 2: Run the tests to verify they fail.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestLoginConsoleChain|TestAuthLoginUsesConfiguredDefault' -count=1`
+Expected: compile FAILURE — `loginConsole` undefined.
+
+- [ ] **Step 3: Implement.** In `auth.go`, add `loginConsole` above `authLogin`, and rewrite `authLogin`'s opening. The flag currently reads `fs.String("console", os.Getenv("CARLOS_CONSOLE"), …)` — the env moves into the chain so each source is checked in order and only once.
+
+```go
+// loginConsole is login's resolution chain: flag → CARLOS_CONSOLE → the
+// configured default → the flagship. Login alone gets the built-in landing
+// place: there may be no credential yet to pick from, and the flagship is
+// the product answer. Every other verb resolves through pick(), which
+// assumes nothing.
+func loginConsole(flagValue string) (string, error) {
+ base := flagValue
+ if base == "" {
+ base = os.Getenv("CARLOS_CONSOLE")
+ }
+ if base == "" {
+ var err error
+ if base, _, err = configDefault(); err != nil {
+ return "", err
+ }
+ }
+ if base == "" {
+ base = defaultConsole
+ }
+ return normalizeConsole(base)
+}
+```
+
+Then in `authLogin`, replace this (current auth.go:539 and 544-551):
+
+```go
+ console := fs.String("console", os.Getenv("CARLOS_CONSOLE"), "the console to log in to (https://…)")
+```
+```go
+ if *console == "" {
+ return errors.New("which console? pass -console https://platform.carlosframework.com " +
+ "(or set CARLOS_CONSOLE). On a box with no browser, don't log in at all — " +
+ "pass -owner <address> to `carlos accounts create` instead")
+ }
+ base, err := normalizeConsole(*console)
+ if err != nil {
+ return err
+ }
+```
+
+with:
+
+```go
+ console := fs.String("console", "", "the console to log in to (default: the configured default, else "+defaultConsole+")")
+```
+```go
+ base, err := loginConsole(*console)
+ if err != nil {
+ return err
+ }
+```
+
+(`normalizeConsole` runs inside `loginConsole`; do not call it twice.)
+
+- [ ] **Step 4: Run the tests to verify they pass.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -count=1`
+Expected: PASS, whole package — the existing login/denied/permission tests must be untouched by the rewrite.
+
+- [ ] **Step 5: Full check and commit.**
+
+Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l auth.go auth_test.go config_test.go`
+
+```bash
+git add auth.go auth_test.go config_test.go
+git commit -m "auth: login resolves through config defaults to the flagship"
+```
+
+---
+
+### Task 4: the `carlos auth default` sub-verb
+
+**Files:**
+- Modify: `auth.go:51-94` (`authUsage`, `RunAuth`)
+- Modify: `auth.go` (add `authDefault` after `authLogout`)
+- Modify: `config_test.go` (add verb test)
+
+**Interfaces:**
+- Consumes: `configDefault()`, `globalConfigPath()`, `saveConfigFile()`, `defaultConsole` (Task 1); `normalizeConsole` (existing).
+- Produces: `func authDefault(out io.Writer, args []string) error`, dispatched from `RunAuth` as `case "default"`.
+
+- [ ] **Step 1: Write the failing test** (append to `config_test.go`):
+
+```go
+func TestAuthDefaultVerb(t *testing.T) {
+ useTempCredentials(t)
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out bytes.Buffer
+
+ // Nothing configured: the report explains the built-in behavior rather
+ // than printing an empty string.
+ if err := authDefault(&out, nil); err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"no default", defaultConsole, "carlos auth default"} {
+ if !strings.Contains(out.String(), want) {
+ t.Fatalf("bare report missing %q:\n%s", want, out.String())
+ }
+ }
+
+ // Set the global; the value is normalized on write and read back with
+ // its source.
+ out.Reset()
+ if err := authDefault(&out, []string{"https://global.example/"}); err != nil {
+ t.Fatal(err)
+ }
+ out.Reset()
+ if err := authDefault(&out, nil); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "https://global.example") ||
+ !strings.Contains(out.String(), filepath.Join(".carlos", "config")) {
+ t.Fatalf("report after set:\n%s", out.String())
+ }
+
+ // -project writes ./.carlos/config, which then overrides the global.
+ out.Reset()
+ if err := authDefault(&out, []string{"-project", "https://project.example"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(cwd, ".carlos", "config")); err != nil {
+ t.Fatalf("-project did not write ./.carlos/config: %v", err)
+ }
+ out.Reset()
+ if err := authDefault(&out, nil); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "https://project.example") {
+ t.Fatalf("project default not effective:\n%s", out.String())
+ }
+
+ // A bad URL is refused at set time, and -project with nothing to set
+ // is a usage error, not a silent no-op.
+ if err := authDefault(&out, []string{"ftp://nope"}); err == nil {
+ t.Fatal("setting an invalid console must fail")
+ }
+ if err := authDefault(&out, []string{"-project"}); err == nil {
+ t.Fatal("-project with no URL must be a usage error")
+ }
+ if err := authDefault(&out, []string{"a", "b"}); err == nil {
+ t.Fatal("two positional args must be a usage error")
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run TestAuthDefaultVerb -count=1`
+Expected: compile FAILURE — `authDefault` undefined.
+
+- [ ] **Step 3: Implement.** Add after `authLogout` in `auth.go`:
+
+```go
+// authDefault shows or sets the default console. Bare: report the effective
+// configured default and which file said so (flag and env are not consulted
+// — this verb reports CONFIG, and someone with CARLOS_CONSOLE exported can
+// see their own shell). With a URL: write ~/.carlos/config, or
+// ./.carlos/config under -project — relative to the working directory,
+// deliberately not "the repo root": the CLI has no VCS awareness, and
+// "run it where you want the file" beats guessing.
+func authDefault(out io.Writer, args []string) error {
+ fs := flag.NewFlagSet("auth default", flag.ContinueOnError)
+ fs.SetOutput(out)
+ project := fs.Bool("project", false, "write ./.carlos/config (this directory) instead of ~/.carlos/config")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ switch fs.NArg() {
+ case 0:
+ if *project {
+ return errors.New("-project sets a default: carlos auth default -project <url>")
+ }
+ console, src, err := configDefault()
+ if err != nil {
+ return err
+ }
+ if console == "" {
+ fmt.Fprintf(out, "no default console configured.\n")
+ fmt.Fprintf(out, "`carlos auth login` defaults to %s; other commands use the\n", defaultConsole)
+ fmt.Fprintf(out, "only stored credential, or ask when there are several.\n")
+ fmt.Fprintf(out, "Set one with: carlos auth default <url> (-project pins this directory)\n")
+ return nil
+ }
+ fmt.Fprintf(out, "%s (from %s)\n", console, src)
+ return nil
+ case 1:
+ path, err := globalConfigPath()
+ perm := os.FileMode(0o700) // ~/.carlos also holds credentials
+ if *project {
+ path, err = filepath.Join(".carlos", "config"), nil
+ perm = 0o755 // meant to be committed; nothing secret inside
+ }
+ if err != nil {
+ return err
+ }
+ if err := saveConfigFile(path, fs.Arg(0), perm); err != nil {
+ return err
+ }
+ // Echo the NORMALIZED value: what was saved, not what was typed.
+ console, err := normalizeConsole(fs.Arg(0))
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "default console: %s (written to %s)\n", console, path)
+ return nil
+ default:
+ return errors.New("usage: carlos auth default [-project] [<url>]")
+ }
+}
+```
+
+- [ ] **Step 4: Wire the dispatch and the usage text.** In `RunAuth` (auth.go:79-89) add a case alongside the others:
+
+```go
+ case "default":
+ err = authDefault(os.Stdout, args[1:])
+```
+
+Replace `authUsage` (auth.go:51-70) with — note the HEADLESS paragraph is verbatim from today:
+
+```go
+const authUsage = `usage: carlos auth <login|whoami|logout|default>
+
+ login [-console <url>] log this terminal in to a deployment's console
+ (default: the configured default console, else
+ https://platform.carlosframework.com). Prints a
+ code; you approve it in a browser that is already
+ signed in to that console. No browser on this
+ machine is needed — the code travels.
+ [-label <name>] what to call this terminal on the console's
+ command-line access page (default: user@host)
+ whoami [-console <url>] ask the console who this terminal is
+ logout [-console <url>] revoke this terminal's token and forget it
+ default [-project] [<url>]
+ show the default console and where it is set, or
+ set it: writes ~/.carlos/config — or, with
+ -project, ./.carlos/config, which overrides the
+ global and is meant to be committed so a checkout
+ pins its own deployment
+
+The token lands in ~/.carlos/credentials (mode 0600), one entry per console
+origin — a laptop can hold credentials for the flagship and a self-hosted
+deployment at once. -console may be omitted when there is exactly one, or
+when a default is set.
+
+HEADLESS AND BOX-SIDE USE: do not log in. A box has no browser and no human,
+and a long-lived token on it is a standing credential nobody is watching.
+Pass -owner <address> to "carlos accounts create" instead — that is the
+documented path, and it needs no identity of its own.
+`
+```
+
+- [ ] **Step 5: Run the tests to verify they pass.**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -count=1`
+Expected: PASS, whole package.
+
+- [ ] **Step 6: Full check and commit.**
+
+Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l auth.go config_test.go`
+
+```bash
+git add auth.go config_test.go
+git commit -m "auth: carlos auth default shows and sets the default console"
+```
+
+---
+
+### Task 5: whole-tree verification
+
+**Files:** none created — this is the gate the repo requires before the branch is done.
+
+- [ ] **Step 1: Run everything, all 14 packages:**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./...
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./...
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1
+gofmt -l config.go config_test.go auth.go auth_test.go
+```
+
+Expected: all clean; gofmt prints nothing. Pay attention to `apimode_test.go` and `internal/release/laddercheck_test.go` — both pin exact strings/channels and must pass unmodified.
+
+- [ ] **Step 2: Confirm no test file was weakened.** `git diff main -- auth_test.go` must show only: the `useTempCredentials` hardening (Task 1) and the deletion of `TestAuthLoginNeedsAConsole` (Task 3, behavior removed by design). Any other loosened assertion is a defect.
+
+- [ ] **Step 3: Commit anything outstanding** (there should be nothing) and report the branch ready for review.