@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.
Files changed (54) hide show
  1. package/README.md +151 -25
  2. package/build/cli.js +7 -0
  3. package/build/commands/ci.js +307 -36
  4. package/build/commands/dismiss.js +6 -0
  5. package/build/commands/doctor.js +170 -33
  6. package/build/commands/feedback.js +433 -0
  7. package/build/commands/init.js +231 -15
  8. package/build/commands/review.js +191 -51
  9. package/build/commands/setup-auth.js +86 -11
  10. package/build/commands/verify-config.js +3 -0
  11. package/build/config/load.js +39 -0
  12. package/build/config/routing.js +7 -0
  13. package/build/config/schema.js +99 -3
  14. package/build/core/adjudicate.js +194 -0
  15. package/build/core/auth.js +127 -10
  16. package/build/core/claude-code.js +691 -0
  17. package/build/core/context-file.js +42 -0
  18. package/build/core/coordinator.js +2 -2
  19. package/build/core/diff.js +1 -0
  20. package/build/core/exec.js +282 -9
  21. package/build/core/log.js +1 -0
  22. package/build/core/noise.js +5 -0
  23. package/build/core/opencode.js +117 -15
  24. package/build/core/prompts.js +330 -5
  25. package/build/core/render.js +274 -45
  26. package/build/core/responses.js +158 -0
  27. package/build/core/review.js +447 -39
  28. package/build/core/schema.js +219 -3
  29. package/build/core/scrub.js +63 -1
  30. package/build/core/stack-confirm.js +137 -0
  31. package/build/core/stack.js +25 -0
  32. package/build/core/step-summary.js +1 -0
  33. package/build/core/suppress.js +2 -0
  34. package/build/core/throttle.js +12 -0
  35. package/build/core/util.js +18 -0
  36. package/build/core/verify.js +18 -1
  37. package/build/reporters/github.js +544 -44
  38. package/build/reporters/terminal.js +2 -0
  39. package/build/sources/github-pr.js +286 -7
  40. package/build/sources/local-git.js +6 -2
  41. package/build/sources/source.js +35 -0
  42. package/package.json +4 -3
  43. package/templates/agents/consistency.md +2 -0
  44. package/templates/agents/correctness.md +2 -0
  45. package/templates/agents/security.md +3 -0
  46. package/templates/atlantis.yml +123 -0
  47. package/templates/command.yml +4 -0
  48. package/templates/config.jsonc +71 -4
  49. package/templates/coordinator.md +34 -9
  50. package/templates/dismiss.yml +4 -0
  51. package/templates/routing.jsonc +3 -0
  52. package/templates/scope-config.jsonc +1 -0
  53. package/templates/shared.md +124 -1
  54. package/templates/workflow.yml +5 -0
@@ -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 { opencodeBinSource } from "../core/opencode.js";
7
- import { git, onPath, repoRoot, run } from "../core/exec.js";
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: 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.
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
- * `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.
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(binDir) {
29
- const command = binDir ? path.join(binDir, "opencode") : "opencode";
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
- 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}`);
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
- await run("gh", ["auth", "status"], { cwd: root });
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
- rootConfig = await loadReviewConfig(root);
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;