@expo/code-review-cli 0.6.0 → 0.8.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 +151 -25
- package/build/cli.js +7 -0
- package/build/commands/ci.js +307 -36
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +170 -33
- package/build/commands/feedback.js +433 -0
- package/build/commands/init.js +231 -15
- package/build/commands/review.js +191 -51
- package/build/commands/setup-auth.js +86 -11
- package/build/commands/verify-config.js +3 -0
- package/build/config/load.js +39 -0
- package/build/config/routing.js +7 -0
- package/build/config/schema.js +99 -3
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +127 -10
- package/build/core/claude-code.js +691 -0
- package/build/core/context-file.js +42 -0
- package/build/core/coordinator.js +2 -2
- package/build/core/diff.js +1 -0
- package/build/core/exec.js +282 -9
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +117 -15
- package/build/core/prompts.js +330 -5
- package/build/core/render.js +274 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +447 -39
- package/build/core/schema.js +219 -3
- package/build/core/scrub.js +63 -1
- package/build/core/stack-confirm.js +137 -0
- package/build/core/stack.js +25 -0
- package/build/core/step-summary.js +1 -0
- package/build/core/suppress.js +2 -0
- package/build/core/throttle.js +12 -0
- package/build/core/util.js +18 -0
- package/build/core/verify.js +18 -1
- package/build/reporters/github.js +544 -44
- package/build/reporters/terminal.js +2 -0
- package/build/sources/github-pr.js +286 -7
- package/build/sources/local-git.js +6 -2
- package/build/sources/source.js +35 -0
- package/package.json +4 -3
- package/templates/agents/consistency.md +2 -0
- package/templates/agents/correctness.md +2 -0
- package/templates/agents/security.md +3 -0
- package/templates/atlantis.yml +123 -0
- package/templates/command.yml +4 -0
- package/templates/config.jsonc +71 -4
- package/templates/coordinator.md +34 -9
- package/templates/dismiss.yml +4 -0
- package/templates/routing.jsonc +3 -0
- package/templates/scope-config.jsonc +1 -0
- package/templates/shared.md +124 -1
- package/templates/workflow.yml +5 -0
package/build/commands/doctor.js
CHANGED
|
@@ -1,38 +1,76 @@
|
|
|
1
|
+
// @ref LLP 0007#doctor-and-setup-auth — a preflight that must never disagree with a real run
|
|
1
2
|
import { loadReviewConfig, loadScopeConfig, loadAuthFromRoot, hasConfig, resolveConfigDir, tokenEnvMismatch, } from "../config/load.js";
|
|
2
3
|
import { loadRoutingManifest, resolveScopes, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
|
|
3
4
|
import readline from "node:readline/promises";
|
|
4
5
|
import { setupAuthCommand } from "./setup-auth.js";
|
|
5
6
|
import { checkProviderAuth } from "../core/auth.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
7
|
+
import { claudeSubscriptionActive, claudeTokenCredential, engineForModel, resolveClaudeCli, } from "../core/claude-code.js";
|
|
8
|
+
import { CLAUDE_CODE_ENGINE, opencodeBinSource } from "../core/opencode.js";
|
|
9
|
+
import { git, onPath, pathInside, repoRoot, resolveOnPath, resolveTrustedTool, run, } from "../core/exec.js";
|
|
8
10
|
import { errorMessage } from "../core/util.js";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
9
12
|
import path from "node:path";
|
|
10
13
|
const USAGE = `ecr doctor — check environment, config, and credentials
|
|
11
14
|
|
|
12
15
|
Usage:
|
|
13
16
|
ecr doctor [--list-scopes]
|
|
14
17
|
|
|
15
|
-
Verifies:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
Verifies: the engines the config's models actually use — opencode on PATH for
|
|
19
|
+
non-anthropic models, and for anthropic models the \`claude\` CLI plus a usable
|
|
20
|
+
Claude credential (subscription login or token env) — plus git (+ gh for
|
|
21
|
+
\`ecr ci\`), that .expo-code-review/ config is valid, agent prompts resolve, and
|
|
22
|
+
the configured model's token env is set. When a routing.jsonc is present, also
|
|
23
|
+
validates every scope, the auth singleton, scope ownership over tracked files,
|
|
24
|
+
and comment-tag uniqueness.
|
|
19
25
|
|
|
20
26
|
Options:
|
|
21
27
|
--list-scopes Print the routing scope table (name, dir, paths, agents, tag)
|
|
22
28
|
`;
|
|
23
29
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
30
|
+
* `<cliPath> --version`, run from tmpdir() (never the inherited, possibly untrusted
|
|
31
|
+
* cwd). `cliPath` is an already-resolved absolute path — doctor may run inside a cloned
|
|
32
|
+
* untrusted repo, so the binary is resolved-and-checked before it reaches here, never a
|
|
33
|
+
* bare `opencode`. Null when it can't be determined — a version we can't read is worth
|
|
34
|
+
* staying quiet about, not failing over.
|
|
27
35
|
*/
|
|
28
|
-
async function opencodeVersion(
|
|
29
|
-
const
|
|
30
|
-
const { stdout, code } = await run(command, ["--version"], { check: false });
|
|
36
|
+
async function opencodeVersion(cliPath) {
|
|
37
|
+
const { stdout, code } = await run(cliPath, ["--version"], { check: false, cwd: tmpdir() });
|
|
31
38
|
if (code !== 0) {
|
|
32
39
|
return null;
|
|
33
40
|
}
|
|
34
41
|
return stdout.trim().split("\n")[0]?.trim() || null;
|
|
35
42
|
}
|
|
43
|
+
// @ref LLP 0007#doctor-and-setup-auth [implements] — folds routed scopes' models in; silently skips unloadable scope configs by design
|
|
44
|
+
/**
|
|
45
|
+
* The engines this repo actually drives, mirroring how real reviews resolve them:
|
|
46
|
+
* engineForModel over every ROOT agent + coordinator model, PLUS every loaded scope
|
|
47
|
+
* config's agents + coordinator. A routed scope can select an anthropic/… model while
|
|
48
|
+
* the root config is OpenCode-only, so without folding scopes in doctor would report
|
|
49
|
+
* success without ever checking the Claude CLI/login the scoped review needs. A scope
|
|
50
|
+
* that fails to load is skipped here — the scope-validation block reports it with the
|
|
51
|
+
* full error. Exported for tests.
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveEngines(root, rootConfig, manifest) {
|
|
54
|
+
const engines = new Set();
|
|
55
|
+
const add = (config) => {
|
|
56
|
+
for (const agent of config.agents) {
|
|
57
|
+
engines.add(engineForModel(agent.model));
|
|
58
|
+
}
|
|
59
|
+
engines.add(engineForModel(config.coordinator.model));
|
|
60
|
+
};
|
|
61
|
+
add(rootConfig);
|
|
62
|
+
if (manifest) {
|
|
63
|
+
for (const scope of manifest.scopes) {
|
|
64
|
+
try {
|
|
65
|
+
add(await loadScopeConfig(root, scope, manifest, rootConfig));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Malformed scope config: reported by the scope-validation block below.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return engines;
|
|
73
|
+
}
|
|
36
74
|
/** Preflight checks so a broken setup surfaces clearly instead of silently no-opping. */
|
|
37
75
|
export async function doctorCommand(argv = []) {
|
|
38
76
|
if (argv.includes("-h") || argv.includes("--help")) {
|
|
@@ -64,30 +102,74 @@ export async function doctorCommand(argv = []) {
|
|
|
64
102
|
if (process.env.ECR_CONFIG_DIR) {
|
|
65
103
|
info(`ECR_CONFIG_DIR override active: root config.jsonc and routing.jsonc read from ${resolveConfigDir(root)} (scope subtrees stay repo-root-relative)`);
|
|
66
104
|
}
|
|
105
|
+
// Peek at the config to resolve the engine BEFORE the opencode checks: a
|
|
106
|
+
// claude-code-only repo never touches OpenCode, and a missing `opencode` CLI
|
|
107
|
+
// must not fail its doctor run. Load errors are swallowed here — the config
|
|
108
|
+
// block below reports them properly and defaults the engine to OpenCode.
|
|
109
|
+
let rootConfig;
|
|
110
|
+
if (hasConfig(root)) {
|
|
111
|
+
try {
|
|
112
|
+
rootConfig = await loadReviewConfig(root);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// reported by the config block below
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Engines actually in use by this config: run engineForModel over every agent
|
|
119
|
+
// model + the coordinator model, ACROSS the root config AND every routed scope.
|
|
120
|
+
// Both blocks below may fire in one run (a mixed config drives OpenCode and the
|
|
121
|
+
// Claude Code CLI at once).
|
|
122
|
+
let engines = new Set(["opencode"]);
|
|
123
|
+
// Auth as real reviews resolve it (loadScopeConfig/`ecr ci`): a routing.jsonc
|
|
124
|
+
// `defaults.auth` OVERRIDES the root config's auth, so the Claude credential
|
|
125
|
+
// checks below must use the overridden value or a monorepo whose manifest swaps
|
|
126
|
+
// the anthropic entry gets the wrong checks here. (The engine set is model-only.)
|
|
127
|
+
let resolvedAuth = rootConfig?.auth ?? [];
|
|
128
|
+
if (rootConfig) {
|
|
129
|
+
try {
|
|
130
|
+
const manifest = await loadRoutingManifest(root);
|
|
131
|
+
resolvedAuth = loadAuthFromRoot(rootConfig, manifest);
|
|
132
|
+
engines = await resolveEngines(root, rootConfig, manifest);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// malformed manifest/auth: reported by the blocks below; keep the opencode default
|
|
136
|
+
engines = new Set(["opencode"]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
67
139
|
// The SDK spawns a bare `opencode`, so the version that actually runs is a PATH
|
|
68
140
|
// lookup. Report which one wins and whether it matches the version this package
|
|
69
141
|
// pins: a stale global install against a newer SDK rejects model ids the SDK
|
|
70
142
|
// considers valid (`ProviderModelNotFoundError`), which is otherwise a baffling
|
|
71
143
|
// failure that only reproduces on one machine. `startOpencode` prepends our own
|
|
72
144
|
// 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
|
-
|
|
145
|
+
if (!engines.has("opencode")) {
|
|
146
|
+
info("OpenCode is not used by this config (claude-code engine) — skipping its checks");
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
const bin = opencodeBinSource();
|
|
150
|
+
const opencodeInstalled = (await onPath("opencode")) || bin.pinned;
|
|
151
|
+
line(opencodeInstalled, opencodeInstalled
|
|
152
|
+
? "opencode CLI available"
|
|
153
|
+
: "opencode CLI NOT found (install `opencode-ai`, or add node_modules/.bin to PATH)");
|
|
154
|
+
if (opencodeInstalled) {
|
|
155
|
+
const pinnedVersion = bin.dir ? await opencodeVersion(path.join(bin.dir, "opencode")) : null;
|
|
156
|
+
// PATH's `opencode`, resolved from a trusted cwd (resolveOnPath) with an in-tree
|
|
157
|
+
// refusal: doctor may run in a cloned untrusted repo, so this drift probe must
|
|
158
|
+
// never execute a bare name against the inherited cwd (a committed shim would win
|
|
159
|
+
// on Windows). onPath above only tests existence — this is the executed one.
|
|
160
|
+
const pathCli = await resolveOnPath("opencode");
|
|
161
|
+
const pathVersion = pathCli && !pathInside(pathCli, process.cwd()) ? await opencodeVersion(pathCli) : null;
|
|
162
|
+
if (pinnedVersion) {
|
|
163
|
+
line(true, `opencode ${pinnedVersion} (bundled with this reviewer; used at runtime)`);
|
|
164
|
+
if (pathVersion && pathVersion !== pinnedVersion) {
|
|
165
|
+
warn(`a different opencode ${pathVersion} is first on your PATH — runs use the bundled ${pinnedVersion}, ` +
|
|
166
|
+
`but other tooling (and \`opencode\` by hand) will use ${pathVersion}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (pathVersion) {
|
|
170
|
+
warn(`using opencode ${pathVersion} from PATH — this reviewer's own \`opencode-ai\` dependency could not be ` +
|
|
171
|
+
`resolved, so the CLI and SDK versions can drift (a stale CLI rejects model ids the SDK accepts)`);
|
|
86
172
|
}
|
|
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
173
|
}
|
|
92
174
|
}
|
|
93
175
|
line(await onPath("git"), "git found on PATH");
|
|
@@ -96,7 +178,10 @@ export async function doctorCommand(argv = []) {
|
|
|
96
178
|
if (await onPath("gh")) {
|
|
97
179
|
let authed = false;
|
|
98
180
|
try {
|
|
99
|
-
|
|
181
|
+
// resolveTrustedTool refuses an in-tree `gh` (throws) — caught here and treated
|
|
182
|
+
// as "found but not authenticated", keeping this probe informational, not fatal.
|
|
183
|
+
const gh = await resolveTrustedTool("gh");
|
|
184
|
+
await run(gh, ["auth", "status"], { cwd: root });
|
|
100
185
|
authed = true;
|
|
101
186
|
}
|
|
102
187
|
catch {
|
|
@@ -112,15 +197,20 @@ export async function doctorCommand(argv = []) {
|
|
|
112
197
|
else {
|
|
113
198
|
info("gh CLI not on PATH — only needed for `ecr ci` (posting PR comments)");
|
|
114
199
|
}
|
|
115
|
-
let rootConfig;
|
|
116
200
|
if (!hasConfig(root)) {
|
|
117
201
|
line(false, `no ${".expo-code-review"}/config.jsonc (run \`ecr init\`)`);
|
|
118
202
|
}
|
|
119
203
|
else {
|
|
120
204
|
try {
|
|
121
|
-
|
|
205
|
+
// Reuse the engine-detection peek above; re-load only if that failed so
|
|
206
|
+
// the error surfaces here with full reporting.
|
|
207
|
+
rootConfig ??= await loadReviewConfig(root);
|
|
122
208
|
line(true, `config valid: ${rootConfig.agents.length} agent(s) [${rootConfig.agents.map((a) => a.id).join(", ")}], coordinator model ${rootConfig.coordinator.model}`);
|
|
123
209
|
line(rootConfig.agents.every((a) => Boolean(a.promptText.trim())), "all agent prompt files resolved and non-empty");
|
|
210
|
+
// Set when the Claude Code engine has no usable credential. checkProviderAuth
|
|
211
|
+
// (below) returns ok:true for every anthropic shape (login fallback), so this
|
|
212
|
+
// is the only signal that flips the exit code and offers the setup-auth fix.
|
|
213
|
+
let claudeCredentialMissing = false;
|
|
124
214
|
const readiness = checkProviderAuth(rootConfig);
|
|
125
215
|
line(readiness.ok, `auth: ${readiness.detail}`);
|
|
126
216
|
// A suspicious-but-not-provably-broken credential: worth saying, never a failure
|
|
@@ -128,9 +218,56 @@ export async function doctorCommand(argv = []) {
|
|
|
128
218
|
if (readiness.warning) {
|
|
129
219
|
warn(`auth: ${readiness.warning}`);
|
|
130
220
|
}
|
|
221
|
+
// Claude Code engine: the `opencode` block above is not load-bearing for
|
|
222
|
+
// these configs, so check the `claude` CLI + subscription login instead.
|
|
223
|
+
try {
|
|
224
|
+
if (engines.has(CLAUDE_CODE_ENGINE)) {
|
|
225
|
+
// Resolve to a trusted absolute path (and refuse an in-tree binary), never a
|
|
226
|
+
// bare `claude`: doctor may run inside a cloned untrusted repo, so a
|
|
227
|
+
// PR-committed shim must not be the thing we probe. Same resolution the
|
|
228
|
+
// review engine uses.
|
|
229
|
+
const claudeCliPath = await resolveClaudeCli();
|
|
230
|
+
line(Boolean(claudeCliPath), claudeCliPath
|
|
231
|
+
? "claude CLI available (Claude Code engine)"
|
|
232
|
+
: "claude CLI NOT found (npm i -g @anthropic-ai/claude-code, then `claude setup-token`)");
|
|
233
|
+
if (claudeCliPath) {
|
|
234
|
+
const version = await run(claudeCliPath, ["--version"], {
|
|
235
|
+
check: false,
|
|
236
|
+
cwd: tmpdir(),
|
|
237
|
+
});
|
|
238
|
+
if (version.code === 0) {
|
|
239
|
+
line(true, `claude ${version.stdout.trim().split("\n")[0]?.trim()}`);
|
|
240
|
+
}
|
|
241
|
+
// @ref LLP 0007#doctor-and-setup-auth [constrained-by] — mirrors startClaudeCode's condition exactly; warn() never flips the exit code
|
|
242
|
+
// Mirror startClaudeCode's credential condition EXACTLY so doctor fails
|
|
243
|
+
// iff a review would: a run needs an active `claude` subscription login OR
|
|
244
|
+
// a token value (the configured tokenEnv, else an ambient
|
|
245
|
+
// CLAUDE_CODE_OAUTH_TOKEN). warn() never flips the exit code, so a missing
|
|
246
|
+
// credential must be a line(false) here, not a ⚠.
|
|
247
|
+
const claudeEntry = resolvedAuth.find((a) => a.provider === "anthropic");
|
|
248
|
+
const hasTokenCredential = Boolean(claudeTokenCredential(claudeEntry));
|
|
249
|
+
const subscriptionActive = await claudeSubscriptionActive({ cliPath: claudeCliPath });
|
|
250
|
+
if (subscriptionActive) {
|
|
251
|
+
info("subscription login: Claude Max/Team account");
|
|
252
|
+
}
|
|
253
|
+
else if (hasTokenCredential) {
|
|
254
|
+
const src = claudeEntry?.tokenEnv ?? "CLAUDE_CODE_OAUTH_TOKEN";
|
|
255
|
+
info(`Claude credential: token env ${src} supplies the OAuth token (no active \`claude\` subscription login)`);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
claudeCredentialMissing = true;
|
|
259
|
+
line(false, "no Claude credential: no active `claude` subscription login and no token env " +
|
|
260
|
+
"value set — run `ecr setup-auth` (or `claude setup-token`)");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
line(false, `auth engine: ${errorMessage(error)}`);
|
|
267
|
+
}
|
|
131
268
|
// A missing credential has a guided fix — offer it right here when someone is
|
|
132
269
|
// at the terminal, rather than making them find the command in the README.
|
|
133
|
-
if (!readiness.ok) {
|
|
270
|
+
if (!readiness.ok || claudeCredentialMissing) {
|
|
134
271
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
135
272
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
136
273
|
let runIt = false;
|