diff --git a/.env.example b/.env.example index f088b6b..5b7a1f9 100644 --- a/.env.example +++ b/.env.example @@ -54,7 +54,22 @@ CMS_URL=http://localhost:3001 # --- Optional: sandcastle dispatch (only when running `pnpm work dispatch --execute`) --- +# Auth (pick one — subscription is preferred): +# +# 1. Subscription mode (recommended for Pro/Max subscribers): +# Run `claude login` on the host once. Sandcastle bind-mounts ~/.claude/ +# into the sandbox so the container's Claude Code CLI uses your session. +# Zero per-task token spend. No env var needed. +# +# 2. API-key mode (fallback when no host creds available): # ANTHROPIC_API_KEY= # OPENAI_API_KEY= + +# Override the path to host Claude Code creds (default: ~/.claude/) +# SANDCASTLE_CLAUDE_CREDS_DIR= + +# GitHub access (optional — for orchestrator-created PRs) # GITHUB_TOKEN= + +# Sandbox provider (default: docker; alternatives: podman, vercel, daytona) # SANDCASTLE_PROVIDER=docker diff --git a/.sandcastle/.env.example b/.sandcastle/.env.example index ed27fb0..a563d98 100644 --- a/.sandcastle/.env.example +++ b/.sandcastle/.env.example @@ -1,11 +1,20 @@ -# Anthropic Claude API -ANTHROPIC_API_KEY= +# .sandcastle/.env — runtime tokens for sandcastle dispatch. +# Copy to .sandcastle/.env (gitignored) and fill what you need. +# +# Most developers don't need ANY of these if they've run `claude login` on +# the host — sandcastle mounts ~/.claude/ into the sandbox by default. -# OpenAI / Codex -OPENAI_API_KEY= +# Anthropic API key (fallback when no host Claude Code session exists) +# ANTHROPIC_API_KEY= -# GitHub (for PR creation by the orchestrator) -GITHUB_TOKEN= +# OpenAI / Codex (alternative) +# OPENAI_API_KEY= -# Sandcastle's own configuration — sandbox provider (docker | podman | vercel | custom) +# GitHub access for orchestrator-created PRs +# GITHUB_TOKEN= + +# Override Claude creds path (default: ~/.claude/) +# SANDCASTLE_CLAUDE_CREDS_DIR= + +# Sandbox provider (docker / podman / vercel / daytona) SANDCASTLE_PROVIDER=docker diff --git a/.sandcastle/Dockerfile b/.sandcastle/Dockerfile index 2de73d6..3b28ee4 100644 --- a/.sandcastle/Dockerfile +++ b/.sandcastle/Dockerfile @@ -1,14 +1,26 @@ -# Minimal sandbox for sandcastle dispatch. -# Will be extended once the CI image is identified (see ADR-future). +# Sandcastle sandbox image — runs the implementer + reviewer agents. +# +# Includes Claude Code CLI so the sandbox can authenticate via the host's +# mounted ~/.claude/ session (sandcastle issue #191 workaround — subscription +# auth, not API-key auth, is our primary flow). Falls back to ANTHROPIC_API_KEY +# when no host credentials are available. + FROM node:22-bookworm-slim # pnpm via corepack (matches the repo's pnpm version) RUN corepack enable && corepack prepare pnpm@9 --activate +# Claude Code CLI — used by sandcastle's claudeCode() agent provider. +# The CLI reads credentials from ~/.claude/ inside the container; the host +# mounts its ~/.claude/ over that path at sandbox start. +RUN npm install -g @anthropic-ai/claude-code + +# Minimal system deps for git operations + healthchecks. +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /workspace -# Repo files are mounted by sandcastle at /workspace at runtime. -# The agent runs `pnpm install --frozen-lockfile` as its first step -# per the implementer prompt template. - CMD ["bash"] diff --git a/docs/decisions/adr-019-sandcastle-for-agent-orchestration.md b/docs/decisions/adr-019-sandcastle-for-agent-orchestration.md index 0c2f4a3..7e34f8a 100644 --- a/docs/decisions/adr-019-sandcastle-for-agent-orchestration.md +++ b/docs/decisions/adr-019-sandcastle-for-agent-orchestration.md @@ -50,12 +50,21 @@ Concretely: orchestrator does NOT mutate state in v1 — it prints suggested mutations for the human to apply. 5. **Two modes:** `pnpm work dispatch` (planning, no agent invoked) and - `pnpm work dispatch --execute` (real sandcastle call, requires - `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`). + `pnpm work dispatch --execute` (real sandcastle call, requires auth — see + point 7). 6. **Reviewer agent verifies generator-first.** Hand-rolled output that should have been a `pnpm turbo gen ` invocation is grounds for rejection. -7. **Bring-your-own-key for cost control.** No bundled API key. Agents only - dispatch when the operator explicitly provides credentials. +7. **Bring-your-own-auth.** Two paths are supported, in priority order: + - **Subscription (primary)** — bind-mount the host's `~/.claude/` into the + sandbox. Claude Code CLI inside the sandbox uses the host's logged-in + subscription session. Zero per-task token spend for Pro/Max subscribers. + Path overridable via `SANDCASTLE_CLAUDE_CREDS_DIR` env var. + - **API key (fallback)** — `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` passed + through to the sandbox env. Used when no host creds directory exists. + - The resolver (`resolveClaudeAuth` in `scripts/work/dispatch.mjs`) picks + automatically with subscription always preferred. Sandcastle's own issue + #191 documents that subscription support won't be added natively; + this mount-based pattern is our workaround promoted to first-class. 8. **Per-task max-attempts honoured (v2).** Each task's frontmatter may carry `max-attempts: N` to bound the implementer↔reviewer retry loop. Default 3. diff --git a/docs/guides/runbook.md b/docs/guides/runbook.md index 946ddde..8c6743a 100644 --- a/docs/guides/runbook.md +++ b/docs/guides/runbook.md @@ -87,7 +87,7 @@ pnpm work ready # all ready stories pnpm work blocked # blocked stories + what they wait on pnpm work rebuild-state # regenerate docs/work/_state.json pnpm work dispatch # print next dispatch plan -pnpm work dispatch --execute # invoke sandcastle (requires ANTHROPIC_API_KEY) +pnpm work dispatch --execute # invoke sandcastle (subscription or API key — see runbook) ``` --- @@ -139,12 +139,15 @@ Copy `.env.example` to `.env` and fill what you need. NOT every variable is requ ### Optional — sandcastle dispatch (only when running `pnpm work dispatch --execute`) -| Var | Why | -| --------------------- | ------------------------------------------------- | -| `ANTHROPIC_API_KEY` | Claude API key (sandcastle's default agent) | -| `OPENAI_API_KEY` | OpenAI/Codex alternative | -| `GITHUB_TOKEN` | GitHub access for PR creation by the orchestrator | -| `SANDCASTLE_PROVIDER` | `docker` (default) / `podman` / `vercel` | +Auth is resolved automatically. Subscription (via `~/.claude/`) is the primary path; API key is the fallback. + +| Var | Why | +| ----------------------------- | ---------------------------------------------------------------------------------- | +| `ANTHROPIC_API_KEY` | Claude API key — fallback when no `~/.claude/` present; not needed for subscribers | +| `OPENAI_API_KEY` | OpenAI/Codex alternative (fallback) | +| `SANDCASTLE_CLAUDE_CREDS_DIR` | Override host Claude creds path (default: `~/.claude/`) | +| `GITHUB_TOKEN` | GitHub access for PR creation by the orchestrator | +| `SANDCASTLE_PROVIDER` | `docker` (default) / `podman` / `vercel` | --- @@ -239,17 +242,14 @@ For the full design see `docs/architecture/agent-first-workflow-and-conformance. ### Prerequisites 1. **Docker running** — sandcastle uses Docker for the sandbox by default. `docker info` should succeed. -2. **Agent API key** — set ONE of: - - `ANTHROPIC_API_KEY` (recommended; sandcastle's default agent is `claudeCode`) - - `OPENAI_API_KEY` (alternative) +2. **Authentication — pick ONE:** + - **Recommended: Claude Pro / Max subscription.** Run `claude login` once on the host. Sandcastle's sandbox bind-mounts your `~/.claude/` into the container so the Claude Code CLI inside the sandbox uses your subscription session. Zero per-task token spend for subscribers. + - **Alternative: API key.** Set `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` in your environment. Falls back automatically when `~/.claude/` is absent. + - **Override the creds path** via `SANDCASTLE_CLAUDE_CREDS_DIR` if your Claude Code config lives somewhere non-standard. 3. **GitHub token** (optional) — `GITHUB_TOKEN` if you want the orchestrator to create PRs. 4. **`.sandcastle/` config present** — already in tree: - - `Dockerfile` — node:22 + pnpm sandbox image - - `prd-eliciter.prompt.md` — interviews humans to draft PRDs - - `adr-eliciter.prompt.md` — same shape, for infrastructure decisions - - `decomposer.prompt.md` — PRD → epic + stories with generator-first task lists - - `implementer.prompt.md` — executes one task; runs all 5 gates before committing - - `reviewer.prompt.md` — reviews implementer's diff against AC + scope + - `Dockerfile` — node:22 + pnpm + Claude Code CLI; reads creds from `~/.claude/` inside the container + - `prd-eliciter.prompt.md`, `adr-eliciter.prompt.md`, `decomposer.prompt.md`, `implementer.prompt.md`, `reviewer.prompt.md` — the five role prompts ### The dispatch flow @@ -305,6 +305,11 @@ This is safe to run anywhere — it never invokes Sandcastle. **Step 2 — Execute** ```bash +# Subscription mode (recommended): +claude login # one-time, host +pnpm work dispatch --execute # uses ~/.claude/ + +# API-key mode (fallback): ANTHROPIC_API_KEY=sk-ant-... pnpm work dispatch --execute ``` @@ -361,8 +366,8 @@ You (the human) then: ### Troubleshooting Sandcastle -**`✗ --execute requires ANTHROPIC_API_KEY or OPENAI_API_KEY in env.`** -— Set one. The default agent is Claude. +**`✗ --execute requires either: 1. Claude Code logged in on host ... 2. ANTHROPIC_API_KEY ...`** +— No auth resolved. Run `claude login` to enable subscription mode (recommended), OR set `ANTHROPIC_API_KEY` (fallback). Override the host creds path via `SANDCASTLE_CLAUDE_CREDS_DIR`. **`Error: Cannot find module '@ai-hero/sandcastle'`** — Run `pnpm install`. Sandcastle is a dev dependency at the workspace root. @@ -381,6 +386,9 @@ You (the human) then: **Cost control** — each dispatch typically uses 50K–200K agent tokens depending on task complexity. The orchestrator does NOT cap retries; if you want to limit, set `max-attempts: 1` in the task's frontmatter (the orchestrator respects this in v2 — for now, just don't re-run dispatch after a reject). +**Sandbox boots but Claude Code inside it says "Not authenticated" / "API key required"** +— The host `~/.claude/` mount didn't make it into the sandbox, OR your local Claude Code session expired. On the host, run `claude` once to confirm your session is live, then re-dispatch. If you're on Linux + SELinux, the mount may have been blocked — check the sandcastle output for SELinux warnings; set `selinuxLabel: "z"` or `false` in dispatch.mjs's docker opts if needed. + ### Cost-aware variant: planning-only loop If you want sandcastle's structure without the agent spend, use planning mode + manual execution: @@ -417,8 +425,8 @@ This gives you the same DAG-aware "what's next?" without invoking any agent. Use **Tests fail in `@repo/turbo-generators` with Vitest worker timeouts** — Known flaky on slow machines. Re-run; if persistent, increase the `turbo-generators` package's vitest `testTimeout`. -**`pnpm work dispatch --execute` errors with "ANTHROPIC_API_KEY required"** -— You're trying to run the sandcastle orchestrator without an agent API key. Set the env var, or run `pnpm work dispatch` (no flag) to just print the plan. +**`pnpm work dispatch --execute` errors with "requires either: 1. Claude Code logged in..."** +— No auth source found. Run `claude login` (subscription mode, recommended), or set `ANTHROPIC_API_KEY` (fallback). Run `pnpm work dispatch` (no flag) to just print the plan without auth. --- diff --git a/docs/work/_state.json b/docs/work/_state.json index b2d2dfc..f242cb9 100644 --- a/docs/work/_state.json +++ b/docs/work/_state.json @@ -1,5 +1,5 @@ { - "updated_at": "2026-05-13T07:05:55.913Z", + "updated_at": "2026-05-13T07:32:16.097Z", "epics": { "agent-workflow-docs-v1": { "status": "done", @@ -382,6 +382,20 @@ } } }, + "sandcastle-subscription-auth-v1": { + "status": "done", + "title": "Sandcastle subscription auth as primary flow", + "stories": { + "01-subscription-first": { + "status": "done", + "title": "Subscription-first dispatch — mount ~/.claude, install claude CLI, docs", + "ac_total": 8, + "ac_completed": 8, + "depends_on": [], + "blocks": [] + } + } + }, "visual-regression-v1": { "status": "done", "title": "Visual regression v1 — Playwright screenshot infrastructure", diff --git a/docs/work/sandcastle-subscription-auth-v1/01-subscription-first/_story.md b/docs/work/sandcastle-subscription-auth-v1/01-subscription-first/_story.md new file mode 100644 index 0000000..5fe9cee --- /dev/null +++ b/docs/work/sandcastle-subscription-auth-v1/01-subscription-first/_story.md @@ -0,0 +1,21 @@ +--- +id: 01-subscription-first +epic: sandcastle-subscription-auth-v1 +title: Subscription-first dispatch — mount ~/.claude, install claude CLI, docs +type: technical-story +status: done +feature: tooling +depends-on: [] +blocks: [] +--- + +## Tasks + +- [x] Story scaffold +- [x] Update .sandcastle/Dockerfile to install @anthropic-ai/claude-code CLI +- [x] Update scripts/work/dispatch.mjs: resolveClaudeAuth helper + sandbox mount + fallback to API key +- [x] Tests for resolveClaudeAuth +- [x] Update runbook "Using Sandcastle" section: subscription is primary flow +- [x] Update ADR-019: subscription auth as primary, API key as fallback +- [x] Update .env.example + .sandcastle/.env.example: reframe ANTHROPIC_API_KEY as fallback +- [x] Final verification + closeout diff --git a/docs/work/sandcastle-subscription-auth-v1/_epic.md b/docs/work/sandcastle-subscription-auth-v1/_epic.md new file mode 100644 index 0000000..85d2dd3 --- /dev/null +++ b/docs/work/sandcastle-subscription-auth-v1/_epic.md @@ -0,0 +1,29 @@ +--- +id: sandcastle-subscription-auth-v1 +prd: null +title: Sandcastle subscription auth as primary flow +type: epic +status: done +features: [tooling, ci] +created: 2026-05-13 +--- + +## Goal + +Subscription-based Claude Code auth (via host `~/.claude` mount) becomes +the default for `pnpm work dispatch --execute`. API-key auth remains as a +fallback. Works for any developer who has run `claude login` on their +host — zero per-user configuration in the repo. + +## Why + +The sandcastle community has explicitly rejected native subscription +support (issue #191, wontfix). But the host's `~/.claude/` directory +contains the logged-in session; mounting it into the sandbox lets the +container's Claude Code CLI use that session. This pattern is +environment-agnostic (each developer has their own `~/.claude`) and +spends zero per-task agent tokens for Pro/Max subscribers. + +## Stories + +- [x] [01 — Subscription-first dispatch + Dockerfile + docs](01-subscription-first/_story.md) diff --git a/scripts/work/dispatch.mjs b/scripts/work/dispatch.mjs index 33aa481..300b05b 100644 --- a/scripts/work/dispatch.mjs +++ b/scripts/work/dispatch.mjs @@ -4,9 +4,12 @@ * (with --execute) invokes sandcastle to run the implementer then reviewer. * * Default mode prints the dispatch plan without invoking sandcastle — - * safe to run anywhere. --execute requires ANTHROPIC_API_KEY in the env. + * safe to run anywhere. --execute requires EITHER: + * 1. Claude Code logged in on host (~/.claude/ — recommended for subscribers) + * 2. ANTHROPIC_API_KEY or OPENAI_API_KEY in env (fallback) */ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { execSync } from "node:child_process"; @@ -86,6 +89,54 @@ ${next.bulletLine.trim()} ${next.storyContent}`; } +/** + * Resolve the auth method for sandcastle dispatch. + * + * Priority: + * 1. Subscription (primary) — mount host's ~/.claude/ into the sandbox. + * Active when the host's Claude creds directory exists. The path + * defaults to ~/.claude/ and can be overridden via the + * SANDCASTLE_CLAUDE_CREDS_DIR env var. + * 2. API key (fallback) — pass ANTHROPIC_API_KEY (or OPENAI_API_KEY) + * through to the sandbox env. + * 3. Neither available → returns { mode: "missing" } and the dispatcher + * prints a clear error before exiting. + * + * Returns: { mode: "subscription", hostPath, sandboxPath } + * | { mode: "api-key", env } + * | { mode: "missing" } + */ +export function resolveClaudeAuth({ + env = process.env, + home = os.homedir(), +} = {}) { + // 1. Subscription path + const credsHostPath = + env.SANDCASTLE_CLAUDE_CREDS_DIR ?? path.join(home, ".claude"); + if (fs.existsSync(credsHostPath)) { + return { + mode: "subscription", + hostPath: credsHostPath, + // Inside the sandbox, claude looks at the agent user's home — tilde + // expansion in MountConfig handles the actual /home/agent/.claude + // resolution. + sandboxPath: "~/.claude", + }; + } + // 2. API key fallback + if (env.ANTHROPIC_API_KEY) { + return { + mode: "api-key", + env: { ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY }, + }; + } + if (env.OPENAI_API_KEY) { + return { mode: "api-key", env: { OPENAI_API_KEY: env.OPENAI_API_KEY } }; + } + // 3. Neither available + return { mode: "missing" }; +} + function printPlan() { const next = findNextTask(); if (!next) { @@ -99,11 +150,16 @@ function printPlan() { console.log(` Bullet: ${next.bulletLine.trim()}`); console.log(` Prompt: .sandcastle/implementer.prompt.md`); console.log(); - console.log("To execute this dispatch, run:"); - console.log(" ANTHROPIC_API_KEY=... pnpm work dispatch --execute"); + console.log("To execute this dispatch:"); + console.log( + " - With Claude subscription: `claude login` (one-time) then `pnpm work dispatch --execute`", + ); + console.log( + " - With API key: `ANTHROPIC_API_KEY=... pnpm work dispatch --execute`", + ); console.log(); console.log( - "(Execute mode requires @ai-hero/sandcastle, a sandbox provider, and an agent API key.)", + "(Execute mode requires @ai-hero/sandcastle, a sandbox provider, and auth — see above.)", ); } @@ -113,24 +169,34 @@ async function executeDispatch() { console.log("No ready task to dispatch."); process.exit(0); } - if (!process.env.ANTHROPIC_API_KEY && !process.env.OPENAI_API_KEY) { + + const auth = resolveClaudeAuth(); + if (auth.mode === "missing") { + console.error("✗ --execute requires either:"); console.error( - "✗ --execute requires ANTHROPIC_API_KEY or OPENAI_API_KEY in env.", + " 1. Claude Code logged in on host (run `claude login` first; ~/.claude/ becomes the auth source — this is the recommended path for Pro/Max subscribers)", + ); + console.error(" 2. ANTHROPIC_API_KEY or OPENAI_API_KEY in env (fallback)"); + console.error(""); + console.error( + " Override Claude creds path via SANDCASTLE_CLAUDE_CREDS_DIR.", ); process.exit(1); } - + console.log( + `Auth mode: ${auth.mode === "subscription" ? `subscription (mounting ${auth.hostPath})` : "api-key"}`, + ); console.log( `Dispatching: ${next.epic} / ${next.story} / ${next.bulletLine.trim()}`, ); const taskSpec = buildTaskSpec(next); - let sandcastle; - let dockerSandbox; + let sandcastleRoot; + let dockerProvider; try { - sandcastle = await import("@ai-hero/sandcastle"); - ({ docker: dockerSandbox } = - await import("@ai-hero/sandcastle/sandboxes/docker")); + sandcastleRoot = await import("@ai-hero/sandcastle"); + const dockerModule = await import("@ai-hero/sandcastle/sandboxes/docker"); + dockerProvider = dockerModule.docker; } catch { console.error( "✗ @ai-hero/sandcastle is not installed. Run `pnpm install` first.", @@ -138,14 +204,30 @@ async function executeDispatch() { process.exit(1); } + // Build sandbox + agent providers based on auth mode + const dockerOpts = {}; + const agentOpts = {}; + if (auth.mode === "subscription") { + dockerOpts.mounts = [ + { + hostPath: auth.hostPath, + sandboxPath: auth.sandboxPath, + readonly: false, + }, + ]; + } else if (auth.mode === "api-key") { + agentOpts.env = auth.env; + } + const sandbox = dockerProvider(dockerOpts); + const agent = sandcastleRoot.claudeCode("claude-sonnet-4-6", agentOpts); + + // Implementer const implementerPrompt = path.join(SANDCASTLE_DIR, "implementer.prompt.md"); let implResult; try { - implResult = await sandcastle.run({ - agent: sandcastle.claudeCode("claude-sonnet-4-6"), - sandbox: dockerSandbox({ - imageName: `sandcastle-dispatch:local`, - }), + implResult = await sandcastleRoot.run({ + agent, + sandbox, promptFile: implementerPrompt, promptArgs: { TASK_FILE_CONTENT: taskSpec }, cwd: REPO_ROOT, @@ -153,7 +235,7 @@ async function executeDispatch() { } catch (e) { console.error("✗ Implementer dispatch failed:", e.message); console.error( - " See .sandcastle/README.md for setup. Provider name(s) may need updating.", + " See docs/guides/runbook.md → 'Using Sandcastle' for setup.", ); process.exit(1); } @@ -161,7 +243,7 @@ async function executeDispatch() { `Implementer returned. Branch: ${implResult.branch}, Commits: ${implResult.commits.length}`, ); - // Reviewer: pass the diff as DIFF prompt variable. + // Reviewer let diff = ""; try { diff = execSync(`git diff main..${implResult.branch}`, { @@ -172,18 +254,16 @@ async function executeDispatch() { diff = "(diff unavailable)"; } const reviewerPrompt = path.join(SANDCASTLE_DIR, "reviewer.prompt.md"); - const reviewResult = await sandcastle.run({ - agent: sandcastle.claudeCode("claude-sonnet-4-6"), - sandbox: dockerSandbox({ - imageName: `sandcastle-dispatch:local`, - }), + const reviewResult = await sandcastleRoot.run({ + agent, + sandbox, promptFile: reviewerPrompt, promptArgs: { TASK_FILE_CONTENT: taskSpec, DIFF: diff }, cwd: REPO_ROOT, }); console.log(`Reviewer returned. stdout follows:\n${reviewResult.stdout}`); - // V1: the orchestrator does NOT auto-mutate state. Print what should happen. + // V1: orchestrator does NOT auto-mutate state. Print what should happen. console.log(); console.log("=== Suggested state mutation ==="); console.log(` Edit ${next.storyPath} — tick the bullet:`); diff --git a/scripts/work/dispatch.test.mjs b/scripts/work/dispatch.test.mjs index 7e80811..79402e0 100644 --- a/scripts/work/dispatch.test.mjs +++ b/scripts/work/dispatch.test.mjs @@ -6,6 +6,7 @@ import { findNextTask, findFirstUncheckedBullet, buildTaskSpec, + resolveClaudeAuth, } from "./dispatch.mjs"; function makeWorkTree({ epics }) { @@ -137,3 +138,63 @@ describe("buildTaskSpec", () => { expect(spec).toContain("## Goal"); }); }); + +describe("resolveClaudeAuth", () => { + it("returns subscription mode when ~/.claude exists on host", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-sub-")); + fs.mkdirSync(path.join(tmpHome, ".claude")); + const result = resolveClaudeAuth({ env: {}, home: tmpHome }); + expect(result.mode).toBe("subscription"); + expect(result.hostPath).toBe(path.join(tmpHome, ".claude")); + expect(result.sandboxPath).toBe("~/.claude"); + }); + + it("honours SANDCASTLE_CLAUDE_CREDS_DIR override", () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "auth-override-")); + const overrideDir = path.join(tmpRoot, "custom-claude"); + fs.mkdirSync(overrideDir); + const result = resolveClaudeAuth({ + env: { SANDCASTLE_CLAUDE_CREDS_DIR: overrideDir }, + home: "/nonexistent", + }); + expect(result.mode).toBe("subscription"); + expect(result.hostPath).toBe(overrideDir); + }); + + it("falls back to ANTHROPIC_API_KEY when ~/.claude does not exist", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-key-")); + // No .claude directory created + const result = resolveClaudeAuth({ + env: { ANTHROPIC_API_KEY: "sk-test" }, + home: tmpHome, + }); + expect(result.mode).toBe("api-key"); + expect(result.env).toEqual({ ANTHROPIC_API_KEY: "sk-test" }); + }); + + it("falls back to OPENAI_API_KEY when only that is set", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-openai-")); + const result = resolveClaudeAuth({ + env: { OPENAI_API_KEY: "sk-openai" }, + home: tmpHome, + }); + expect(result.mode).toBe("api-key"); + expect(result.env).toEqual({ OPENAI_API_KEY: "sk-openai" }); + }); + + it("returns missing when neither subscription nor API key available", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-missing-")); + const result = resolveClaudeAuth({ env: {}, home: tmpHome }); + expect(result.mode).toBe("missing"); + }); + + it("prefers subscription over API key when both available", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-both-")); + fs.mkdirSync(path.join(tmpHome, ".claude")); + const result = resolveClaudeAuth({ + env: { ANTHROPIC_API_KEY: "sk-test" }, + home: tmpHome, + }); + expect(result.mode).toBe("subscription"); + }); +});