@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.
- package/README.md +33 -12
- package/build/commands/ci.js +8 -8
- package/build/commands/doctor.js +167 -33
- package/build/commands/setup-auth.js +83 -11
- package/build/config/schema.js +7 -3
- package/build/core/auth.js +122 -9
- package/build/core/claude-code.js +680 -0
- package/build/core/exec.js +278 -9
- package/build/core/opencode.js +95 -15
- package/build/core/prompts.js +19 -2
- package/build/core/render.js +21 -2
- package/build/core/review.js +158 -25
- package/build/core/schema.js +6 -1
- package/build/core/scrub.js +59 -1
- package/build/core/throttle.js +10 -0
- package/build/core/util.js +17 -0
- package/build/core/verify.js +13 -1
- package/build/reporters/github.js +79 -13
- package/build/sources/github-pr.js +14 -7
- package/build/sources/local-git.js +3 -2
- package/package.json +3 -3
- package/templates/config.jsonc +21 -3
- package/templates/shared.md +28 -0
|
@@ -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
|
+
}
|