pike/glyph Public Public: anyone can read this; nobody can change it.
forked from apps/glyph
workbenchthis machine
Files 20b002cea95af30a05955adee40ddec8a6b7120a
| 1 | /** |
| 2 | * Running Claude Code to draw. The forge puts `claude` on the PATH every app |
| 3 | * inherits; it signs in with the token in CLAUDE_CODE_OAUTH_TOKEN and keeps |
| 4 | * its state under the app's data directory, the one place an app may write. |
| 5 | * It is started with no tools at all — drawing is words in, words out — and |
| 6 | * with the environment named here and nothing else, so no other secret of |
| 7 | * the app reaches it. |
| 8 | * |
| 9 | * On the forge an app runs in a cell whose only way out is the forge's |
| 10 | * egress proxy, named in HTTPS_PROXY; without it Claude Code finds no DNS. |
| 11 | * So the proxy variables go through, and the API's hosts are declared in |
| 12 | * forge.json `egress`. (Learned on apps/radar.) |
| 13 | */ |
| 14 | |
| 15 | import { mkdirSync } from "node:fs"; |
| 16 | import { join } from "node:path"; |
| 17 | |
| 18 | /** Claude Code with these arguments, this prompt on stdin, its stdout back. */ |
| 19 | export type RunClaude = (args: readonly string[], prompt: string) => Promise<string>; |
| 20 | |
| 21 | export const MODEL = "claude-opus-5-5"; |
| 22 | const TIMEOUT_MS = 5 * 60 * 1000; |
| 23 | |
| 24 | /** Passed through when set: the cell's way out, and a CA the machine may add. */ |
| 25 | const PASSED = [ |
| 26 | "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "NO_PROXY", "no_proxy", "NODE_EXTRA_CA_CERTS", |
| 27 | ] as const; |
| 28 | |
| 29 | /** The arguments for one drawing: print mode, one JSON answer, no tools, no session kept. */ |
| 30 | export function drawArgs(model = MODEL): string[] { |
| 31 | return [ |
| 32 | "-p", "--output-format", "json", |
| 33 | "--tools", "", "--strict-mcp-config", "--no-session-persistence", |
| 34 | "--disable-slash-commands", "--model", model, |
| 35 | ]; |
| 36 | } |
| 37 | |
| 38 | /** The whole environment Claude Code is started with. */ |
| 39 | export function claudeEnv( |
| 40 | token: string, |
| 41 | config: string, |
| 42 | from: Readonly<Record<string, string | undefined>> = process.env, |
| 43 | ): Record<string, string> { |
| 44 | const env: Record<string, string> = { |
| 45 | PATH: from.PATH ?? "/usr/local/bin:/usr/bin:/bin", |
| 46 | HOME: from.HOME ?? config, |
| 47 | LANG: from.LANG ?? "C.UTF-8", |
| 48 | CLAUDE_CONFIG_DIR: config, |
| 49 | CLAUDE_CODE_OAUTH_TOKEN: token, |
| 50 | // No telemetry, error reports or self-updates: hosts the cell does not declare. |
| 51 | CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", |
| 52 | }; |
| 53 | for (const name of PASSED) { |
| 54 | const value = from[name]; |
| 55 | if (value) env[name] = value; |
| 56 | } |
| 57 | return env; |
| 58 | } |
| 59 | |
| 60 | export function claudeRunner(token: string, dataDir: string, timeoutMs = TIMEOUT_MS): RunClaude { |
| 61 | return async (args, prompt) => { |
| 62 | const config = join(dataDir, "claude"); |
| 63 | mkdirSync(config, { recursive: true }); |
| 64 | const env = claudeEnv(token, config); |
| 65 | if (Bun.which("claude", { PATH: env.PATH ?? "" }) === null) |
| 66 | throw new Error("claude is not on the PATH here"); |
| 67 | const proc = Bun.spawn(["claude", ...args], { |
| 68 | env, |
| 69 | cwd: config, |
| 70 | stdin: new Blob([prompt]), |
| 71 | stdout: "pipe", |
| 72 | stderr: "pipe", |
| 73 | }); |
| 74 | const timer = setTimeout(() => proc.kill(), timeoutMs); |
| 75 | try { |
| 76 | const [out, err, code] = await Promise.all([ |
| 77 | new Response(proc.stdout).text(), |
| 78 | new Response(proc.stderr).text(), |
| 79 | proc.exited, |
| 80 | ]); |
| 81 | if (code !== 0 && !out.includes('"result"')) |
| 82 | throw new Error(`claude exited ${code}: ${err.trim().slice(0, 200)}`); |
| 83 | return out; |
| 84 | } finally { |
| 85 | clearTimeout(timer); |
| 86 | } |
| 87 | }; |
| 88 | } |
| 89 | |
| 90 | /** The model's final text from `--output-format json`, or why there is none. */ |
| 91 | export function resultText(stdout: string): { ok: true; text: string } | { ok: false; why: string } { |
| 92 | let parsed: unknown; |
| 93 | try { |
| 94 | parsed = JSON.parse(stdout.trim().split("\n").at(-1) ?? ""); |
| 95 | } catch { |
| 96 | return { ok: false, why: "Claude's answer was not JSON." }; |
| 97 | } |
| 98 | const r = (parsed ?? {}) as Record<string, unknown>; |
| 99 | if (r.is_error === true || typeof r.result !== "string") |
| 100 | return { ok: false, why: `Claude stopped: ${String(r.result ?? r.subtype ?? "no result").slice(0, 200)}` }; |
| 101 | return { ok: true, text: r.result }; |
| 102 | } |