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:`);