/** * Running Claude Code to draw. The forge puts `claude` on the PATH every app * inherits; it signs in with the token in CLAUDE_CODE_OAUTH_TOKEN and keeps * its state under the app's data directory, the one place an app may write. * It is started with no tools at all — drawing is words in, words out — and * with the environment named here and nothing else, so no other secret of * the app reaches it. * * On the forge an app runs in a cell whose only way out is the forge's * egress proxy, named in HTTPS_PROXY; without it Claude Code finds no DNS. * So the proxy variables go through, and the API's hosts are declared in * forge.json `egress`. (Learned on apps/radar.) */ import { mkdirSync } from "node:fs"; import { join } from "node:path"; /** Claude Code with these arguments, this prompt on stdin, its stdout back. */ export type RunClaude = (args: readonly string[], prompt: string) => Promise; export const MODEL = "claude-opus-5-5"; const TIMEOUT_MS = 5 * 60 * 1000; /** Passed through when set: the cell's way out, and a CA the machine may add. */ const PASSED = [ "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "NO_PROXY", "no_proxy", "NODE_EXTRA_CA_CERTS", ] as const; /** The arguments for one drawing: print mode, one JSON answer, no tools, no session kept. */ export function drawArgs(model = MODEL): string[] { return [ "-p", "--output-format", "json", "--tools", "", "--strict-mcp-config", "--no-session-persistence", "--disable-slash-commands", "--model", model, ]; } /** The whole environment Claude Code is started with. */ export function claudeEnv( token: string, config: string, from: Readonly> = process.env, ): Record { const env: Record = { PATH: from.PATH ?? "/usr/local/bin:/usr/bin:/bin", HOME: from.HOME ?? config, LANG: from.LANG ?? "C.UTF-8", CLAUDE_CONFIG_DIR: config, CLAUDE_CODE_OAUTH_TOKEN: token, // No telemetry, error reports or self-updates: hosts the cell does not declare. CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", }; for (const name of PASSED) { const value = from[name]; if (value) env[name] = value; } return env; } export function claudeRunner(token: string, dataDir: string, timeoutMs = TIMEOUT_MS): RunClaude { return async (args, prompt) => { const config = join(dataDir, "claude"); mkdirSync(config, { recursive: true }); const env = claudeEnv(token, config); if (Bun.which("claude", { PATH: env.PATH ?? "" }) === null) throw new Error("claude is not on the PATH here"); const proc = Bun.spawn(["claude", ...args], { env, cwd: config, stdin: new Blob([prompt]), stdout: "pipe", stderr: "pipe", }); const timer = setTimeout(() => proc.kill(), timeoutMs); try { const [out, err, code] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); if (code !== 0 && !out.includes('"result"')) throw new Error(`claude exited ${code}: ${err.trim().slice(0, 200)}`); return out; } finally { clearTimeout(timer); } }; } /** The model's final text from `--output-format json`, or why there is none. */ export function resultText(stdout: string): { ok: true; text: string } | { ok: false; why: string } { let parsed: unknown; try { parsed = JSON.parse(stdout.trim().split("\n").at(-1) ?? ""); } catch { return { ok: false, why: "Claude's answer was not JSON." }; } const r = (parsed ?? {}) as Record; if (r.is_error === true || typeof r.result !== "string") return { ok: false, why: `Claude stopped: ${String(r.result ?? r.subtype ?? "no result").slice(0, 200)}` }; return { ok: true, text: r.result }; }