@expo/code-review-cli 0.4.0 → 0.5.1

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
@@ -26,7 +26,12 @@ flowchart TD
26
26
  ## Usage
27
27
 
28
28
  Run via `npx @expo/code-review-cli <command>` (or the `ecr` / `expo-code-review`
29
- binary once installed).
29
+ binary once installed). On a repo that already has `.expo-code-review/` set up,
30
+ getting model credentials for local runs is one command:
31
+
32
+ ```bash
33
+ npx @expo/code-review-cli setup-auth
34
+ ```
30
35
 
31
36
  Reviewing a PR (`--pr`/`ci`) needs the GitHub CLI — `brew install gh && gh auth login`.
32
37
  Everything else the reviewer needs (including the `opencode` runtime) ships with the
@@ -34,28 +39,30 @@ package.
34
39
 
35
40
  ### First-time setup
36
41
 
37
- Scaffold, add credentials, verify.
38
-
39
42
  ```bash
40
- # Scaffold .expo-code-review/ + a CI workflow (--no-workflow to skip)
43
+ # 1. Scaffold .expo-code-review/ + a CI workflow (--no-workflow to skip)
41
44
  npx @expo/code-review-cli init
45
+ # 2. Get model credentials — guided; prints the export lines for your shell config
46
+ npx @expo/code-review-cli setup-auth
47
+ # 3. Verify env, config, and credentials
48
+ npx @expo/code-review-cli doctor
42
49
  ```
43
50
 
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:
51
+ `setup-auth` reads the repo's config and walks through each credential it needs:
52
+ an OpenAI **API key** (the scaffolded default it prints where to create the key
53
+ and the exact restricted permissions to grant), and/or a **ChatGPT/Codex
54
+ subscription** sign-in (it runs OpenCode's browser login and extracts the token
55
+ for you). `doctor` offers to run it whenever a credential is missing.
47
56
 
48
- ```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...
53
- # Check env, config, and credentials
54
- npx @expo/code-review-cli doctor
55
- ```
57
+ In CI, store the same values as repo secrets (`OPENAI_API_KEY`; plus
58
+ `CODEX_OAUTH_REFRESH_TOKEN` for the mixed setup) the scaffolded workflow
59
+ forwards them.
56
60
 
57
- Prefer an Anthropic **API key**, or **OpenAI/GPT** or another provider? See
58
- [Other providers & auth modes](#other-providers) at the bottom.
61
+ **Have a ChatGPT Plus/Pro (Codex) subscription? Use both.** The recommended
62
+ production setup pairs the subscription (runs the default models at no marginal
63
+ cost) with the usage-based key (covers only the pro-tier models the subscription
64
+ excludes) — see [the mixed setup](#other-providers) below. Prefer
65
+ **Anthropic/Claude** or another provider? Same section.
59
66
 
60
67
  ### Reviewing (already configured)
61
68
 
@@ -103,6 +110,7 @@ is a ready example to adapt.
103
110
  | `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
104
111
  | `ecr init --monorepo` | …and add a `routing.jsonc` routing manifest (one default scope). |
105
112
  | `ecr init --scope <dir>` | Scaffold a per-team scope under `<dir>` and register it in the manifest. |
113
+ | `ecr setup-auth [--yes]` | Walk through getting model credentials for local runs (ChatGPT sign-in and/or API keys), printing the `export` lines for your shell config. |
106
114
  | `ecr review [options]` | Review local changes and print an advisory review (default command). |
107
115
  | `ecr review --scope <name>` | Review only one routing scope over just that scope's changed files. |
108
116
  | `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
@@ -142,7 +150,7 @@ your-monorepo/
142
150
  // Central guardrails every scope inherits and CANNOT override.
143
151
  "defaults": {
144
152
  // The ONLY place auth/tokenEnv is honored (besides the root config.jsonc).
145
- "auth": { "mode": "oauth", "provider": "anthropic", "tokenEnv": "ANTHROPIC_OAUTH_API_KEY" },
153
+ "auth": { "mode": "api-key", "provider": "openai", "tokenEnv": "OPENAI_API_KEY" },
146
154
  "enforceAgents": ["security"], // always runs on every scope, roster or not
147
155
  "commentTag": "expo-ai-code-reviewer" // per-scope markers derive from this
148
156
  },
@@ -160,7 +168,7 @@ your-monorepo/
160
168
  // server/www/.expo-code-review/config.jsonc (the www team owns this)
161
169
  {
162
170
  // NO "auth" block — locked centrally; a tokenEnv here is rejected by loader + CI guard.
163
- "model": "anthropic/claude-sonnet-5",
171
+ "model": "openai/gpt-5.5",
164
172
  "policy": { "includeSuggestions": false },
165
173
  "noise": { "additionalIgnores": ["server/www/**/__generated__/**"] }
166
174
  // shared.md, coordinator.md, agents/*.md live beside this file — the www team's roster.
@@ -185,7 +193,7 @@ your-monorepo/
185
193
  the override swaps the root config/manifest against the *real* scope tree, it
186
194
  does not relocate the scopes themselves.**
187
195
  - **Passes budget** — `defaults`-level `budget` bounds total review time:
188
- `totalPassesMinutes` (default 32) is split across active scopes (which run
196
+ `totalPassesMinutes` (default 55) is split across active scopes (which run
189
197
  sequentially in one `ecr ci`), clamped up to `minScopeMinutes` (default 5) so a
190
198
  single scope still gets a workable window. When enough scopes are active that the
191
199
  floor would overshoot the total, `ecr ci` keeps the floor but warns, and `ecr
@@ -249,7 +257,7 @@ Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-i
249
257
  suppressed.
250
258
 
251
259
  Built on the [OpenCode](https://opencode.ai) SDK, which spawns the model provider
252
- and applies Anthropic prompt caching automatically.
260
+ and applies the provider's prompt caching automatically.
253
261
 
254
262
  </details>
255
263
 
@@ -269,16 +277,17 @@ cache, in three places:
269
277
  a CI artifact) with the same totals plus per-pass `agentTokens`, the raw
270
278
  per-agent findings, coverage notes, and what the verifier dropped.
271
279
 
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
280
+ **How the caching works.** Provider prompt caching is a *prefix match*: the
281
+ provider caches the rendered prompt up to a point, and any byte change anywhere
274
282
  in that prefix invalidates everything after it. The reviewer is laid out so the
275
283
  prefix is stable — the system prompt (`shared.md` + the agent's own `.md`) is
276
284
  byte-identical for every chunk an agent reviews, while the volatile parts (the
277
285
  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.
286
+ never touch it. OpenAI caches automatically (no write premium; cached input is
287
+ billed at a steep discount and shows up as `cache read`); Anthropic charges a
288
+ small premium to **write** the cache (~1.25× input) and ~0.1× input to **read**
289
+ it. Entries live minutes, refreshed on use comfortably covering a run's
290
+ concurrent calls.
282
291
 
283
292
  **Reading the numbers.** Hit rate = `cache read / (cache read + input)` — the
284
293
  share of prompt tokens served from cache instead of being reprocessed at full
@@ -317,7 +326,7 @@ show writes (there is nothing to re-read within the run).
317
326
  ---
318
327
  description: One line the router uses to decide relevance.
319
328
  alwaysRun: true # run even when the router would skip this agent
320
- model: anthropic/claude-sonnet-5 # override the default model
329
+ model: openai/gpt-5.5-pro # override the default model
321
330
  temperature: 0.1
322
331
  ---
323
332
 
@@ -326,14 +335,14 @@ temperature: 0.1
326
335
 
327
336
  For a real-world example, see eas-cli's
328
337
  [`.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`.
338
+ — correctness/security/consistency agents, a stronger model for security + the
339
+ coordinator, and per-repo `noise.additionalIgnores`.
331
340
 
332
341
  `config.jsonc` (JSONC — comments + trailing commas supported):
333
342
 
334
343
  ```jsonc
335
344
  {
336
- "model": "anthropic/claude-sonnet-5", // default model for the specialists
345
+ "model": "openai/gpt-5.5", // default model for the specialists
337
346
  "policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
338
347
  "chunk": { "maxChangedLines": 1000, "maxFiles": 20, "concurrency": 6 },
339
348
  "noise": { "additionalIgnores": ["packages/*/build/**"] },
@@ -342,8 +351,8 @@ per-repo `noise.additionalIgnores`.
342
351
  "skipLabel": "ai-review:skip" }, // "label" (only labeled PRs)
343
352
  "breakGlass": { "marker": "/skip-review" }, // PR body marker that skips the review
344
353
  "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" }
354
+ "auth": { "mode": "api-key", "provider": "openai",
355
+ "tokenEnv": "OPENAI_API_KEY" }
347
356
  }
348
357
  ```
349
358
 
@@ -357,14 +366,19 @@ Precedence: **`REVIEWER_MODEL` env** (global override) → per-file **frontmatte
357
366
  setup, and a developer can override everything locally.
358
367
 
359
368
  - **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.
369
+ reasoning-tier model — **`openai/gpt-5.5`** is the quality/speed sweet spot
370
+ (the scaffolded default). The **pro tier** finds more but is slower and more
371
+ expensive, so scope it to the highest-stakes agent: **security runs on
372
+ `openai/gpt-5.5-pro`** (set in `security.md` frontmatter), the rest on the default.
364
373
  - **The coordinator** makes the final call (dedupe / re-judge / decide) — worth a
365
374
  strong model; set it in `coordinator.md` frontmatter.
366
375
  - 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).
376
+ (e.g. `openai/gpt-5.4-mini`) is the most direct lever (a real recall tradeoff —
377
+ measure it).
378
+ - **Every run logs which model actually answered each pass** — in the job log
379
+ (`Models used — …`), the Actions step summary table, and the run log's
380
+ `agentModels` — and warns loudly if a pass ran on a different model than
381
+ configured, so a provider-side substitution can never pass unnoticed.
368
382
 
369
383
  There is no automatic cross-provider "equivalent" fallback — that would silently
370
384
  change which model reviewed your code. Use an explicit override instead.
@@ -374,16 +388,32 @@ change which model reviewed your code. Use an explicit override instead.
374
388
  <details>
375
389
  <summary><b>Reliability</b> — never hangs, never silently drops work</summary>
376
390
 
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).
391
+ - **Per-task time caps** — chunk passes 15 min; coordinator 10 min. A global passes
392
+ budget (55 min) bounds all passes incl. the subdivision waves, fitting inside the
393
+ CI job's `timeout-minutes` (90).
394
+ - **The cross-file pass is elastic** — it gets whatever is left of the passes budget
395
+ rather than a fixed cap, because it's the one pass whose scope can't be traded for
396
+ convergence: halving its file set deletes exactly the coverage it exists for. Chunk
397
+ passes run alongside it under their own caps, so a long cross-file pass doesn't
398
+ starve them.
380
399
  - **Tool-call cap** — a pass that makes too many `read`/`grep` calls without
381
400
  finishing is *wandering*, not converging; hitting the cap trips the soft landing.
401
+ The cross-file ceiling scales with the diff's file count (its diffs are inlined, so
402
+ tool calls go to *tracing*, not fetching).
403
+ - **Stall detection** — a pass whose reply stops changing entirely (no new tool call,
404
+ no streamed text or reasoning, no token growth) has a wedged model request, not a
405
+ hard problem. After 4 min of silence it's abandoned and retried once from a clean
406
+ session, inside the same budget — instead of spending the whole cap on a dead
407
+ request. Progress lines say how long a reply has been silent, so this is legible in
408
+ the CI log.
382
409
  - **Soft landing on timeout** — at either cap, the run is interrupted and the agent
383
410
  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
411
+ Tools are disabled for that request, so the salvage step can't resume investigating
412
+ instead of answering.
413
+ - **Subdivide-on-timeout** a reviewer pass that times out with nothing to show has
414
+ its chunk split in half and the halves re-reviewed (recursively, down to a single
415
+ file), then a fast **no-tools fallback** over the inlined diff (the cross-file pass
416
+ skips straight to the fallback, which still sees the whole diff). Only a genuinely
387
417
  un-reducible pass reports a coverage gap — and it is always reported, never silent.
388
418
  - **Parse failures are retried** (same session, then once in a bounded fresh
389
419
  session) — separate from the timeout path.
@@ -448,23 +478,74 @@ visible even in CI (where the run log is ephemeral).
448
478
  <details>
449
479
  <summary><b>Other providers & auth modes</b></summary>
450
480
 
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`
481
+ The recommended setup is an OpenAI API key — see Usage above. Alternatives, all
482
+ set in `config.auth` (credentials come from OpenCode):
483
+
484
+ - **ChatGPT/Codex subscription (OAuth) + usage-based API key the recommended
485
+ mix.** OpenAI permits subscription auth in third-party tools, and OpenCode
486
+ ships the plugin for it so the reviewer runs its default models on the
487
+ subscription (zero marginal cost) and reserves the metered key for pro-tier
488
+ models the subscription doesn't offer (`gpt-5.5-pro` is subscription-excluded).
489
+ Use the per-provider map form:
490
+
491
+ ```jsonc
492
+ "auth": { "providers": {
493
+ "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN" },
494
+ "openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
495
+ } }
496
+ ```
497
+
498
+ `openai-api` is an alias the reviewer synthesizes in the OpenCode config
499
+ (`upstream` names the SDK it's backed by): agents reference `openai-api/gpt-5.5-pro`
500
+ in frontmatter while everything else stays on `openai/gpt-5.5`. Notes:
501
+
502
+ - **The oauth `tokenEnv` holds the refresh token** from an `opencode auth login`
503
+ ChatGPT sign-in (copy `.openai.refresh` out of OpenCode's `auth.json`) —
504
+ access tokens are short-lived, so the refresh token is the durable secret and
505
+ OpenCode mints access tokens on demand. Refresh-token reuse across runs is
506
+ verified, so a static CI secret works.
507
+ - **The API key needs exactly two permissions** — a *Restricted* key with
508
+ *Model capabilities*: **Responses → Request** and **Chat completions →
509
+ Request**; everything else (including *List models*) stays None. Create it
510
+ in a dedicated, budget-capped project. (`ecr setup-auth` prints these
511
+ instructions too.)
512
+ - **In CI**, set the `ECR_EXPECTED_TOKEN_ENV` repo variable to the
513
+ comma-separated set of both env names
514
+ (`CODEX_OAUTH_REFRESH_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
515
+ workflow.
516
+ - **Auditability**: every pass logs which provider/model answered it (job log,
517
+ step summary, run log), so the subscription/API split is visible per run.
518
+ One caveat: OpenCode can't price alias models (they're config-declared), so
519
+ pro passes report `$0` in the run log's cost column — token counts are
520
+ correct, and the OpenAI project dashboard is the source of truth for spend.
521
+ - **Anthropic / Claude (API key)** — set `auth.provider` to `"anthropic"`, point
522
+ `tokenEnv` at the env var holding a Console API key (e.g. `ANTHROPIC_API_KEY`),
523
+ and use `anthropic/...` model ids; the key is sent as `x-api-key`. Note that
524
+ Claude Pro/Max **subscription** tokens cannot be used here: Anthropic prohibits
525
+ them in third-party tools, and OpenCode has no Anthropic OAuth support — only an
526
+ API key works.
527
+ - **Another provider** — the current path is the `REVIEWER_MODEL`
461
528
  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).)*
529
+ e.g. `REVIEWER_MODEL=google/gemini-3-pro`. It overrides every agent's model
530
+ and uses your OpenCode login, so no `auth` block is needed. *(Per-agent
531
+ provider mixing beyond the alias mechanism above is on the
532
+ [roadmap](./ROADMAP.md).)*
466
533
 
467
534
  There is no shared fallback key; if a run fails for lack of credentials, authenticate
468
535
  a provider in OpenCode. `ecr doctor` diagnoses setup.
469
536
 
537
+ **Setup errors fail fast, with the fix in the message.** A bad credential or model id
538
+ would otherwise fail every pass identically — a run that spends its whole budget
539
+ rediscovering one fixable thing, then reports N coverage gaps. So before any pass runs:
540
+
541
+ - **The credential's shape is checked.** OpenCode refuses a malformed credential by
542
+ dropping the provider entirely, which then surfaces as "model not found" for every
543
+ model, with nothing pointing at the credential. A truncated value, surrounding
544
+ whitespace, or a token that can't work for the configured `auth.mode` is rejected
545
+ by name.
546
+ - **Configured model ids are checked against the running server**, so a typo or an id
547
+ the provider doesn't have is reported once, up front, with the close matches.
548
+ - **`ecr doctor` reports the `opencode` version actually in use** and warns when a
549
+ different one is first on your `PATH` — runs use the version this package pins.
550
+
470
551
  </details>
package/build/cli.js CHANGED
@@ -4,6 +4,7 @@ import { dismissCommand } from "./commands/dismiss.js";
4
4
  import { doctorCommand } from "./commands/doctor.js";
5
5
  import { initCommand } from "./commands/init.js";
6
6
  import { reviewCommand } from "./commands/review.js";
7
+ import { setupAuthCommand } from "./commands/setup-auth.js";
7
8
  import { verifyConfigCommand } from "./commands/verify-config.js";
8
9
  const USAGE = `expo-code-review (ecr) — config-driven AI code reviewer
9
10
 
@@ -13,6 +14,7 @@ Usage:
13
14
  ecr dismiss --pr <n> <id...> Hide a finding on a PR (see \`ecr dismiss --help\`).
14
15
  ecr undismiss --pr <n> <id...> Restore a dismissed finding.
15
16
  ecr init [--monorepo] [--scope <dir>] Scaffold .expo-code-review/ in this repo.
17
+ ecr setup-auth [--yes] Walk through getting model credentials for local runs.
16
18
  ecr doctor [--list-scopes] Check environment, config, credentials, and scopes.
17
19
  ecr verify-config [--expected <env>] [--json] Refuse to run if a config could redirect the credential (CI guard).
18
20
 
@@ -47,6 +49,9 @@ async function main() {
47
49
  case "init":
48
50
  await initCommand(rest);
49
51
  break;
52
+ case "setup-auth":
53
+ await setupAuthCommand(rest);
54
+ break;
50
55
  case "doctor":
51
56
  await doctorCommand(rest);
52
57
  break;
@@ -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,12 @@
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
+ import readline from "node:readline/promises";
4
+ import { setupAuthCommand } from "./setup-auth.js";
3
5
  import { checkProviderAuth } from "../core/auth.js";
6
+ import { opencodeBinSource } from "../core/opencode.js";
4
7
  import { git, onPath, repoRoot, run } from "../core/exec.js";
5
8
  import { errorMessage } from "../core/util.js";
9
+ import path from "node:path";
6
10
  const USAGE = `ecr doctor — check environment, config, and credentials
7
11
 
8
12
  Usage:
@@ -16,6 +20,19 @@ ownership over tracked files, and comment-tag uniqueness.
16
20
  Options:
17
21
  --list-scopes Print the routing scope table (name, dir, paths, agents, tag)
18
22
  `;
23
+ /**
24
+ * `opencode --version`, run from `binDir` if given (so the bundled CLI can be asked
25
+ * directly) or from PATH otherwise. Null when it can't be determined — a version we
26
+ * can't read is worth staying quiet about, not failing over.
27
+ */
28
+ async function opencodeVersion(binDir) {
29
+ const command = binDir ? path.join(binDir, "opencode") : "opencode";
30
+ const { stdout, code } = await run(command, ["--version"], { check: false });
31
+ if (code !== 0) {
32
+ return null;
33
+ }
34
+ return stdout.trim().split("\n")[0]?.trim() || null;
35
+ }
19
36
  /** Preflight checks so a broken setup surfaces clearly instead of silently no-opping. */
20
37
  export async function doctorCommand(argv = []) {
21
38
  if (argv.includes("-h") || argv.includes("--help")) {
@@ -47,10 +64,32 @@ export async function doctorCommand(argv = []) {
47
64
  if (process.env.ECR_CONFIG_DIR) {
48
65
  info(`ECR_CONFIG_DIR override active: root config.jsonc and routing.jsonc read from ${resolveConfigDir(root)} (scope subtrees stay repo-root-relative)`);
49
66
  }
50
- const opencodeInstalled = await onPath("opencode");
67
+ // The SDK spawns a bare `opencode`, so the version that actually runs is a PATH
68
+ // lookup. Report which one wins and whether it matches the version this package
69
+ // pins: a stale global install against a newer SDK rejects model ids the SDK
70
+ // considers valid (`ProviderModelNotFoundError`), which is otherwise a baffling
71
+ // failure that only reproduces on one machine. `startOpencode` prepends our own
72
+ // bin dir so the pinned one wins at runtime — this just makes the drift visible.
73
+ const bin = opencodeBinSource();
74
+ const opencodeInstalled = (await onPath("opencode")) || bin.pinned;
51
75
  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)");
76
+ ? "opencode CLI available"
77
+ : "opencode CLI NOT found (install `opencode-ai`, or add node_modules/.bin to PATH)");
78
+ if (opencodeInstalled) {
79
+ const pinnedVersion = bin.pinned ? await opencodeVersion(bin.dir) : null;
80
+ const pathVersion = await opencodeVersion(null);
81
+ if (pinnedVersion) {
82
+ line(true, `opencode ${pinnedVersion} (bundled with this reviewer; used at runtime)`);
83
+ if (pathVersion && pathVersion !== pinnedVersion) {
84
+ warn(`a different opencode ${pathVersion} is first on your PATH — runs use the bundled ${pinnedVersion}, ` +
85
+ `but other tooling (and \`opencode\` by hand) will use ${pathVersion}`);
86
+ }
87
+ }
88
+ else if (pathVersion) {
89
+ warn(`using opencode ${pathVersion} from PATH — this reviewer's own \`opencode-ai\` dependency could not be ` +
90
+ `resolved, so the CLI and SDK versions can drift (a stale CLI rejects model ids the SDK accepts)`);
91
+ }
92
+ }
54
93
  line(await onPath("git"), "git found on PATH");
55
94
  // `gh` is only needed for `ecr ci` (posting PR comments), so treat it as
56
95
  // informational (ℹ) rather than a hard failure for local `ecr review` users.
@@ -84,6 +123,34 @@ export async function doctorCommand(argv = []) {
84
123
  line(rootConfig.agents.every((a) => Boolean(a.promptText.trim())), "all agent prompt files resolved and non-empty");
85
124
  const readiness = checkProviderAuth(rootConfig);
86
125
  line(readiness.ok, `auth: ${readiness.detail}`);
126
+ // A suspicious-but-not-provably-broken credential: worth saying, never a failure
127
+ // (the shape rules are heuristics — see checkOauthTokenShape).
128
+ if (readiness.warning) {
129
+ warn(`auth: ${readiness.warning}`);
130
+ }
131
+ // A missing credential has a guided fix — offer it right here when someone is
132
+ // at the terminal, rather than making them find the command in the README.
133
+ if (!readiness.ok) {
134
+ if (process.stdin.isTTY && process.stdout.isTTY) {
135
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
136
+ let runIt = false;
137
+ try {
138
+ const answer = (await rl.question(" Run `ecr setup-auth` to fix this now? [Y/n] "))
139
+ .trim()
140
+ .toLowerCase();
141
+ runIt = answer === "" || answer === "y" || answer === "yes";
142
+ }
143
+ finally {
144
+ rl.close();
145
+ }
146
+ if (runIt) {
147
+ await setupAuthCommand([]);
148
+ }
149
+ }
150
+ else {
151
+ info("run `ecr setup-auth` for a guided credential setup");
152
+ }
153
+ }
87
154
  }
88
155
  catch (error) {
89
156
  line(false, `config invalid: ${errorMessage(error)}`);
@@ -139,14 +206,18 @@ export async function doctorCommand(argv = []) {
139
206
  // auth singleton: exactly one honored source (defaults.auth or root config auth).
140
207
  const auth = loadAuthFromRoot(rootConfig, manifest);
141
208
  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}"`);
209
+ line(true, `auth singleton: honored from ${hasManifestAuth ? "routing.jsonc defaults.auth" : "root config.jsonc"} ` +
210
+ `(${auth.map((entry) => `${entry.mode}/${entry.provider}`).join(", ")})`);
211
+ const expected = process.env.ECR_EXPECTED_TOKEN_ENV;
212
+ if (expected) {
213
+ const mismatch = tokenEnvMismatch(auth, expected);
214
+ if (mismatch) {
215
+ line(false, `auth: ${mismatch}`);
147
216
  }
148
- else {
149
- line(Boolean(process.env[auth.tokenEnv]), `auth token env ${auth.tokenEnv} is ${process.env[auth.tokenEnv] ? "set" : "NOT set"}`);
217
+ }
218
+ for (const entry of auth) {
219
+ if (entry.tokenEnv) {
220
+ line(Boolean(process.env[entry.tokenEnv]), `auth token env ${entry.tokenEnv} (${entry.provider}) is ${process.env[entry.tokenEnv] ? "set" : "NOT set"}`);
150
221
  }
151
222
  }
152
223
  // Owner-table dry run over tracked files (graft 4).