apps/glyph Public The code is public; the app answers only to 1 person who can change it and the crew.

Files 65c469b86b750b044d3235f2387522d1b8b0ce0b
102 lines · 3.8 KB · ~1.1k tok Raw History
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
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts