@syv-ai/rulecast 0.2.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.
package/dist/cli.js CHANGED
@@ -3,14 +3,17 @@ import {
3
3
  ADAPTERS,
4
4
  adapterByName,
5
5
  builtinDetectors,
6
- createRegistry
7
- } from "./chunk-C6C5EER7.js";
6
+ createRegistry,
7
+ renderBacklog,
8
+ summarise
9
+ } from "./chunk-IKONOTME.js";
8
10
  import {
9
11
  CONFIG_FILE,
10
12
  LockTimeoutError,
11
13
  MANIFEST_FILE,
12
14
  NO_PROMPT,
13
15
  RepoFetchError,
16
+ WORKTREE,
14
17
  allFiles,
15
18
  cacheHome,
16
19
  cachedRepo,
@@ -20,8 +23,10 @@ import {
20
23
  checkDetectors,
21
24
  compile,
22
25
  compileManifest,
26
+ createReferenceResolver,
23
27
  debugLogger,
24
28
  defaultConfig,
29
+ detectionFor,
25
30
  diagnosticText,
26
31
  ensureProjectState,
27
32
  ensureRepo,
@@ -29,18 +34,21 @@ import {
29
34
  fetchingRepos,
30
35
  fixedRepo,
31
36
  git,
37
+ headCommit,
32
38
  mergeBase,
33
39
  parseConfig,
34
40
  projectStateDir,
35
41
  readConfigData,
36
42
  readManifest,
43
+ readYamlFile,
37
44
  repoLabel,
38
45
  rulesOfKind,
39
46
  runDetection,
40
47
  runPipeline,
41
48
  stagedFiles,
42
- withLock
43
- } from "./chunk-RODZRI7B.js";
49
+ withLock,
50
+ withScratchTree
51
+ } from "./chunk-WUFU4LW2.js";
44
52
  import {
45
53
  VERSION,
46
54
  detectorCacheDir,
@@ -49,12 +57,14 @@ import {
49
57
  isDetectorRule,
50
58
  isNotFound,
51
59
  memoryCache,
60
+ onPath,
52
61
  parseVersion,
53
62
  readSourceFile,
54
63
  renderAgentText,
64
+ renderTemplate,
55
65
  scanHeadings,
56
66
  tagsOf
57
- } from "./chunk-ZEFEQKG5.js";
67
+ } from "./chunk-DQUCE2IK.js";
58
68
  import {
59
69
  Cancelled
60
70
  } from "./chunk-K37BHCEK.js";
@@ -202,21 +212,31 @@ function inFile(file, transform) {
202
212
  throw new Error(`${file}: ${errorMessage(error)}`);
203
213
  }
204
214
  }
205
- async function hooksInstalled(root, adapter, verifyMs) {
215
+ async function hookCommandResolves(root, signal) {
216
+ return existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")) || await onPath("rulecast", signal);
217
+ }
218
+ async function hookState(root, adapter, verifyMs) {
206
219
  const install = installOf(adapter);
207
220
  const command = install.command(existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")));
208
221
  for (const { file } of install.scopes) {
209
222
  const current = await readSettings(root, file);
210
223
  if (current === null) continue;
211
- if (inFile(file, () => install.merge(current.value, command, verifyMs)).added.length === 0) return file;
224
+ const merged = inFile(file, () => install.merge(current.value, command, verifyMs));
225
+ if (merged.added.length === 0) return { file, stale: merged.updated ?? [] };
212
226
  }
213
227
  return null;
214
228
  }
215
229
  async function installHooks(root, adapter, scope, verifyMs) {
216
230
  const install = installOf(adapter);
217
231
  const command = install.command(existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")));
218
- const already = await hooksInstalled(root, adapter, verifyMs);
219
- if (already !== null) return { file: already, added: [] };
232
+ const already = await hookState(root, adapter, verifyMs);
233
+ if (already !== null && already.stale.length === 0) return { file: already.file, added: [] };
234
+ if (already !== null) {
235
+ const current2 = (await readSettings(root, already.file))?.value ?? {};
236
+ const merged2 = inFile(already.file, () => install.merge(current2, command, verifyMs));
237
+ await writeSettings(root, already.file, merged2.settings);
238
+ return { file: already.file, added: [], updated: merged2.updated ?? [] };
239
+ }
220
240
  const target = install.scopes.find((candidate) => candidate.scope === scope);
221
241
  if (!target) throw new UsageError(`${adapter.name} has no ${scope} settings`);
222
242
  const current = (await readSettings(root, target.file))?.value ?? {};
@@ -253,8 +273,10 @@ async function loadProjectConfig(root, command) {
253
273
  return config.value;
254
274
  }
255
275
  function printInstall(io, adapter, result) {
276
+ const updated = result.updated ?? [];
256
277
  io.stdout(
257
- result.added.length === 0 ? `${adapter.label} hooks already installed in ${result.file}
278
+ updated.length > 0 ? `updated ${adapter.label} hooks in ${result.file} to the current command: ${updated.join(", ")}
279
+ ` : result.added.length === 0 ? `${adapter.label} hooks already installed in ${result.file}
258
280
  ` : `installed ${adapter.label} hooks in ${result.file}: ${result.added.join(", ")}
259
281
  `
260
282
  );
@@ -440,12 +462,16 @@ async function doctorCommand(root, args, registry, io) {
440
462
  }
441
463
  }
442
464
  io.stdout("\nhooks\n");
465
+ const resolves = await hookCommandResolves(root);
443
466
  for (const adapter of ADAPTERS) {
444
467
  if (adapter.install === null) continue;
445
- const file = await hooksInstalled(root, adapter, project.config.timeouts.verifyMs);
446
- const level = file === null ? "warning" : "ok";
468
+ const state = await hookState(root, adapter, project.config.timeouts.verifyMs);
469
+ const file = state?.file ?? null;
470
+ const stale = (state?.stale ?? []).length > 0;
471
+ const level = file === null || !resolves || stale ? "warning" : "ok";
447
472
  count(level);
448
- io.stdout(line(level, adapter.label, file ?? "not installed (run rulecast install)"));
473
+ const detail = file === null ? "not installed (run rulecast install)" : stale ? `${file} \u2014 but the hooks use an old command; run rulecast install` : resolves ? file : `${file} \u2014 but "rulecast" is on neither the PATH nor node_modules/.bin, so the hooks do nothing. Add it to the project (pnpm add -D @syv-ai/rulecast) and run rulecast install again.`;
474
+ io.stdout(line(level, adapter.label, detail));
449
475
  }
450
476
  io.stdout("\ncache\n");
451
477
  io.stdout(` home ${home}
@@ -460,12 +486,14 @@ async function doctorCommand(root, args, registry, io) {
460
486
  `);
461
487
  }
462
488
  io.stdout("\ndry run\n");
463
- const dry = await dryRun(project, registry);
489
+ const dry = await dryRun(project, detectionFor(project, registry, stateDir, createReferenceResolver(root)));
464
490
  if (dry.length === 0) io.stdout(" nothing to run\n");
465
491
  for (const result of dry) {
466
492
  count(result.level);
467
493
  io.stdout(line(result.level, result.rule, result.detail));
468
494
  }
495
+ if (dry.some((result) => result.tried)) io.stdout(` ${DRY_RUN_FOOTER}
496
+ `);
469
497
  io.stdout(
470
498
  `
471
499
  ${errors + warnings === 0 ? "no problems found" : [plural(errors, "error"), plural(warnings, "warning")].join(", ")}
@@ -473,7 +501,8 @@ ${errors + warnings === 0 ? "no problems found" : [plural(errors, "error"), plur
473
501
  );
474
502
  return errors > 0 ? 2 : 0;
475
503
  }
476
- async function dryRun(project, registry) {
504
+ var DRY_RUN_FOOTER = "the dry run tries one file per rule; rulecast run --all-files checks them all";
505
+ async function dryRun(project, detection) {
477
506
  if (project.rules.length === 0) return [];
478
507
  let files;
479
508
  try {
@@ -483,31 +512,38 @@ async function dryRun(project, registry) {
483
512
  }
484
513
  const lines = [];
485
514
  for (const rule of project.rules) {
515
+ if (!rule.enabled) {
516
+ lines.push({ rule: rule.id, level: "skipped", detail: "disabled" });
517
+ continue;
518
+ }
486
519
  if (!isDetectorRule(rule)) {
487
520
  lines.push({ rule: rule.id, level: "ok", detail: "context only, nothing to run" });
488
521
  continue;
489
522
  }
490
- if (rule.detector.kind === "llm") {
491
- lines.push({ rule: rule.id, level: "skipped", detail: "llm rules are not dry-run (a model call costs money)" });
523
+ if (detection.registry.get(rule.detector.kind)?.metered === true) {
524
+ lines.push({
525
+ rule: rule.id,
526
+ level: "skipped",
527
+ detail: `${rule.detector.kind} rules are not dry-run (each run costs money)`
528
+ });
492
529
  continue;
493
530
  }
494
- const file = files.find((candidate) => rule.matches(candidate));
531
+ const matching = files.filter((candidate) => rule.matches(candidate));
532
+ const file = matching[0];
495
533
  if (file === void 0) {
496
534
  lines.push({ rule: rule.id, level: "warning", detail: "no file in the project matches this rule" });
497
535
  continue;
498
536
  }
499
537
  const timeoutMs = project.config.timeouts.verifyMs;
500
538
  const output = await runDetection({
501
- root: project.root,
539
+ // Spread over the project's context, so the two deviations read as deviations. The memory
540
+ // cache is the reason given in this function's own comment; context is unused because only
541
+ // the llm detector reads it and llm rules never reach here.
542
+ detection: { ...detection, cacheFor: () => memoryCache(), contextFor: async () => [] },
502
543
  event: "verify",
503
544
  selections: [{ rule, files: [file] }],
504
545
  changes: /* @__PURE__ */ new Map(),
505
546
  read: (name) => readSourceFile(project.root, name),
506
- registry,
507
- cacheFor: () => memoryCache(),
508
- // Only the llm detector reads context, and llm rules never reach here.
509
- contextFor: async () => [],
510
- settings: { llm: project.config.llm },
511
547
  timeoutMs
512
548
  });
513
549
  const failure = output.errors[0];
@@ -523,12 +559,150 @@ async function dryRun(project, registry) {
523
559
  lines.push({
524
560
  rule: rule.id,
525
561
  level: "ok",
526
- detail: `${file}, ${matches === 0 ? "no match" : matches === 1 ? "1 match" : `${matches} matches`}`
562
+ detail: `tried ${file} (1 of ${plural(matching.length, "matching file")}): ${matches === 0 ? "no match" : matches === 1 ? "1 match" : `${matches} matches`}`,
563
+ tried: true
527
564
  });
528
565
  }
529
566
  return lines;
530
567
  }
531
568
 
569
+ // src/commands/help.ts
570
+ var COMMANDS = [
571
+ {
572
+ name: "init",
573
+ synopsis: "rulecast init [--rules id,id | --no-rules] [--agent <name>... | --no-agents] [--scope shared|personal] [--yes]",
574
+ summary: "Set rulecast up: config, catalog rules, agent hooks",
575
+ flags: [
576
+ ["--rules id,id", "Install these catalog rules instead of the ones that apply to your files"],
577
+ ["--no-rules", "Install no catalog rules"],
578
+ ["--agent <name>", "Install hooks for this agent (claude-code); repeatable"],
579
+ ["--no-agents", "Install no agent hooks"],
580
+ ["--scope shared|personal", "Write hooks to the shared or the personal settings file"],
581
+ ["--yes", "Take every default and write without asking"]
582
+ ]
583
+ },
584
+ {
585
+ name: "install",
586
+ synopsis: "rulecast install [--agent <name>]... [--scope shared|personal]",
587
+ summary: "Add the agent hooks, or upgrade them, and fetch missing rule repos",
588
+ flags: [
589
+ ["--agent <name>", "Only this agent; repeatable. Default: every supported agent"],
590
+ ["--scope shared|personal", "Write to the shared or the personal settings file"]
591
+ ]
592
+ },
593
+ {
594
+ name: "uninstall",
595
+ synopsis: "rulecast uninstall [--agent <name>]...",
596
+ summary: "Remove the agent hooks rulecast added",
597
+ flags: [["--agent <name>", "Only this agent; repeatable"]]
598
+ },
599
+ {
600
+ name: "run",
601
+ synopsis: "rulecast run [RULE_ID] [--all-files | --files F...] [--from-ref A [--to-ref B]] [--summary] [--format terminal|agent|json|sarif] [--session <id>] [--llm | --no-llm]",
602
+ summary: "Check staged files (the default), changed files, or everything",
603
+ flags: [
604
+ ["RULE_ID", "Run only this rule"],
605
+ ["--all-files", "Every file git knows about, with the adoption backlog"],
606
+ ["--files F...", "These files"],
607
+ ["--from-ref A", "Files changed since the merge base with A, judged against it"],
608
+ ["--to-ref B", "With --from-ref: up to B, read at B (pre-push, CI)"],
609
+ ["--summary", "Print the backlog instead of every finding"],
610
+ ["--format", "terminal, agent, json or sarif"],
611
+ ["--session <id>", "The files an agent session edited, as at its Stop"],
612
+ ["--llm", "Include metered (llm) rules in a staged run, which skips them by default"],
613
+ ["--no-llm", "Skip metered (llm) rules in any run"]
614
+ ]
615
+ },
616
+ {
617
+ name: "list",
618
+ synopsis: "rulecast list [RULE_ID] [--format terminal|json]",
619
+ summary: "Show every configured rule: where it comes from, what it matches, what it cites",
620
+ flags: [
621
+ ["RULE_ID", "Only this rule, with its message and the text of the sections it cites"],
622
+ ["--format", "terminal or json"]
623
+ ]
624
+ },
625
+ {
626
+ name: "test",
627
+ synopsis: "rulecast test [RULE_ID] [--against PATH...]",
628
+ summary: "Run each rule's good/bad examples; --against says how much it would flag",
629
+ flags: [
630
+ ["RULE_ID", "Only this rule. Needed for llm rules, which cost money per example"],
631
+ ["--against PATH...", "With RULE_ID: fire the rule over real files and count what it finds"]
632
+ ]
633
+ },
634
+ {
635
+ name: "validate",
636
+ synopsis: "rulecast validate [file...]",
637
+ summary: "Check the config (and a rules manifest) and print diagnostics",
638
+ flags: [["file...", "A config (repos:) or a manifest (a list of rules), whatever its name"]]
639
+ },
640
+ {
641
+ name: "doctor",
642
+ synopsis: "rulecast doctor",
643
+ summary: "Compile, check the environment and hooks, dry-run every rule",
644
+ flags: []
645
+ },
646
+ {
647
+ name: "autoupdate",
648
+ synopsis: "rulecast autoupdate [--freeze] [--repo URL]...",
649
+ summary: "Move pinned rule repos to their latest tag",
650
+ flags: [
651
+ ["--freeze", "Pin the commit SHA, with the tag in a comment"],
652
+ ["--repo URL", "Only this repo; repeatable"]
653
+ ]
654
+ },
655
+ {
656
+ name: "try-repo",
657
+ synopsis: "rulecast try-repo <path|url> [RULE_ID] [--ref REV] [run flags]",
658
+ summary: "Run a rule repo against your project without configuring it",
659
+ flags: [["--ref REV", "The revision of a URL repo (default HEAD)"]]
660
+ },
661
+ {
662
+ name: "clean",
663
+ synopsis: "rulecast clean [--project]",
664
+ summary: "Delete the cache",
665
+ flags: [["--project", "Only this project's directory"]]
666
+ },
667
+ {
668
+ name: "warm",
669
+ synopsis: "rulecast warm [--detector <kind>]...",
670
+ summary: "Build detector caches ahead of time",
671
+ flags: [["--detector <kind>", "Only this detector kind; repeatable"]]
672
+ },
673
+ {
674
+ name: "hook",
675
+ synopsis: "rulecast hook <adapter>",
676
+ summary: "Answer an agent hook on stdin (the agent runs this, not you)",
677
+ flags: []
678
+ }
679
+ ];
680
+ var NAME_WIDTH = Math.max(...COMMANDS.map((command) => command.name.length));
681
+ function usage() {
682
+ return [
683
+ `rulecast ${VERSION}`,
684
+ "",
685
+ "usage: rulecast <command> [flags]",
686
+ "",
687
+ ...COMMANDS.map((command) => ` ${command.name.padEnd(NAME_WIDTH)} ${command.summary}`),
688
+ "",
689
+ "rulecast <command> --help shows a command's flags.",
690
+ ""
691
+ ].join("\n");
692
+ }
693
+ function commandHelp(name) {
694
+ const command = COMMANDS.find((candidate) => candidate.name === name);
695
+ if (command === void 0) return null;
696
+ const width = Math.max(0, ...command.flags.map(([flag]) => flag.length));
697
+ return [
698
+ command.synopsis,
699
+ "",
700
+ command.summary,
701
+ ...command.flags.length === 0 ? [] : ["", ...command.flags.map(([flag, text]) => ` ${flag.padEnd(width)} ${text}`)],
702
+ ""
703
+ ].join("\n");
704
+ }
705
+
532
706
  // src/core/detection/warm.ts
533
707
  import path4 from "path";
534
708
  var WARM_LOCK = { waitMs: 0, staleMs: 10 * 6e4, retryMs: 20 };
@@ -764,6 +938,26 @@ function detectionSummary(detection) {
764
938
  }
765
939
  return parts.length === 0 ? "nothing yet" : parts.join(" \xB7 ");
766
940
  }
941
+ var STOPWORDS = /* @__PURE__ */ new Set(["the", "a", "an", "in", "of", "and", "or", "to", "for", "md", "mdx", "readme"]);
942
+ var words = (text) => new Set(
943
+ text.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w !== "" && !STOPWORDS.has(w))
944
+ );
945
+ function matchHeading(spec, docs) {
946
+ const stem = path5.basename(spec.path).replace(/\.[^.]+$/, "");
947
+ const wanted = words(`${stem} ${spec.anchor ?? ""}`);
948
+ let best = null;
949
+ for (const doc of docs) {
950
+ for (const heading of doc.headings) {
951
+ if (heading.slug === "") continue;
952
+ const score = [...words(heading.text)].filter((word) => wanted.has(word)).length;
953
+ if (score === 0) continue;
954
+ if (best === null || score > best.score || score === best.score && heading.level < best.level) {
955
+ best = { value: `@${doc.file}#${heading.slug}`, score, level: heading.level };
956
+ }
957
+ }
958
+ }
959
+ return best?.value ?? null;
960
+ }
767
961
  function docChoices(doc) {
768
962
  const choices = [{ value: `@${doc.file}`, label: doc.file }];
769
963
  const trail = [];
@@ -858,13 +1052,13 @@ function addCatalogRules(text, catalog, rules) {
858
1052
  const entry = repos.items.find((item) => isMap2(item) && item.get("repo") === catalog.url);
859
1053
  let edit;
860
1054
  if (entry) {
861
- const listed = entry.get("rules", true);
1055
+ const listed2 = entry.get("rules", true);
862
1056
  const rulesKey = pairKey(entry, "rules");
863
- if (!isSeq2(listed) || rulesKey === null) throw new ConfigTextError(`${catalog.url} has no rules list`);
864
- const present = new Set(listed.items.map((item) => isMap2(item) ? item.get("id") : void 0));
1057
+ if (!isSeq2(listed2) || rulesKey === null) throw new ConfigTextError(`${catalog.url} has no rules list`);
1058
+ const present = new Set(listed2.items.map((item) => isMap2(item) ? item.get("id") : void 0));
865
1059
  const added = rules.filter((rule) => !present.has(rule.id));
866
1060
  if (added.length === 0) return text;
867
- edit = appendToSeq(text, listed, rulesKey, added.flatMap(ruleLines));
1061
+ edit = appendToSeq(text, listed2, rulesKey, added.flatMap(ruleLines));
868
1062
  } else {
869
1063
  if (rules.length === 0) return text;
870
1064
  edit = appendToSeq(text, repos, reposKey, repoLines(catalog, rules));
@@ -917,7 +1111,19 @@ async function planAgent(choice, context) {
917
1111
  };
918
1112
  for (const { file } of install.scopes) {
919
1113
  const text2 = await context.readText(file);
920
- if (text2 !== null && merge(parseSettings(text2, file), file).added.length === 0) return null;
1114
+ if (text2 === null) continue;
1115
+ const merged2 = merge(parseSettings(text2, file), file);
1116
+ if (merged2.added.length > 0) continue;
1117
+ const updated = merged2.updated ?? [];
1118
+ if (updated.length === 0) return null;
1119
+ return {
1120
+ file,
1121
+ content: `${JSON.stringify(merged2.settings, null, 2)}
1122
+ `,
1123
+ created: false,
1124
+ summary: `~${plural2(updated.length, "hook")} updated (${choice.adapter.label})`,
1125
+ commit: install.scopes.find((entry) => entry.file === file)?.scope === "shared"
1126
+ };
921
1127
  }
922
1128
  const target = install.scopes.find((entry) => entry.scope === choice.scope);
923
1129
  if (target === void 0) throw new PlanError(`${choice.adapter.label} has no ${choice.scope} settings`);
@@ -948,6 +1154,7 @@ function reviewText(changes) {
948
1154
  }
949
1155
 
950
1156
  // src/commands/init.ts
1157
+ var NO_HOOKS = "No agent hooks: rulecast will not run in your agent until you run rulecast install --agent <name>.";
951
1158
  var DEFAULT_CATALOG = "https://github.com/syv-ai/rulecast";
952
1159
  var SCOPES = ["shared", "personal"];
953
1160
  var installable = () => ADAPTERS.filter((adapter) => adapter.install !== null);
@@ -958,11 +1165,13 @@ function parseFlags(args) {
958
1165
  rules: { type: "string" },
959
1166
  "no-rules": { type: "boolean", default: false },
960
1167
  agent: { type: "string", multiple: true },
1168
+ "no-agents": { type: "boolean", default: false },
961
1169
  scope: { type: "string" },
962
1170
  yes: { type: "boolean", default: false }
963
1171
  }
964
1172
  });
965
1173
  if (values.rules !== void 0 && values["no-rules"]) throw new UsageError("use --rules or --no-rules, not both");
1174
+ if (values.agent !== void 0 && values["no-agents"]) throw new UsageError("use --agent or --no-agents, not both");
966
1175
  const scope = values.scope ?? null;
967
1176
  if (scope !== null && !SCOPES.includes(scope)) {
968
1177
  throw new UsageError(`unknown scope "${scope}" (use shared or personal)`);
@@ -976,7 +1185,14 @@ function parseFlags(args) {
976
1185
  return adapter;
977
1186
  });
978
1187
  const rules = values.rules === void 0 ? null : values.rules.split(",").map((id) => id.trim()).filter(Boolean);
979
- return { rules, noRules: values["no-rules"], agents, scope, yes: values.yes };
1188
+ return {
1189
+ rules,
1190
+ noRules: values["no-rules"],
1191
+ agents,
1192
+ noAgents: values["no-agents"],
1193
+ scope,
1194
+ yes: values.yes
1195
+ };
980
1196
  }
981
1197
  function projectRoot(cwd) {
982
1198
  const found = findRoot(cwd);
@@ -1024,7 +1240,8 @@ var idOf = (entry) => {
1024
1240
  return typeof id === "string" ? id : null;
1025
1241
  };
1026
1242
  var groupOf = (id) => id.includes("/") ? id.slice(0, id.indexOf("/")) : "general";
1027
- async function chooseRules(catalog, installed, files, flags, ui) {
1243
+ async function chooseRules(catalog, installed, files, flags, ui, registry) {
1244
+ const metered = (rule) => rule.detector !== null && registry.get(rule.detector.kind)?.metered === true;
1028
1245
  const available = catalog.rules.filter((rule) => !installed.has(rule.id));
1029
1246
  if (flags.noRules) return [];
1030
1247
  if (flags.rules !== null) {
@@ -1033,7 +1250,7 @@ async function chooseRules(catalog, installed, files, flags, ui) {
1033
1250
  if (unknown !== void 0) throw new UsageError(`"${unknown}" is not a rule in ${catalog.label}`);
1034
1251
  return available.filter((rule) => flags.rules.includes(rule.id));
1035
1252
  }
1036
- const preselected = available.filter((rule) => rule.detector?.kind !== "llm" && files.some((file) => rule.matches(file))).map((rule) => rule.id);
1253
+ const preselected = available.filter((rule) => !metered(rule) && files.some((file) => rule.matches(file))).map((rule) => rule.id);
1037
1254
  if (ui.prompter === null) return available.filter((rule) => preselected.includes(rule.id));
1038
1255
  if (available.length === 0) {
1039
1256
  ui.say(`Every rule from ${catalog.label} is already configured.`, "Rules");
@@ -1047,7 +1264,7 @@ async function chooseRules(catalog, installed, files, flags, ui) {
1047
1264
  choices.push({
1048
1265
  value: rule.id,
1049
1266
  label: group === "general" ? rule.id : rule.id.slice(group.length + 1),
1050
- hint: done ? "installed" : hintFor(rule),
1267
+ hint: done ? "installed" : hintFor(rule, registry),
1051
1268
  disabled: done
1052
1269
  });
1053
1270
  groups[group] = choices;
@@ -1059,11 +1276,10 @@ async function chooseRules(catalog, installed, files, flags, ui) {
1059
1276
  });
1060
1277
  return available.filter((rule) => chosen.includes(rule.id));
1061
1278
  }
1062
- function hintFor(rule) {
1279
+ function hintFor(rule, registry) {
1063
1280
  const description = rule.description ?? rule.name;
1064
- if (rule.detector?.kind !== "llm") return description;
1065
- const model = rule.detector.config.model ?? "a model";
1066
- return `llm \xB7 ${model} \xB7 sends file contents to your provider \u2014 ${description}`;
1281
+ const cost = rule.detector === null ? void 0 : registry.get(rule.detector.kind)?.cost?.(rule.detector.config);
1282
+ return cost === void 0 ? description : `${cost} \u2014 ${description}`;
1067
1283
  }
1068
1284
  async function chooseConventions(rules, detection, ui) {
1069
1285
  const choices = detection.docs.flatMap(docChoices);
@@ -1078,20 +1294,27 @@ async function chooseConventions(rules, detection, ui) {
1078
1294
  label: "keep the package's doc",
1079
1295
  hint: rule.context.map((spec) => spec.ref).join(", ")
1080
1296
  };
1297
+ const matched = rule.context.map((spec) => matchHeading(spec, detection.docs)).find((value) => value !== null);
1081
1298
  const answer = await ui.prompter.select({
1082
1299
  message: `Conventions for ${rule.id}`,
1083
1300
  choices: [keep, ...choices],
1084
- initialValue: ""
1301
+ initialValue: matched !== void 0 && choices.some((choice) => choice.value === matched) ? matched : ""
1085
1302
  });
1086
1303
  selections.push({ id: rule.id, context: answer === "" ? null : [answer] });
1087
1304
  }
1088
1305
  return selections;
1089
1306
  }
1307
+ function defaultAgents(detected, adapters) {
1308
+ const found = adapters.filter((adapter) => detected.includes(adapter.name));
1309
+ return found.length > 0 ? found : [...adapters];
1310
+ }
1090
1311
  async function chooseAgents(detection, flags, ui) {
1091
1312
  const detected = detection.agents.filter((agent) => agent.adapter !== null).map((agent) => agent.name);
1313
+ const defaults = defaultAgents(detected, installable());
1092
1314
  let chosen;
1093
- if (flags.agents.length > 0) chosen = flags.agents;
1094
- else if (ui.prompter === null) chosen = installable().filter((adapter) => detected.includes(adapter.name));
1315
+ if (flags.noAgents) chosen = [];
1316
+ else if (flags.agents.length > 0) chosen = flags.agents;
1317
+ else if (ui.prompter === null) chosen = defaults;
1095
1318
  else {
1096
1319
  const choices = installable().map((adapter) => ({
1097
1320
  value: adapter.name,
@@ -1108,12 +1331,13 @@ async function chooseAgents(detection, flags, ui) {
1108
1331
  });
1109
1332
  }
1110
1333
  }
1111
- const names = await ui.prompter.multiselect({ message: "Install hooks for", choices, initialValues: detected });
1334
+ const names = await ui.prompter.multiselect({
1335
+ message: "Install hooks for",
1336
+ choices,
1337
+ initialValues: defaults.map((adapter) => adapter.name)
1338
+ });
1112
1339
  chosen = installable().filter((adapter) => names.includes(adapter.name));
1113
1340
  }
1114
- if (chosen.length === 0) {
1115
- ui.say("No agent hooks will be installed. Add them later with rulecast install --agent <name>.", "Agents");
1116
- }
1117
1341
  const agents = [];
1118
1342
  for (const adapter of chosen) {
1119
1343
  let scope = flags.scope ?? "shared";
@@ -1172,7 +1396,7 @@ async function run(root, flags, registry, io, ui) {
1172
1396
  const catalog = typeof loaded === "string" ? null : loaded;
1173
1397
  if (typeof loaded === "string") ui.say(`${loaded}. Continuing without catalog rules.`, "Catalog unavailable");
1174
1398
  const installed = new Set((configured?.rules ?? []).map(idOf).filter((id) => id !== null));
1175
- const rules = catalog === null ? [] : await chooseRules(catalog, installed, files, flags, ui);
1399
+ const rules = catalog === null ? [] : await chooseRules(catalog, installed, files, flags, ui, registry);
1176
1400
  const selections = await chooseConventions(rules, detection, ui);
1177
1401
  const agents = await chooseAgents(detection, flags, ui);
1178
1402
  const changes = await planInit(
@@ -1184,9 +1408,10 @@ async function run(root, flags, registry, io, ui) {
1184
1408
  readText: (file) => readSourceFile(root, file)
1185
1409
  }
1186
1410
  );
1187
- if (changes.length === 0) ui.say("Nothing to change.", "Review");
1411
+ const noHooks = agents.length === 0 ? [NO_HOOKS] : [];
1412
+ if (changes.length === 0) ui.say(["Nothing to change.", ...noHooks].join("\n"), "Review");
1188
1413
  else {
1189
- ui.say(reviewText(changes), "Review");
1414
+ ui.say([reviewText(changes), ...noHooks].join("\n"), "Review");
1190
1415
  if (ui.prompter !== null) {
1191
1416
  if (!await ui.prompter.confirm({ message: "Write?", initialValue: true })) {
1192
1417
  ui.prompter.outro("Nothing written.");
@@ -1238,14 +1463,160 @@ async function initCommand(args, registry, io) {
1238
1463
  }
1239
1464
  }
1240
1465
 
1241
- // src/commands/run.ts
1466
+ // src/commands/list.ts
1242
1467
  import { parseArgs as parseArgs5 } from "util";
1468
+ import { stringify } from "yaml";
1469
+ function listed(rule, files) {
1470
+ return {
1471
+ id: rule.id,
1472
+ name: rule.name,
1473
+ source: rule.source,
1474
+ enabled: rule.enabled,
1475
+ stages: [...rule.stages],
1476
+ detector: rule.detector?.kind ?? null,
1477
+ config: rule.detector?.config ?? null,
1478
+ severity: rule.severity,
1479
+ files: rule.patterns.files,
1480
+ exclude: rule.patterns.exclude,
1481
+ matchingFiles: files === null ? null : files.filter((file) => rule.matches(file)).length,
1482
+ context: rule.context.map((spec) => spec.ref)
1483
+ };
1484
+ }
1485
+ async function projectFiles(root) {
1486
+ try {
1487
+ return await allFiles(root);
1488
+ } catch {
1489
+ return null;
1490
+ }
1491
+ }
1492
+ var MATCHING_SHOWN = 20;
1493
+ function matchingText(count) {
1494
+ if (count === null) return "";
1495
+ if (count === 0) return " \u2014 matches no file in this project";
1496
+ return ` \u2014 matches ${count} ${count === 1 ? "file" : "files"}`;
1497
+ }
1498
+ async function listCommand(root, args, registry, io) {
1499
+ const { values, positionals } = parseArgs5({
1500
+ args,
1501
+ allowPositionals: true,
1502
+ options: { format: { type: "string", default: "terminal" } }
1503
+ });
1504
+ if (positionals.length > 1) throw new UsageError(`unexpected arguments: ${positionals.slice(1).join(" ")}`);
1505
+ if (values.format !== "terminal" && values.format !== "json") {
1506
+ throw new UsageError(`unknown format "${values.format}" (use terminal or json)`);
1507
+ }
1508
+ if (!hasProject(root)) throw new Error(`no ${CONFIG_FILE} in ${io.cwd} or its parents`);
1509
+ const ruleId = positionals[0] ?? null;
1510
+ const project = await compile({ root, registry, repos: fetchingRepos(cacheHome(io.env)) });
1511
+ const compiled = project.rules.filter((rule) => ruleId === null || rule.id === ruleId);
1512
+ if (ruleId !== null && compiled.length === 0) throw new UsageError(`no rule "${ruleId}" (see rulecast validate)`);
1513
+ const files = await projectFiles(root);
1514
+ const rules = compiled.map((rule) => listed(rule, files));
1515
+ if (ruleId !== null) {
1516
+ const rule = compiled[0];
1517
+ const resolver = createReferenceResolver(root);
1518
+ rules[0].matching = (files ?? []).filter((file) => rule.matches(file));
1519
+ rules[0].message = rule.message;
1520
+ rules[0].sections = await Promise.all(
1521
+ rule.context.map(async (spec) => {
1522
+ const resolved = await resolver.resolve(spec);
1523
+ return { ref: spec.ref, content: resolved.found ? resolved.content : null };
1524
+ })
1525
+ );
1526
+ }
1527
+ if (values.format === "json") {
1528
+ io.stdout(`${JSON.stringify(rules, null, 2)}
1529
+ `);
1530
+ return 0;
1531
+ }
1532
+ const out = [];
1533
+ for (const rule of rules) {
1534
+ const what = rule.detector === null ? "touch" : `${rule.detector} \xB7 ${rule.stages.join(", ")} \xB7 ${rule.severity}`;
1535
+ out.push(`${rule.id}${rule.enabled ? "" : " (disabled)"} ${what} [${rule.source}]`);
1536
+ out.push(` ${rule.name}`);
1537
+ const scope = `files ${rule.files === "" ? "(all)" : rule.files}${rule.exclude === "^$" ? "" : `, exclude ${rule.exclude}`}`;
1538
+ out.push(` ${scope}${matchingText(rule.matchingFiles)}`);
1539
+ if (rule.config !== null) {
1540
+ out.push(
1541
+ " detects:",
1542
+ ...stringify(rule.config).trimEnd().split("\n").map((line2) => ` ${line2}`)
1543
+ );
1544
+ }
1545
+ if (rule.matching !== void 0 && rule.matching.length > 0) {
1546
+ const shown = rule.matching.slice(0, MATCHING_SHOWN);
1547
+ const more = rule.matching.length - shown.length;
1548
+ out.push(` matching ${shown.join(", ")}${more > 0 ? ` and ${more} more` : ""}`);
1549
+ }
1550
+ if (rule.message !== void 0 && rule.message !== null) out.push(` message ${rule.message}`);
1551
+ if (rule.context.length > 0) out.push(` cites ${rule.context.join(", ")}`);
1552
+ out.push("");
1553
+ for (const section of rule.sections ?? []) {
1554
+ if (section.content === null) out.push(`--- ${section.ref} --- not found`, "");
1555
+ else out.push(`--- ${section.ref} ---`, section.content, "");
1556
+ }
1557
+ }
1558
+ if (ruleId === null) {
1559
+ const errors = project.diagnostics.filter((diagnostic) => diagnostic.level === "error");
1560
+ for (const diagnostic of errors) out.push(`not compiled: ${diagnosticText(diagnostic)}`);
1561
+ out.push(
1562
+ `${rules.length} ${rules.length === 1 ? "rule" : "rules"}${errors.length > 0 ? `, ${errors.length} not compiled (rulecast validate)` : ""}`,
1563
+ ""
1564
+ );
1565
+ out.push("rulecast list <rule-id> shows one rule with the text of the sections it cites.");
1566
+ }
1567
+ io.stdout(`${out.join("\n").trimEnd()}
1568
+ `);
1569
+ return 0;
1570
+ }
1571
+
1572
+ // src/commands/run.ts
1573
+ import { parseArgs as parseArgs6 } from "util";
1243
1574
 
1244
1575
  // src/adapters/cli/format.ts
1245
1576
  var CLI_FORMATS = ["terminal", "agent", "json", "sarif"];
1577
+ function ignoredLine(ignored) {
1578
+ return `ignored: ${plural3(ignored.length, "finding")} by rulecast-ignore comments (rulecast run --format json lists them)`;
1579
+ }
1580
+ function skippedLine(skipped) {
1581
+ const rules = skipped.length === 1 ? "metered rule" : "metered rules";
1582
+ return `skipped ${skipped.length} ${rules} (${skipped.join(", ")}): costs money per file. Pass --llm to include ${skipped.length === 1 ? "it" : "them"}.`;
1583
+ }
1584
+ var TOP_FILES = 10;
1246
1585
  var plural3 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
1586
+ function checkedLine(checked) {
1587
+ switch (checked.selection) {
1588
+ case "staged":
1589
+ return `checked ${plural3(checked.files, "staged file")}`;
1590
+ case "range":
1591
+ return `checked ${plural3(checked.files, "changed file")}`;
1592
+ case "all":
1593
+ return `checked all ${plural3(checked.files, "file")}`;
1594
+ case "files":
1595
+ return `checked ${plural3(checked.files, "file")}`;
1596
+ case "session":
1597
+ return "checked the session's edited files";
1598
+ }
1599
+ }
1600
+ var NOTHING_STAGED = "nothing staged: 0 files checked (rulecast run --all-files checks everything)";
1601
+ function problemsBlock(warnings) {
1602
+ return warnings.length === 0 ? [] : ["rulecast problems:", ...warnings.map((warning) => ` - ${warning}`), ""];
1603
+ }
1247
1604
  function terminal(delivery, options) {
1248
1605
  const out = [];
1606
+ if (options.checked?.selection === "staged" && options.checked.files === 0 && delivery.warnings.length === 0) {
1607
+ return NOTHING_STAGED;
1608
+ }
1609
+ if (options.findings === false) {
1610
+ out.push(...problemsBlock(delivery.warnings));
1611
+ if (options.backlog === true) out.push(renderBacklog(summarise(delivery), { topFiles: TOP_FILES }));
1612
+ if (options.backlog === true && (options.ignored ?? []).length > 0) out.push("", ignoredLine(options.ignored));
1613
+ const tail = [
1614
+ ...(options.skipped ?? []).length > 0 ? [skippedLine(options.skipped)] : [],
1615
+ ...options.checked !== void 0 ? [checkedLine(options.checked)] : []
1616
+ ];
1617
+ if (tail.length > 0) out.push(...out.length > 0 ? [""] : [], ...tail);
1618
+ return out.join("\n");
1619
+ }
1249
1620
  const shown = /* @__PURE__ */ new Map();
1250
1621
  const hidden = /* @__PURE__ */ new Map();
1251
1622
  for (const finding of delivery.findings) {
@@ -1264,31 +1635,46 @@ function terminal(delivery, options) {
1264
1635
  }
1265
1636
  for (const [rule, rest] of hidden) out.push(`\u2026and ${rest.length} more for ${rule}`);
1266
1637
  if (out.length > 0) out.push("");
1267
- for (const summary of delivery.preexistingSummary) {
1268
- out.push(`pre-existing (not blocking): ${summary.rule} \xD7${summary.count} in ${summary.file}`);
1638
+ if (delivery.preexistingSummary.length > 0) {
1639
+ out.push("backlog in the files checked (not from your change):");
1640
+ for (const summary of delivery.preexistingSummary) {
1641
+ out.push(` ${summary.rule} \xD7${summary.count} in ${summary.file}`);
1642
+ }
1643
+ if (options.backlog !== true) out.push(" see all of it: rulecast run --all-files --summary");
1269
1644
  }
1270
1645
  if (delivery.references.length > 0) {
1271
1646
  out.push(`conventions: ${delivery.references.map((reference) => reference.ref).join(", ")}`);
1272
1647
  }
1273
1648
  if (delivery.preexistingSummary.length > 0 || delivery.references.length > 0) out.push("");
1274
- if (delivery.warnings.length > 0) {
1275
- out.push("warnings:", ...delivery.warnings.map((warning) => ` - ${warning}`), "");
1276
- }
1649
+ out.push(...problemsBlock(delivery.warnings));
1650
+ for (const notice of delivery.notices ?? []) out.push(notice, "");
1651
+ if ((options.skipped ?? []).length > 0) out.push(skippedLine(options.skipped));
1652
+ if (options.checked !== void 0) out.push(checkedLine(options.checked));
1277
1653
  const errors = delivery.findings.filter((finding) => finding.severity === "error").length;
1278
1654
  const warningsCount = delivery.findings.length - errors;
1655
+ const problems = delivery.warnings.length === 0 ? "" : ` \xB7 rulecast: ${plural3(delivery.warnings.length, "problem")} (see above)`;
1279
1656
  out.push(
1280
- delivery.findings.length === 0 ? "no findings" : `${plural3(errors, "error")}, ${plural3(warningsCount, "warning")}`
1657
+ (delivery.findings.length === 0 ? "no findings" : `findings: ${plural3(errors, "error")}, ${plural3(warningsCount, "warning")}`) + problems
1281
1658
  );
1659
+ if (options.backlog === true) out.push("", renderBacklog(summarise(delivery), { topFiles: TOP_FILES, note: false }));
1660
+ if (options.backlog === true && (options.ignored ?? []).length > 0) out.push("", ignoredLine(options.ignored));
1282
1661
  return out.join("\n");
1283
1662
  }
1284
- function json(delivery) {
1663
+ function json(delivery, options) {
1285
1664
  return JSON.stringify(
1286
1665
  {
1287
1666
  findings: delivery.findings,
1667
+ // Always, whatever flags were given: a consumer that did not ask for it can ignore a key, and
1668
+ // one that wanted it cannot conjure it. This is the number a team tracks over time.
1669
+ backlog: summarise(delivery),
1288
1670
  preexistingSummary: delivery.preexistingSummary,
1289
1671
  references: delivery.references,
1290
1672
  touches: delivery.touches,
1291
1673
  warnings: delivery.warnings,
1674
+ skipped: options.skipped ?? [],
1675
+ checked: options.checked ?? null,
1676
+ ignored: options.ignored ?? [],
1677
+ notices: delivery.notices ?? [],
1292
1678
  stop: delivery.stop
1293
1679
  },
1294
1680
  null,
@@ -1331,7 +1717,7 @@ function formatDelivery(delivery, format, options) {
1331
1717
  case "agent":
1332
1718
  return renderAgentText(delivery, options);
1333
1719
  case "json":
1334
- return json(delivery);
1720
+ return json(delivery, options);
1335
1721
  case "sarif":
1336
1722
  return sarif(delivery);
1337
1723
  }
@@ -1343,18 +1729,20 @@ function exitCodeFor(delivery, failed) {
1343
1729
 
1344
1730
  // src/commands/run.ts
1345
1731
  function parseRunArgs(args) {
1346
- const { values, tokens } = parseArgs5({
1732
+ const { values, tokens } = parseArgs6({
1347
1733
  args,
1348
1734
  allowPositionals: true,
1349
1735
  tokens: true,
1350
1736
  options: {
1351
1737
  files: { type: "boolean", default: false },
1352
1738
  "all-files": { type: "boolean", default: false },
1739
+ summary: { type: "boolean", default: false },
1353
1740
  "from-ref": { type: "string" },
1354
1741
  "to-ref": { type: "string" },
1355
1742
  format: { type: "string", default: "terminal" },
1356
1743
  session: { type: "string" },
1357
1744
  "no-llm": { type: "boolean", default: false },
1745
+ llm: { type: "boolean", default: false },
1358
1746
  ref: { type: "string" }
1359
1747
  }
1360
1748
  });
@@ -1378,6 +1766,8 @@ function parseRunArgs(args) {
1378
1766
  if (values.files && files.length === 0) throw new UsageError("--files needs at least one file");
1379
1767
  if (values.files && values["all-files"]) throw new UsageError("--all-files and --files cannot be combined");
1380
1768
  if (toRef !== null && fromRef === null) throw new UsageError("--to-ref needs --from-ref");
1769
+ if (values.summary && format === "sarif") throw new UsageError("--summary cannot be combined with --format sarif");
1770
+ if (values.llm && values["no-llm"]) throw new UsageError("--llm and --no-llm cannot be combined");
1381
1771
  if (fromRef !== null && (values.files || values["all-files"])) {
1382
1772
  throw new UsageError("--from-ref cannot be combined with --all-files or --files");
1383
1773
  }
@@ -1385,27 +1775,41 @@ function parseRunArgs(args) {
1385
1775
  leading,
1386
1776
  files: values.files ? files : null,
1387
1777
  allFiles: values["all-files"],
1778
+ summary: values.summary,
1388
1779
  fromRef,
1389
1780
  toRef,
1390
1781
  format,
1391
1782
  session: values.session ?? null,
1392
1783
  noLlm: values["no-llm"],
1784
+ llm: values.llm,
1393
1785
  ref: values.ref ?? null
1394
1786
  };
1395
1787
  }
1396
1788
  async function selectFiles(root, cwd, run2) {
1397
1789
  if (run2.files !== null) {
1398
1790
  const files = run2.files.map((file) => toProjectPath(root, cwd, file)).filter((file) => file !== null);
1399
- return { files, baseCommit: null };
1791
+ return { selection: "files", files, baseCommit: null, content: WORKTREE };
1400
1792
  }
1401
1793
  if (run2.fromRef !== null) {
1402
1794
  const base = await mergeBase(root, run2.fromRef, run2.toRef ?? "HEAD");
1403
- const files = run2.toRef === null ? await changedFilesSince(root, base) : await changedFilesBetween(root, base, run2.toRef);
1404
- return { files, baseCommit: base };
1795
+ if (run2.toRef === null) {
1796
+ return { selection: "range", files: await changedFilesSince(root, base), baseCommit: base, content: WORKTREE };
1797
+ }
1798
+ return {
1799
+ selection: "range",
1800
+ files: await changedFilesBetween(root, base, run2.toRef),
1801
+ baseCommit: base,
1802
+ content: { kind: "commit", ref: run2.toRef }
1803
+ };
1405
1804
  }
1406
- if (run2.allFiles) return { files: await allFiles(root), baseCommit: null };
1407
- if (run2.session !== null) return { files: [], baseCommit: null };
1408
- return { files: await stagedFiles(root), baseCommit: null };
1805
+ if (run2.allFiles) return { selection: "all", files: await allFiles(root), baseCommit: null, content: WORKTREE };
1806
+ if (run2.session !== null) return { selection: "session", files: [], baseCommit: null, content: WORKTREE };
1807
+ return {
1808
+ selection: "staged",
1809
+ files: await stagedFiles(root),
1810
+ baseCommit: await headCommit(root),
1811
+ content: { kind: "index" }
1812
+ };
1409
1813
  }
1410
1814
  async function executeRun({ project, ruleId, run: run2, registry, io }) {
1411
1815
  const root = project.root;
@@ -1413,6 +1817,9 @@ async function executeRun({ project, ruleId, run: run2, registry, io }) {
1413
1817
  throw new UsageError(`no rule "${ruleId}" (see rulecast validate)`);
1414
1818
  }
1415
1819
  const selected = await selectFiles(root, io.cwd, run2);
1820
+ const skipMetered = run2.noLlm || selected.selection === "staged" && !run2.llm;
1821
+ const meteredKinds = new Set(registry.kinds().filter((kind) => registry.get(kind)?.metered === true));
1822
+ const skipped = skipMetered ? project.rules.filter((rule) => rule.enabled && rule.detector !== null && meteredKinds.has(rule.detector.kind)).filter((rule) => ruleId === null || rule.id === ruleId).filter((rule) => rule.stages.includes("verify") && selected.files.some((file) => rule.matches(file))).map((rule) => rule.id) : [];
1416
1823
  const result = await runPipeline({
1417
1824
  project,
1418
1825
  stateDir: ensureProjectState(cacheHome(io.env), root),
@@ -1420,15 +1827,28 @@ async function executeRun({ project, ruleId, run: run2, registry, io }) {
1420
1827
  kind: "verify",
1421
1828
  files: selected.files,
1422
1829
  baseCommit: selected.baseCommit ?? void 0,
1830
+ content: selected.content,
1423
1831
  session: run2.session === null ? void 0 : { id: run2.session },
1424
1832
  cwd: root
1425
1833
  },
1426
1834
  registry,
1427
1835
  maxContextChars: null,
1428
- skipDetectorKinds: run2.noLlm ? /* @__PURE__ */ new Set(["llm"]) : void 0,
1836
+ skipDetectorKinds: skipMetered ? meteredKinds : void 0,
1429
1837
  onlyRules: ruleId === null ? void 0 : /* @__PURE__ */ new Set([ruleId])
1430
1838
  });
1431
- const text = formatDelivery(result.delivery, run2.format, { maxMatchesPerRule: project.config.maxMatchesPerRule });
1839
+ const text = formatDelivery(result.delivery, run2.format, {
1840
+ maxMatchesPerRule: project.config.maxMatchesPerRule,
1841
+ findings: !run2.summary,
1842
+ backlog: run2.allFiles || run2.summary,
1843
+ skipped,
1844
+ checked: { files: selected.files.length, selection: selected.selection },
1845
+ ignored: (result.ignored ?? []).map(({ rule, match, reason }) => ({
1846
+ rule: rule.id,
1847
+ file: match.file,
1848
+ line: match.line,
1849
+ reason
1850
+ }))
1851
+ });
1432
1852
  if (text) io.stdout(`${text}
1433
1853
  `);
1434
1854
  return exitCodeFor(result.delivery, result.failed);
@@ -1442,13 +1862,269 @@ async function runCommand(root, args, registry, io) {
1442
1862
  return executeRun({ project, ruleId: run2.leading[0] ?? null, run: run2, registry, io });
1443
1863
  }
1444
1864
 
1865
+ // src/commands/test.ts
1866
+ import path7 from "path";
1867
+ import { parseArgs as parseArgs7 } from "util";
1868
+
1869
+ // src/core/examples.ts
1870
+ var EXAMPLES_DIR = ".rulecast-examples-";
1871
+ function skippedResult(rule) {
1872
+ return { ...emptyResult(rule), skipped: true };
1873
+ }
1874
+ function emptyResult(rule) {
1875
+ return {
1876
+ rule,
1877
+ outcomes: [],
1878
+ recall: { matched: 0, total: 0 },
1879
+ precision: { correct: 0, total: 0 },
1880
+ missing: false,
1881
+ empty: false,
1882
+ skipped: false
1883
+ };
1884
+ }
1885
+ async function runExamples(input) {
1886
+ const { rule } = input;
1887
+ const result = emptyResult(rule.id);
1888
+ if (rule.examples === null) return { ...result, missing: true };
1889
+ const cases = [
1890
+ ...rule.examples.good.map((example, index) => ({ kind: "good", index, example })),
1891
+ ...rule.examples.bad.map((example, index) => ({ kind: "bad", index, example }))
1892
+ ];
1893
+ if (cases.length === 0) return { ...result, empty: true };
1894
+ for (const { kind, index, example } of cases) {
1895
+ const outcome = await withScratchTree(
1896
+ input.detection.root,
1897
+ EXAMPLES_DIR,
1898
+ /* @__PURE__ */ new Map([[example.path, example.code]]),
1899
+ (scratch) => runOne(input, scratch, example)
1900
+ );
1901
+ const fired2 = outcome.findings.length > 0;
1902
+ const passed = outcome.error === void 0 && fired2 === (kind === "bad");
1903
+ result.outcomes.push({ kind, index, path: example.path, ...outcome, passed });
1904
+ }
1905
+ const fired = (outcome) => outcome.findings.length > 0;
1906
+ const bad = result.outcomes.filter((outcome) => outcome.kind === "bad");
1907
+ const goodFired = result.outcomes.filter((outcome) => outcome.kind === "good" && fired(outcome));
1908
+ const badFired = bad.filter(fired);
1909
+ result.recall = { matched: badFired.length, total: bad.length };
1910
+ result.precision = { correct: badFired.length, total: badFired.length + goodFired.length };
1911
+ return result;
1912
+ }
1913
+ async function runOne(input, scratch, example) {
1914
+ const relative = `${scratch}/${example.path}`;
1915
+ const output = await runDetection({
1916
+ detection: input.detection,
1917
+ event: "verify",
1918
+ selections: [{ rule: input.rule, files: [relative] }],
1919
+ // No baseline: an example is entirely new, which is what an author is asking about.
1920
+ changes: /* @__PURE__ */ new Map(),
1921
+ read: (name) => readSourceFile(input.detection.root, name),
1922
+ timeoutMs: input.timeoutMs
1923
+ });
1924
+ const failure = output.errors[0]?.message ?? (output.timedOut.length > 0 ? "timed out" : void 0);
1925
+ if (failure !== void 0) return { findings: [], error: failure };
1926
+ const findings = output.findings.map(({ match }) => ({
1927
+ line: match.line,
1928
+ column: match.column,
1929
+ text: match.text,
1930
+ message: renderTemplate(input.rule.message, {
1931
+ ...match.captures,
1932
+ file: example.path,
1933
+ line: String(match.line),
1934
+ column: String(match.column),
1935
+ text: match.text,
1936
+ rule: input.rule.id
1937
+ })
1938
+ }));
1939
+ return { findings };
1940
+ }
1941
+
1942
+ // src/commands/test.ts
1943
+ function parseTestArgs(args) {
1944
+ const { values, positionals } = parseArgs7({
1945
+ args,
1946
+ allowPositionals: true,
1947
+ options: { against: { type: "string", multiple: true } }
1948
+ });
1949
+ if (positionals.length > 1) throw new UsageError(`unexpected arguments: ${positionals.slice(1).join(" ")}`);
1950
+ const against = values.against ?? [];
1951
+ const ruleId = positionals[0] ?? null;
1952
+ if (against.length > 0 && ruleId === null) throw new UsageError("--against needs a RULE_ID");
1953
+ return { ruleId, against };
1954
+ }
1955
+ var FEW_VIOLATIONS = 25;
1956
+ var SMALL_SAMPLE = 25;
1957
+ function againstNote(violations, violatingFiles, matchingFiles) {
1958
+ if (matchingFiles < SMALL_SAMPLE) {
1959
+ return [
1960
+ ` Only ${matchingFiles} matching ${matchingFiles === 1 ? "file" : "files"}: too few for these numbers to say how often the rule is broken.`,
1961
+ " Judge the rule by the doc it enforces."
1962
+ ];
1963
+ }
1964
+ if (violations === 0) {
1965
+ return [
1966
+ " Nothing to fix today is not the same as nothing to catch: this counts the",
1967
+ " stock, not how often an edit would break the rule."
1968
+ ];
1969
+ }
1970
+ if (violatingFiles * 2 > matchingFiles) {
1971
+ return [" Most matching files violate it: check it is a convention, not a description of the code."];
1972
+ }
1973
+ if (violations < FEW_VIOLATIONS) {
1974
+ return [
1975
+ " Few violations: a rule nobody breaks costs an agent context and catches little",
1976
+ ` (8 of 15 measured conventions were under ${FEW_VIOLATIONS}).`
1977
+ ];
1978
+ }
1979
+ return [];
1980
+ }
1981
+ var plural4 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
1982
+ var touchRule = (id) => `${id} is a touch rule: it delivers context and has nothing to test
1983
+ `;
1984
+ var ratio = (part, whole) => whole === 0 ? " \u2014 " : (part / whole).toFixed(2);
1985
+ function summaryLine(rule, result) {
1986
+ const id = rule.id.padEnd(36);
1987
+ if (result.skipped) return ` ${id}skipped (llm; name the rule to run it)`;
1988
+ if (result.missing || result.empty) return ` ${id}no examples`;
1989
+ const passed = result.outcomes.filter((outcome) => outcome.passed).length;
1990
+ const score = `${passed}/${result.outcomes.length}`.padEnd(7);
1991
+ return ` ${id}${score} P ${ratio(result.precision.correct, result.precision.total)} R ${ratio(result.recall.matched, result.recall.total)}`;
1992
+ }
1993
+ var MATCH_PREVIEW = 60;
1994
+ function matchedLine(text) {
1995
+ const shown = text.length > MATCH_PREVIEW ? `${text.slice(0, MATCH_PREVIEW)}\u2026` : text;
1996
+ return `matched ${JSON.stringify(shown)}${/\r?\n/.test(text) ? ", across a line break" : ""}`;
1997
+ }
1998
+ function failureLines(rule, result) {
1999
+ const out = [];
2000
+ for (const outcome of result.outcomes) {
2001
+ if (outcome.passed) continue;
2002
+ const name = `${outcome.kind}[${outcome.index}] ${outcome.path}`;
2003
+ if (outcome.error !== void 0) {
2004
+ out.push(` ${name} \u2014 ${rule.detector.kind} failed: ${outcome.error}`);
2005
+ continue;
2006
+ }
2007
+ if (outcome.kind === "bad") {
2008
+ out.push(` ${name} \u2014 no finding`);
2009
+ continue;
2010
+ }
2011
+ const source = (rule.examples?.good[outcome.index]?.code ?? "").split(/\r?\n/);
2012
+ for (const finding of outcome.findings) {
2013
+ out.push(` ${name} \u2014 fired at line ${finding.line}`, ` ${source[finding.line - 1]?.trim() ?? ""}`);
2014
+ out.push(` ${matchedLine(finding.text)}`);
2015
+ }
2016
+ }
2017
+ return out;
2018
+ }
2019
+ var AGAINST_TOP_FILES = 10;
2020
+ async function reportAgainst(root, rule, paths, detection, timeoutMs, io) {
2021
+ const prefixes = paths.map((given) => {
2022
+ if (path7.resolve(io.cwd, given) === path7.resolve(root)) return "";
2023
+ const resolved = toProjectPath(root, io.cwd, given);
2024
+ if (resolved === null) throw new UsageError(`${given} is outside the project`);
2025
+ return resolved;
2026
+ });
2027
+ const under = (file) => prefixes.some((prefix) => prefix === "" || file === prefix || file.startsWith(`${prefix}/`));
2028
+ const files = (await allFiles(root)).filter((file) => under(file) && rule.matches(file));
2029
+ if (files.length === 0) {
2030
+ io.stdout(`
2031
+ no files under ${paths.join(", ")} match ${rule.id}
2032
+
2033
+ `);
2034
+ return 0;
2035
+ }
2036
+ const output = await runDetection({
2037
+ detection,
2038
+ event: "verify",
2039
+ selections: [{ rule, files }],
2040
+ changes: /* @__PURE__ */ new Map(),
2041
+ read: (file) => readSourceFile(root, file),
2042
+ timeoutMs
2043
+ });
2044
+ const failure = output.errors[0];
2045
+ if (failure !== void 0) throw new Error(`${rule.detector.kind} detector failed: ${failure.message}`);
2046
+ const byFile = /* @__PURE__ */ new Map();
2047
+ for (const { match } of output.findings) byFile.set(match.file, (byFile.get(match.file) ?? 0) + 1);
2048
+ const worst = [...byFile].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, AGAINST_TOP_FILES);
2049
+ const width = Math.max(...worst.map(([file]) => file.length), 0);
2050
+ const violations = output.findings.length;
2051
+ const out = ["", ` ${plural4(violations, "violation")} in ${byFile.size} of ${files.length} matching files`];
2052
+ if (worst.length > 0) {
2053
+ out.push("", ...worst.map(([file, count]) => ` ${file.padEnd(width + 2)}${count}`));
2054
+ if (byFile.size > worst.length) out.push(` \u2026and ${byFile.size - worst.length} more files`);
2055
+ }
2056
+ const note = againstNote(violations, byFile.size, files.length);
2057
+ if (note.length > 0) out.push("", ...note);
2058
+ out.push("");
2059
+ io.stdout(out.join("\n"));
2060
+ return 0;
2061
+ }
2062
+ async function testCommand(root, args, registry, io) {
2063
+ const { ruleId, against } = parseTestArgs(args);
2064
+ if (!hasProject(root)) throw new Error(`no ${CONFIG_FILE} in ${io.cwd} or its parents`);
2065
+ const project = await compile({ root, registry, repos: fetchingRepos(cacheHome(io.env)) });
2066
+ if (ruleId !== null && !project.rules.some((rule) => rule.id === ruleId)) {
2067
+ throw new UsageError(`no rule "${ruleId}" (see rulecast validate)`);
2068
+ }
2069
+ const rules = project.rules.filter(isDetectorRule).filter((rule) => ruleId === null || rule.id === ruleId);
2070
+ if (ruleId !== null && rules.length === 0) {
2071
+ io.stdout(touchRule(ruleId));
2072
+ return 0;
2073
+ }
2074
+ const stateDir = ensureProjectState(cacheHome(io.env), root);
2075
+ const resolver = createReferenceResolver(root);
2076
+ const detection = detectionFor(project, registry, stateDir, resolver);
2077
+ const timeoutMs = project.config.timeouts.verifyMs;
2078
+ if (against.length > 0) return await reportAgainst(root, rules[0], against, detection, timeoutMs, io);
2079
+ const lines = [""];
2080
+ const noExamples = [];
2081
+ const notRun = [];
2082
+ let failedRules = 0;
2083
+ let ranRules = 0;
2084
+ for (const rule of rules) {
2085
+ const result = (
2086
+ // Spec §6: a metered rule runs only when asked for by id — never as part of "test them all".
2087
+ ruleId === null && registry.get(rule.detector.kind)?.metered === true ? skippedResult(rule.id) : await runExamples({ detection, rule, timeoutMs })
2088
+ );
2089
+ lines.push(summaryLine(rule, result));
2090
+ if (result.missing || result.empty) {
2091
+ noExamples.push(rule.id);
2092
+ continue;
2093
+ }
2094
+ if (result.skipped) {
2095
+ notRun.push(rule.id);
2096
+ continue;
2097
+ }
2098
+ ranRules++;
2099
+ if (result.outcomes.some((outcome) => !outcome.passed)) {
2100
+ failedRules++;
2101
+ lines.push(...failureLines(rule, result));
2102
+ }
2103
+ }
2104
+ if (rules.length === 0) {
2105
+ io.stdout("no rules with a detector\n");
2106
+ return 0;
2107
+ }
2108
+ if (noExamples.length > 0) lines.push("", ` no examples: ${noExamples.join(", ")}`);
2109
+ if (notRun.length > 0) {
2110
+ lines.push("", ` not run: ${notRun.join(", ")} (rulecast test <id> runs one; it costs money per example)`);
2111
+ }
2112
+ lines.push(
2113
+ "",
2114
+ failedRules === 0 ? ranRules === 0 ? " nothing to run" : ` ${ranRules} ${ranRules === 1 ? "rule" : "rules"} passed` : ` ${failedRules} of ${ranRules} rules failed`,
2115
+ ""
2116
+ );
2117
+ io.stdout(lines.join("\n"));
2118
+ return failedRules === 0 ? 0 : 1;
2119
+ }
2120
+
1445
2121
  // src/commands/try-repo.ts
1446
2122
  import { existsSync as existsSync5, statSync as statSync2 } from "fs";
1447
2123
  import { mkdtemp, rm as rm2 } from "fs/promises";
1448
2124
  import { tmpdir as tmpdir2 } from "os";
1449
- import path7 from "path";
2125
+ import path8 from "path";
1450
2126
  var isDirectory = (file) => existsSync5(file) && statSync2(file).isDirectory();
1451
- var isGitRepo = (dir) => existsSync5(path7.join(dir, ".git")) || existsSync5(path7.join(dir, "HEAD"));
2127
+ var isGitRepo = (dir) => existsSync5(path8.join(dir, ".git")) || existsSync5(path8.join(dir, "HEAD"));
1452
2128
  async function baseConfigData(root) {
1453
2129
  if (!hasProject(root)) return {};
1454
2130
  const data = await readConfigData(root);
@@ -1460,16 +2136,16 @@ async function tryRepoCommand(root, args, registry, io) {
1460
2136
  const [repo, ruleId, ...extra] = run2.leading;
1461
2137
  if (repo === void 0) throw new UsageError("try-repo needs a repository path or URL");
1462
2138
  if (extra.length > 0) throw new UsageError(`unexpected arguments: ${extra.join(" ")}`);
1463
- const local = path7.resolve(io.cwd, repo);
2139
+ const local = path8.resolve(io.cwd, repo);
1464
2140
  const directory = isDirectory(local);
1465
2141
  if (directory && run2.ref !== null && !isGitRepo(local)) throw new UsageError("--ref only applies to URLs");
1466
2142
  const fromDirectory = directory && run2.ref === null;
1467
2143
  const rev = fromDirectory ? "working-tree" : run2.ref ?? "HEAD";
1468
- const temp = fromDirectory ? null : await mkdtemp(path7.join(tmpdir2(), "rulecast-try-repo-"));
2144
+ const temp = fromDirectory ? null : await mkdtemp(path8.join(tmpdir2(), "rulecast-try-repo-"));
1469
2145
  try {
1470
2146
  const dir = temp ?? local;
1471
2147
  if (temp !== null) await fetchCheckout(directory ? local : repo, rev, temp);
1472
- const label = fromDirectory ? `${path7.basename(local)}@${rev}` : repoLabel(repo, rev);
2148
+ const label = fromDirectory ? `${path8.basename(local)}@${rev}` : repoLabel(repo, rev);
1473
2149
  const manifest = await readManifest(dir);
1474
2150
  if (!manifest.ok) throw new Error(`${label}: ${manifest.message}`);
1475
2151
  const ids = manifest.value.flatMap((rule) => {
@@ -1490,23 +2166,42 @@ async function tryRepoCommand(root, args, registry, io) {
1490
2166
 
1491
2167
  // src/commands/validate.ts
1492
2168
  import { existsSync as existsSync6 } from "fs";
1493
- import path8 from "path";
2169
+ import path9 from "path";
1494
2170
  function formatDiagnostic(diagnostic) {
1495
2171
  return `${diagnostic.level === "warning" ? "warning: " : ""}${diagnosticText(diagnostic)}`;
1496
2172
  }
2173
+ function kindOf(full, data) {
2174
+ const name = path9.basename(full);
2175
+ if (name === CONFIG_FILE) return "config";
2176
+ if (name === MANIFEST_FILE) return "manifest";
2177
+ if (!data.ok) return null;
2178
+ if (Array.isArray(data.value)) return "manifest";
2179
+ if (typeof data.value === "object" && data.value !== null && "repos" in data.value) return "config";
2180
+ return null;
2181
+ }
1497
2182
  async function validateCommand(root, args, registry, io) {
1498
- const targets = args.length > 0 ? args.map((file) => ({ shown: file, full: path8.resolve(io.cwd, file) })) : [CONFIG_FILE, MANIFEST_FILE].filter((name) => existsSync6(path8.join(root, name))).map((name) => ({ shown: name, full: path8.join(root, name) }));
2183
+ const targets = args.length > 0 ? args.map((file) => ({ shown: file, full: path9.resolve(io.cwd, file) })) : [CONFIG_FILE, MANIFEST_FILE].filter((name) => existsSync6(path9.join(root, name))).map((name) => ({ shown: name, full: path9.join(root, name) }));
1499
2184
  if (targets.length === 0) throw new Error(`nothing to validate: no ${CONFIG_FILE} or ${MANIFEST_FILE}`);
1500
- for (const { shown, full } of targets) {
1501
- const name = path8.basename(full);
1502
- if (name !== CONFIG_FILE && name !== MANIFEST_FILE) {
1503
- throw new UsageError(`${shown}: not a ${CONFIG_FILE} or ${MANIFEST_FILE}`);
1504
- }
1505
- }
2185
+ const read = await Promise.all(
2186
+ targets.map(async (target) => {
2187
+ const data = await readYamlFile(target.full);
2188
+ const unparsedYaml = !data.ok && /\.ya?ml$/.test(target.full);
2189
+ const kind = kindOf(target.full, data) ?? (unparsedYaml ? "manifest" : null);
2190
+ if (kind === null) {
2191
+ throw new UsageError(`${target.shown}: neither a config (repos:) nor a manifest (a list of rules)`);
2192
+ }
2193
+ return { ...target, data, kind };
2194
+ })
2195
+ );
1506
2196
  let failed = false;
1507
- for (const { shown, full } of targets) {
1508
- const dir = path8.dirname(full);
1509
- const result = path8.basename(full) === CONFIG_FILE ? await compile({ root: dir, registry, repos: fetchingRepos(cacheHome(io.env)) }) : await compileManifest(dir, registry);
2197
+ for (const { shown, full, data, kind } of read) {
2198
+ const dir = path9.dirname(full);
2199
+ const result = kind === "config" ? await compile({
2200
+ root: dir,
2201
+ registry,
2202
+ repos: fetchingRepos(cacheHome(io.env)),
2203
+ configData: data.ok ? data.value : void 0
2204
+ }) : await compileManifest(dir, registry, full);
1510
2205
  for (const diagnostic of result.diagnostics) io.stdout(`${shown}: ${formatDiagnostic(diagnostic)}
1511
2206
  `);
1512
2207
  if (result.diagnostics.some((diagnostic) => diagnostic.level === "error")) {
@@ -1521,10 +2216,10 @@ async function validateCommand(root, args, registry, io) {
1521
2216
  }
1522
2217
 
1523
2218
  // src/commands/warm.ts
1524
- import { parseArgs as parseArgs6 } from "util";
2219
+ import { parseArgs as parseArgs8 } from "util";
1525
2220
  var WARM_TIMEOUT_MS = 5 * 6e4;
1526
2221
  async function warmCommand(root, args, registry, io) {
1527
- const { values } = parseArgs6({ args, options: { detector: { type: "string", multiple: true } } });
2222
+ const { values } = parseArgs8({ args, options: { detector: { type: "string", multiple: true } } });
1528
2223
  if (!hasProject(root)) throw new Error(`no .rulecast-config.yaml in ${root} or its parents (run rulecast init)`);
1529
2224
  const home = cacheHome(io.env);
1530
2225
  const stateDir = ensureProjectState(home, root);
@@ -1554,23 +2249,23 @@ async function warmCommand(root, args, registry, io) {
1554
2249
  }
1555
2250
 
1556
2251
  // src/commands/main.ts
1557
- var USAGE = `usage:
1558
- rulecast init [--rules id,id | --no-rules] [--agent <name>]... [--scope shared|personal] [--yes]
1559
- rulecast install [--agent <name>]... [--scope shared|personal]
1560
- rulecast uninstall [--agent <name>]...
1561
- rulecast run [RULE_ID] [--all-files | --files F...] [--from-ref A [--to-ref B]] [--format terminal|agent|json|sarif] [--session <id>] [--no-llm]
1562
- rulecast autoupdate [--freeze] [--repo URL]...
1563
- rulecast try-repo <path|url> [RULE_ID] [--ref REV] [run flags]
1564
- rulecast validate [file...]
1565
- rulecast clean [--project]
1566
- rulecast hook <adapter>
1567
- rulecast warm [--detector <kind>]...
1568
- rulecast doctor
1569
- `;
1570
2252
  async function main(argv, io) {
1571
2253
  const [command, ...args] = argv;
1572
2254
  const registry = createRegistry([...builtinDetectors]);
1573
2255
  const root = findRoot(io.cwd);
2256
+ if (command === "--version" || command === "-v" || command === "version") {
2257
+ io.stdout(`${VERSION}
2258
+ `);
2259
+ return 0;
2260
+ }
2261
+ const help = command === "help" && args[0] !== void 0 ? args[0] : args.includes("--help") || args.includes("-h") ? command : null;
2262
+ if (help !== null && help !== void 0) {
2263
+ const text = commandHelp(help);
2264
+ if (text !== null) {
2265
+ io.stdout(text);
2266
+ return 0;
2267
+ }
2268
+ }
1574
2269
  try {
1575
2270
  switch (command) {
1576
2271
  case "init":
@@ -1581,10 +2276,14 @@ async function main(argv, io) {
1581
2276
  return await uninstallCommand(root, args, io);
1582
2277
  case "run":
1583
2278
  return await runCommand(root, args, registry, io);
2279
+ case "list":
2280
+ return await listCommand(root, args, registry, io);
1584
2281
  case "autoupdate":
1585
2282
  return await autoupdateCommand(root, args, io);
1586
2283
  case "try-repo":
1587
2284
  return await tryRepoCommand(root, args, registry, io);
2285
+ case "test":
2286
+ return await testCommand(root, args, registry, io);
1588
2287
  case "validate":
1589
2288
  return await validateCommand(root, args, registry, io);
1590
2289
  case "clean":
@@ -1598,7 +2297,8 @@ async function main(argv, io) {
1598
2297
  case void 0:
1599
2298
  case "help":
1600
2299
  case "--help":
1601
- io.stdout(USAGE);
2300
+ case "-h":
2301
+ io.stdout(usage());
1602
2302
  return command === void 0 ? 2 : 0;
1603
2303
  default:
1604
2304
  throw new UsageError(`unknown command "${command}"`);
@@ -1607,7 +2307,8 @@ async function main(argv, io) {
1607
2307
  io.stderr(`rulecast: ${errorMessage(error)}
1608
2308
  `);
1609
2309
  if (error instanceof UsageError || error.code?.startsWith("ERR_PARSE_ARGS")) {
1610
- io.stderr(USAGE);
2310
+ io.stderr(`
2311
+ ${command !== void 0 && commandHelp(command) || usage()}`);
1611
2312
  }
1612
2313
  return 2;
1613
2314
  }
@@ -1625,7 +2326,7 @@ function spawnDetached(command, args, cwd) {
1625
2326
  // src/init/clipboard.ts
1626
2327
  import { spawn as spawn2 } from "child_process";
1627
2328
  import { accessSync, constants, statSync as statSync3 } from "fs";
1628
- import path9 from "path";
2329
+ import path10 from "path";
1629
2330
  var CANDIDATES = [
1630
2331
  { command: "pbcopy", args: [] },
1631
2332
  { command: "wl-copy", args: [] },
@@ -1642,9 +2343,9 @@ function isExecutable(file) {
1642
2343
  }
1643
2344
  }
1644
2345
  function findOnPath(name, env) {
1645
- for (const dir of (env.PATH ?? "").split(path9.delimiter)) {
2346
+ for (const dir of (env.PATH ?? "").split(path10.delimiter)) {
1646
2347
  if (dir === "") continue;
1647
- const candidate = path9.join(dir, name);
2348
+ const candidate = path10.join(dir, name);
1648
2349
  if (isExecutable(candidate)) return candidate;
1649
2350
  }
1650
2351
  return null;