@expo/code-review-cli 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,680 @@
1
+ import { tmpdir } from "node:os";
2
+ import path from "node:path";
3
+ import { checkAuthEntry } from "./auth.js";
4
+ import { pathInside, resolveOnPath, run } from "./exec.js";
5
+ import { addTokenUsage, AgentTimeoutError, CLAUDE_CODE_ENGINE, CROSS_CUTTING_AGENT, VERIFIER_AGENT, withTransientRetry, } from "./opencode.js";
6
+ import { RateLimitWatch } from "./throttle.js";
7
+ /** Coarse per-pass wander bound; the review's own maxWaitMs is the real ceiling. */
8
+ const CLAUDE_MAX_TURNS = 60;
9
+ /** Fallback per-pass ceiling when a caller passes no maxWaitMs. */
10
+ const DEFAULT_MAX_WAIT_MS = 8 * 60 * 1000;
11
+ /**
12
+ * A stateless `claude -p` pass emits no incremental tool lines to the caller, so a
13
+ * long pass would look hung. Emit a "still working" heartbeat this often (matching
14
+ * opencode.ts's HEARTBEAT_MS) to keep the progress signal alive.
15
+ */
16
+ const CLAUDE_HEARTBEAT_MS = 45_000;
17
+ /**
18
+ * OpenCode read-tool name → Claude Code tool name. These three are the only tools
19
+ * this engine ever grants; write/exec/net tools are always denied (review is
20
+ * read-only). `list` has no scoped Claude equivalent (Glob covers discovery) and is
21
+ * ignored.
22
+ */
23
+ const READ_TOOL_MAP = {
24
+ read: "Read",
25
+ grep: "Grep",
26
+ glob: "Glob",
27
+ };
28
+ const ALL_READ_TOOLS = ["Read", "Grep", "Glob"];
29
+ /**
30
+ * Tools never available to a review pass, whatever the role. A DENY enumeration is
31
+ * the only workable containment: permission rules cannot fail closed here — reads
32
+ * inside the workspace are default-ALLOWED even when an allow list is present but
33
+ * unmatched, and a `*` deny breaks tool calling outright (both verified against
34
+ * claude 2.1.212). The residual risk — a FUTURE CLI version shipping a new
35
+ * read-capable tool this list doesn't name — is bounded by pinning the CLI version
36
+ * (the scaffolded workflow installs an exact @anthropic-ai/claude-code version;
37
+ * bump it deliberately and revisit this list). Unknown names are ignored by the
38
+ * CLI, so denying tools that don't exist in a given version is harmless.
39
+ */
40
+ const ALWAYS_DENIED_TOOLS = [
41
+ "Bash",
42
+ "Edit",
43
+ "Write",
44
+ "NotebookEdit",
45
+ "NotebookRead",
46
+ "WebFetch",
47
+ "WebSearch",
48
+ "Task",
49
+ "TodoWrite",
50
+ "BashOutput",
51
+ "KillShell",
52
+ "ExitPlanMode",
53
+ ];
54
+ /**
55
+ * Env vars forwarded to the `claude` child — what a CLI needs to run (PATH,
56
+ * locale, tmp, proxies, its own config dir) and nothing else. See startClaudeCode
57
+ * for why this is an allowlist.
58
+ */
59
+ const CHILD_ENV_ALLOWLIST = [
60
+ "PATH",
61
+ "HOME",
62
+ "USER",
63
+ "LOGNAME",
64
+ "SHELL",
65
+ "TERM",
66
+ "LANG",
67
+ "LC_ALL",
68
+ "LC_CTYPE",
69
+ "TZ",
70
+ "TMPDIR",
71
+ "TEMP",
72
+ "TMP",
73
+ "XDG_CONFIG_HOME",
74
+ "XDG_DATA_HOME",
75
+ "XDG_CACHE_HOME",
76
+ "XDG_STATE_HOME",
77
+ "HTTP_PROXY",
78
+ "HTTPS_PROXY",
79
+ "NO_PROXY",
80
+ "http_proxy",
81
+ "https_proxy",
82
+ "no_proxy",
83
+ "CLAUDE_CONFIG_DIR",
84
+ // Windows equivalents of the above.
85
+ "SYSTEMROOT",
86
+ "SYSTEMDRIVE",
87
+ "USERPROFILE",
88
+ "APPDATA",
89
+ "LOCALAPPDATA",
90
+ "PROGRAMFILES",
91
+ "COMSPEC",
92
+ "PATHEXT",
93
+ ];
94
+ const MISSING_CLI_MESSAGE = "The `claude` CLI is not installed. Install Claude Code (npm i -g " +
95
+ "@anthropic-ai/claude-code) and run `claude setup-token` on a Max/Team " +
96
+ "subscription, then `ecr doctor`.";
97
+ /**
98
+ * Infer ONE agent's engine from its resolved model alone: an `anthropic/…` model
99
+ * runs through the Claude Code CLI, any other provider through OpenCode. The engine
100
+ * is a pure function of the model id — no auth, no run-level state — so a single run
101
+ * may drive BOTH engines at once (per agent, by model). ALL anthropic models are
102
+ * served by the CLI; the retired anthropic-via-OpenCode x-api-key path no longer
103
+ * exists (the CLI accepts an API key too).
104
+ */
105
+ export function engineForModel(model) {
106
+ const slash = model.indexOf("/");
107
+ const provider = slash > 0 ? model.slice(0, slash) : model;
108
+ return provider === "anthropic" ? CLAUDE_CODE_ENGINE : "opencode";
109
+ }
110
+ /**
111
+ * Map every dispatchable agent id → its engine + model: each reviewer id, plus the
112
+ * fixed cross-cutting / verifier / coordinator roles. This modelOf is the single
113
+ * source for which model backs each id — startClaudeCode (below) consumes it directly
114
+ * instead of rebuilding it, and buildOpencodeConfig (opencode.ts) folds the same ids
115
+ * into its richer per-role agent records. The per-model inference converges to one
116
+ * engine automatically when every model is identical (e.g. under REVIEWER_MODEL), so
117
+ * no run-level convergence code is needed.
118
+ *
119
+ * `agents` scopes the reviewer ids to a specific run's SELECTED agents (an explicit
120
+ * `--agents` subset); it defaults to the full roster. usesOpencode/usesClaude then
121
+ * report only the engines that run actually drives, so a subset whose passes never
122
+ * touch Claude doesn't force startClaudeCode (missing CLI/token) for nothing. The
123
+ * fixed roles always run, so the verifier/cross-cutting shared model and the
124
+ * coordinator model stay on the FULL roster (config.agents[0] / coordinator) — those
125
+ * passes use them regardless of which reviewers were selected.
126
+ */
127
+ export function buildEngineMap(config, agents = config.agents) {
128
+ const modelOf = {};
129
+ for (const agent of agents) {
130
+ modelOf[agent.id] = agent.model;
131
+ }
132
+ const shared = config.agents[0]?.model ?? config.coordinator.model;
133
+ modelOf[CROSS_CUTTING_AGENT] = shared;
134
+ modelOf[VERIFIER_AGENT] = shared;
135
+ modelOf["coordinator"] = config.coordinator.model;
136
+ const engineOf = {};
137
+ for (const [id, model] of Object.entries(modelOf)) {
138
+ engineOf[id] = engineForModel(model);
139
+ }
140
+ const engines = new Set(Object.values(engineOf));
141
+ return {
142
+ engineOf,
143
+ modelOf,
144
+ usesOpencode: engines.has("opencode"),
145
+ usesClaude: engines.has(CLAUDE_CODE_ENGINE),
146
+ };
147
+ }
148
+ /** Strip a leading `provider/` segment for the CLI's `--model` flag. */
149
+ export function claudeModelId(configModel) {
150
+ const slash = configModel.indexOf("/");
151
+ return slash >= 0 ? configModel.slice(slash + 1) : configModel;
152
+ }
153
+ /**
154
+ * Whether a configured model id and the model that actually answered are the same
155
+ * family, ignoring a trailing dated suffix (`claude-haiku-4-5-20251001` matches
156
+ * `claude-haiku-4-5` / `anthropic/claude-haiku-4-5`). A plain fallback within the
157
+ * family reports the CONFIGURED id (no spurious substitution note); a real swap to
158
+ * a different family reports the actual id so the substitution surfaces.
159
+ */
160
+ export function claudeModelMatches(requested, actualKey) {
161
+ const normalize = (id) => claudeModelId(id).replace(/-\d{8}$/, "");
162
+ return normalize(requested) === normalize(actualKey);
163
+ }
164
+ /**
165
+ * The read-only, trust-isolated, subscription-forced argv (minus the leading
166
+ * binary). Task text is fed on stdin, not here. NOT `--bare` (bare mode ignores
167
+ * CLAUDE_CODE_OAUTH_TOKEN/keychain OAuth); `--safe-mode` disables
168
+ * CLAUDE.md/hooks/MCP/plugins while KEEPING OAuth.
169
+ *
170
+ * The granted read tools vary by role (see the `tools` option): a reviewer gets its
171
+ * configured read/grep/glob, the cross-file and verifier passes get read+grep only
172
+ * (Glob withheld — directory crawling is what made them wander), and the coordinator
173
+ * plus the no-tools fallback get none. Whatever the role, every GRANTED read tool is
174
+ * path-scoped to the review tree (`//<cwd>/**`, Claude Code's absolute-path rule)
175
+ * with `dontAsk` denying any call that matches no allow rule. This narrows the
176
+ * prompt-injection exfil path for DIRECT out-of-tree reads: untrusted PR content is
177
+ * the review input and findings are posted as PR comments, so any unscoped
178
+ * read-capable tool (a bare `Grep` no less than a bare `Read`) would let an injected
179
+ * instruction read `~/.claude/.credentials.json` (the subscription token this engine
180
+ * authenticates with), `/proc/self/environ`, `.env*`, or SSH keys and emit them into
181
+ * a finding. Verified empirically against the installed CLI: in-tree Read/Grep
182
+ * succeed, out-of-tree Read/Grep/Glob (`/etc`, `~/.zshrc`) are denied by the
183
+ * unmatched-rule denial; a withheld read tool is denied BY NAME because an EMPTY
184
+ * allow list default-allows reads. NO scoped deny rules: `Read(//**)` would deny the
185
+ * tree itself (paths resolve to absolute), and `Read(~/**)` denies the whole tree
186
+ * whenever the repo lives under the home directory — the common case.
187
+ *
188
+ * This is NOT, by itself, a boundary against a symlink committed inside the PR-head
189
+ * tree (e.g. `docs/notes.md -> ~/.claude/.credentials.json`): the permission rule
190
+ * matches the literal path ARGUMENT, which is in-tree, but Read/Grep then follow the
191
+ * symlink via fs and return the out-of-tree target's contents. That gap is closed
192
+ * UPSTREAM of this argv, where there is still a filesystem to preflight: read-root
193
+ * materialization strips symlinks that resolve outside the tree
194
+ * (removeEscapingSymlinks in scrub.ts, run by prepareReadRootAsync). Runs whose read
195
+ * root is the user's own checkout (local diffs) don't get the sweep — the user is
196
+ * the trust principal for their own tree's symlinks.
197
+ */
198
+ export function buildClaudeArgs(opts) {
199
+ // Permission rules are gitignore-style with forward slashes; a Windows cwd
200
+ // (`C:\Users\dev\repo`) must be normalized or every rule silently matches
201
+ // nothing and dontAsk denies all reads.
202
+ const scopeRoot = opts.cwd.replace(/\\/g, "/");
203
+ const scope = (tool) => `${tool}(/${scopeRoot}/**)`;
204
+ const requested = opts.tools ?? ["read", "grep", "glob"];
205
+ const enabled = ALL_READ_TOOLS.filter((claudeName) => requested.some((name) => READ_TOOL_MAP[name] === claudeName));
206
+ // Read tools NOT granted are denied by name (see the `tools` doc above) — the
207
+ // scoped allow rules alone don't deny them when the allow list is empty.
208
+ const deniedReadTools = ALL_READ_TOOLS.filter((tool) => !enabled.includes(tool));
209
+ return [
210
+ "-p",
211
+ "--output-format",
212
+ "json",
213
+ "--model",
214
+ opts.model,
215
+ "--append-system-prompt",
216
+ opts.system,
217
+ ...(enabled.length > 0 ? ["--allowedTools", ...enabled.map(scope)] : []),
218
+ "--disallowedTools",
219
+ ...deniedReadTools,
220
+ ...ALWAYS_DENIED_TOOLS,
221
+ "--permission-mode",
222
+ "dontAsk",
223
+ "--strict-mcp-config",
224
+ "--safe-mode",
225
+ "--max-turns",
226
+ String(opts.maxTurns ?? CLAUDE_MAX_TURNS),
227
+ ];
228
+ }
229
+ /**
230
+ * The model that actually answered, out of the result's modelUsage keys. The CLI
231
+ * also bills its own internal helper calls there (a haiku entry appears alongside
232
+ * the main model, often FIRST — key order is meaningless), so prefer the key
233
+ * matching the requested family and fall back to the largest output-token count
234
+ * (the main model dominates output; helpers emit a trickle).
235
+ */
236
+ export function pickAnsweringModel(requested, modelOutputTokens) {
237
+ const keys = Object.keys(modelOutputTokens);
238
+ const familyMatch = keys.find((key) => claudeModelMatches(requested, key));
239
+ if (familyMatch) {
240
+ return familyMatch;
241
+ }
242
+ return keys.sort((a, b) => (modelOutputTokens[b] ?? 0) - (modelOutputTokens[a] ?? 0))[0];
243
+ }
244
+ /**
245
+ * Parse the `--output-format json` result object. Keys off `is_error` / a parse
246
+ * failure, NOT `subtype` — `subtype` stays `"success"` on some API errors.
247
+ */
248
+ export function parseClaudeResult(stdout) {
249
+ let parsed;
250
+ try {
251
+ parsed = JSON.parse(stdout);
252
+ }
253
+ catch {
254
+ return {
255
+ text: "",
256
+ cost: 0,
257
+ tokens: {},
258
+ modelOutputTokens: {},
259
+ isError: true,
260
+ errorText: stdout.trim(),
261
+ };
262
+ }
263
+ const usage = (parsed.usage ?? {});
264
+ const num = (value) => typeof value === "number" ? value : undefined;
265
+ const tokens = {
266
+ input: num(usage.input_tokens),
267
+ output: num(usage.output_tokens),
268
+ cache: {
269
+ write: num(usage.cache_creation_input_tokens),
270
+ read: num(usage.cache_read_input_tokens),
271
+ },
272
+ };
273
+ const modelUsage = (parsed.modelUsage ?? {});
274
+ const modelOutputTokens = {};
275
+ for (const [key, value] of Object.entries(modelUsage)) {
276
+ modelOutputTokens[key] = num(value?.outputTokens) ?? 0;
277
+ }
278
+ const result = typeof parsed.result === "string" ? parsed.result : "";
279
+ const isError = parsed.is_error === true;
280
+ return {
281
+ text: result,
282
+ cost: num(parsed.total_cost_usd) ?? 0,
283
+ tokens,
284
+ modelOutputTokens,
285
+ isError,
286
+ errorText: isError ? result || stdout.trim() : "",
287
+ };
288
+ }
289
+ /** Classify a Claude Code failure so the caller can pick backoff vs. hard fail. */
290
+ export function classifyClaudeError(errorText, apiStatus) {
291
+ if (apiStatus === 401 || apiStatus === 403) {
292
+ return "auth";
293
+ }
294
+ if (apiStatus === 429) {
295
+ return "rate-limit";
296
+ }
297
+ if (/authentication_failed|oauth_org_not_allowed|invalid.?api.?key|\b401\b/i.test(errorText)) {
298
+ return "auth";
299
+ }
300
+ if (/usage limit reached/i.test(errorText)) {
301
+ return "usage-limit";
302
+ }
303
+ if (/\b429\b|rate.?limit|too many requests|overloaded|quota/i.test(errorText)) {
304
+ return "rate-limit";
305
+ }
306
+ return "other";
307
+ }
308
+ /**
309
+ * The reset epoch (ms) a `usage limit reached|<epoch>` message carries, or null.
310
+ * Parsed defensively — the trailing `|<epoch>` is folklore, in seconds or ms.
311
+ */
312
+ export function usageLimitResetMs(errorText) {
313
+ const match = /\|\s*(\d{6,})/.exec(errorText);
314
+ if (!match) {
315
+ return null;
316
+ }
317
+ const value = Number(match[1]);
318
+ if (!Number.isFinite(value) || value <= 0) {
319
+ return null;
320
+ }
321
+ // A 10-digit value is epoch seconds; a 13-digit is already ms.
322
+ return value < 1e12 ? value * 1000 : value;
323
+ }
324
+ /**
325
+ * The thrown-error text for a usage-limit hit. Deliberately phrased so
326
+ * isTransientApiError MISSES it — no "rate limit"/"429"/"too many requests"/
327
+ * "overloaded" — because a subscription usage cap resets hours later, not in
328
+ * seconds: without this it matched the 429 pattern and burned the whole
329
+ * RATE_LIMIT_BACKOFF schedule on three doomed retries. Failing fast surfaces the
330
+ * reset time instead. The interpolated reset epoch is a long digit run with no
331
+ * internal word boundary, so it can't spuriously match `\b429\b`/`\b50x\b`.
332
+ */
333
+ export function usageLimitMessage(errorText) {
334
+ const resetMs = usageLimitResetMs(errorText);
335
+ const when = resetMs ? new Date(resetMs).toISOString() : "later";
336
+ return (`Claude Code usage limit reached; resets ${when}. This is a subscription usage cap, ` +
337
+ `not a transient throttle — retrying will not clear it. (${errorText})`);
338
+ }
339
+ /** Agent/coordinator default temperatures (mirrors load.ts resolveTemp fallbacks). */
340
+ const DEFAULT_AGENT_TEMPERATURE = 0.1;
341
+ const DEFAULT_COORDINATOR_TEMPERATURE = 0;
342
+ /**
343
+ * A one-time run note when a config sets a NON-default temperature under the
344
+ * claude-code engine: the `claude` CLI exposes no temperature flag, so every
345
+ * configured temperature is silently dropped. Only non-default values are flagged (a
346
+ * config left on the default never expected an effect), so a plain setup stays quiet.
347
+ * Returns null when there is nothing to surface.
348
+ */
349
+ export function claudeTemperatureNote(config, engineOf) {
350
+ // Only CLAUDE-ROUTED passes drop their temperature; in a mixed run an
351
+ // OpenCode-routed agent's tuned temperature IS honored and must not be flagged.
352
+ const tuned = config.agents.some((agent) => engineOf[agent.id] === CLAUDE_CODE_ENGINE &&
353
+ agent.temperature !== DEFAULT_AGENT_TEMPERATURE) ||
354
+ (engineOf["coordinator"] === CLAUDE_CODE_ENGINE &&
355
+ config.coordinator.temperature !== DEFAULT_COORDINATOR_TEMPERATURE);
356
+ return tuned
357
+ ? "temperature settings are not supported by the claude-code engine and were ignored " +
358
+ "(for the claude-routed passes)"
359
+ : null;
360
+ }
361
+ /**
362
+ * One prompt → text/cost/tokens/model, as a single `claude -p` subprocess (the
363
+ * Claude analogue of OpenCode's promptAgent; no sessions/polling).
364
+ */
365
+ export async function runClaudePrompt(handle, args) {
366
+ const maxWaitMs = args.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
367
+ const configuredModel = handle.models[args.agent] ?? handle.defaultModel;
368
+ // Per-role tools mirror buildOpencodeConfig (reviewers → configured set;
369
+ // cross-cutting/verifier → read+grep; coordinator → none). maxToolCalls:0 is
370
+ // review.ts's no-tools-fallback tripwire — deny every tool for that pass.
371
+ const configuredTools = handle.tools[args.agent] ?? ["read", "grep", "glob"];
372
+ const tools = args.maxToolCalls === 0 ? [] : configuredTools;
373
+ // A soft tool-call ceiling doubles as the CLI's per-pass turn bound (the closest
374
+ // stateless analogue of OpenCode's mid-run tool-call cap).
375
+ const maxTurns = args.maxToolCalls != null && args.maxToolCalls > 0 ? args.maxToolCalls : undefined;
376
+ // A stateless `claude -p` pass streams nothing back, so emit a heartbeat while it
377
+ // runs or a long pass looks hung (cleared in finally, whatever the outcome).
378
+ const heartbeatStart = Date.now();
379
+ const heartbeat = args.onActivity
380
+ ? setInterval(() => {
381
+ args.onActivity?.(`still working… ${Math.round((Date.now() - heartbeatStart) / 1000)}s elapsed`);
382
+ }, CLAUDE_HEARTBEAT_MS)
383
+ : undefined;
384
+ heartbeat?.unref?.();
385
+ let result;
386
+ try {
387
+ result = await run(handle.cliPath, buildClaudeArgs({
388
+ model: claudeModelId(configuredModel),
389
+ system: args.system,
390
+ cwd: process.cwd(),
391
+ tools,
392
+ maxTurns,
393
+ }), {
394
+ input: args.text,
395
+ env: handle.childEnv,
396
+ cwd: process.cwd(),
397
+ timeout: maxWaitMs,
398
+ check: false,
399
+ });
400
+ }
401
+ finally {
402
+ if (heartbeat) {
403
+ clearInterval(heartbeat);
404
+ }
405
+ }
406
+ // Our own deadline fired and killed the child. A killed child can't wrap up,
407
+ // so there is no finalize salvage here.
408
+ if (result.timedOut) {
409
+ throw new AgentTimeoutError(args.agent, Math.round(maxWaitMs / 60000), 0, undefined, "time");
410
+ }
411
+ // A non-timeout signal is a crash (SIGSEGV, OOM SIGKILL, external kill), not a
412
+ // timeout — surface it as a hard error rather than the subdivide/retry path.
413
+ if (result.signal) {
414
+ throw new Error(`Claude Code was killed by signal ${result.signal}: ${result.stderr.trim() || "(no output)"}`);
415
+ }
416
+ // Truncated output can't be parsed as JSON; report the cause plainly instead of
417
+ // letting it fall through as a generic parse failure.
418
+ if (result.overflowed) {
419
+ throw new Error("claude output exceeded the 64MB buffer and was truncated");
420
+ }
421
+ const parsed = parseClaudeResult(result.stdout);
422
+ if (parsed.isError) {
423
+ const kind = classifyClaudeError(parsed.errorText);
424
+ if (kind === "rate-limit" || kind === "usage-limit") {
425
+ handle.rateLimit.note();
426
+ if (kind === "usage-limit") {
427
+ // Non-transient by construction (see usageLimitMessage): fail fast with the
428
+ // reset time instead of retrying a cap that won't clear for hours.
429
+ throw new Error(usageLimitMessage(parsed.errorText));
430
+ }
431
+ throw new Error(`Claude Code rate limit hit. (${parsed.errorText})`);
432
+ }
433
+ if (kind === "auth") {
434
+ // Non-transient (see isTransientApiError): must propagate, not retry.
435
+ throw new Error(`Claude Code authentication failed (${parsed.errorText}). Re-mint with ` +
436
+ "`claude setup-token`, set CLAUDE_CODE_OAUTH_TOKEN, and check `claude auth status` / " +
437
+ "`ecr doctor`.");
438
+ }
439
+ throw new Error(parsed.errorText ||
440
+ `claude exited with code ${result.code}: ${result.stderr.trim() || result.stdout.trim() || "(no output)"}`);
441
+ }
442
+ const answered = pickAnsweringModel(configuredModel, parsed.modelOutputTokens);
443
+ const model = answered
444
+ ? claudeModelMatches(configuredModel, answered)
445
+ ? configuredModel
446
+ : `anthropic/${answered}`
447
+ : configuredModel;
448
+ return { text: parsed.text, cost: parsed.cost, sessionID: "", tokens: parsed.tokens, model };
449
+ }
450
+ /**
451
+ * The claude analogue of opencode.ts's CORRECTIVE. NOT shared: that one says
452
+ * "your previous reply could not be parsed", which is true in OpenCode's
453
+ * same-session follow-up but false here — each `claude -p` invocation is a fresh
454
+ * stateless process with no previous reply to reference.
455
+ */
456
+ const CLAUDE_CORRECTIVE = "\n\nIMPORTANT: reply with ONLY the single JSON object described above — no prose, " +
457
+ "no code fences, no partial output.";
458
+ /**
459
+ * Prompt via the Claude Code CLI and parse the reply, mirroring OpenCode's
460
+ * promptAndParse: transient retry on the first call, then one corrective re-run
461
+ * (a fresh process — the diff is inlined, so re-read is a cache hit).
462
+ */
463
+ export async function claudeCodePromptAndParse(handle, args, parse) {
464
+ let cost = 0;
465
+ let model;
466
+ const tokens = {};
467
+ const record = (result) => {
468
+ cost += result.cost;
469
+ addTokenUsage(tokens, result.tokens);
470
+ model = result.model ?? model;
471
+ };
472
+ const first = await withTransientRetry(`Agent "${args.agent}"`, args.onActivity, () => runClaudePrompt(handle, args));
473
+ record(first);
474
+ try {
475
+ return { value: parse(first.text), cost, truncated: false, tokens, model };
476
+ }
477
+ catch {
478
+ const retry = await runClaudePrompt(handle, { ...args, text: args.text + CLAUDE_CORRECTIVE });
479
+ record(retry);
480
+ try {
481
+ return { value: parse(retry.text), cost, truncated: false, tokens, model };
482
+ }
483
+ catch (finalError) {
484
+ throw new Error(`Agent "${args.agent}" did not return parseable JSON after retries: ${finalError instanceof Error ? finalError.message : String(finalError)}`);
485
+ }
486
+ }
487
+ }
488
+ /**
489
+ * Preflight: the CLI must exist, and every configured model must be an Anthropic
490
+ * id — a non-anthropic provider prefix (e.g. a leftover `openai/gpt-…` agent or
491
+ * coordinator frontmatter) would otherwise fail every pass routed to it at
492
+ * request time. Model-id validity within Anthropic is left to per-call
493
+ * `is_error` (Claude validates at request time).
494
+ */
495
+ export async function assertClaudeModels(handle, models) {
496
+ const foreign = [...new Set(models)].filter((model) => {
497
+ const slash = model.indexOf("/");
498
+ return slash > 0 && model.slice(0, slash) !== "anthropic";
499
+ });
500
+ if (foreign.length > 0) {
501
+ throw new Error(`The claude-code engine can only run anthropic/… models, but the config resolves ` +
502
+ `to: ${foreign.join(", ")}. Point every agent AND the coordinator (frontmatter in ` +
503
+ `coordinator.md) at anthropic/… model ids.`);
504
+ }
505
+ const { code } = await run(handle.cliPath, ["--version"], {
506
+ env: handle.childEnv,
507
+ check: false,
508
+ });
509
+ if (code !== 0) {
510
+ throw new Error(MISSING_CLI_MESSAGE);
511
+ }
512
+ }
513
+ /** A `claude auth status --text` line that indicates a usable Max/Team login. */
514
+ const SUBSCRIPTION_STATUS_RE = /max|team|subscription|logged in/i;
515
+ /**
516
+ * Resolve the host `claude` binary the way this engine trusts it: a PATH lookup from
517
+ * a trusted cwd (resolveOnPath, never the inherited one) and a refusal of any binary
518
+ * that resolves INSIDE the current tree. Null when unresolved or in-tree.
519
+ *
520
+ * Every `claude` spawn goes through a resolved-and-checked absolute path, never a
521
+ * bare name: the process may have chdir'd into an untrusted PR-head tree (a review)
522
+ * and doctor/setup-auth may run inside a cloned untrusted repo, so a bare name lets a
523
+ * PR-committed `claude` shim win the lookup and run with ambient secrets in its env.
524
+ * startClaudeCode keeps its own inline resolution (it needs to distinguish "missing"
525
+ * from "in-tree" for its error messages); the read-only callers use this.
526
+ */
527
+ export async function resolveClaudeCli() {
528
+ const cliPath = await resolveOnPath("claude");
529
+ if (!cliPath || pathInside(cliPath, process.cwd())) {
530
+ return null;
531
+ }
532
+ return cliPath;
533
+ }
534
+ /**
535
+ * Whether a Claude Max/Team subscription login is active locally. Shared by
536
+ * startClaudeCode, `ecr doctor`, and `ecr setup-auth` so they agree on the
537
+ * load-bearing regex.
538
+ *
539
+ * SECURITY: this may run AFTER the process has chdir'd into the untrusted PR-head
540
+ * tree (startClaudeCode calls it mid-run), and doctor/setup-auth may themselves run
541
+ * inside a cloned untrusted repo. So it must never spawn a BARE `claude` with the
542
+ * inherited cwd — on Windows (and some PATH setups) that resolves the current
543
+ * directory first, letting a PR-committed `claude` shim run with ambient secrets in
544
+ * its env. startClaudeCode passes the CLI it already resolved and pathInside-checked
545
+ * plus its allowlisted childEnv; doctor/setup-auth pass nothing and get the same
546
+ * trusted resolution internally. Either way the probe runs from tmpdir(), never cwd.
547
+ */
548
+ export async function claudeSubscriptionActive(cli = {}) {
549
+ const cliPath = cli.cliPath ?? (await resolveClaudeCli());
550
+ if (!cliPath) {
551
+ return false;
552
+ }
553
+ const status = await run(cliPath, ["auth", "status", "--text"], {
554
+ check: false,
555
+ cwd: tmpdir(),
556
+ env: cli.env,
557
+ });
558
+ return status.code === 0 && SUBSCRIPTION_STATUS_RE.test(status.stdout);
559
+ }
560
+ /**
561
+ * The forwardable Claude credential for the anthropic auth entry, or undefined.
562
+ * Resolution order: the entry's tokenEnv value when set, else an ambient
563
+ * CLAUDE_CODE_OAUTH_TOKEN (the var `ecr setup-auth`/`claude setup-token` export and
564
+ * the child-env allowlist otherwise drops), else undefined (the local `claude` login
565
+ * covers the run). Ambient ANTHROPIC_API_KEY is deliberately NOT consulted unless it
566
+ * is the configured tokenEnv — config wins over ambient env.
567
+ *
568
+ * The value is classified by shape: an "sk-ant-oat…" subscription OAuth token is
569
+ * forwarded as CLAUDE_CODE_OAUTH_TOKEN; any other value (an "sk-ant-api…" Console
570
+ * key) as ANTHROPIC_API_KEY. Shared by startClaudeCode (what it forwards) and `ecr
571
+ * doctor` (what it reports) so the fail-fast check and the doctor verdict never drift.
572
+ */
573
+ export function claudeTokenCredential(entry, env = process.env) {
574
+ const value = (entry?.tokenEnv ? env[entry.tokenEnv] : undefined) ?? env.CLAUDE_CODE_OAUTH_TOKEN;
575
+ if (!value) {
576
+ return undefined;
577
+ }
578
+ return { value, kind: value.startsWith("sk-ant-oat") ? "oauth" : "api-key" };
579
+ }
580
+ /** Start the Claude Code engine: resolve the CLI and build the subscription env. */
581
+ export async function startClaudeCode(config) {
582
+ const cliPath = await resolveOnPath("claude");
583
+ if (!cliPath) {
584
+ throw new Error(MISSING_CLI_MESSAGE);
585
+ }
586
+ // SECURITY backstop to resolveOnPath's trusted-cwd lookup: by the time this runs
587
+ // the process is chdir'd into the untrusted PR-head tree, and executing a binary
588
+ // that lives INSIDE that tree would hand the reviewed PR arbitrary code execution
589
+ // with the engine credential in its environment. Never run an in-tree `claude`.
590
+ if (pathInside(cliPath, process.cwd())) {
591
+ throw new Error(`refusing to run a \`claude\` binary found inside the reviewed tree (${cliPath}) — ` +
592
+ `install Claude Code on the host (npm i -g @anthropic-ai/claude-code).`);
593
+ }
594
+ // The child env is an ALLOWLIST, never a copy of process.env: the review runs
595
+ // over untrusted PR content, so ambient secrets (GH_TOKEN, CI tokens…) must not
596
+ // exist in the child's environment at all — and Anthropic's documented
597
+ // precedence lets ANTHROPIC_API_KEY/AUTH_TOKEN override the subscription OAuth,
598
+ // so leaving them out also forces the subscription. Never log values.
599
+ const childEnv = {};
600
+ for (const name of CHILD_ENV_ALLOWLIST) {
601
+ if (process.env[name] !== undefined) {
602
+ childEnv[name] = process.env[name];
603
+ }
604
+ }
605
+ const entry = config.auth.find((auth) => auth.provider === "anthropic");
606
+ if (entry) {
607
+ // Re-run the deny-list AT THE FORWARDING SITE: prepareAuth/checkProviderAuth
608
+ // are bypassed entirely under REVIEWER_MODEL, and this is the one code path
609
+ // that still forwards a config-named secret in that case. Without this, a
610
+ // config could point tokenEnv at GITHUB_TOKEN (FORBIDDEN_TOKEN_ENVS) or a
611
+ // non-anthropic provider's key and ship it to Anthropic as the bearer.
612
+ const readiness = checkAuthEntry(entry);
613
+ if (!readiness.ok) {
614
+ throw new Error(readiness.detail);
615
+ }
616
+ }
617
+ // Forward the resolved credential: the configured tokenEnv's value, or an ambient
618
+ // CLAUDE_CODE_OAUTH_TOKEN when no anthropic entry names one (the var the allowlist
619
+ // otherwise drops, so without this the token `ecr setup-auth` tells users to export
620
+ // is a no-op and a headless run still fails). An "sk-ant-oat…" subscription token
621
+ // goes in as CLAUDE_CODE_OAUTH_TOKEN; an Anthropic API key as ANTHROPIC_API_KEY —
622
+ // the CLI reads either, and setting one never sets the other. See
623
+ // claudeTokenCredential — doctor mirrors it.
624
+ const credential = claudeTokenCredential(entry);
625
+ if (credential) {
626
+ if (credential.kind === "oauth") {
627
+ childEnv.CLAUDE_CODE_OAUTH_TOKEN = credential.value;
628
+ }
629
+ else {
630
+ childEnv.ANTHROPIC_API_KEY = credential.value;
631
+ }
632
+ }
633
+ // Fail fast with the fix in hand, before spending any pass budget: with no
634
+ // credential of any kind AND no local `claude` login, every pass would fail
635
+ // identically.
636
+ if (!childEnv.CLAUDE_CODE_OAUTH_TOKEN &&
637
+ !childEnv.ANTHROPIC_API_KEY &&
638
+ !(await claudeSubscriptionActive({ cliPath, env: childEnv }))) {
639
+ throw new Error("No Claude credential found: " +
640
+ (entry?.tokenEnv
641
+ ? `token env "${entry.tokenEnv}" is not set and no \`claude\` login is active. `
642
+ : "no `claude` login is active. ") +
643
+ "Run `claude setup-token` (Max/Team) and export the token, or log in with `claude`.");
644
+ }
645
+ // The id→model map is exactly buildEngineMap's modelOf (same reviewer ids plus the
646
+ // fixed cross-cutting / verifier / coordinator roles and their fallback), so derive
647
+ // it there rather than re-deriving it here — one source for which model backs each id.
648
+ const { modelOf: models } = buildEngineMap(config);
649
+ const tools = {};
650
+ for (const agent of config.agents) {
651
+ // A reviewer's configured tool map → the OpenCode tool names it enables
652
+ // (buildClaudeArgs keeps only the read-capable subset and scopes it).
653
+ tools[agent.id] = Object.entries(agent.tools)
654
+ .filter(([, enabled]) => enabled)
655
+ .map(([name]) => name);
656
+ }
657
+ // Mirror buildOpencodeConfig's fixed roles: the cross-file and verifier passes get
658
+ // read+grep (Glob withheld — crawling is what made them wander); the coordinator
659
+ // consolidates findings and needs no repo tools.
660
+ tools[CROSS_CUTTING_AGENT] = ["read", "grep"];
661
+ tools[VERIFIER_AGENT] = ["read", "grep"];
662
+ tools["coordinator"] = [];
663
+ const defaultModel = config.agents[0]?.model ?? config.coordinator.model;
664
+ return {
665
+ client: undefined,
666
+ url: "",
667
+ close: () => { },
668
+ // NOT the default watch file: that is the host's real OpenCode log, which this
669
+ // engine never writes — stale 429s from unrelated OpenCode use would be counted
670
+ // as evidence for this run. A nonexistent path keeps check() at zero; evidence
671
+ // for this engine arrives via note() in runClaudePrompt.
672
+ rateLimit: new RateLimitWatch(path.join(tmpdir(), `ecr-claude-${process.pid}-norate.log`)),
673
+ engine: CLAUDE_CODE_ENGINE,
674
+ models,
675
+ tools,
676
+ defaultModel,
677
+ cliPath,
678
+ childEnv,
679
+ };
680
+ }