@expo/code-review-cli 0.3.0 → 0.4.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 (42) hide show
  1. package/README.md +183 -6
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +406 -43
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +173 -26
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +118 -30
  8. package/build/commands/verify-config.js +214 -0
  9. package/build/config/load.js +154 -52
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +116 -12
  12. package/build/core/auth.js +32 -29
  13. package/build/core/coordinator.js +5 -5
  14. package/build/core/diff.js +19 -19
  15. package/build/core/exec.js +10 -10
  16. package/build/core/log.js +3 -3
  17. package/build/core/noise.js +52 -52
  18. package/build/core/opencode.js +44 -44
  19. package/build/core/prompts.js +157 -148
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +147 -85
  22. package/build/core/router.js +10 -10
  23. package/build/core/schema.js +26 -12
  24. package/build/core/step-summary.js +18 -0
  25. package/build/core/suppress.js +7 -7
  26. package/build/core/tools.js +9 -9
  27. package/build/core/util.js +2 -2
  28. package/build/core/verify.js +25 -25
  29. package/build/reporters/github.js +103 -51
  30. package/build/reporters/terminal.js +19 -19
  31. package/build/sources/github-pr.js +21 -21
  32. package/build/sources/local-git.js +20 -20
  33. package/build/sources/source.js +35 -1
  34. package/package.json +6 -1
  35. package/templates/agents/security.md +5 -0
  36. package/templates/command.yml +164 -0
  37. package/templates/coordinator.md +5 -3
  38. package/templates/dismiss.yml +110 -0
  39. package/templates/routing.jsonc +27 -0
  40. package/templates/scope-config.jsonc +25 -0
  41. package/templates/shared.md +12 -0
  42. package/templates/workflow.yml +50 -20
@@ -1,74 +1,221 @@
1
- import { loadReviewConfig, hasConfig } from '../config/load.js';
2
- import { checkProviderAuth } from '../core/auth.js';
3
- import { onPath, repoRoot, run } from '../core/exec.js';
4
- import { errorMessage } from '../core/util.js';
1
+ import { loadReviewConfig, loadScopeConfig, loadAuthFromRoot, hasConfig, resolveConfigDir, } from "../config/load.js";
2
+ import { loadRoutingManifest, resolveScopes, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
3
+ import { checkProviderAuth } from "../core/auth.js";
4
+ import { git, onPath, repoRoot, run } from "../core/exec.js";
5
+ import { errorMessage } from "../core/util.js";
5
6
  const USAGE = `ecr doctor — check environment, config, and credentials
6
7
 
7
8
  Usage:
8
- ecr doctor
9
+ ecr doctor [--list-scopes]
9
10
 
10
11
  Verifies: opencode + git (+ gh for \`ecr ci\`) on PATH, .expo-code-review/ config is
11
- valid, agent prompts resolve, and the configured model's token env is set.
12
+ valid, agent prompts resolve, and the configured model's token env is set. When a
13
+ routing.jsonc is present, also validates every scope, the auth singleton, scope
14
+ ownership over tracked files, and comment-tag uniqueness.
15
+
16
+ Options:
17
+ --list-scopes Print the routing scope table (name, dir, paths, agents, tag)
12
18
  `;
13
19
  /** Preflight checks so a broken setup surfaces clearly instead of silently no-opping. */
14
20
  export async function doctorCommand(argv = []) {
15
- if (argv.includes('-h') || argv.includes('--help')) {
21
+ if (argv.includes("-h") || argv.includes("--help")) {
16
22
  process.stdout.write(USAGE);
17
23
  return;
18
24
  }
19
25
  const root = (await repoRoot()) ?? process.cwd();
26
+ if (argv.includes("--list-scopes")) {
27
+ await listScopes(root);
28
+ return;
29
+ }
20
30
  let ok = true;
21
31
  const line = (pass, message) => {
22
32
  if (!pass) {
23
33
  ok = false;
24
34
  }
25
- process.stdout.write(` ${pass ? '' : ''} ${message}\n`);
35
+ process.stdout.write(` ${pass ? "" : ""} ${message}\n`);
36
+ };
37
+ const info = (message) => {
38
+ process.stdout.write(` ℹ ${message}\n`);
39
+ };
40
+ const warn = (message) => {
41
+ process.stdout.write(` ⚠ ${message}\n`);
26
42
  };
27
43
  process.stdout.write(`expo-code-review doctor (repo: ${root})\n`);
28
- const opencodeInstalled = await onPath('opencode');
44
+ // When the ROOT config dir is overridden, config.jsonc AND routing.jsonc are
45
+ // read from the resolved dir (scope subtrees stay repo-root-relative). Surface
46
+ // it so a green doctor run can't hide that it checked a non-default root.
47
+ if (process.env.ECR_CONFIG_DIR) {
48
+ info(`ECR_CONFIG_DIR override active: root config.jsonc and routing.jsonc read from ${resolveConfigDir(root)} (scope subtrees stay repo-root-relative)`);
49
+ }
50
+ const opencodeInstalled = await onPath("opencode");
29
51
  line(opencodeInstalled, opencodeInstalled
30
- ? 'opencode CLI found on PATH'
31
- : 'opencode CLI NOT on PATH (install `opencode-ai`, or add node_modules/.bin to PATH)');
32
- line(await onPath('git'), 'git found on PATH');
52
+ ? "opencode CLI found on PATH"
53
+ : "opencode CLI NOT on PATH (install `opencode-ai`, or add node_modules/.bin to PATH)");
54
+ line(await onPath("git"), "git found on PATH");
33
55
  // `gh` is only needed for `ecr ci` (posting PR comments), so treat it as
34
56
  // informational (ℹ) rather than a hard failure for local `ecr review` users.
35
- const info = (message) => {
36
- process.stdout.write(` ℹ ${message}\n`);
37
- };
38
- if (await onPath('gh')) {
57
+ if (await onPath("gh")) {
39
58
  let authed = false;
40
59
  try {
41
- await run('gh', ['auth', 'status'], { cwd: root });
60
+ await run("gh", ["auth", "status"], { cwd: root });
42
61
  authed = true;
43
62
  }
44
63
  catch {
45
64
  authed = false;
46
65
  }
47
66
  if (authed) {
48
- line(true, 'gh CLI found and authenticated (used by `ecr ci`)');
67
+ line(true, "gh CLI found and authenticated (used by `ecr ci`)");
49
68
  }
50
69
  else {
51
- info('gh CLI found but not authenticated — run `gh auth login` before `ecr ci`');
70
+ info("gh CLI found but not authenticated — run `gh auth login` before `ecr ci`");
52
71
  }
53
72
  }
54
73
  else {
55
- info('gh CLI not on PATH — only needed for `ecr ci` (posting PR comments)');
74
+ info("gh CLI not on PATH — only needed for `ecr ci` (posting PR comments)");
56
75
  }
76
+ let rootConfig;
57
77
  if (!hasConfig(root)) {
58
- line(false, `no ${'.expo-code-review'}/config.jsonc (run \`ecr init\`)`);
78
+ line(false, `no ${".expo-code-review"}/config.jsonc (run \`ecr init\`)`);
59
79
  }
60
80
  else {
61
81
  try {
62
- const config = await loadReviewConfig(root);
63
- line(true, `config valid: ${config.agents.length} agent(s) [${config.agents.map(a => a.id).join(', ')}], coordinator model ${config.coordinator.model}`);
64
- line(config.agents.every(a => Boolean(a.promptText.trim())), 'all agent prompt files resolved and non-empty');
65
- const readiness = checkProviderAuth(config);
82
+ rootConfig = await loadReviewConfig(root);
83
+ line(true, `config valid: ${rootConfig.agents.length} agent(s) [${rootConfig.agents.map((a) => a.id).join(", ")}], coordinator model ${rootConfig.coordinator.model}`);
84
+ line(rootConfig.agents.every((a) => Boolean(a.promptText.trim())), "all agent prompt files resolved and non-empty");
85
+ const readiness = checkProviderAuth(rootConfig);
66
86
  line(readiness.ok, `auth: ${readiness.detail}`);
67
87
  }
68
88
  catch (error) {
69
89
  line(false, `config invalid: ${errorMessage(error)}`);
70
90
  }
71
91
  }
72
- process.stdout.write(ok ? '\nAll good.\n' : '\nIssues found (see above).\n');
92
+ // Routing manifest checks (only when a routing.jsonc is present).
93
+ let manifest = null;
94
+ try {
95
+ manifest = await loadRoutingManifest(root);
96
+ }
97
+ catch (error) {
98
+ line(false, `routing.jsonc invalid: ${errorMessage(error)}`);
99
+ }
100
+ if (manifest && rootConfig) {
101
+ process.stdout.write("\nRouting manifest:\n");
102
+ line(true, `manifest valid: ${manifest.scopes.length} scope(s), comment mode "${manifest.comment}"`);
103
+ // enforceAgents must exist in the ROOT roster.
104
+ for (const id of manifest.defaults.enforceAgents) {
105
+ const present = rootConfig.agents.some((agent) => agent.id === id);
106
+ line(present, present
107
+ ? `enforced agent "${id}" found in the root roster`
108
+ : `enforced agent "${id}" is NOT in the root roster (defaults.enforceAgents)`);
109
+ }
110
+ for (const scope of manifest.scopes) {
111
+ let scopeConfig;
112
+ try {
113
+ scopeConfig = await loadScopeConfig(root, scope, manifest, rootConfig);
114
+ }
115
+ catch (error) {
116
+ // A scope config declaring auth/breakGlass/commentTag surfaces its Zod
117
+ // error HERE, before CI.
118
+ line(false, `scope ${scope.name}: ${errorMessage(error)}`);
119
+ continue;
120
+ }
121
+ line(true, `scope ${scope.name}: ${scopeConfig.agents.length} agent(s) [${scopeConfig.agents.map((a) => a.id).join(", ")}], config ${scope.config}`);
122
+ }
123
+ // Passes-budget headroom: active scopes run sequentially, so the worst case
124
+ // is every scope active at the per-scope floor. If scopes.length × the floor
125
+ // exceeds the total, runs can outlast the total budget (a ⚠, not a failure —
126
+ // tune budget.* or the job timeout).
127
+ const totalMs = manifest.budget.totalPassesMinutes * 60_000;
128
+ const minMs = manifest.budget.minScopeMinutes * 60_000;
129
+ const { overshoot } = scopePassesBudgetMs(totalMs, minMs, manifest.scopes.length);
130
+ if (overshoot) {
131
+ warn(`passes budget: ${manifest.scopes.length} scopes × ${manifest.budget.minScopeMinutes}m floor = ${manifest.scopes.length * manifest.budget.minScopeMinutes}m worst case exceeds budget.totalPassesMinutes (${manifest.budget.totalPassesMinutes}m) — raise the job timeout or trim scopes`);
132
+ }
133
+ else {
134
+ line(true, `passes budget: worst case ${manifest.scopes.length} scopes × ${manifest.budget.minScopeMinutes}m floor fits budget.totalPassesMinutes (${manifest.budget.totalPassesMinutes}m)`);
135
+ }
136
+ // Per-scope comment markers are always derived (`<tag>:<scope>`, unique by
137
+ // scope-name uniqueness) and the scope schema rejects commentTag overrides,
138
+ // so marker collisions are impossible by construction — nothing to check.
139
+ // auth singleton: exactly one honored source (defaults.auth or root config auth).
140
+ const auth = loadAuthFromRoot(rootConfig, manifest);
141
+ const hasManifestAuth = Boolean(manifest.defaults.auth);
142
+ line(true, `auth singleton: honored from ${hasManifestAuth ? "routing.jsonc defaults.auth" : "root config.jsonc"} (${auth.mode}/${auth.provider})`);
143
+ if (auth.tokenEnv) {
144
+ const expected = process.env.ECR_EXPECTED_TOKEN_ENV;
145
+ if (expected && expected !== auth.tokenEnv) {
146
+ line(false, `auth.tokenEnv "${auth.tokenEnv}" != ECR_EXPECTED_TOKEN_ENV "${expected}"`);
147
+ }
148
+ else {
149
+ line(Boolean(process.env[auth.tokenEnv]), `auth token env ${auth.tokenEnv} is ${process.env[auth.tokenEnv] ? "set" : "NOT set"}`);
150
+ }
151
+ }
152
+ // Owner-table dry run over tracked files (graft 4).
153
+ try {
154
+ const tracked = (await git(["ls-files"], root))
155
+ .split("\n")
156
+ .map((f) => f.trim())
157
+ .filter(Boolean);
158
+ const resolution = resolveScopes(manifest, tracked);
159
+ const hasCatchAll = manifest.scopes.some((scope) => scope.paths.includes("**/*"));
160
+ line(resolution.unmatched.length === 0 || hasCatchAll, resolution.unmatched.length === 0
161
+ ? `scope coverage: all ${tracked.length} tracked file(s) match a scope`
162
+ : `scope coverage: ${resolution.unmatched.length} file(s) match no scope${hasCatchAll ? " (ok — a **/* catch-all exists)" : " (add a **/* catch-all)"}`);
163
+ if (resolution.overlaps.length > 0) {
164
+ warn(`${resolution.overlaps.length} file(s) match >1 scope (last-match wins):`);
165
+ for (const owner of formatOwnerTable(resolution, 20)) {
166
+ process.stdout.write(`${owner}\n`);
167
+ }
168
+ }
169
+ }
170
+ catch (error) {
171
+ info(`scope coverage: could not run \`git ls-files\` (${errorMessage(error)})`);
172
+ }
173
+ }
174
+ process.stdout.write(ok ? "\nAll good.\n" : "\nIssues found (see ✗ above).\n");
175
+ process.exitCode = ok ? 0 : 1;
176
+ }
177
+ /** Print the routing scope table; exit 0/1 on manifest validity alone. */
178
+ async function listScopes(root) {
179
+ let manifest;
180
+ try {
181
+ manifest = await loadRoutingManifest(root);
182
+ }
183
+ catch (error) {
184
+ process.stdout.write(` ✗ routing.jsonc invalid: ${errorMessage(error)}\n`);
185
+ process.exitCode = 1;
186
+ return;
187
+ }
188
+ if (!manifest) {
189
+ process.stdout.write(`No ${".expo-code-review"}/routing.jsonc — run \`ecr init --monorepo\`.\n`);
190
+ process.exitCode = 0;
191
+ return;
192
+ }
193
+ let rootConfig;
194
+ try {
195
+ rootConfig = await loadReviewConfig(root);
196
+ }
197
+ catch (error) {
198
+ process.stdout.write(` ✗ root config invalid: ${errorMessage(error)}\n`);
199
+ process.exitCode = 1;
200
+ return;
201
+ }
202
+ process.stdout.write(`Routing scopes (comment mode: ${manifest.comment}):\n\n`);
203
+ let ok = true;
204
+ for (const scope of manifest.scopes) {
205
+ process.stdout.write(` ${scope.name}\n`);
206
+ process.stdout.write(` config: ${scope.config}\n`);
207
+ process.stdout.write(` paths: ${scope.paths.join(", ")}\n`);
208
+ try {
209
+ const config = await loadScopeConfig(root, scope, manifest, rootConfig);
210
+ process.stdout.write(` agents: ${config.agents.map((a) => (a.alwaysRun ? `${a.id}*` : a.id)).join(", ")}\n`);
211
+ process.stdout.write(` tag: ${config.commentTag}\n`);
212
+ }
213
+ catch (error) {
214
+ ok = false;
215
+ process.stdout.write(` ERROR: ${errorMessage(error)}\n`);
216
+ }
217
+ process.stdout.write("\n");
218
+ }
219
+ process.stdout.write("(* = enforced, alwaysRun)\n");
73
220
  process.exitCode = ok ? 0 : 1;
74
221
  }
@@ -1,41 +1,56 @@
1
- import { cp, mkdir, writeFile } from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
3
- import path from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { CONFIG_DIRNAME } from '../config/load.js';
6
- import { repoRoot } from '../core/exec.js';
7
- import { errorMessage } from '../core/util.js';
8
- const TEMPLATES_DIR = fileURLToPath(new URL('../../templates/', import.meta.url));
1
+ import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { CONFIG_DIRNAME } from "../config/load.js";
6
+ import { ROUTING_FILENAME } from "../config/routing.js";
7
+ import { RoutingScopeSchema } from "../config/schema.js";
8
+ import { repoRoot } from "../core/exec.js";
9
+ import { errorMessage } from "../core/util.js";
10
+ const TEMPLATES_DIR = fileURLToPath(new URL("../../templates/", import.meta.url));
9
11
  const USAGE = `ecr init — scaffold .expo-code-review/ in the current repo
10
12
 
11
13
  Usage:
12
- ecr init [--no-workflow] [--force]
14
+ ecr init [--no-workflow] [--force] Scaffold the root config (+ CI workflow)
15
+ ecr init --monorepo [--force] …and add a routing.jsonc (one default scope)
16
+ ecr init --scope <dir> [--force] Scaffold a per-team scope under <dir> and
17
+ register it in the root routing.jsonc
13
18
 
14
19
  Options:
15
- --no-workflow Skip writing the CI workflow (.github/workflows/expo-code-review.yml)
20
+ --monorepo Also write .expo-code-review/routing.jsonc (routing manifest)
21
+ --scope <dir> Scaffold <dir>/.expo-code-review/ (no auth) + add a scope entry
22
+ --no-workflow Skip writing the CI workflows (review, command, and dismiss
23
+ under .github/workflows/)
16
24
  --force Overwrite existing files
17
25
  -h, --help Show this help
18
26
  `;
19
27
  export async function initCommand(argv) {
20
- if (argv.includes('-h') || argv.includes('--help')) {
28
+ if (argv.includes("-h") || argv.includes("--help")) {
21
29
  process.stdout.write(USAGE);
22
30
  return;
23
31
  }
24
32
  try {
25
- await scaffold(argv);
33
+ const scopeDir = parseValue(argv, "--scope");
34
+ if (scopeDir != null) {
35
+ await scaffoldScope(argv, scopeDir);
36
+ }
37
+ else {
38
+ await scaffold(argv);
39
+ }
26
40
  }
27
41
  catch (error) {
28
42
  process.stderr.write(`init failed: ${errorMessage(error)}\n`);
29
43
  process.exitCode = 2;
30
44
  }
31
45
  }
32
- /** Scaffold .expo-code-review/ (and optionally the CI workflow) into the repo. */
46
+ /** Scaffold .expo-code-review/ (and optionally the CI workflow + routing manifest). */
33
47
  async function scaffold(argv) {
34
- const force = argv.includes('--force');
48
+ const force = argv.includes("--force");
35
49
  // The CI workflow is scaffolded by default (most repos adopting this want it);
36
50
  // `--no-workflow` opts out. `--with-workflow` is still accepted as a no-op for
37
51
  // back-compat.
38
- const withWorkflow = !argv.includes('--no-workflow');
52
+ const withWorkflow = !argv.includes("--no-workflow");
53
+ const monorepo = argv.includes("--monorepo");
39
54
  const root = (await repoRoot()) ?? process.cwd();
40
55
  const configDir = path.join(root, CONFIG_DIRNAME);
41
56
  // Create only the config dir; let copyInto create prompts/ so it reports
@@ -43,40 +58,235 @@ async function scaffold(argv) {
43
58
  await mkdir(configDir, { recursive: true });
44
59
  const created = [];
45
60
  const skipped = [];
46
- await copyInto(path.join(TEMPLATES_DIR, 'config.jsonc'), path.join(configDir, 'config.jsonc'), force, created, skipped, root);
47
- await copyInto(path.join(TEMPLATES_DIR, 'shared.md'), path.join(configDir, 'shared.md'), force, created, skipped, root);
48
- await copyInto(path.join(TEMPLATES_DIR, 'coordinator.md'), path.join(configDir, 'coordinator.md'), force, created, skipped, root);
49
- await copyInto(path.join(TEMPLATES_DIR, 'agents'), path.join(configDir, 'agents'), force, created, skipped, root);
50
- const gitignorePath = path.join(configDir, '.gitignore');
61
+ await copyInto(path.join(TEMPLATES_DIR, "config.jsonc"), path.join(configDir, "config.jsonc"), force, created, skipped, root);
62
+ await copyInto(path.join(TEMPLATES_DIR, "shared.md"), path.join(configDir, "shared.md"), force, created, skipped, root);
63
+ await copyInto(path.join(TEMPLATES_DIR, "coordinator.md"), path.join(configDir, "coordinator.md"), force, created, skipped, root);
64
+ await copyInto(path.join(TEMPLATES_DIR, "agents"), path.join(configDir, "agents"), force, created, skipped, root);
65
+ const gitignorePath = path.join(configDir, ".gitignore");
51
66
  if (force || !existsSync(gitignorePath)) {
52
- await writeFile(gitignorePath, '.runs/\n', 'utf8');
67
+ await writeFile(gitignorePath, ".runs/\n", "utf8");
53
68
  created.push(path.relative(root, gitignorePath));
54
69
  }
55
70
  else {
56
71
  skipped.push(path.relative(root, gitignorePath));
57
72
  }
73
+ if (monorepo) {
74
+ await copyInto(path.join(TEMPLATES_DIR, ROUTING_FILENAME), path.join(configDir, ROUTING_FILENAME), force, created, skipped, root);
75
+ }
58
76
  if (withWorkflow) {
59
- const workflowDir = path.join(root, '.github', 'workflows');
77
+ const workflowDir = path.join(root, ".github", "workflows");
60
78
  await mkdir(workflowDir, { recursive: true });
61
- await copyInto(path.join(TEMPLATES_DIR, 'workflow.yml'), path.join(workflowDir, 'expo-code-review.yml'), force, created, skipped, root);
79
+ // The auto (pull_request) workflow, plus the two issue_comment command
80
+ // workflows: `/review` (on-demand one-shot) and `/dismiss` (hide a finding).
81
+ await copyInto(path.join(TEMPLATES_DIR, "workflow.yml"), path.join(workflowDir, "expo-code-review.yml"), force, created, skipped, root);
82
+ await copyInto(path.join(TEMPLATES_DIR, "command.yml"), path.join(workflowDir, "expo-code-review-command.yml"), force, created, skipped, root);
83
+ await copyInto(path.join(TEMPLATES_DIR, "dismiss.yml"), path.join(workflowDir, "expo-code-review-dismiss.yml"), force, created, skipped, root);
84
+ }
85
+ reportFiles(created, skipped);
86
+ process.stdout.write([
87
+ "",
88
+ "Next steps:",
89
+ ` 1. Customize ${CONFIG_DIRNAME}/agents/*.md (and shared.md, coordinator.md) for this repo.`,
90
+ " 2. Configure a model provider in OpenCode (or set REVIEWER_MODEL).",
91
+ " 3. Run `ecr doctor`, then `ecr review`.",
92
+ withWorkflow
93
+ ? " 4. Add the model-key secret referenced by the workflow, then add an `ai-review` label to a PR."
94
+ : " 4. (No CI workflow written — re-run without `--no-workflow` to add it.)",
95
+ monorepo
96
+ ? ` 5. Add per-team scopes with \`ecr init --scope <dir>\` (see ${CONFIG_DIRNAME}/${ROUTING_FILENAME}).`
97
+ : ` 5. Monorepo? Run \`ecr init --monorepo\` to add a routing manifest.`,
98
+ "",
99
+ ].join("\n"));
100
+ }
101
+ /**
102
+ * Scaffold a per-team scope under <dir>: <dir>/.expo-code-review/ with a no-auth
103
+ * config, prompts and agents, then register the scope in the root routing.jsonc.
104
+ */
105
+ async function scaffoldScope(argv, scopeDirRaw) {
106
+ const force = argv.includes("--force");
107
+ const root = (await repoRoot()) ?? process.cwd();
108
+ const scopeDir = scopeDirRaw.replace(/\/+$/, "");
109
+ const routingPath = path.join(root, CONFIG_DIRNAME, ROUTING_FILENAME);
110
+ if (!existsSync(routingPath)) {
111
+ throw new Error(`no ${CONFIG_DIRNAME}/${ROUTING_FILENAME} — run \`ecr init --monorepo\` first`);
112
+ }
113
+ // Derive the scope entry and validate it BEFORE creating any files: the name must
114
+ // satisfy RoutingScopeSchema's kebab-case rule (derived by sanitizing the dir,
115
+ // apps/Foo_Bar -> apps-foo-bar), and the config path is rejected when absolute or
116
+ // containing ".." — validating first keeps a traversal dir (e.g. `--scope
117
+ // ../../outside`) from orphaning files outside the repo, and a bad name from
118
+ // making routing.jsonc unloadable and silently stopping every review.
119
+ const entry = {
120
+ name: scopeDir
121
+ .toLowerCase()
122
+ .replace(/[^a-z0-9]+/g, "-")
123
+ .replace(/^-+|-+$/g, ""),
124
+ paths: [`${scopeDir}/**`],
125
+ config: scopeDir,
126
+ };
127
+ const parsed = RoutingScopeSchema.safeParse(entry);
128
+ if (!parsed.success) {
129
+ throw new Error(`scope dir "${scopeDir}" yields an invalid scope entry (${parsed.error.issues[0]?.message}); ` +
130
+ `rename the directory or add the scope to ${CONFIG_DIRNAME}/${ROUTING_FILENAME} manually`);
131
+ }
132
+ const configDir = path.join(root, scopeDir, CONFIG_DIRNAME);
133
+ await mkdir(configDir, { recursive: true });
134
+ const created = [];
135
+ const skipped = [];
136
+ // The scope's config.jsonc is the auth-free scope template.
137
+ await copyInto(path.join(TEMPLATES_DIR, "scope-config.jsonc"), path.join(configDir, "config.jsonc"), force, created, skipped, root);
138
+ await copyInto(path.join(TEMPLATES_DIR, "shared.md"), path.join(configDir, "shared.md"), force, created, skipped, root);
139
+ await copyInto(path.join(TEMPLATES_DIR, "coordinator.md"), path.join(configDir, "coordinator.md"), force, created, skipped, root);
140
+ await copyInto(path.join(TEMPLATES_DIR, "agents"), path.join(configDir, "agents"), force, created, skipped, root);
141
+ const gitignorePath = path.join(configDir, ".gitignore");
142
+ if (force || !existsSync(gitignorePath)) {
143
+ await writeFile(gitignorePath, ".runs/\n", "utf8");
144
+ created.push(path.relative(root, gitignorePath));
145
+ }
146
+ else {
147
+ skipped.push(path.relative(root, gitignorePath));
148
+ }
149
+ // Register the scope in the root routing manifest, preserving comments/formatting.
150
+ const raw = await readFile(routingPath, "utf8");
151
+ const updated = appendScopeEntry(raw, entry);
152
+ let manifestNote;
153
+ if (updated == null) {
154
+ manifestNote = ` ! could not locate the "scopes" array in ${CONFIG_DIRNAME}/${ROUTING_FILENAME}; add this entry manually:\n ${JSON.stringify(entry)}`;
62
155
  }
156
+ else if (updated === raw) {
157
+ manifestNote = ` skipped ${CONFIG_DIRNAME}/${ROUTING_FILENAME} (scope "${entry.name}" already present)`;
158
+ }
159
+ else {
160
+ await writeFile(routingPath, updated, "utf8");
161
+ manifestNote = ` updated ${CONFIG_DIRNAME}/${ROUTING_FILENAME} (+ scope "${entry.name}")`;
162
+ }
163
+ reportFiles(created, skipped);
164
+ process.stdout.write(`${manifestNote}\n`);
165
+ process.stdout.write([
166
+ "",
167
+ "Next steps:",
168
+ ` 1. Customize ${scopeDir}/${CONFIG_DIRNAME}/agents/*.md for this team.`,
169
+ ` 2. Add to CODEOWNERS so only the team edits its scope:`,
170
+ ` /${scopeDir}/${CONFIG_DIRNAME}/ @your-team`,
171
+ " 3. Run `ecr doctor --list-scopes` to verify routing.",
172
+ "",
173
+ ].join("\n"));
174
+ }
175
+ /**
176
+ * Insert a scope entry before the closing ] of the "scopes" array in raw JSONC,
177
+ * preserving comments/formatting. Returns the new text, the original text unchanged
178
+ * when a scope of the same name is already present, or null when the array can't be
179
+ * located (caller then prints the entry for manual addition).
180
+ */
181
+ export function appendScopeEntry(routingRaw, entry) {
182
+ const keyMatch = routingRaw.search(/"scopes"\s*:/);
183
+ if (keyMatch === -1) {
184
+ return null;
185
+ }
186
+ const arrayStart = routingRaw.indexOf("[", keyMatch);
187
+ if (arrayStart === -1) {
188
+ return null;
189
+ }
190
+ // Find the matching close bracket by depth, skipping strings and // and /* */
191
+ // comments, and remember the last CONTENT character inside the array — users
192
+ // annotate routing.jsonc with comments, so the separating comma must land
193
+ // after the last entry, never inside a trailing comment.
194
+ let depth = 0;
195
+ let end = -1;
196
+ let lastContent = -1;
197
+ let i = arrayStart;
198
+ while (i < routingRaw.length) {
199
+ const char = routingRaw[i];
200
+ if (char === "/" && routingRaw[i + 1] === "/") {
201
+ const newline = routingRaw.indexOf("\n", i);
202
+ if (newline === -1) {
203
+ break;
204
+ }
205
+ i = newline;
206
+ continue;
207
+ }
208
+ if (char === "/" && routingRaw[i + 1] === "*") {
209
+ const close = routingRaw.indexOf("*/", i + 2);
210
+ if (close === -1) {
211
+ break;
212
+ }
213
+ i = close + 2;
214
+ continue;
215
+ }
216
+ if (char === '"') {
217
+ i++;
218
+ while (i < routingRaw.length && routingRaw[i] !== '"') {
219
+ if (routingRaw[i] === "\\") {
220
+ i++;
221
+ }
222
+ i++;
223
+ }
224
+ lastContent = i;
225
+ i++;
226
+ continue;
227
+ }
228
+ if (char === "[") {
229
+ depth++;
230
+ if (i > arrayStart) {
231
+ lastContent = i;
232
+ }
233
+ }
234
+ else if (char === "]") {
235
+ depth--;
236
+ if (depth === 0) {
237
+ end = i;
238
+ break;
239
+ }
240
+ lastContent = i;
241
+ }
242
+ else if (!/\s/.test(char)) {
243
+ lastContent = i;
244
+ }
245
+ i++;
246
+ }
247
+ if (end === -1) {
248
+ return null;
249
+ }
250
+ // Idempotent: don't add a duplicate name.
251
+ const inner = routingRaw.slice(arrayStart + 1, end);
252
+ if (new RegExp(`"name"\\s*:\\s*"${escapeRegExp(entry.name)}"`).test(inner)) {
253
+ return routingRaw;
254
+ }
255
+ const line = ` { "name": ${JSON.stringify(entry.name)}, "paths": ${JSON.stringify(entry.paths)}, "config": ${JSON.stringify(entry.config)} }`;
256
+ const hasEntries = lastContent > arrayStart;
257
+ const needsComma = hasEntries && routingRaw[lastContent] !== ",";
258
+ // Insert the comma immediately after the last entry's final character (before
259
+ // any trailing comment), then append the new entry line before the ']'.
260
+ const withComma = needsComma
261
+ ? `${routingRaw.slice(0, lastContent + 1)},${routingRaw.slice(lastContent + 1)}`
262
+ : routingRaw;
263
+ const endAdjusted = needsComma ? end + 1 : end;
264
+ const before = withComma.slice(0, endAdjusted).replace(/\s*$/, "");
265
+ const after = withComma.slice(endAdjusted);
266
+ return `${before}\n${line}\n ${after}`;
267
+ }
268
+ function escapeRegExp(value) {
269
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
270
+ }
271
+ function reportFiles(created, skipped) {
63
272
  for (const file of created) {
64
273
  process.stdout.write(` created ${file}\n`);
65
274
  }
66
275
  for (const file of skipped) {
67
276
  process.stdout.write(` skipped ${file} (exists; use --force to overwrite)\n`);
68
277
  }
69
- process.stdout.write([
70
- '',
71
- 'Next steps:',
72
- ` 1. Customize ${CONFIG_DIRNAME}/agents/*.md (and shared.md, coordinator.md) for this repo.`,
73
- ' 2. Configure a model provider in OpenCode (or set REVIEWER_MODEL).',
74
- ' 3. Run `ecr doctor`, then `ecr review`.',
75
- withWorkflow
76
- ? ' 4. Add the model-key secret referenced by the workflow, then add an `ai-review` label to a PR.'
77
- : ' 4. (No CI workflow written — re-run without `--no-workflow` to add it.)',
78
- '',
79
- ].join('\n'));
278
+ }
279
+ /** Parse a `--flag <value>` option; returns undefined when the flag is absent. */
280
+ function parseValue(argv, flag) {
281
+ const index = argv.indexOf(flag);
282
+ if (index === -1) {
283
+ return undefined;
284
+ }
285
+ const value = argv[index + 1];
286
+ if (!value || value.startsWith("--")) {
287
+ throw new Error(`${flag} requires a value`);
288
+ }
289
+ return value;
80
290
  }
81
291
  async function copyInto(src, dest, force, created, skipped, root) {
82
292
  const existed = existsSync(dest);