@expo/code-review-cli 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -12
- package/build/commands/ci.js +8 -8
- package/build/commands/doctor.js +167 -33
- package/build/commands/setup-auth.js +83 -11
- package/build/config/schema.js +7 -3
- package/build/core/auth.js +122 -9
- package/build/core/claude-code.js +680 -0
- package/build/core/exec.js +278 -9
- package/build/core/opencode.js +95 -15
- package/build/core/prompts.js +19 -2
- package/build/core/render.js +21 -2
- package/build/core/review.js +158 -25
- package/build/core/schema.js +6 -1
- package/build/core/scrub.js +59 -1
- package/build/core/throttle.js +10 -0
- package/build/core/util.js +17 -0
- package/build/core/verify.js +13 -1
- package/build/reporters/github.js +79 -13
- package/build/sources/github-pr.js +14 -7
- package/build/sources/local-git.js +3 -2
- package/package.json +3 -3
- package/templates/config.jsonc +21 -3
- package/templates/shared.md +28 -0
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
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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.
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
574
|
-
the provider doesn't have is reported once, up front,
|
|
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
|
|
package/build/commands/ci.js
CHANGED
|
@@ -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 (${
|
|
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> ${
|
|
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
|
-
|
|
451
|
-
|
|
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
|
|
527
|
+
const gh = await resolveTrustedTool("gh");
|
|
528
|
+
const { stdout } = await run(gh, [
|
|
529
529
|
"pr",
|
|
530
530
|
"view",
|
|
531
531
|
String(prNumber),
|
package/build/commands/doctor.js
CHANGED
|
@@ -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 {
|
|
7
|
-
import {
|
|
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:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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(
|
|
29
|
-
const
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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 &&
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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";
|
package/build/config/schema.js
CHANGED
|
@@ -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).
|
|
80
|
-
// anthropic
|
|
81
|
-
//
|
|
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
|