@expo/code-review-cli 0.4.0 → 0.5.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 CHANGED
@@ -41,21 +41,33 @@ Scaffold, add credentials, verify.
41
41
  npx @expo/code-review-cli init
42
42
  ```
43
43
 
44
- Then give it model credentials. **Recommended: a Claude Pro/Max subscription** — the
45
- scaffolded config uses OAuth by default, so just mint a token and export it under the
46
- env var your `config.jsonc`'s `auth.tokenEnv` names:
44
+ Then give it model credentials. **Default: an OpenAI API key** — the scaffolded
45
+ config reviews with GPT via `auth.mode "api-key"`.
46
+
47
+ Create the key in the OpenAI dashboard, scoped to the minimum the reviewer needs:
48
+
49
+ - Put it in a **dedicated project** (not "Default project") so you can set a
50
+ monthly budget + alert on it and see the reviewer's spend in isolation.
51
+ - Make it a **Restricted** key with exactly two permissions, both under *Model
52
+ capabilities*: **Responses (/v1/responses) → Request** and **Chat completions
53
+ (/v1/chat/completions) → Request**. Everything else — including *List models* —
54
+ stays **None** (the reviewer resolves model ids from its own catalog and only
55
+ ever makes inference requests).
47
56
 
48
57
  ```bash
49
- # Mint a Claude Pro/Max token (prints an sk-ant-oat… token)
50
- claude setup-token
51
- # Export it under the env var your config.jsonc's auth.tokenEnv names
52
- export ANTHROPIC_OAUTH_API_KEY=sk-ant-oat...
58
+ export OPENAI_API_KEY=sk-proj-...
53
59
  # Check env, config, and credentials
54
60
  npx @expo/code-review-cli doctor
55
61
  ```
56
62
 
57
- Prefer an Anthropic **API key**, or **OpenAI/GPT** or another provider? See
58
- [Other providers & auth modes](#other-providers) at the bottom.
63
+ In CI, store the same key as the `OPENAI_API_KEY` repo secret (the scaffolded
64
+ workflow forwards it).
65
+
66
+ **Have a ChatGPT Plus/Pro (Codex) subscription? Use both.** The recommended
67
+ production setup pairs the subscription (runs the default models at no marginal
68
+ cost) with the usage-based key (covers only the pro-tier models the subscription
69
+ excludes) — see [the mixed setup](#other-providers) below. Prefer
70
+ **Anthropic/Claude** or another provider? Same section.
59
71
 
60
72
  ### Reviewing (already configured)
61
73
 
@@ -142,7 +154,7 @@ your-monorepo/
142
154
  // Central guardrails every scope inherits and CANNOT override.
143
155
  "defaults": {
144
156
  // The ONLY place auth/tokenEnv is honored (besides the root config.jsonc).
145
- "auth": { "mode": "oauth", "provider": "anthropic", "tokenEnv": "ANTHROPIC_OAUTH_API_KEY" },
157
+ "auth": { "mode": "api-key", "provider": "openai", "tokenEnv": "OPENAI_API_KEY" },
146
158
  "enforceAgents": ["security"], // always runs on every scope, roster or not
147
159
  "commentTag": "expo-ai-code-reviewer" // per-scope markers derive from this
148
160
  },
@@ -160,7 +172,7 @@ your-monorepo/
160
172
  // server/www/.expo-code-review/config.jsonc (the www team owns this)
161
173
  {
162
174
  // NO "auth" block — locked centrally; a tokenEnv here is rejected by loader + CI guard.
163
- "model": "anthropic/claude-sonnet-5",
175
+ "model": "openai/gpt-5.5",
164
176
  "policy": { "includeSuggestions": false },
165
177
  "noise": { "additionalIgnores": ["server/www/**/__generated__/**"] }
166
178
  // shared.md, coordinator.md, agents/*.md live beside this file — the www team's roster.
@@ -185,7 +197,7 @@ your-monorepo/
185
197
  the override swaps the root config/manifest against the *real* scope tree, it
186
198
  does not relocate the scopes themselves.**
187
199
  - **Passes budget** — `defaults`-level `budget` bounds total review time:
188
- `totalPassesMinutes` (default 32) is split across active scopes (which run
200
+ `totalPassesMinutes` (default 55) is split across active scopes (which run
189
201
  sequentially in one `ecr ci`), clamped up to `minScopeMinutes` (default 5) so a
190
202
  single scope still gets a workable window. When enough scopes are active that the
191
203
  floor would overshoot the total, `ecr ci` keeps the floor but warns, and `ecr
@@ -249,7 +261,7 @@ Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-i
249
261
  suppressed.
250
262
 
251
263
  Built on the [OpenCode](https://opencode.ai) SDK, which spawns the model provider
252
- and applies Anthropic prompt caching automatically.
264
+ and applies the provider's prompt caching automatically.
253
265
 
254
266
  </details>
255
267
 
@@ -269,16 +281,17 @@ cache, in three places:
269
281
  a CI artifact) with the same totals plus per-pass `agentTokens`, the raw
270
282
  per-agent findings, coverage notes, and what the verifier dropped.
271
283
 
272
- **How the caching works.** Anthropic prompt caching is a *prefix match*: the
273
- provider caches the rendered prompt up to a marker, and any byte change anywhere
284
+ **How the caching works.** Provider prompt caching is a *prefix match*: the
285
+ provider caches the rendered prompt up to a point, and any byte change anywhere
274
286
  in that prefix invalidates everything after it. The reviewer is laid out so the
275
287
  prefix is stable — the system prompt (`shared.md` + the agent's own `.md`) is
276
288
  byte-identical for every chunk an agent reviews, while the volatile parts (the
277
289
  diff, file lists, PR metadata) travel in the user message *after* the prefix and
278
- never touch it. The first call per agent **writes** the cache (`cache write`,
279
- billed ~1.25× the input price); every later chunk in the run **reads** it
280
- (`cache read`, ~0.1× the input price). Cache entries live ~5 minutes and are
281
- refreshed on use, which comfortably covers a run's concurrent calls.
290
+ never touch it. OpenAI caches automatically (no write premium; cached input is
291
+ billed at a steep discount and shows up as `cache read`); Anthropic charges a
292
+ small premium to **write** the cache (~1.25× input) and ~0.1× input to **read**
293
+ it. Entries live minutes, refreshed on use comfortably covering a run's
294
+ concurrent calls.
282
295
 
283
296
  **Reading the numbers.** Hit rate = `cache read / (cache read + input)` — the
284
297
  share of prompt tokens served from cache instead of being reprocessed at full
@@ -317,7 +330,7 @@ show writes (there is nothing to re-read within the run).
317
330
  ---
318
331
  description: One line the router uses to decide relevance.
319
332
  alwaysRun: true # run even when the router would skip this agent
320
- model: anthropic/claude-sonnet-5 # override the default model
333
+ model: openai/gpt-5.5-pro # override the default model
321
334
  temperature: 0.1
322
335
  ---
323
336
 
@@ -326,14 +339,14 @@ temperature: 0.1
326
339
 
327
340
  For a real-world example, see eas-cli's
328
341
  [`.expo-code-review/`](https://github.com/expo/eas-cli/tree/main/.expo-code-review)
329
- — correctness/security/consistency agents, Opus for security + the coordinator, and
330
- per-repo `noise.additionalIgnores`.
342
+ — correctness/security/consistency agents, a stronger model for security + the
343
+ coordinator, and per-repo `noise.additionalIgnores`.
331
344
 
332
345
  `config.jsonc` (JSONC — comments + trailing commas supported):
333
346
 
334
347
  ```jsonc
335
348
  {
336
- "model": "anthropic/claude-sonnet-5", // default model for the specialists
349
+ "model": "openai/gpt-5.5", // default model for the specialists
337
350
  "policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
338
351
  "chunk": { "maxChangedLines": 1000, "maxFiles": 20, "concurrency": 6 },
339
352
  "noise": { "additionalIgnores": ["packages/*/build/**"] },
@@ -342,8 +355,8 @@ per-repo `noise.additionalIgnores`.
342
355
  "skipLabel": "ai-review:skip" }, // "label" (only labeled PRs)
343
356
  "breakGlass": { "marker": "/skip-review" }, // PR body marker that skips the review
344
357
  "commentTag": "expo-ai-code-reviewer", // hidden tag used to find/update the comment
345
- "auth": { "mode": "oauth", "provider": "anthropic",
346
- "tokenEnv": "ANTHROPIC_OAUTH_API_KEY" }
358
+ "auth": { "mode": "api-key", "provider": "openai",
359
+ "tokenEnv": "OPENAI_API_KEY" }
347
360
  }
348
361
  ```
349
362
 
@@ -357,14 +370,19 @@ Precedence: **`REVIEWER_MODEL` env** (global override) → per-file **frontmatte
357
370
  setup, and a developer can override everything locally.
358
371
 
359
372
  - **Specialist agents** (correctness/security/consistency) benefit from a
360
- reasoning-tier model — **Sonnet** is the quality/speed sweet spot (default for
361
- correctness/consistency). **Opus** finds more but is slower and more
362
- rate-limited, so scope it to the highest-stakes agent: **security runs on Opus**
363
- (set in `security.md` frontmatter), the rest on Sonnet.
373
+ reasoning-tier model — **`openai/gpt-5.5`** is the quality/speed sweet spot
374
+ (the scaffolded default). The **pro tier** finds more but is slower and more
375
+ expensive, so scope it to the highest-stakes agent: **security runs on
376
+ `openai/gpt-5.5-pro`** (set in `security.md` frontmatter), the rest on the default.
364
377
  - **The coordinator** makes the final call (dedupe / re-judge / decide) — worth a
365
378
  strong model; set it in `coordinator.md` frontmatter.
366
379
  - If latency/timeouts dominate on big PRs, moving the specialists to a faster model
367
- is the most direct lever (a real recall tradeoff — measure it).
380
+ (e.g. `openai/gpt-5.4-mini`) is the most direct lever (a real recall tradeoff —
381
+ measure it).
382
+ - **Every run logs which model actually answered each pass** — in the job log
383
+ (`Models used — …`), the Actions step summary table, and the run log's
384
+ `agentModels` — and warns loudly if a pass ran on a different model than
385
+ configured, so a provider-side substitution can never pass unnoticed.
368
386
 
369
387
  There is no automatic cross-provider "equivalent" fallback — that would silently
370
388
  change which model reviewed your code. Use an explicit override instead.
@@ -374,16 +392,32 @@ change which model reviewed your code. Use an explicit override instead.
374
392
  <details>
375
393
  <summary><b>Reliability</b> — never hangs, never silently drops work</summary>
376
394
 
377
- - **Per-task time caps** — chunk passes 15 min; cross-cutting 25 min; coordinator
378
- 10 min. A global passes budget (32 min) bounds all passes incl. the subdivision
379
- waves, fitting inside the CI job's `timeout-minutes` (60).
395
+ - **Per-task time caps** — chunk passes 15 min; coordinator 10 min. A global passes
396
+ budget (55 min) bounds all passes incl. the subdivision waves, fitting inside the
397
+ CI job's `timeout-minutes` (90).
398
+ - **The cross-file pass is elastic** — it gets whatever is left of the passes budget
399
+ rather than a fixed cap, because it's the one pass whose scope can't be traded for
400
+ convergence: halving its file set deletes exactly the coverage it exists for. Chunk
401
+ passes run alongside it under their own caps, so a long cross-file pass doesn't
402
+ starve them.
380
403
  - **Tool-call cap** — a pass that makes too many `read`/`grep` calls without
381
404
  finishing is *wandering*, not converging; hitting the cap trips the soft landing.
405
+ The cross-file ceiling scales with the diff's file count (its diffs are inlined, so
406
+ tool calls go to *tracing*, not fetching).
407
+ - **Stall detection** — a pass whose reply stops changing entirely (no new tool call,
408
+ no streamed text or reasoning, no token growth) has a wedged model request, not a
409
+ hard problem. After 4 min of silence it's abandoned and retried once from a clean
410
+ session, inside the same budget — instead of spending the whole cap on a dead
411
+ request. Progress lines say how long a reply has been silent, so this is legible in
412
+ the CI log.
382
413
  - **Soft landing on timeout** — at either cap, the run is interrupted and the agent
383
414
  is asked to return the findings it already has, rather than discarding its work.
384
- - **Subdivide-on-timeout** a pass that times out with nothing to show has its
385
- chunk split in half and the halves re-reviewed (recursively, down to a single
386
- file), then a fast **no-tools fallback** over the inlined diff. Only a genuinely
415
+ Tools are disabled for that request, so the salvage step can't resume investigating
416
+ instead of answering.
417
+ - **Subdivide-on-timeout** a reviewer pass that times out with nothing to show has
418
+ its chunk split in half and the halves re-reviewed (recursively, down to a single
419
+ file), then a fast **no-tools fallback** over the inlined diff (the cross-file pass
420
+ skips straight to the fallback, which still sees the whole diff). Only a genuinely
387
421
  un-reducible pass reports a coverage gap — and it is always reported, never silent.
388
422
  - **Parse failures are retried** (same session, then once in a bounded fresh
389
423
  session) — separate from the timeout path.
@@ -448,23 +482,72 @@ visible even in CI (where the run log is ephemeral).
448
482
  <details>
449
483
  <summary><b>Other providers & auth modes</b></summary>
450
484
 
451
- The recommended setup is a Claude Pro/Max subscription (OAuth) — see Usage above.
452
- Alternatives, all set in `config.auth` (credentials come from OpenCode):
453
-
454
- - **Anthropic API key** set `auth.mode` to `"api-key"` and point `tokenEnv` at the
455
- env var holding the key (e.g. `ANTHROPIC_API_KEY`); it's sent as `x-api-key`. Omit
456
- the `auth` block entirely to fall back to OpenCode's own login / `ANTHROPIC_API_KEY`.
457
- - **OAuth (Pro/Max)** `tokenEnv` holds an `sk-ant-oat…` token from
458
- `claude setup-token` (*not* an x-api-key); it's written to an isolated OpenCode
459
- `auth.json` as a bearer credential, using the native subscription path.
460
- - **OpenAI / GPT, or another provider** — the current path is the `REVIEWER_MODEL`
485
+ The recommended setup is an OpenAI API key — see Usage above. Alternatives, all
486
+ set in `config.auth` (credentials come from OpenCode):
487
+
488
+ - **ChatGPT/Codex subscription (OAuth) + usage-based API key the recommended
489
+ mix.** OpenAI permits subscription auth in third-party tools, and OpenCode
490
+ ships the plugin for it so the reviewer runs its default models on the
491
+ subscription (zero marginal cost) and reserves the metered key for pro-tier
492
+ models the subscription doesn't offer (`gpt-5.5-pro` is subscription-excluded).
493
+ Use the per-provider map form:
494
+
495
+ ```jsonc
496
+ "auth": { "providers": {
497
+ "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN" },
498
+ "openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
499
+ } }
500
+ ```
501
+
502
+ `openai-api` is an alias the reviewer synthesizes in the OpenCode config
503
+ (`upstream` names the SDK it's backed by): agents reference `openai-api/gpt-5.5-pro`
504
+ in frontmatter while everything else stays on `openai/gpt-5.5`. Notes:
505
+
506
+ - **The oauth `tokenEnv` holds the refresh token** from an `opencode auth login`
507
+ ChatGPT sign-in (copy `.openai.refresh` out of OpenCode's `auth.json`) —
508
+ access tokens are short-lived, so the refresh token is the durable secret and
509
+ OpenCode mints access tokens on demand. Refresh-token reuse across runs is
510
+ verified, so a static CI secret works.
511
+ - **The API key needs the same two permissions** as the default setup above
512
+ (Responses + Chat completions → Request; all else None), in a budget-capped
513
+ project.
514
+ - **In CI**, set the `ECR_EXPECTED_TOKEN_ENV` repo variable to the
515
+ comma-separated set of both env names
516
+ (`CODEX_OAUTH_REFRESH_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
517
+ workflow.
518
+ - **Auditability**: every pass logs which provider/model answered it (job log,
519
+ step summary, run log), so the subscription/API split is visible per run.
520
+ One caveat: OpenCode can't price alias models (they're config-declared), so
521
+ pro passes report `$0` in the run log's cost column — token counts are
522
+ correct, and the OpenAI project dashboard is the source of truth for spend.
523
+ - **Anthropic / Claude (API key)** — set `auth.provider` to `"anthropic"`, point
524
+ `tokenEnv` at the env var holding a Console API key (e.g. `ANTHROPIC_API_KEY`),
525
+ and use `anthropic/...` model ids; the key is sent as `x-api-key`. Note that
526
+ Claude Pro/Max **subscription** tokens cannot be used here: Anthropic prohibits
527
+ them in third-party tools, and OpenCode has no Anthropic OAuth support — only an
528
+ API key works.
529
+ - **Another provider** — the current path is the `REVIEWER_MODEL`
461
530
  env override: `opencode auth login` once (pick the provider), then run with
462
- e.g. `REVIEWER_MODEL=openai/gpt-5.4-mini-fast`. It overrides every agent's model
463
- and uses your OpenCode login, so no `auth` block is needed. *(First-class
464
- per-provider config Anthropic/OpenAI/others in `config.jsonc`, and mixing them
465
- per agent — is on the [roadmap](./ROADMAP.md).)*
531
+ e.g. `REVIEWER_MODEL=google/gemini-3-pro`. It overrides every agent's model
532
+ and uses your OpenCode login, so no `auth` block is needed. *(Per-agent
533
+ provider mixing beyond the alias mechanism above is on the
534
+ [roadmap](./ROADMAP.md).)*
466
535
 
467
536
  There is no shared fallback key; if a run fails for lack of credentials, authenticate
468
537
  a provider in OpenCode. `ecr doctor` diagnoses setup.
469
538
 
539
+ **Setup errors fail fast, with the fix in the message.** A bad credential or model id
540
+ would otherwise fail every pass identically — a run that spends its whole budget
541
+ rediscovering one fixable thing, then reports N coverage gaps. So before any pass runs:
542
+
543
+ - **The credential's shape is checked.** OpenCode refuses a malformed credential by
544
+ dropping the provider entirely, which then surfaces as "model not found" for every
545
+ model, with nothing pointing at the credential. A truncated value, surrounding
546
+ whitespace, or a token that can't work for the configured `auth.mode` is rejected
547
+ by name.
548
+ - **Configured model ids are checked against the running server**, so a typo or an id
549
+ the provider doesn't have is reported once, up front, with the close matches.
550
+ - **`ecr doctor` reports the `opencode` version actually in use** and warns when a
551
+ different one is first on your `PATH` — runs use the version this package pins.
552
+
470
553
  </details>
@@ -1,5 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { loadAuthFromRoot, loadReviewConfig, loadScopeConfig } from "../config/load.js";
2
+ import { loadAuthFromRoot, loadReviewConfig, loadScopeConfig, tokenEnvMismatch, } from "../config/load.js";
3
3
  import { loadRoutingManifest, resolveScopes, scopedCommentTag, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
4
4
  import { repoRoot, run } from "../core/exec.js";
5
5
  import { errorMessage } from "../core/util.js";
@@ -128,9 +128,12 @@ async function runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate
128
128
  // bash guard is a text sweep (layer 2) and can't see through JSON escapes; this
129
129
  // check compares the tokenEnv the loader actually honors.
130
130
  const expectedTokenEnv = process.env.ECR_EXPECTED_TOKEN_ENV;
131
- if (expectedTokenEnv && config.auth.tokenEnv !== expectedTokenEnv) {
132
- process.stderr.write(`CI reviewer: auth.tokenEnv "${config.auth.tokenEnv ?? "(none)"}" != ECR_EXPECTED_TOKEN_ENV "${expectedTokenEnv}"; refusing to run.\n`);
133
- return;
131
+ if (expectedTokenEnv) {
132
+ const mismatch = tokenEnvMismatch(config.auth, expectedTokenEnv);
133
+ if (mismatch) {
134
+ process.stderr.write(`CI reviewer: ${mismatch}; refusing to run.\n`);
135
+ return;
136
+ }
134
137
  }
135
138
  // Config-driven trigger policy (.expo-code-review/config.jsonc → review): decide
136
139
  // whether this PR should be reviewed at all (bypassed by a manual /review).
@@ -225,8 +228,9 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
225
228
  const expectedTokenEnv = process.env.ECR_EXPECTED_TOKEN_ENV;
226
229
  if (expectedTokenEnv) {
227
230
  const honored = loadAuthFromRoot(rootConfig, manifest);
228
- if (honored.tokenEnv !== expectedTokenEnv) {
229
- process.stderr.write(`CI reviewer: honored auth.tokenEnv "${honored.tokenEnv ?? "(none)"}" != ECR_EXPECTED_TOKEN_ENV "${expectedTokenEnv}"; refusing to run.\n`);
231
+ const mismatch = tokenEnvMismatch(honored, expectedTokenEnv);
232
+ if (mismatch) {
233
+ process.stderr.write(`CI reviewer: honored ${mismatch}; refusing to run.\n`);
230
234
  return;
231
235
  }
232
236
  }
@@ -1,8 +1,10 @@
1
- import { loadReviewConfig, loadScopeConfig, loadAuthFromRoot, hasConfig, resolveConfigDir, } from "../config/load.js";
1
+ import { loadReviewConfig, loadScopeConfig, loadAuthFromRoot, hasConfig, resolveConfigDir, tokenEnvMismatch, } from "../config/load.js";
2
2
  import { loadRoutingManifest, resolveScopes, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
3
3
  import { checkProviderAuth } from "../core/auth.js";
4
+ import { opencodeBinSource } from "../core/opencode.js";
4
5
  import { git, onPath, repoRoot, run } from "../core/exec.js";
5
6
  import { errorMessage } from "../core/util.js";
7
+ import path from "node:path";
6
8
  const USAGE = `ecr doctor — check environment, config, and credentials
7
9
 
8
10
  Usage:
@@ -16,6 +18,19 @@ ownership over tracked files, and comment-tag uniqueness.
16
18
  Options:
17
19
  --list-scopes Print the routing scope table (name, dir, paths, agents, tag)
18
20
  `;
21
+ /**
22
+ * `opencode --version`, run from `binDir` if given (so the bundled CLI can be asked
23
+ * directly) or from PATH otherwise. Null when it can't be determined — a version we
24
+ * can't read is worth staying quiet about, not failing over.
25
+ */
26
+ async function opencodeVersion(binDir) {
27
+ const command = binDir ? path.join(binDir, "opencode") : "opencode";
28
+ const { stdout, code } = await run(command, ["--version"], { check: false });
29
+ if (code !== 0) {
30
+ return null;
31
+ }
32
+ return stdout.trim().split("\n")[0]?.trim() || null;
33
+ }
19
34
  /** Preflight checks so a broken setup surfaces clearly instead of silently no-opping. */
20
35
  export async function doctorCommand(argv = []) {
21
36
  if (argv.includes("-h") || argv.includes("--help")) {
@@ -47,10 +62,32 @@ export async function doctorCommand(argv = []) {
47
62
  if (process.env.ECR_CONFIG_DIR) {
48
63
  info(`ECR_CONFIG_DIR override active: root config.jsonc and routing.jsonc read from ${resolveConfigDir(root)} (scope subtrees stay repo-root-relative)`);
49
64
  }
50
- const opencodeInstalled = await onPath("opencode");
65
+ // The SDK spawns a bare `opencode`, so the version that actually runs is a PATH
66
+ // lookup. Report which one wins and whether it matches the version this package
67
+ // pins: a stale global install against a newer SDK rejects model ids the SDK
68
+ // considers valid (`ProviderModelNotFoundError`), which is otherwise a baffling
69
+ // failure that only reproduces on one machine. `startOpencode` prepends our own
70
+ // bin dir so the pinned one wins at runtime — this just makes the drift visible.
71
+ const bin = opencodeBinSource();
72
+ const opencodeInstalled = (await onPath("opencode")) || bin.pinned;
51
73
  line(opencodeInstalled, opencodeInstalled
52
- ? "opencode CLI found on PATH"
53
- : "opencode CLI NOT on PATH (install `opencode-ai`, or add node_modules/.bin to PATH)");
74
+ ? "opencode CLI available"
75
+ : "opencode CLI NOT found (install `opencode-ai`, or add node_modules/.bin to PATH)");
76
+ if (opencodeInstalled) {
77
+ const pinnedVersion = bin.pinned ? await opencodeVersion(bin.dir) : null;
78
+ const pathVersion = await opencodeVersion(null);
79
+ if (pinnedVersion) {
80
+ line(true, `opencode ${pinnedVersion} (bundled with this reviewer; used at runtime)`);
81
+ if (pathVersion && pathVersion !== pinnedVersion) {
82
+ warn(`a different opencode ${pathVersion} is first on your PATH — runs use the bundled ${pinnedVersion}, ` +
83
+ `but other tooling (and \`opencode\` by hand) will use ${pathVersion}`);
84
+ }
85
+ }
86
+ else if (pathVersion) {
87
+ warn(`using opencode ${pathVersion} from PATH — this reviewer's own \`opencode-ai\` dependency could not be ` +
88
+ `resolved, so the CLI and SDK versions can drift (a stale CLI rejects model ids the SDK accepts)`);
89
+ }
90
+ }
54
91
  line(await onPath("git"), "git found on PATH");
55
92
  // `gh` is only needed for `ecr ci` (posting PR comments), so treat it as
56
93
  // informational (ℹ) rather than a hard failure for local `ecr review` users.
@@ -84,6 +121,11 @@ export async function doctorCommand(argv = []) {
84
121
  line(rootConfig.agents.every((a) => Boolean(a.promptText.trim())), "all agent prompt files resolved and non-empty");
85
122
  const readiness = checkProviderAuth(rootConfig);
86
123
  line(readiness.ok, `auth: ${readiness.detail}`);
124
+ // A suspicious-but-not-provably-broken credential: worth saying, never a failure
125
+ // (the shape rules are heuristics — see checkOauthTokenShape).
126
+ if (readiness.warning) {
127
+ warn(`auth: ${readiness.warning}`);
128
+ }
87
129
  }
88
130
  catch (error) {
89
131
  line(false, `config invalid: ${errorMessage(error)}`);
@@ -139,14 +181,18 @@ export async function doctorCommand(argv = []) {
139
181
  // auth singleton: exactly one honored source (defaults.auth or root config auth).
140
182
  const auth = loadAuthFromRoot(rootConfig, manifest);
141
183
  const hasManifestAuth = Boolean(manifest.defaults.auth);
142
- line(true, `auth singleton: honored from ${hasManifestAuth ? "routing.jsonc defaults.auth" : "root config.jsonc"} (${auth.mode}/${auth.provider})`);
143
- if (auth.tokenEnv) {
144
- const expected = process.env.ECR_EXPECTED_TOKEN_ENV;
145
- if (expected && expected !== auth.tokenEnv) {
146
- line(false, `auth.tokenEnv "${auth.tokenEnv}" != ECR_EXPECTED_TOKEN_ENV "${expected}"`);
184
+ line(true, `auth singleton: honored from ${hasManifestAuth ? "routing.jsonc defaults.auth" : "root config.jsonc"} ` +
185
+ `(${auth.map((entry) => `${entry.mode}/${entry.provider}`).join(", ")})`);
186
+ const expected = process.env.ECR_EXPECTED_TOKEN_ENV;
187
+ if (expected) {
188
+ const mismatch = tokenEnvMismatch(auth, expected);
189
+ if (mismatch) {
190
+ line(false, `auth: ${mismatch}`);
147
191
  }
148
- else {
149
- line(Boolean(process.env[auth.tokenEnv]), `auth token env ${auth.tokenEnv} is ${process.env[auth.tokenEnv] ? "set" : "NOT set"}`);
192
+ }
193
+ for (const entry of auth) {
194
+ if (entry.tokenEnv) {
195
+ line(Boolean(process.env[entry.tokenEnv]), `auth token env ${entry.tokenEnv} (${entry.provider}) is ${process.env[entry.tokenEnv] ? "set" : "NOT set"}`);
150
196
  }
151
197
  }
152
198
  // Owner-table dry run over tracked files (graft 4).
@@ -14,15 +14,17 @@ The canonical pre-review guard (ships with the CLI). It sweeps EVERY
14
14
  plain recursive walk (skipping node_modules/.git, so a staged-but-unreferenced
15
15
  config can't hide from git's index), parses each with the real comment-aware JSONC
16
16
  parser (never regex-scraping), and refuses to run (exit 1) when:
17
- • auth.tokenEnv (config) / defaults.auth.tokenEnv (routing.jsonc) appears more
18
- than once, or in a non-root file, or with --expected / ECR_EXPECTED_TOKEN_ENV
19
- set differs from the expected name or is absent (count must be exactly one);
17
+ a tokenEnv (auth.tokenEnv, or any auth.providers.<id>.tokenEnv; routing.jsonc:
18
+ same under defaults.auth) appears in a non-root file, in more than one root
19
+ file, twice under the same name, or with --expected / ECR_EXPECTED_TOKEN_ENV
20
+ set (comma-separated) — the declared set differs from the expected set;
20
21
  • a non-root config declares auth, breakGlass, or commentTag (root-locked keys);
21
22
  • any file fails to parse (fail-closed), reporting the parse error.
22
23
  Exit 0 = safe to run the review.
23
24
 
24
25
  Options:
25
- --expected <ENV_NAME> Require tokenEnv to equal this (else ECR_EXPECTED_TOKEN_ENV).
26
+ --expected <ENVS> Require the declared tokenEnv set to equal this
27
+ comma-separated set (else ECR_EXPECTED_TOKEN_ENV).
26
28
  --json Emit {ok, findings:[{file, problem}]} on stdout.
27
29
  `;
28
30
  const CONFIG_FILENAMES = new Set(["config.jsonc", "config.json", ROUTING_FILENAME]);
@@ -63,22 +65,38 @@ function asObject(value) {
63
65
  ? value
64
66
  : undefined;
65
67
  }
68
+ /** Every tokenEnv an auth block names — legacy single, or one per providers entry. */
69
+ function collectTokenEnvs(auth) {
70
+ if (!auth) {
71
+ return [];
72
+ }
73
+ const found = [];
74
+ if (typeof auth.tokenEnv === "string") {
75
+ found.push(auth.tokenEnv);
76
+ }
77
+ const providers = asObject(auth.providers);
78
+ for (const entry of Object.values(providers ?? {})) {
79
+ const tokenEnv = asObject(entry)?.tokenEnv;
80
+ if (typeof tokenEnv === "string") {
81
+ found.push(tokenEnv);
82
+ }
83
+ }
84
+ return found;
85
+ }
66
86
  /** Read the security-relevant declarations from a parsed config/routing object. */
67
87
  function extractFacts(file, parsed) {
68
88
  if (path.basename(file) === ROUTING_FILENAME) {
69
89
  // routing.jsonc locks auth under defaults.auth (defaults.auth.tokenEnv).
70
90
  const defaults = asObject(parsed.defaults);
71
- const auth = asObject(defaults?.auth);
72
91
  return {
73
- tokenEnv: typeof auth?.tokenEnv === "string" ? auth.tokenEnv : undefined,
92
+ tokenEnvs: collectTokenEnvs(asObject(defaults?.auth)),
74
93
  declaresAuth: Boolean(defaults) && "auth" in defaults,
75
94
  declaresBreakGlass: false, // routing.jsonc has no breakGlass concept
76
95
  declaresCommentTag: Boolean(defaults) && "commentTag" in defaults,
77
96
  };
78
97
  }
79
- const auth = asObject(parsed.auth);
80
98
  return {
81
- tokenEnv: typeof auth?.tokenEnv === "string" ? auth.tokenEnv : undefined,
99
+ tokenEnvs: collectTokenEnvs(asObject(parsed.auth)),
82
100
  declaresAuth: "auth" in parsed,
83
101
  declaresBreakGlass: "breakGlass" in parsed,
84
102
  declaresCommentTag: "commentTag" in parsed,
@@ -113,8 +131,8 @@ export async function verifyConfig(root, options = {}) {
113
131
  continue;
114
132
  }
115
133
  const facts = extractFacts(file, object);
116
- if (facts.tokenEnv !== undefined) {
117
- tokenEnvOccurrences.push({ file: rel(file), value: facts.tokenEnv, isRoot });
134
+ for (const value of facts.tokenEnvs) {
135
+ tokenEnvOccurrences.push({ file: rel(file), value, isRoot });
118
136
  }
119
137
  if (!isRoot) {
120
138
  const locked = [];
@@ -135,38 +153,58 @@ export async function verifyConfig(root, options = {}) {
135
153
  }
136
154
  }
137
155
  }
138
- // tokenEnv must appear at most once, only in a root-owned file.
156
+ // tokenEnvs may only be declared in root-owned files…
139
157
  for (const occurrence of tokenEnvOccurrences.filter((o) => !o.isRoot)) {
140
158
  findings.push({
141
159
  file: occurrence.file,
142
160
  problem: `tokenEnv "${occurrence.value}" is declared outside the root config; only a root-owned config.jsonc/config.json or routing.jsonc may name the forwarded credential`,
143
161
  });
144
162
  }
145
- if (tokenEnvOccurrences.length > 1) {
163
+ // …and all in ONE root file (multiple entries in one auth block are fine —
164
+ // that's the multi-provider map — but split across files there is no single
165
+ // honored source and a stale/staged second file could smuggle a credential).
166
+ const rootOccurrences = tokenEnvOccurrences.filter((o) => o.isRoot);
167
+ const rootFiles = [...new Set(rootOccurrences.map((o) => o.file))];
168
+ if (rootFiles.length > 1) {
146
169
  findings.push({
147
- file: tokenEnvOccurrences.map((o) => o.file).join(", "),
148
- problem: `tokenEnv is declared in ${tokenEnvOccurrences.length} files; it must appear exactly once, in a root-owned config`,
170
+ file: rootFiles.join(", "),
171
+ problem: `tokenEnv is declared in ${rootFiles.length} root files; all credential env names must live in ONE root-owned config`,
149
172
  });
150
173
  }
151
- // With an expectation set, exactly one root occurrence equal to it is required.
174
+ // Duplicate names within a file are a config bug worth failing on too: two auth
175
+ // entries forwarding the same env var means one of them is misconfigured.
176
+ const seen = new Set();
177
+ for (const occurrence of rootOccurrences) {
178
+ if (seen.has(occurrence.value)) {
179
+ findings.push({
180
+ file: occurrence.file,
181
+ problem: `tokenEnv "${occurrence.value}" is declared more than once; each credential env name must appear exactly once`,
182
+ });
183
+ }
184
+ seen.add(occurrence.value);
185
+ }
186
+ // With an expectation set, the declared names must equal the expected SET
187
+ // exactly (comma-separated; order-insensitive). A missing name is as much a
188
+ // finding as an extra one — a PR must not add, drop, or repoint credentials.
152
189
  const expected = options.expected;
153
190
  if (expected) {
154
- const rootOccurrences = tokenEnvOccurrences.filter((o) => o.isRoot);
155
- if (rootOccurrences.length === 0) {
191
+ const expectedSet = expected
192
+ .split(",")
193
+ .map((name) => name.trim())
194
+ .filter(Boolean)
195
+ .sort();
196
+ const declared = [...seen].sort();
197
+ if (declared.length === 0) {
156
198
  findings.push({
157
199
  file: path.join(CONFIG_DIRNAME, "config.jsonc"),
158
- problem: `no tokenEnv found, but an expected value "${expected}" is set exactly one root-owned tokenEnv is required`,
200
+ problem: `no tokenEnv found, but expected "${expectedSet.join(", ")}" — the root-owned config must name exactly those credential env(s)`,
159
201
  });
160
202
  }
161
- else {
162
- for (const occurrence of rootOccurrences) {
163
- if (occurrence.value !== expected) {
164
- findings.push({
165
- file: occurrence.file,
166
- problem: `tokenEnv "${occurrence.value}" != expected "${expected}" — a PR must not repoint which secret is forwarded to the model provider`,
167
- });
168
- }
169
- }
203
+ else if (JSON.stringify(declared) !== JSON.stringify(expectedSet)) {
204
+ findings.push({
205
+ file: rootFiles.join(", ") || path.join(CONFIG_DIRNAME, "config.jsonc"),
206
+ problem: `declared tokenEnv set [${declared.join(", ")}] != expected [${expectedSet.join(", ")}] — a PR must not add, drop, or repoint which secrets are forwarded to model providers`,
207
+ });
170
208
  }
171
209
  }
172
210
  return { ok: findings.length === 0, findings };