feat(scripts): dispatch.mjs — subscription-first auth via ~/.claude mount

This commit is contained in:
2026-05-13 09:28:20 +02:00
parent 793772a34d
commit 936611ba62

View File

@@ -4,9 +4,12 @@
* (with --execute) invokes sandcastle to run the implementer then reviewer. * (with --execute) invokes sandcastle to run the implementer then reviewer.
* *
* Default mode prints the dispatch plan without invoking sandcastle — * 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 fs from "node:fs";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
@@ -86,6 +89,54 @@ ${next.bulletLine.trim()}
${next.storyContent}`; ${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() { function printPlan() {
const next = findNextTask(); const next = findNextTask();
if (!next) { if (!next) {
@@ -99,11 +150,16 @@ function printPlan() {
console.log(` Bullet: ${next.bulletLine.trim()}`); console.log(` Bullet: ${next.bulletLine.trim()}`);
console.log(` Prompt: .sandcastle/implementer.prompt.md`); console.log(` Prompt: .sandcastle/implementer.prompt.md`);
console.log(); console.log();
console.log("To execute this dispatch, run:"); console.log("To execute this dispatch:");
console.log(" ANTHROPIC_API_KEY=... pnpm work dispatch --execute"); 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();
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."); console.log("No ready task to dispatch.");
process.exit(0); 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( 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); process.exit(1);
} }
console.log(
`Auth mode: ${auth.mode === "subscription" ? `subscription (mounting ${auth.hostPath})` : "api-key"}`,
);
console.log( console.log(
`Dispatching: ${next.epic} / ${next.story} / ${next.bulletLine.trim()}`, `Dispatching: ${next.epic} / ${next.story} / ${next.bulletLine.trim()}`,
); );
const taskSpec = buildTaskSpec(next); const taskSpec = buildTaskSpec(next);
let sandcastle; let sandcastleRoot;
let dockerSandbox; let dockerProvider;
try { try {
sandcastle = await import("@ai-hero/sandcastle"); sandcastleRoot = await import("@ai-hero/sandcastle");
({ docker: dockerSandbox } = const dockerModule = await import("@ai-hero/sandcastle/sandboxes/docker");
await import("@ai-hero/sandcastle/sandboxes/docker")); dockerProvider = dockerModule.docker;
} catch { } catch {
console.error( console.error(
"✗ @ai-hero/sandcastle is not installed. Run `pnpm install` first.", "✗ @ai-hero/sandcastle is not installed. Run `pnpm install` first.",
@@ -138,14 +204,30 @@ async function executeDispatch() {
process.exit(1); 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"); const implementerPrompt = path.join(SANDCASTLE_DIR, "implementer.prompt.md");
let implResult; let implResult;
try { try {
implResult = await sandcastle.run({ implResult = await sandcastleRoot.run({
agent: sandcastle.claudeCode("claude-sonnet-4-6"), agent,
sandbox: dockerSandbox({ sandbox,
imageName: `sandcastle-dispatch:local`,
}),
promptFile: implementerPrompt, promptFile: implementerPrompt,
promptArgs: { TASK_FILE_CONTENT: taskSpec }, promptArgs: { TASK_FILE_CONTENT: taskSpec },
cwd: REPO_ROOT, cwd: REPO_ROOT,
@@ -153,7 +235,7 @@ async function executeDispatch() {
} catch (e) { } catch (e) {
console.error("✗ Implementer dispatch failed:", e.message); console.error("✗ Implementer dispatch failed:", e.message);
console.error( 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); process.exit(1);
} }
@@ -161,7 +243,7 @@ async function executeDispatch() {
`Implementer returned. Branch: ${implResult.branch}, Commits: ${implResult.commits.length}`, `Implementer returned. Branch: ${implResult.branch}, Commits: ${implResult.commits.length}`,
); );
// Reviewer: pass the diff as DIFF prompt variable. // Reviewer
let diff = ""; let diff = "";
try { try {
diff = execSync(`git diff main..${implResult.branch}`, { diff = execSync(`git diff main..${implResult.branch}`, {
@@ -172,18 +254,16 @@ async function executeDispatch() {
diff = "(diff unavailable)"; diff = "(diff unavailable)";
} }
const reviewerPrompt = path.join(SANDCASTLE_DIR, "reviewer.prompt.md"); const reviewerPrompt = path.join(SANDCASTLE_DIR, "reviewer.prompt.md");
const reviewResult = await sandcastle.run({ const reviewResult = await sandcastleRoot.run({
agent: sandcastle.claudeCode("claude-sonnet-4-6"), agent,
sandbox: dockerSandbox({ sandbox,
imageName: `sandcastle-dispatch:local`,
}),
promptFile: reviewerPrompt, promptFile: reviewerPrompt,
promptArgs: { TASK_FILE_CONTENT: taskSpec, DIFF: diff }, promptArgs: { TASK_FILE_CONTENT: taskSpec, DIFF: diff },
cwd: REPO_ROOT, cwd: REPO_ROOT,
}); });
console.log(`Reviewer returned. stdout follows:\n${reviewResult.stdout}`); 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();
console.log("=== Suggested state mutation ==="); console.log("=== Suggested state mutation ===");
console.log(` Edit ${next.storyPath} — tick the bullet:`); console.log(` Edit ${next.storyPath} — tick the bullet:`);