@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 CHANGED
@@ -110,7 +110,7 @@ is a ready example to adapt.
110
110
  | `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
111
111
  | `ecr init --monorepo` | …and add a `routing.jsonc` routing manifest (one default scope). |
112
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. |
113
+ | `ecr setup-auth [--yes]` | Walk through getting model credentials for local runs (ChatGPT/Claude sign-in and/or API keys), printing the `export` lines for your shell config. |
114
114
  | `ecr review [options]` | Review local changes and print an advisory review (default command). |
115
115
  | `ecr review --scope <name>` | Review only one routing scope over just that scope's changed files. |
116
116
  | `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
@@ -545,18 +545,36 @@ set in `config.auth` (credentials come from OpenCode):
545
545
  One caveat: OpenCode can't price alias models (they're config-declared), so
546
546
  pro passes report `$0` in the run log's cost column — token counts are
547
547
  correct, and the OpenAI project dashboard is the source of truth for spend.
548
- - **Anthropic / Claude (API key)** — set `auth.provider` to `"anthropic"`, point
549
- `tokenEnv` at the env var holding a Console API key (e.g. `ANTHROPIC_API_KEY`),
550
- and use `anthropic/...` model ids; the key is sent as `x-api-key`. Note that
551
- Claude Pro/Max **subscription** tokens cannot be used here: Anthropic prohibits
552
- them in third-party tools, and OpenCode has no Anthropic OAuth support — only an
553
- API key works.
548
+ - **Anthropic / Claude** — use `anthropic/...` model ids and every anthropic pass
549
+ runs through the **Claude Code CLI** (`claude -p --output-format json`), inferred
550
+ from the model. The credential is (in order) a `tokenEnv` you name, an ambient
551
+ `CLAUDE_CODE_OAUTH_TOKEN`, or your local `claude` login an `auth` entry is
552
+ entirely optional. Run `claude setup-token` for a Max/Team subscription token
553
+ (forwarded as `CLAUDE_CODE_OAUTH_TOKEN`) or point `tokenEnv` at an Anthropic
554
+ Console API key (`sk-ant-api…`, forwarded as `ANTHROPIC_API_KEY`); the CLI reads
555
+ either. `ecr setup-auth` walks you through it. Each pass is trust-isolated and
556
+ read-only: it runs with `--safe-mode` (no `CLAUDE.md`/hooks/MCP/plugins),
557
+ `--strict-mcp-config`, `--permission-mode dontAsk`, and only the
558
+ `Read`/`Grep`/`Glob` tools — never `Bash`/`Edit`/`Write`/`WebFetch`/`WebSearch`.
559
+ The child env is an allowlist that omits ambient `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`
560
+ (only the configured credential is re-injected).
561
+ ```jsonc
562
+ // Optional — no anthropic entry at all falls back to your `claude` login.
563
+ "auth": { "providers": {
564
+ "anthropic": { "tokenEnv": "CLAUDE_CODE_OAUTH_TOKEN" }
565
+ } }
566
+ ```
554
567
  - **Another provider** — the current path is the `REVIEWER_MODEL`
555
568
  env override: `opencode auth login` once (pick the provider), then run with
556
569
  e.g. `REVIEWER_MODEL=google/gemini-3-pro`. It overrides every agent's model
557
- and uses your OpenCode login, so no `auth` block is needed. *(Per-agent
558
- provider mixing beyond the alias mechanism above is on the
559
- [roadmap](./ROADMAP.md).)*
570
+ and uses your OpenCode login, so no `auth` block is needed.
571
+
572
+ Engines are inferred **per agent** from that agent's resolved model alone: an
573
+ `anthropic/…` agent runs through the Claude Code CLI while other agents run through
574
+ OpenCode — in the SAME run. So an anthropic model may coexist with an `openai` (or
575
+ any other) OpenCode provider, and each agent's `model` selects its engine.
576
+ `REVIEWER_MODEL` still overrides every agent's model (and therefore every agent's
577
+ engine), converging the whole run onto one engine.
560
578
 
561
579
  There is no shared fallback key; if a run fails for lack of credentials, authenticate
562
580
  a provider in OpenCode. `ecr doctor` diagnoses setup.
@@ -570,8 +588,11 @@ rediscovering one fixable thing, then reports N coverage gaps. So before any pas
570
588
  model, with nothing pointing at the credential. A truncated value, surrounding
571
589
  whitespace, or a token that can't work for the configured `auth.mode` is rejected
572
590
  by name.
573
- - **Configured model ids are checked against the running server**, so a typo or an id
574
- the provider doesn't have is reported once, up front, with the close matches.
591
+ - **Configured model ids for OpenCode-routed providers are checked against the running
592
+ server**, so a typo or an id the provider doesn't have is reported once, up front,
593
+ with the close matches. `anthropic/…` (Claude Code) model ids aren't checked up
594
+ front — Claude validates them per-request, so a typo there surfaces as a per-pass
595
+ error instead.
575
596
  - **`ecr doctor` reports the `opencode` version actually in use** and warns when a
576
597
  different one is first on your `PATH` — runs use the version this package pins.
577
598
 
@@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { CONFIG_DIRNAME, hasScopeConfig, loadAuthFromRoot, loadReviewConfig, loadScopeConfig, tokenEnvMismatch, } from "../config/load.js";
4
4
  import { loadRoutingManifest, resolveScopes, scopedCommentTag, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
5
- import { repoRoot, run } from "../core/exec.js";
6
- import { errorMessage } from "../core/util.js";
5
+ import { repoRoot, resolveTrustedTool, run } from "../core/exec.js";
6
+ import { errorMessage, publicFailureReason } from "../core/util.js";
7
7
  import { buildDiffLineIndex } from "../core/render.js";
8
8
  import { runReview } from "../core/review.js";
9
9
  import { GitHubPRSource } from "../sources/github-pr.js";
@@ -134,7 +134,7 @@ export async function ciCommand(argv = []) {
134
134
  const reason = errorMessage(error);
135
135
  process.stderr.write(`CI reviewer: could not materialize the PR's base commit for trusted configuration ` +
136
136
  `(failing closed, not reviewing): ${reason}\n`);
137
- await postTerminalFailureNote(repo, prNumber, cwd, `it could not load trusted configuration from the PR's base commit (${reason}). ` +
137
+ await postTerminalFailureNote(repo, prNumber, cwd, `it could not load trusted configuration from the PR's base commit (${publicFailureReason(error)}). ` +
138
138
  `This usually means the runner has no git checkout or no usable GH_TOKEN; re-run once fixed`);
139
139
  return;
140
140
  }
@@ -285,7 +285,7 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
285
285
  await reporter.report({
286
286
  decision: "approve_with_comments",
287
287
  findings: [],
288
- summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed:\n\n> ${reason}`,
288
+ summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed:\n\n> ${publicFailureReason(error)}`,
289
289
  incomplete: [],
290
290
  });
291
291
  }
@@ -447,9 +447,8 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
447
447
  });
448
448
  }
449
449
  catch (error) {
450
- const reason = errorMessage(error);
451
- process.stderr.write(`CI reviewer: [${scope.name}] failed (non-blocking): ${reason}\n`);
452
- review = failureReview(scope.name, reason);
450
+ process.stderr.write(`CI reviewer: [${scope.name}] failed (non-blocking): ${errorMessage(error)}\n`);
451
+ review = failureReview(scope.name, publicFailureReason(error));
453
452
  }
454
453
  results.push({ scope: scope.name, isDefault, review });
455
454
  }
@@ -525,7 +524,8 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
525
524
  */
526
525
  async function fetchPrLabels(repo, prNumber, cwd) {
527
526
  try {
528
- const { stdout } = await run("gh", [
527
+ const gh = await resolveTrustedTool("gh");
528
+ const { stdout } = await run(gh, [
529
529
  "pr",
530
530
  "view",
531
531
  String(prNumber),
@@ -3,36 +3,72 @@ import { loadRoutingManifest, resolveScopes, scopePassesBudgetMs, formatOwnerTab
3
3
  import readline from "node:readline/promises";
4
4
  import { setupAuthCommand } from "./setup-auth.js";
5
5
  import { checkProviderAuth } from "../core/auth.js";
6
- import { opencodeBinSource } from "../core/opencode.js";
7
- import { git, onPath, repoRoot, run } from "../core/exec.js";
6
+ import { claudeSubscriptionActive, claudeTokenCredential, engineForModel, resolveClaudeCli, } from "../core/claude-code.js";
7
+ import { CLAUDE_CODE_ENGINE, opencodeBinSource } from "../core/opencode.js";
8
+ import { git, onPath, pathInside, repoRoot, resolveOnPath, resolveTrustedTool, run, } from "../core/exec.js";
8
9
  import { errorMessage } from "../core/util.js";
10
+ import { tmpdir } from "node:os";
9
11
  import path from "node:path";
10
12
  const USAGE = `ecr doctor — check environment, config, and credentials
11
13
 
12
14
  Usage:
13
15
  ecr doctor [--list-scopes]
14
16
 
15
- Verifies: opencode + git (+ gh for \`ecr ci\`) on PATH, .expo-code-review/ config is
16
- valid, agent prompts resolve, and the configured model's token env is set. When a
17
- routing.jsonc is present, also validates every scope, the auth singleton, scope
18
- ownership over tracked files, and comment-tag uniqueness.
17
+ Verifies: the engines the config's models actually use opencode on PATH for
18
+ non-anthropic models, and for anthropic models the \`claude\` CLI plus a usable
19
+ Claude credential (subscription login or token env) plus git (+ gh for
20
+ \`ecr ci\`), that .expo-code-review/ config is valid, agent prompts resolve, and
21
+ the configured model's token env is set. When a routing.jsonc is present, also
22
+ validates every scope, the auth singleton, scope ownership over tracked files,
23
+ and comment-tag uniqueness.
19
24
 
20
25
  Options:
21
26
  --list-scopes Print the routing scope table (name, dir, paths, agents, tag)
22
27
  `;
23
28
  /**
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.
29
+ * `<cliPath> --version`, run from tmpdir() (never the inherited, possibly untrusted
30
+ * cwd). `cliPath` is an already-resolved absolute path doctor may run inside a cloned
31
+ * untrusted repo, so the binary is resolved-and-checked before it reaches here, never a
32
+ * bare `opencode`. Null when it can't be determined — a version we can't read is worth
33
+ * staying quiet about, not failing over.
27
34
  */
28
- async function opencodeVersion(binDir) {
29
- const command = binDir ? path.join(binDir, "opencode") : "opencode";
30
- const { stdout, code } = await run(command, ["--version"], { check: false });
35
+ async function opencodeVersion(cliPath) {
36
+ const { stdout, code } = await run(cliPath, ["--version"], { check: false, cwd: tmpdir() });
31
37
  if (code !== 0) {
32
38
  return null;
33
39
  }
34
40
  return stdout.trim().split("\n")[0]?.trim() || null;
35
41
  }
42
+ /**
43
+ * The engines this repo actually drives, mirroring how real reviews resolve them:
44
+ * engineForModel over every ROOT agent + coordinator model, PLUS every loaded scope
45
+ * config's agents + coordinator. A routed scope can select an anthropic/… model while
46
+ * the root config is OpenCode-only, so without folding scopes in doctor would report
47
+ * success without ever checking the Claude CLI/login the scoped review needs. A scope
48
+ * that fails to load is skipped here — the scope-validation block reports it with the
49
+ * full error. Exported for tests.
50
+ */
51
+ export async function resolveEngines(root, rootConfig, manifest) {
52
+ const engines = new Set();
53
+ const add = (config) => {
54
+ for (const agent of config.agents) {
55
+ engines.add(engineForModel(agent.model));
56
+ }
57
+ engines.add(engineForModel(config.coordinator.model));
58
+ };
59
+ add(rootConfig);
60
+ if (manifest) {
61
+ for (const scope of manifest.scopes) {
62
+ try {
63
+ add(await loadScopeConfig(root, scope, manifest, rootConfig));
64
+ }
65
+ catch {
66
+ // Malformed scope config: reported by the scope-validation block below.
67
+ }
68
+ }
69
+ }
70
+ return engines;
71
+ }
36
72
  /** Preflight checks so a broken setup surfaces clearly instead of silently no-opping. */
37
73
  export async function doctorCommand(argv = []) {
38
74
  if (argv.includes("-h") || argv.includes("--help")) {
@@ -64,30 +100,74 @@ export async function doctorCommand(argv = []) {
64
100
  if (process.env.ECR_CONFIG_DIR) {
65
101
  info(`ECR_CONFIG_DIR override active: root config.jsonc and routing.jsonc read from ${resolveConfigDir(root)} (scope subtrees stay repo-root-relative)`);
66
102
  }
103
+ // Peek at the config to resolve the engine BEFORE the opencode checks: a
104
+ // claude-code-only repo never touches OpenCode, and a missing `opencode` CLI
105
+ // must not fail its doctor run. Load errors are swallowed here — the config
106
+ // block below reports them properly and defaults the engine to OpenCode.
107
+ let rootConfig;
108
+ if (hasConfig(root)) {
109
+ try {
110
+ rootConfig = await loadReviewConfig(root);
111
+ }
112
+ catch {
113
+ // reported by the config block below
114
+ }
115
+ }
116
+ // Engines actually in use by this config: run engineForModel over every agent
117
+ // model + the coordinator model, ACROSS the root config AND every routed scope.
118
+ // Both blocks below may fire in one run (a mixed config drives OpenCode and the
119
+ // Claude Code CLI at once).
120
+ let engines = new Set(["opencode"]);
121
+ // Auth as real reviews resolve it (loadScopeConfig/`ecr ci`): a routing.jsonc
122
+ // `defaults.auth` OVERRIDES the root config's auth, so the Claude credential
123
+ // checks below must use the overridden value or a monorepo whose manifest swaps
124
+ // the anthropic entry gets the wrong checks here. (The engine set is model-only.)
125
+ let resolvedAuth = rootConfig?.auth ?? [];
126
+ if (rootConfig) {
127
+ try {
128
+ const manifest = await loadRoutingManifest(root);
129
+ resolvedAuth = loadAuthFromRoot(rootConfig, manifest);
130
+ engines = await resolveEngines(root, rootConfig, manifest);
131
+ }
132
+ catch {
133
+ // malformed manifest/auth: reported by the blocks below; keep the opencode default
134
+ engines = new Set(["opencode"]);
135
+ }
136
+ }
67
137
  // The SDK spawns a bare `opencode`, so the version that actually runs is a PATH
68
138
  // lookup. Report which one wins and whether it matches the version this package
69
139
  // pins: a stale global install against a newer SDK rejects model ids the SDK
70
140
  // considers valid (`ProviderModelNotFoundError`), which is otherwise a baffling
71
141
  // failure that only reproduces on one machine. `startOpencode` prepends our own
72
142
  // 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;
75
- line(opencodeInstalled, opencodeInstalled
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}`);
143
+ if (!engines.has("opencode")) {
144
+ info("OpenCode is not used by this config (claude-code engine) skipping its checks");
145
+ }
146
+ else {
147
+ const bin = opencodeBinSource();
148
+ const opencodeInstalled = (await onPath("opencode")) || bin.pinned;
149
+ line(opencodeInstalled, opencodeInstalled
150
+ ? "opencode CLI available"
151
+ : "opencode CLI NOT found (install `opencode-ai`, or add node_modules/.bin to PATH)");
152
+ if (opencodeInstalled) {
153
+ const pinnedVersion = bin.dir ? await opencodeVersion(path.join(bin.dir, "opencode")) : null;
154
+ // PATH's `opencode`, resolved from a trusted cwd (resolveOnPath) with an in-tree
155
+ // refusal: doctor may run in a cloned untrusted repo, so this drift probe must
156
+ // never execute a bare name against the inherited cwd (a committed shim would win
157
+ // on Windows). onPath above only tests existence — this is the executed one.
158
+ const pathCli = await resolveOnPath("opencode");
159
+ const pathVersion = pathCli && !pathInside(pathCli, process.cwd()) ? await opencodeVersion(pathCli) : null;
160
+ if (pinnedVersion) {
161
+ line(true, `opencode ${pinnedVersion} (bundled with this reviewer; used at runtime)`);
162
+ if (pathVersion && pathVersion !== pinnedVersion) {
163
+ warn(`a different opencode ${pathVersion} is first on your PATH — runs use the bundled ${pinnedVersion}, ` +
164
+ `but other tooling (and \`opencode\` by hand) will use ${pathVersion}`);
165
+ }
166
+ }
167
+ else if (pathVersion) {
168
+ warn(`using opencode ${pathVersion} from PATH — this reviewer's own \`opencode-ai\` dependency could not be ` +
169
+ `resolved, so the CLI and SDK versions can drift (a stale CLI rejects model ids the SDK accepts)`);
86
170
  }
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
171
  }
92
172
  }
93
173
  line(await onPath("git"), "git found on PATH");
@@ -96,7 +176,10 @@ export async function doctorCommand(argv = []) {
96
176
  if (await onPath("gh")) {
97
177
  let authed = false;
98
178
  try {
99
- await run("gh", ["auth", "status"], { cwd: root });
179
+ // resolveTrustedTool refuses an in-tree `gh` (throws) caught here and treated
180
+ // as "found but not authenticated", keeping this probe informational, not fatal.
181
+ const gh = await resolveTrustedTool("gh");
182
+ await run(gh, ["auth", "status"], { cwd: root });
100
183
  authed = true;
101
184
  }
102
185
  catch {
@@ -112,15 +195,20 @@ export async function doctorCommand(argv = []) {
112
195
  else {
113
196
  info("gh CLI not on PATH — only needed for `ecr ci` (posting PR comments)");
114
197
  }
115
- let rootConfig;
116
198
  if (!hasConfig(root)) {
117
199
  line(false, `no ${".expo-code-review"}/config.jsonc (run \`ecr init\`)`);
118
200
  }
119
201
  else {
120
202
  try {
121
- rootConfig = await loadReviewConfig(root);
203
+ // Reuse the engine-detection peek above; re-load only if that failed so
204
+ // the error surfaces here with full reporting.
205
+ rootConfig ??= await loadReviewConfig(root);
122
206
  line(true, `config valid: ${rootConfig.agents.length} agent(s) [${rootConfig.agents.map((a) => a.id).join(", ")}], coordinator model ${rootConfig.coordinator.model}`);
123
207
  line(rootConfig.agents.every((a) => Boolean(a.promptText.trim())), "all agent prompt files resolved and non-empty");
208
+ // Set when the Claude Code engine has no usable credential. checkProviderAuth
209
+ // (below) returns ok:true for every anthropic shape (login fallback), so this
210
+ // is the only signal that flips the exit code and offers the setup-auth fix.
211
+ let claudeCredentialMissing = false;
124
212
  const readiness = checkProviderAuth(rootConfig);
125
213
  line(readiness.ok, `auth: ${readiness.detail}`);
126
214
  // A suspicious-but-not-provably-broken credential: worth saying, never a failure
@@ -128,9 +216,55 @@ export async function doctorCommand(argv = []) {
128
216
  if (readiness.warning) {
129
217
  warn(`auth: ${readiness.warning}`);
130
218
  }
219
+ // Claude Code engine: the `opencode` block above is not load-bearing for
220
+ // these configs, so check the `claude` CLI + subscription login instead.
221
+ try {
222
+ if (engines.has(CLAUDE_CODE_ENGINE)) {
223
+ // Resolve to a trusted absolute path (and refuse an in-tree binary), never a
224
+ // bare `claude`: doctor may run inside a cloned untrusted repo, so a
225
+ // PR-committed shim must not be the thing we probe. Same resolution the
226
+ // review engine uses.
227
+ const claudeCliPath = await resolveClaudeCli();
228
+ line(Boolean(claudeCliPath), claudeCliPath
229
+ ? "claude CLI available (Claude Code engine)"
230
+ : "claude CLI NOT found (npm i -g @anthropic-ai/claude-code, then `claude setup-token`)");
231
+ if (claudeCliPath) {
232
+ const version = await run(claudeCliPath, ["--version"], {
233
+ check: false,
234
+ cwd: tmpdir(),
235
+ });
236
+ if (version.code === 0) {
237
+ line(true, `claude ${version.stdout.trim().split("\n")[0]?.trim()}`);
238
+ }
239
+ // Mirror startClaudeCode's credential condition EXACTLY so doctor fails
240
+ // iff a review would: a run needs an active `claude` subscription login OR
241
+ // a token value (the configured tokenEnv, else an ambient
242
+ // CLAUDE_CODE_OAUTH_TOKEN). warn() never flips the exit code, so a missing
243
+ // credential must be a line(false) here, not a ⚠.
244
+ const claudeEntry = resolvedAuth.find((a) => a.provider === "anthropic");
245
+ const hasTokenCredential = Boolean(claudeTokenCredential(claudeEntry));
246
+ const subscriptionActive = await claudeSubscriptionActive({ cliPath: claudeCliPath });
247
+ if (subscriptionActive) {
248
+ info("subscription login: Claude Max/Team account");
249
+ }
250
+ else if (hasTokenCredential) {
251
+ const src = claudeEntry?.tokenEnv ?? "CLAUDE_CODE_OAUTH_TOKEN";
252
+ info(`Claude credential: token env ${src} supplies the OAuth token (no active \`claude\` subscription login)`);
253
+ }
254
+ else {
255
+ claudeCredentialMissing = true;
256
+ line(false, "no Claude credential: no active `claude` subscription login and no token env " +
257
+ "value set — run `ecr setup-auth` (or `claude setup-token`)");
258
+ }
259
+ }
260
+ }
261
+ }
262
+ catch (error) {
263
+ line(false, `auth engine: ${errorMessage(error)}`);
264
+ }
131
265
  // A missing credential has a guided fix — offer it right here when someone is
132
266
  // at the terminal, rather than making them find the command in the README.
133
- if (!readiness.ok) {
267
+ if (!readiness.ok || claudeCredentialMissing) {
134
268
  if (process.stdin.isTTY && process.stdout.isTTY) {
135
269
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
136
270
  let runIt = false;
@@ -5,7 +5,8 @@ import path from "node:path";
5
5
  import readline from "node:readline/promises";
6
6
  import { hasConfig, loadReviewConfig } from "../config/load.js";
7
7
  import { jwtExpiryMs } from "../core/auth.js";
8
- import { opencodeBinSource } from "../core/opencode.js";
8
+ import { claudeSubscriptionActive, resolveClaudeCli } from "../core/claude-code.js";
9
+ import { resolveOpencodeCli } from "../core/opencode.js";
9
10
  import { errorMessage } from "../core/util.js";
10
11
  const USAGE = `ecr setup-auth — set up model credentials for local runs
11
12
 
@@ -15,6 +16,10 @@ getting each credential:
15
16
  \`opencode auth login\` (interactive; opens your browser), then prints the
16
17
  \`export <tokenEnv>=…\` line to add to your shell config. An existing
17
18
  OpenCode ChatGPT sign-in is reused instead of re-authenticating.
19
+ • a Claude Max/Team subscription (any anthropic/… model): reuses an active
20
+ \`claude\` login when present, or runs \`claude setup-token\` (interactive;
21
+ opens your browser) and prints the \`export <tokenEnv>=…\` line for
22
+ CI/headless runs.
18
23
  • an API key (api-key entries): prints where to create the key, the exact
19
24
  permissions it needs, and the export line to fill in.
20
25
 
@@ -24,10 +29,21 @@ with the default env name.
24
29
  Options:
25
30
  --yes Skip confirmation prompts (still interactive during the login itself).
26
31
  `;
27
- export function planFromAuth(auth) {
32
+ export function planFromAuth(auth, models = []) {
28
33
  const plan = { manualKeys: [], unsupported: [] };
34
+ // anthropic is always served by the Claude Code CLI — a `claude setup-token`
35
+ // subscription login covers it. Trigger on either an explicit anthropic auth
36
+ // entry OR any anthropic/… model in the roster (an entry is entirely optional).
37
+ const anthropicEntry = auth.find((entry) => entry.provider === "anthropic");
38
+ const usesAnthropicModel = models.some((model) => model === "anthropic" || model.startsWith("anthropic/"));
39
+ if (anthropicEntry || usesAnthropicModel) {
40
+ plan.claudeLogin = { tokenEnv: anthropicEntry?.tokenEnv ?? "CLAUDE_CODE_OAUTH_TOKEN" };
41
+ }
29
42
  for (const entry of auth) {
30
- if (entry.mode === "oauth" && entry.provider === "openai" && entry.tokenEnv) {
43
+ if (entry.provider === "anthropic") {
44
+ continue; // handled above (claude engine); mode is irrelevant here.
45
+ }
46
+ else if (entry.mode === "oauth" && entry.provider === "openai" && entry.tokenEnv) {
31
47
  plan.chatgptLogin = { tokenEnv: entry.tokenEnv };
32
48
  }
33
49
  else if (entry.mode === "api-key" && entry.tokenEnv) {
@@ -104,7 +120,10 @@ export async function setupAuthCommand(argv = []) {
104
120
  let plan;
105
121
  if (hasConfig(process.cwd())) {
106
122
  const config = await loadReviewConfig(process.cwd());
107
- plan = planFromAuth(config.auth);
123
+ plan = planFromAuth(config.auth, [
124
+ ...config.agents.map((agent) => agent.model),
125
+ config.coordinator.model,
126
+ ]);
108
127
  }
109
128
  else {
110
129
  err("No .expo-code-review config here — setting up the default ChatGPT/Codex flow.");
@@ -112,11 +131,55 @@ export async function setupAuthCommand(argv = []) {
112
131
  { provider: "openai", mode: "oauth", tokenEnv: "CODEX_OAUTH_ACCESS_TOKEN" },
113
132
  ]);
114
133
  }
115
- if (!plan.chatgptLogin && plan.manualKeys.length === 0 && plan.unsupported.length === 0) {
134
+ if (!plan.chatgptLogin &&
135
+ !plan.claudeLogin &&
136
+ plan.manualKeys.length === 0 &&
137
+ plan.unsupported.length === 0) {
116
138
  out("This repo's auth config needs no local credential setup (OpenCode's own login covers it).");
117
139
  return;
118
140
  }
119
141
  const exports = [];
142
+ if (plan.claudeLogin) {
143
+ const { tokenEnv } = plan.claudeLogin;
144
+ if (process.env[tokenEnv]) {
145
+ err(`✓ ${tokenEnv} is already set in this shell — skipping the Claude subscription login.`);
146
+ }
147
+ else {
148
+ // Resolve to a trusted absolute path (refusing an in-tree binary), never a
149
+ // bare `claude`: setup-auth may run inside a cloned untrusted repo, so a
150
+ // PR-committed shim must not be the `claude` we probe or hand the terminal to.
151
+ const claudeCliPath = await resolveClaudeCli();
152
+ // A live local `claude` login already covers interactive runs — only CI or
153
+ // a headless box needs the token in an env var.
154
+ const loggedIn = await claudeSubscriptionActive({ cliPath: claudeCliPath ?? undefined });
155
+ if (loggedIn) {
156
+ err("✓ A Claude Max/Team subscription login is active locally — `ecr review` works now. " +
157
+ `You only need ${tokenEnv} for CI/headless runs.`);
158
+ }
159
+ err("`claude setup-token` mints a 1-year subscription token (opens your browser).");
160
+ if (!(await confirm("Run it now?", yes))) {
161
+ err("Skipped `claude setup-token`.");
162
+ }
163
+ else if (!claudeCliPath) {
164
+ throw new Error("The `claude` CLI is not installed on this host (npm i -g " +
165
+ "@anthropic-ai/claude-code); nothing was changed.");
166
+ }
167
+ else {
168
+ const result = spawnSync(claudeCliPath, ["setup-token"], {
169
+ stdio: "inherit",
170
+ cwd: os.tmpdir(),
171
+ });
172
+ if (result.status !== 0) {
173
+ throw new Error(`\`claude setup-token\` exited with ${result.status ?? "a signal"}; nothing was changed.`);
174
+ }
175
+ // setup-token prints the token to the terminal and persists it nowhere we
176
+ // can read back, so the user pastes it into the export line themselves.
177
+ err(`Copy the token \`claude setup-token\` just printed and paste it in place of the ` +
178
+ `placeholder below.`);
179
+ exports.push(exportLine(tokenEnv, "<paste the token setup-token printed>"));
180
+ }
181
+ }
182
+ }
120
183
  if (plan.chatgptLogin) {
121
184
  const { tokenEnv } = plan.chatgptLogin;
122
185
  if (process.env[tokenEnv]) {
@@ -141,9 +204,20 @@ export async function setupAuthCommand(argv = []) {
141
204
  err("Skipped the ChatGPT sign-in.");
142
205
  }
143
206
  else {
144
- const binDir = opencodeBinSource().dir;
145
- const opencode = binDir ? path.join(binDir, "opencode") : "opencode";
146
- const result = spawnSync(opencode, ["auth", "login"], { stdio: "inherit" });
207
+ // Resolve to a trusted absolute path (our bundled shim, else PATH with an
208
+ // in-tree refusal), never a bare `opencode`: setup-auth may run inside a
209
+ // cloned untrusted repo, so a PR-committed shim must not be the CLI we hand
210
+ // the terminal to. Run from tmpdir(), never the (possibly untrusted) cwd —
211
+ // the login writes to OpenCode's global auth store, not the working dir.
212
+ const opencodeCli = await resolveOpencodeCli();
213
+ if (!opencodeCli) {
214
+ throw new Error("The `opencode` CLI is not available (install `opencode-ai`, or add " +
215
+ "node_modules/.bin to PATH); nothing was changed.");
216
+ }
217
+ const result = spawnSync(opencodeCli, ["auth", "login"], {
218
+ stdio: "inherit",
219
+ cwd: os.tmpdir(),
220
+ });
147
221
  if (result.status !== 0) {
148
222
  throw new Error(`\`opencode auth login\` exited with ${result.status ?? "a signal"}; nothing was changed.`);
149
223
  }
@@ -190,9 +264,7 @@ export async function setupAuthCommand(argv = []) {
190
264
  for (const entry of plan.unsupported) {
191
265
  err("");
192
266
  err(`auth for "${entry.provider}" is mode "oauth", which has no automated setup flow here` +
193
- (entry.provider === "anthropic"
194
- ? " — and cannot work: Anthropic prohibits subscription tokens in third-party tools. Use an API key instead."
195
- : `. Set ${entry.tokenEnv ?? "its token env"} manually.`));
267
+ `. Set ${entry.tokenEnv ?? "its token env"} manually.`);
196
268
  }
197
269
  if (exports.length > 0) {
198
270
  const rc = process.env.SHELL?.includes("zsh") ? "~/.zshrc" : "your shell config";
@@ -76,9 +76,13 @@ export const ReviewConfigSchema = z.object({
76
76
  // "oauth": tokenEnv holds an OAuth token, injected into an isolated
77
77
  // OpenCode auth.json. For "openai" this is the REFRESH token from a
78
78
  // ChatGPT/Codex sign-in (OpenCode's codex plugin mints access tokens
79
- // from it). NOTE: anthropic oauth cannot work — OpenCode has no
80
- // anthropic OAuth plugin and Anthropic prohibits subscription tokens
81
- // in third-party tools.
79
+ // from it).
80
+ // NOTE: provider "anthropic" is ALWAYS served by the Claude Code CLI
81
+ // (the engine is inferred from the `anthropic/…` model, not this mode) —
82
+ // for anthropic, mode is irrelevant; tokenEnv optionally names the
83
+ // credential env (an "sk-ant-oat…" subscription token or an Anthropic
84
+ // API key), and no entry at all falls back to the machine's `claude`
85
+ // login. See core/claude-code.ts.
82
86
  mode: z.enum(["api-key", "oauth"]).default("api-key"),
83
87
  tokenEnv: z.string().optional(),
84
88
  // Set ⇒ this provider id is an ALIAS synthesized into the OpenCode