@syv-ai/rulecast 0.1.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 ADDED
@@ -0,0 +1,1664 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ ADAPTERS,
4
+ CONFIG_FILE,
5
+ LockTimeoutError,
6
+ MANIFEST_FILE,
7
+ NO_PROMPT,
8
+ RepoFetchError,
9
+ VERSION,
10
+ adapterByName,
11
+ allFiles,
12
+ builtinDetectors,
13
+ cacheHome,
14
+ cachedRepo,
15
+ cachedRepos,
16
+ changedFilesBetween,
17
+ changedFilesSince,
18
+ checkDetectors,
19
+ compile,
20
+ compileManifest,
21
+ createRegistry,
22
+ debugLogger,
23
+ defaultConfig,
24
+ detectorCacheDir,
25
+ diagnosticText,
26
+ diskCache,
27
+ ensureProjectState,
28
+ ensureRepo,
29
+ errorMessage,
30
+ fetchCheckout,
31
+ fetchingRepos,
32
+ fixedRepo,
33
+ git,
34
+ isNotFound,
35
+ memoryCache,
36
+ mergeBase,
37
+ parseConfig,
38
+ parseVersion,
39
+ projectStateDir,
40
+ readConfigData,
41
+ readManifest,
42
+ readSourceFile,
43
+ renderAgentText,
44
+ repoLabel,
45
+ runDetection,
46
+ runPipeline,
47
+ scanHeadings,
48
+ stagedFiles,
49
+ tagsOf,
50
+ withLock
51
+ } from "./chunk-POEWJTZ2.js";
52
+ import {
53
+ Cancelled
54
+ } from "./chunk-K37BHCEK.js";
55
+
56
+ // src/cli.ts
57
+ import { text as readAll } from "stream/consumers";
58
+
59
+ // src/commands/autoupdate.ts
60
+ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
61
+ import path3 from "path";
62
+ import { parseArgs as parseArgs2 } from "util";
63
+
64
+ // src/core/config/edit.ts
65
+ import { isMap, isScalar, isSeq, parseDocument } from "yaml";
66
+ var FROZEN = /^\s*#\s*frozen:/;
67
+ function quoted(value, type) {
68
+ if (type === "QUOTE_DOUBLE") return JSON.stringify(value);
69
+ if (type === "QUOTE_SINGLE") return `'${value.replaceAll("'", "''")}'`;
70
+ return value;
71
+ }
72
+ function setRevs(text, updates) {
73
+ if (updates.length === 0) return text;
74
+ const doc = parseDocument(text, { keepSourceTokens: true });
75
+ const repos = doc.get("repos", true);
76
+ if (!isSeq(repos)) throw new Error("repos must be a list");
77
+ const edits = [];
78
+ for (const update of updates) {
79
+ const entry = repos.items[update.index];
80
+ if (!isMap(entry)) throw new Error(`repos[${update.index}] is not a mapping`);
81
+ const rev = entry.get("rev", true);
82
+ if (!isScalar(rev) || !rev.range) throw new Error(`repos[${update.index}] has no rev`);
83
+ const [start, valueEnd] = rev.range;
84
+ const value = quoted(update.rev, rev.type);
85
+ if (entry.flow) {
86
+ edits.push({ start, end: valueEnd, text: value });
87
+ continue;
88
+ }
89
+ const newline = text.indexOf("\n", valueEnd);
90
+ let end = newline === -1 ? text.length : newline;
91
+ if (text[end - 1] === "\r") end--;
92
+ let tail = text.slice(valueEnd, end);
93
+ if (update.frozenTag !== null) tail = ` # frozen: ${update.frozenTag}`;
94
+ else if (FROZEN.test(tail)) tail = "";
95
+ edits.push({ start, end, text: value + tail });
96
+ }
97
+ let result = text;
98
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
99
+ result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
100
+ }
101
+ return result;
102
+ }
103
+
104
+ // src/core/repos/tags.ts
105
+ import { tmpdir } from "os";
106
+ var TAG_REF = "refs/tags/";
107
+ var PEELED = "^{}";
108
+ async function remoteTags(url) {
109
+ const result = await git(tmpdir(), ["ls-remote", "--tags", url], NO_PROMPT);
110
+ if (!result.ok) throw new RepoFetchError(result.stderr.trim() || `git ls-remote ${url} failed`);
111
+ const tags = /* @__PURE__ */ new Map();
112
+ for (const line2 of result.stdout.split("\n")) {
113
+ const [sha, ref] = line2.split(" ");
114
+ if (!sha || !ref?.startsWith(TAG_REF)) continue;
115
+ const peeled = ref.endsWith(PEELED);
116
+ const name = ref.slice(TAG_REF.length, peeled ? -PEELED.length : void 0);
117
+ if (peeled || !tags.has(name)) tags.set(name, { sha, peeled });
118
+ }
119
+ return [...tags].map(([name, { sha }]) => ({ name, sha })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
120
+ }
121
+ function versionOf(name) {
122
+ return /^v?\d+\.\d+\.\d+$/.test(name) ? parseVersion(name.replace(/^v/, "")) : null;
123
+ }
124
+ function latestTag(tags) {
125
+ let best = null;
126
+ for (const tag of tags) {
127
+ const version = versionOf(tag.name);
128
+ if (!version) continue;
129
+ if (best === null || compareVersions(version, best.version) > 0) best = { tag, version };
130
+ }
131
+ return best?.tag ?? null;
132
+ }
133
+ function compareVersions(a, b) {
134
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
135
+ }
136
+
137
+ // src/commands/install.ts
138
+ import { existsSync as existsSync2 } from "fs";
139
+ import { mkdir, readFile, writeFile } from "fs/promises";
140
+ import path2 from "path";
141
+ import { parseArgs } from "util";
142
+
143
+ // src/commands/project.ts
144
+ import { existsSync } from "fs";
145
+ import path from "path";
146
+ function findRoot(cwd) {
147
+ for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
148
+ if (existsSync(path.join(dir, CONFIG_FILE))) return dir;
149
+ if (path.dirname(dir) === dir) return path.resolve(cwd);
150
+ }
151
+ }
152
+ function hasProject(root) {
153
+ return existsSync(path.join(root, CONFIG_FILE));
154
+ }
155
+ function toProjectPath(root, cwd, file) {
156
+ const relative = path.relative(root, path.resolve(cwd, file));
157
+ if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
158
+ return null;
159
+ }
160
+ return relative.split(path.sep).join("/");
161
+ }
162
+
163
+ // src/commands/usage.ts
164
+ var UsageError = class extends Error {
165
+ };
166
+
167
+ // src/commands/install.ts
168
+ async function readSettings(root, file) {
169
+ let text;
170
+ try {
171
+ text = await readFile(path2.join(root, file), "utf8");
172
+ } catch (error) {
173
+ if (isNotFound(error)) return null;
174
+ throw error;
175
+ }
176
+ try {
177
+ return { value: JSON.parse(text) };
178
+ } catch (error) {
179
+ throw new Error(`${file}: ${errorMessage(error)}`);
180
+ }
181
+ }
182
+ async function writeSettings(root, file, settings) {
183
+ const full = path2.join(root, file);
184
+ await mkdir(path2.dirname(full), { recursive: true });
185
+ await writeFile(full, `${JSON.stringify(settings, null, 2)}
186
+ `);
187
+ }
188
+ function installOf(adapter) {
189
+ if (!adapter.install) throw new UsageError(`${adapter.name} has no hooks to install`);
190
+ return adapter.install;
191
+ }
192
+ function inFile(file, transform) {
193
+ try {
194
+ return transform();
195
+ } catch (error) {
196
+ throw new Error(`${file}: ${errorMessage(error)}`);
197
+ }
198
+ }
199
+ async function hooksInstalled(root, adapter, verifyMs) {
200
+ const install = installOf(adapter);
201
+ const command = install.command(existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")));
202
+ for (const { file } of install.scopes) {
203
+ const current = await readSettings(root, file);
204
+ if (current === null) continue;
205
+ if (inFile(file, () => install.merge(current.value, command, verifyMs)).added.length === 0) return file;
206
+ }
207
+ return null;
208
+ }
209
+ async function installHooks(root, adapter, scope, verifyMs) {
210
+ const install = installOf(adapter);
211
+ const command = install.command(existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")));
212
+ const already = await hooksInstalled(root, adapter, verifyMs);
213
+ if (already !== null) return { file: already, added: [] };
214
+ const target = install.scopes.find((candidate) => candidate.scope === scope);
215
+ if (!target) throw new UsageError(`${adapter.name} has no ${scope} settings`);
216
+ const current = (await readSettings(root, target.file))?.value ?? {};
217
+ const merged = inFile(target.file, () => install.merge(current, command, verifyMs));
218
+ await writeSettings(root, target.file, merged.settings);
219
+ return { file: target.file, added: merged.added };
220
+ }
221
+ async function uninstallHooks(root, adapter) {
222
+ const install = installOf(adapter);
223
+ const results = [];
224
+ for (const { file } of install.scopes) {
225
+ const current = await readSettings(root, file);
226
+ if (current === null) continue;
227
+ const { settings, removed } = inFile(file, () => install.remove(current.value));
228
+ if (removed.length === 0) continue;
229
+ await writeSettings(root, file, settings);
230
+ results.push({ file, removed });
231
+ }
232
+ return results;
233
+ }
234
+ function selectAdapters(names) {
235
+ if (names === void 0) return ADAPTERS.filter((adapter) => adapter.install !== null);
236
+ const known = ADAPTERS.filter((adapter) => adapter.install !== null).map((adapter) => adapter.name);
237
+ return names.map((name) => {
238
+ const adapter = adapterByName(name);
239
+ if (!adapter?.install) throw new UsageError(`unknown agent "${name}" (use ${known.join(", ")})`);
240
+ return adapter;
241
+ });
242
+ }
243
+ async function loadProjectConfig(root, command) {
244
+ const data = await readConfigData(root);
245
+ const config = data.ok ? parseConfig(data.value) : data;
246
+ if (!config.ok) throw new Error(`${config.message} (fix it, then run rulecast ${command} again)`);
247
+ return config.value;
248
+ }
249
+ function printInstall(io, adapter, result) {
250
+ io.stdout(
251
+ result.added.length === 0 ? `${adapter.label} hooks already installed in ${result.file}
252
+ ` : `installed ${adapter.label} hooks in ${result.file}: ${result.added.join(", ")}
253
+ `
254
+ );
255
+ }
256
+ async function fetchMissingRepos(config, io) {
257
+ const home = cacheHome(io.env);
258
+ let ok = true;
259
+ for (const entry of config.repos) {
260
+ if (entry.repo === "local" || entry.rev === void 0) continue;
261
+ if (cachedRepo(home, entry.repo, entry.rev) !== null) continue;
262
+ const label = repoLabel(entry.repo, entry.rev);
263
+ try {
264
+ await ensureRepo(home, entry.repo, entry.rev);
265
+ io.stdout(`fetched ${label}
266
+ `);
267
+ } catch (error) {
268
+ io.stderr(`rulecast: could not fetch ${label}: ${errorMessage(error)}
269
+ `);
270
+ ok = false;
271
+ }
272
+ }
273
+ return ok;
274
+ }
275
+ async function installCommand(root, args, io) {
276
+ const { values } = parseArgs({
277
+ args,
278
+ options: { agent: { type: "string", multiple: true }, scope: { type: "string", default: "shared" } }
279
+ });
280
+ const scope = values.scope;
281
+ if (scope !== "shared" && scope !== "personal") {
282
+ throw new UsageError(`unknown scope "${scope}" (use shared, personal)`);
283
+ }
284
+ const adapters = selectAdapters(values.agent);
285
+ if (!hasProject(root)) throw new Error(`no ${CONFIG_FILE} in ${io.cwd} or its parents (run rulecast init)`);
286
+ const config = await loadProjectConfig(root, "install");
287
+ for (const adapter of adapters) {
288
+ printInstall(io, adapter, await installHooks(root, adapter, scope, config.timeouts.verifyMs));
289
+ }
290
+ return await fetchMissingRepos(config, io) ? 0 : 2;
291
+ }
292
+ async function uninstallCommand(root, args, io) {
293
+ const { values } = parseArgs({ args, options: { agent: { type: "string", multiple: true } } });
294
+ for (const adapter of selectAdapters(values.agent)) {
295
+ const results = await uninstallHooks(root, adapter);
296
+ if (results.length === 0) io.stdout(`no ${adapter.label} hooks to remove
297
+ `);
298
+ for (const { file, removed } of results) {
299
+ io.stdout(`removed ${adapter.label} hooks from ${file}: ${removed.join(", ")}
300
+ `);
301
+ }
302
+ }
303
+ return 0;
304
+ }
305
+
306
+ // src/commands/autoupdate.ts
307
+ async function autoupdateCommand(root, args, io) {
308
+ const { values } = parseArgs2({
309
+ args,
310
+ options: { freeze: { type: "boolean", default: false }, repo: { type: "string", multiple: true } }
311
+ });
312
+ if (!hasProject(root)) throw new Error(`no ${CONFIG_FILE} in ${io.cwd} or its parents`);
313
+ const config = await loadProjectConfig(root, "autoupdate");
314
+ const only = values.repo;
315
+ for (const url of only ?? []) {
316
+ if (!config.repos.some((entry) => entry.repo === url)) throw new UsageError(`no repo "${url}" in ${CONFIG_FILE}`);
317
+ }
318
+ const updates = [];
319
+ let failed = false;
320
+ for (const [index, entry] of config.repos.entries()) {
321
+ if (entry.repo === "local" || entry.rev === void 0) continue;
322
+ if (only && !only.includes(entry.repo)) continue;
323
+ let latest;
324
+ try {
325
+ latest = latestTag(await remoteTags(entry.repo));
326
+ } catch (error) {
327
+ io.stderr(`rulecast: ${entry.repo}: ${errorMessage(error)}
328
+ `);
329
+ failed = true;
330
+ continue;
331
+ }
332
+ if (latest === null) {
333
+ io.stdout(`${entry.repo}: no version tags
334
+ `);
335
+ continue;
336
+ }
337
+ const rev = values.freeze ? latest.sha : latest.name;
338
+ if (rev === entry.rev) {
339
+ io.stdout(`${entry.repo}: already up to date
340
+ `);
341
+ continue;
342
+ }
343
+ io.stdout(`${entry.repo}: updating ${entry.rev} -> ${rev}${values.freeze ? ` (frozen: ${latest.name})` : ""}
344
+ `);
345
+ updates.push({ index, rev, frozenTag: values.freeze ? latest.name : null });
346
+ }
347
+ if (updates.length > 0) {
348
+ const file = path3.join(root, CONFIG_FILE);
349
+ await writeFile2(file, setRevs(await readFile2(file, "utf8"), updates));
350
+ }
351
+ return failed ? 2 : 0;
352
+ }
353
+
354
+ // src/commands/clean.ts
355
+ import { existsSync as existsSync3 } from "fs";
356
+ import { rm } from "fs/promises";
357
+ import { parseArgs as parseArgs3 } from "util";
358
+ async function cleanCommand(root, args, io) {
359
+ const { values } = parseArgs3({ args, options: { project: { type: "boolean", default: false } } });
360
+ const home = cacheHome(io.env);
361
+ let target = home;
362
+ if (values.project) {
363
+ if (!hasProject(root)) throw new Error(`no ${CONFIG_FILE} in ${io.cwd} or its parents`);
364
+ target = projectStateDir(home, root);
365
+ }
366
+ if (!existsSync3(target)) {
367
+ io.stdout("nothing to clean\n");
368
+ return 0;
369
+ }
370
+ await rm(target, { recursive: true, force: true });
371
+ io.stdout(`removed ${target}
372
+ `);
373
+ return 0;
374
+ }
375
+
376
+ // src/commands/doctor.ts
377
+ var CHECK_TIMEOUT_MS = 3e4;
378
+ function line(level, what, detail) {
379
+ return ` ${level.padEnd(7)} ${what}${detail === "" ? "" : ` \u2014 ${detail}`}
380
+ `;
381
+ }
382
+ function naming(rules) {
383
+ if (rules.length === 0) return "";
384
+ if (rules.length <= 3) return ` (${rules.join(", ")})`;
385
+ return ` (${rules.slice(0, 3).join(", ")} and ${rules.length - 3} more)`;
386
+ }
387
+ function plural(count, noun) {
388
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
389
+ }
390
+ async function doctorCommand(root, args, registry, io) {
391
+ if (args.length > 0) throw new UsageError(`doctor takes no arguments: ${args.join(" ")}`);
392
+ const home = cacheHome(io.env);
393
+ io.stdout("rulecast doctor\n\n");
394
+ if (!hasProject(root)) {
395
+ io.stdout(`home ${home}
396
+ `);
397
+ io.stderr(`rulecast: no ${CONFIG_FILE} in ${io.cwd} or its parents (run rulecast init)
398
+ `);
399
+ return 2;
400
+ }
401
+ let errors = 0;
402
+ let warnings = 0;
403
+ const count = (level) => {
404
+ if (level === "error") errors++;
405
+ if (level === "warning") warnings++;
406
+ };
407
+ const stateDir = ensureProjectState(home, root);
408
+ io.stdout(`project ${root}
409
+ `);
410
+ const project = await compile({ root, registry, repos: fetchingRepos(home) });
411
+ const configErrors = project.diagnostics.filter((diagnostic) => diagnostic.level === "error").length;
412
+ const configWarnings = project.diagnostics.length - configErrors;
413
+ io.stdout(
414
+ `config ${CONFIG_FILE} \u2014 ${plural(project.rules.length, "rule")}, ${plural(configErrors, "error")}, ${plural(configWarnings, "warning")}
415
+ `
416
+ );
417
+ for (const diagnostic of project.diagnostics) {
418
+ count(diagnostic.level);
419
+ io.stdout(line(diagnostic.level, diagnosticText(diagnostic), ""));
420
+ }
421
+ const results = await checkDetectors({
422
+ project,
423
+ registry,
424
+ settings: { llm: project.config.llm },
425
+ env: io.env,
426
+ timeoutMs: CHECK_TIMEOUT_MS
427
+ });
428
+ if (results.length > 0) {
429
+ io.stdout("\nenvironment\n");
430
+ for (const result of results) {
431
+ count(result.level);
432
+ const rules = result.level === "ok" ? "" : naming(result.rules);
433
+ io.stdout(line(result.level, result.what, `${result.detail}${rules}`));
434
+ }
435
+ }
436
+ io.stdout("\nhooks\n");
437
+ for (const adapter of ADAPTERS) {
438
+ if (adapter.install === null) continue;
439
+ const file = await hooksInstalled(root, adapter, project.config.timeouts.verifyMs);
440
+ const level = file === null ? "warning" : "ok";
441
+ count(level);
442
+ io.stdout(line(level, adapter.label, file ?? "not installed (run rulecast install)"));
443
+ }
444
+ io.stdout("\ncache\n");
445
+ io.stdout(` home ${home}
446
+ `);
447
+ io.stdout(` project ${stateDir}
448
+ `);
449
+ for (const entry of project.config.repos) {
450
+ if (entry.repo === "local" || entry.rev === void 0) continue;
451
+ const label = repoLabel(entry.repo, entry.rev);
452
+ const cached = cachedRepo(home, entry.repo, entry.rev) !== null;
453
+ io.stdout(` repos ${label} \u2014 ${cached ? "cached" : "not fetched (run rulecast install)"}
454
+ `);
455
+ }
456
+ io.stdout("\ndry run\n");
457
+ const dry = await dryRun(project, registry);
458
+ if (dry.length === 0) io.stdout(" nothing to run\n");
459
+ for (const result of dry) {
460
+ count(result.level);
461
+ io.stdout(line(result.level, result.rule, result.detail));
462
+ }
463
+ io.stdout(
464
+ `
465
+ ${errors + warnings === 0 ? "no problems found" : [plural(errors, "error"), plural(warnings, "warning")].join(", ")}
466
+ `
467
+ );
468
+ return errors > 0 ? 2 : 0;
469
+ }
470
+ async function dryRun(project, registry) {
471
+ if (project.rules.length === 0) return [];
472
+ let files;
473
+ try {
474
+ files = await allFiles(project.root);
475
+ } catch (error) {
476
+ return [{ rule: "not run", level: "warning", detail: errorMessage(error) }];
477
+ }
478
+ const lines = [];
479
+ for (const rule of project.rules) {
480
+ if (rule.detector === null) {
481
+ lines.push({ rule: rule.id, level: "ok", detail: "context only, nothing to run" });
482
+ continue;
483
+ }
484
+ if (rule.detector.kind === "llm") {
485
+ lines.push({ rule: rule.id, level: "skipped", detail: "llm rules are not dry-run (a model call costs money)" });
486
+ continue;
487
+ }
488
+ const file = files.find((candidate) => rule.matches(candidate));
489
+ if (file === void 0) {
490
+ lines.push({ rule: rule.id, level: "warning", detail: "no file in the project matches this rule" });
491
+ continue;
492
+ }
493
+ const timeoutMs = project.config.timeouts.verifyMs;
494
+ const output = await runDetection({
495
+ root: project.root,
496
+ event: "verify",
497
+ selections: [{ rule, files: [file] }],
498
+ changes: /* @__PURE__ */ new Map(),
499
+ read: (name) => readSourceFile(project.root, name),
500
+ registry,
501
+ cacheFor: () => memoryCache(),
502
+ // Only the llm detector reads context, and llm rules never reach here.
503
+ contextFor: async () => [],
504
+ settings: { llm: project.config.llm },
505
+ timeoutMs
506
+ });
507
+ const failure = output.errors[0];
508
+ if (failure) {
509
+ lines.push({ rule: rule.id, level: "error", detail: failure.message });
510
+ continue;
511
+ }
512
+ if (output.timedOut.length > 0) {
513
+ lines.push({ rule: rule.id, level: "error", detail: `timed out after ${timeoutMs} ms` });
514
+ continue;
515
+ }
516
+ const matches = output.findings.length;
517
+ lines.push({
518
+ rule: rule.id,
519
+ level: "ok",
520
+ detail: `${file}, ${matches === 0 ? "no match" : matches === 1 ? "1 match" : `${matches} matches`}`
521
+ });
522
+ }
523
+ return lines;
524
+ }
525
+
526
+ // src/core/detection/warm.ts
527
+ import path4 from "path";
528
+ var WARM_LOCK = { waitMs: 0, staleMs: 10 * 6e4, retryMs: 20 };
529
+ function warmableKinds(project, registry) {
530
+ const kinds = /* @__PURE__ */ new Set();
531
+ for (const rule of project.rules) {
532
+ const kind = rule.detector?.kind;
533
+ if (kind !== void 0 && registry.get(kind)?.warm !== void 0) kinds.add(kind);
534
+ }
535
+ return [...kinds];
536
+ }
537
+ async function warmDetectors(options) {
538
+ const { root, stateDir, project, registry } = options;
539
+ const result = { warmed: [], skipped: [], errors: [] };
540
+ const kinds = warmableKinds(project, registry).filter((kind) => options.kinds?.includes(kind) ?? true);
541
+ const signal = AbortSignal.timeout(options.timeoutMs);
542
+ await Promise.all(
543
+ kinds.map(async (kind) => {
544
+ const detector = registry.get(kind);
545
+ const rules = project.rules.filter((rule) => rule.detector?.kind === kind).map((rule) => ({ id: rule.id, config: rule.detector.config }));
546
+ try {
547
+ await withLock(
548
+ path4.join(stateDir, "warm", kind),
549
+ () => detector.warm({ rules, cache: diskCache(detectorCacheDir(stateDir, kind)), cwd: root, signal }),
550
+ WARM_LOCK
551
+ );
552
+ result.warmed.push(kind);
553
+ } catch (error) {
554
+ if (error instanceof LockTimeoutError) result.skipped.push(kind);
555
+ else result.errors.push({ kind, message: errorMessage(error) });
556
+ }
557
+ })
558
+ );
559
+ return result;
560
+ }
561
+
562
+ // src/commands/hook.ts
563
+ async function hookCommand(args, registry, io) {
564
+ const name = args[0] ?? "";
565
+ const adapter = adapterByName(name);
566
+ if (!adapter) {
567
+ io.stderr(`rulecast: unknown hook adapter "${name}" (use ${ADAPTERS.map((known) => known.name).join(", ")})
568
+ `);
569
+ return 0;
570
+ }
571
+ let input;
572
+ try {
573
+ input = JSON.parse(await io.readStdin());
574
+ } catch (error) {
575
+ io.stderr(`rulecast: could not read hook input: ${errorMessage(error)}
576
+ `);
577
+ return 0;
578
+ }
579
+ const parsed = adapter.parse(input);
580
+ if (!parsed || !parsed.event && !parsed.warmup) return 0;
581
+ const root = findRoot(parsed.cwd);
582
+ if (!hasProject(root)) return 0;
583
+ const home = cacheHome(io.env);
584
+ const stateDir = ensureProjectState(home, root);
585
+ const log = debugLogger(stateDir);
586
+ try {
587
+ const compileProject = () => compile({ root, registry, repos: cachedRepos(home) });
588
+ if (parsed.warmup) {
589
+ const kinds = warmableKinds(await compileProject(), registry);
590
+ if (kinds.length > 0) io.startWarm(root, kinds);
591
+ }
592
+ if (parsed.event) {
593
+ await handleEvent({
594
+ root,
595
+ stateDir,
596
+ cwd: parsed.cwd,
597
+ event: parsed.event,
598
+ adapter,
599
+ registry,
600
+ io,
601
+ log,
602
+ compileProject
603
+ });
604
+ }
605
+ } catch (error) {
606
+ log(`hook ${adapter.name}: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
607
+ io.stderr(`rulecast: ${errorMessage(error)}
608
+ `);
609
+ }
610
+ return 0;
611
+ }
612
+ async function handleEvent(context) {
613
+ const { root, stateDir, cwd, event, adapter, registry, io, log } = context;
614
+ const files = event.files.map((file) => toProjectPath(root, cwd, file)).filter((file) => file !== null);
615
+ if ((event.kind === "touch" || event.kind === "edit" || event.kind === "guard") && files.length === 0) return;
616
+ const projectEvent = { ...event, files, cwd: root };
617
+ const project = await context.compileProject();
618
+ const result = await runPipeline({
619
+ project,
620
+ stateDir,
621
+ event: projectEvent,
622
+ registry,
623
+ maxContextChars: adapter.maxContextChars,
624
+ restoredFiles: adapter.restoredFiles,
625
+ stopGate: projectEvent.kind === "verify",
626
+ log
627
+ });
628
+ const output = adapter.format(result.delivery, projectEvent, { maxMatchesPerRule: project.config.maxMatchesPerRule });
629
+ if (output.stdout !== "") io.stdout(output.stdout);
630
+ const missed = result.deadlineMissed.filter((kind) => registry.get(kind)?.warm !== void 0);
631
+ if (missed.length > 0) io.startWarm(root, missed);
632
+ }
633
+
634
+ // src/commands/init.ts
635
+ import { execFileSync } from "child_process";
636
+ import { existsSync as existsSync4 } from "fs";
637
+ import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
638
+ import path6 from "path";
639
+ import { parseArgs as parseArgs4 } from "util";
640
+ import { parse } from "yaml";
641
+
642
+ // src/init/detect.ts
643
+ import { statSync } from "fs";
644
+ import path5 from "path";
645
+ var KNOWN_AGENTS = [
646
+ { name: "cursor", label: "Cursor", markers: [".cursor/"] },
647
+ { name: "codex", label: "Codex", markers: [".codex/"] }
648
+ ];
649
+ var IMPORTS_AGENTS = /^[ \t]*@(\.\/)?AGENTS\.md[ \t]*$/m;
650
+ var NOT_A_DOC = /^(readme|changelog|license)/i;
651
+ var AGENT_DOCS = /* @__PURE__ */ new Set(["AGENTS.md", "CLAUDE.md"]);
652
+ var basename = (file) => path5.posix.basename(file);
653
+ var segments = (file) => file.split("/");
654
+ var inNodeModules = (file) => segments(file).includes("node_modules");
655
+ var inDotDir = (file) => segments(file).slice(0, -1).some((segment) => segment.startsWith("."));
656
+ var byDepth = (a, b) => segments(a).length - segments(b).length || (a < b ? -1 : a > b ? 1 : 0);
657
+ function usesReact(text) {
658
+ if (text === null) return false;
659
+ try {
660
+ const manifest = JSON.parse(text);
661
+ return ["dependencies", "devDependencies", "peerDependencies"].some((key) => {
662
+ const dependencies = manifest[key];
663
+ return typeof dependencies === "object" && dependencies !== null && "react" in dependencies;
664
+ });
665
+ } catch {
666
+ return false;
667
+ }
668
+ }
669
+ async function detectStack(files, read) {
670
+ const tags = /* @__PURE__ */ new Set();
671
+ const names = /* @__PURE__ */ new Set();
672
+ for (const file of files) {
673
+ for (const tag of tagsOf(file)) tags.add(tag);
674
+ names.add(basename(file));
675
+ }
676
+ const stack = [];
677
+ const pythonMarker = names.has("pyproject.toml") || [...names].some((name) => /^requirements.*\.txt$/.test(name));
678
+ if (tags.has("python") || pythonMarker) stack.push("python");
679
+ if (tags.has("ts") || tags.has("tsx")) stack.push("typescript");
680
+ if (tags.has("javascript") || tags.has("jsx")) stack.push("javascript");
681
+ for (const file of files.filter((candidate) => basename(candidate) === "package.json")) {
682
+ if (usesReact(await read(file))) {
683
+ stack.push("react");
684
+ break;
685
+ }
686
+ }
687
+ return stack;
688
+ }
689
+ async function detectDocs(files, read) {
690
+ const present = new Set(files);
691
+ const named = (name) => files.filter((file) => basename(file) === name).sort(byDepth);
692
+ const importedBy = /* @__PURE__ */ new Map();
693
+ const standalone = [];
694
+ for (const file of named("CLAUDE.md")) {
695
+ const sibling = path5.posix.join(path5.posix.dirname(file), "AGENTS.md");
696
+ const text = await read(file);
697
+ if (text !== null && present.has(sibling) && IMPORTS_AGENTS.test(text)) importedBy.set(sibling, file);
698
+ else standalone.push(file);
699
+ }
700
+ const others = files.filter((file) => {
701
+ const tags = tagsOf(file);
702
+ const name = basename(file);
703
+ return (tags.has("markdown") || tags.has("mdx")) && !AGENT_DOCS.has(name) && !NOT_A_DOC.test(name) && !inDotDir(file);
704
+ }).sort();
705
+ const docs = [];
706
+ const add = async (file, by) => {
707
+ const text = await read(file);
708
+ if (text !== null) docs.push({ file, headings: scanHeadings(text), importedBy: by });
709
+ };
710
+ for (const file of named("AGENTS.md")) await add(file, importedBy.get(file) ?? null);
711
+ for (const file of standalone) await add(file, null);
712
+ for (const file of others) await add(file, null);
713
+ return docs;
714
+ }
715
+ function detectAgents(input) {
716
+ const agents = [];
717
+ for (const adapter of input.adapters) {
718
+ const marker = adapter.install?.markers.find((candidate) => input.exists(candidate));
719
+ if (marker !== void 0) agents.push({ name: adapter.name, label: adapter.label, adapter, marker });
720
+ }
721
+ for (const known of KNOWN_AGENTS) {
722
+ const marker = known.markers.find((candidate) => input.exists(candidate));
723
+ if (marker !== void 0) agents.push({ name: known.name, label: known.label, adapter: null, marker });
724
+ }
725
+ return agents;
726
+ }
727
+ function markerExists(root) {
728
+ return (marker) => {
729
+ try {
730
+ const stat = statSync(path5.join(root, marker));
731
+ return !marker.endsWith("/") || stat.isDirectory();
732
+ } catch {
733
+ return false;
734
+ }
735
+ };
736
+ }
737
+ async function detectProject(input) {
738
+ const files = input.files.filter((file) => !inNodeModules(file));
739
+ const docs = await detectDocs(files, input.read);
740
+ const primary = docs.find((doc) => basename(doc.file) === "AGENTS.md") ?? docs.find((doc) => basename(doc.file) === "CLAUDE.md");
741
+ return {
742
+ stack: await detectStack(files, input.read),
743
+ docs,
744
+ primaryDoc: primary?.file ?? null,
745
+ agents: detectAgents(input)
746
+ };
747
+ }
748
+ function detectionSummary(detection) {
749
+ const parts = [...detection.stack];
750
+ for (const doc of detection.docs) {
751
+ if (!AGENT_DOCS.has(basename(doc.file))) continue;
752
+ parts.push(doc.importedBy === null ? doc.file : `${doc.file} (imported by ${doc.importedBy})`);
753
+ }
754
+ const others = detection.docs.filter((doc) => !AGENT_DOCS.has(basename(doc.file))).length;
755
+ if (others > 0) parts.push(`${others} other ${others === 1 ? "doc" : "docs"}`);
756
+ for (const agent of detection.agents) {
757
+ parts.push(`${agent.label} (${agent.marker}${agent.adapter === null ? ", not supported yet" : ""})`);
758
+ }
759
+ return parts.length === 0 ? "nothing yet" : parts.join(" \xB7 ");
760
+ }
761
+ function docChoices(doc) {
762
+ const choices = [{ value: `@${doc.file}`, label: doc.file }];
763
+ const trail = [];
764
+ for (const heading of doc.headings) {
765
+ while (trail.length > 0 && trail.at(-1).level >= heading.level) trail.pop();
766
+ trail.push(heading);
767
+ if (heading.slug === "") continue;
768
+ choices.push({
769
+ value: `@${doc.file}#${heading.slug}`,
770
+ label: [doc.file, ...trail.map((entry) => entry.text)].join(" \u203A ")
771
+ });
772
+ }
773
+ return choices;
774
+ }
775
+
776
+ // src/init/draft-prompt.ts
777
+ function draftPrompt(tag, doc) {
778
+ return [
779
+ `Read https://raw.githubusercontent.com/syv-ai/rulecast/${tag}/agents/DRAFT-RULES.md`,
780
+ `and follow it to draft rulecast rules for this project from ${doc ?? "the project's docs"}.`
781
+ ].join("\n");
782
+ }
783
+
784
+ // src/init/config-text.ts
785
+ import { isMap as isMap2, isScalar as isScalar2, isSeq as isSeq2, parseDocument as parseDocument2 } from "yaml";
786
+ var ConfigTextError = class extends Error {
787
+ };
788
+ var HEADER = [
789
+ "# rulecast config: https://github.com/syv-ai/rulecast",
790
+ "# Rules from rule repos are pinned by rev; add your own rules under repo: local."
791
+ ];
792
+ var scalar = (value) => /^[A-Za-z0-9_./:@~+-]+$/.test(value) ? value : JSON.stringify(value);
793
+ function ruleLines(rule) {
794
+ const lines = [`- id: ${rule.id}`];
795
+ if (rule.context !== null) lines.push(" context:", ...rule.context.map((ref) => ` - ${JSON.stringify(ref)}`));
796
+ return lines;
797
+ }
798
+ function repoLines(catalog, rules) {
799
+ return [
800
+ `- repo: ${scalar(catalog.url)}`,
801
+ ` rev: ${scalar(catalog.rev)}`,
802
+ " rules:",
803
+ ...rules.flatMap(ruleLines).map((line2) => ` ${line2}`)
804
+ ];
805
+ }
806
+ var indent = (lines, column) => lines.map((line2) => `${" ".repeat(column)}${line2}
807
+ `).join("");
808
+ function newConfigText(catalog, rules) {
809
+ const lines = [...HEADER, "repos:"];
810
+ if (catalog !== null && rules.length > 0) lines.push(...repoLines(catalog, rules).map((line2) => ` ${line2}`));
811
+ lines.push(" - repo: local", " rules: []");
812
+ return `${lines.join("\n")}
813
+ `;
814
+ }
815
+ function columnOf(text, offset) {
816
+ return offset - (text.lastIndexOf("\n", offset - 1) + 1);
817
+ }
818
+ function lineEndAfter(text, offset) {
819
+ const newline = text.indexOf("\n", offset);
820
+ return newline === -1 ? text.length : newline + 1;
821
+ }
822
+ function appendToSeq(text, seq, keyNode, items) {
823
+ const [start, valueEnd] = seq.range;
824
+ if (seq.flow) {
825
+ const at = lineEndAfter(text, valueEnd);
826
+ const rest = text.slice(valueEnd, at).trim();
827
+ if (seq.items.length > 0 || rest !== "" && !rest.startsWith("#")) {
828
+ throw new ConfigTextError("write repos and their rules in block style so rulecast init can add to them");
829
+ }
830
+ let from = start;
831
+ while (from > 0 && (text[from - 1] === " " || text[from - 1] === " ")) from--;
832
+ const column2 = columnOf(text, keyNode.range[0]) + 2;
833
+ return { start: from, end: at, insert: `${rest === "" ? "" : ` ${rest}`}
834
+ ${indent(items, column2)}` };
835
+ }
836
+ const column = columnOf(text, start);
837
+ const prefix = valueEnd === 0 || text[valueEnd - 1] === "\n" ? "" : "\n";
838
+ return { start: valueEnd, end: valueEnd, insert: `${prefix}${indent(items, column)}` };
839
+ }
840
+ function pairKey(map, key) {
841
+ const pair = map.items.find((item) => isScalar2(item.key) && item.key.value === key);
842
+ return pair && isScalar2(pair.key) ? pair.key : null;
843
+ }
844
+ function addCatalogRules(text, catalog, rules) {
845
+ const doc = parseDocument2(text, { keepSourceTokens: true });
846
+ if (doc.errors.length > 0) throw new ConfigTextError(doc.errors[0].message);
847
+ const root = doc.contents;
848
+ if (!isMap2(root)) throw new ConfigTextError("the config must be a mapping");
849
+ const reposKey = pairKey(root, "repos");
850
+ const repos = root.get("repos", true);
851
+ if (reposKey === null || !isSeq2(repos)) throw new ConfigTextError("the config has no repos list");
852
+ const entry = repos.items.find((item) => isMap2(item) && item.get("repo") === catalog.url);
853
+ let edit;
854
+ if (entry) {
855
+ const listed = entry.get("rules", true);
856
+ const rulesKey = pairKey(entry, "rules");
857
+ if (!isSeq2(listed) || rulesKey === null) throw new ConfigTextError(`${catalog.url} has no rules list`);
858
+ const present = new Set(listed.items.map((item) => isMap2(item) ? item.get("id") : void 0));
859
+ const added = rules.filter((rule) => !present.has(rule.id));
860
+ if (added.length === 0) return text;
861
+ edit = appendToSeq(text, listed, rulesKey, added.flatMap(ruleLines));
862
+ } else {
863
+ if (rules.length === 0) return text;
864
+ edit = appendToSeq(text, repos, reposKey, repoLines(catalog, rules));
865
+ }
866
+ const updated = text.slice(0, edit.start) + edit.insert + text.slice(edit.end);
867
+ const check = parseDocument2(updated);
868
+ if (check.errors.length > 0) throw new ConfigTextError(`could not update the config: ${check.errors[0].message}`);
869
+ return updated;
870
+ }
871
+
872
+ // src/init/plan.ts
873
+ var PlanError = class extends Error {
874
+ };
875
+ var plural2 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
876
+ function parseSettings(text, file) {
877
+ try {
878
+ return JSON.parse(text);
879
+ } catch (error) {
880
+ throw new PlanError(`${file}: ${error instanceof Error ? error.message : String(error)}`);
881
+ }
882
+ }
883
+ function planConfig(selections, configText) {
884
+ const { catalog, rules } = selections;
885
+ const from = catalog === null ? "" : ` from ${repoLabel(catalog.url, catalog.rev)}`;
886
+ if (configText === null) {
887
+ const summary = rules.length === 0 ? "new, no rules yet" : `new, ${plural2(rules.length, "rule")}${from}`;
888
+ return { file: CONFIG_FILE, content: newConfigText(catalog, rules), created: true, summary, commit: true };
889
+ }
890
+ if (catalog === null || rules.length === 0) return null;
891
+ const content = addCatalogRules(configText, catalog, rules);
892
+ if (content === configText) return null;
893
+ return {
894
+ file: CONFIG_FILE,
895
+ content,
896
+ created: false,
897
+ summary: `+${plural2(rules.length, "rule")}${from}`,
898
+ commit: true
899
+ };
900
+ }
901
+ async function planAgent(choice, context) {
902
+ const install = choice.adapter.install;
903
+ if (install === null) return null;
904
+ const command = install.command(context.local);
905
+ const merge = (settings, file) => {
906
+ try {
907
+ return install.merge(settings, command, context.verifyMs);
908
+ } catch (error) {
909
+ throw new PlanError(`${file}: ${error instanceof Error ? error.message : String(error)}`);
910
+ }
911
+ };
912
+ for (const { file } of install.scopes) {
913
+ const text2 = await context.readText(file);
914
+ if (text2 !== null && merge(parseSettings(text2, file), file).added.length === 0) return null;
915
+ }
916
+ const target = install.scopes.find((entry) => entry.scope === choice.scope);
917
+ if (target === void 0) throw new PlanError(`${choice.adapter.label} has no ${choice.scope} settings`);
918
+ const text = await context.readText(target.file);
919
+ const merged = merge(text === null ? {} : parseSettings(text, target.file), target.file);
920
+ return {
921
+ file: target.file,
922
+ content: `${JSON.stringify(merged.settings, null, 2)}
923
+ `,
924
+ created: text === null,
925
+ summary: `+${plural2(merged.added.length, "hook")} (${choice.adapter.label})`,
926
+ commit: choice.scope === "shared"
927
+ };
928
+ }
929
+ async function planInit(selections, context) {
930
+ const changes = [];
931
+ const config = planConfig(selections, context.configText);
932
+ if (config !== null) changes.push(config);
933
+ for (const choice of selections.agents) {
934
+ const change = await planAgent(choice, context);
935
+ if (change !== null) changes.push(change);
936
+ }
937
+ return changes;
938
+ }
939
+ function reviewText(changes) {
940
+ const width = Math.max(...changes.map((change) => change.file.length));
941
+ return changes.map((change) => `${change.file.padEnd(width)} ${change.summary}`).join("\n");
942
+ }
943
+
944
+ // src/commands/init.ts
945
+ var DEFAULT_CATALOG = "https://github.com/syv-ai/rulecast";
946
+ var SCOPES = ["shared", "personal"];
947
+ var installable = () => ADAPTERS.filter((adapter) => adapter.install !== null);
948
+ function parseFlags(args) {
949
+ const { values } = parseArgs4({
950
+ args,
951
+ options: {
952
+ rules: { type: "string" },
953
+ "no-rules": { type: "boolean", default: false },
954
+ agent: { type: "string", multiple: true },
955
+ scope: { type: "string" },
956
+ yes: { type: "boolean", default: false }
957
+ }
958
+ });
959
+ if (values.rules !== void 0 && values["no-rules"]) throw new UsageError("use --rules or --no-rules, not both");
960
+ const scope = values.scope ?? null;
961
+ if (scope !== null && !SCOPES.includes(scope)) {
962
+ throw new UsageError(`unknown scope "${scope}" (use shared or personal)`);
963
+ }
964
+ const agents = (values.agent ?? []).map((name) => {
965
+ const adapter = adapterByName(name);
966
+ if (!adapter?.install) {
967
+ const names = installable().map((each) => each.name);
968
+ throw new UsageError(`unknown agent "${name}" (use ${names.join(", ")})`);
969
+ }
970
+ return adapter;
971
+ });
972
+ const rules = values.rules === void 0 ? null : values.rules.split(",").map((id) => id.trim()).filter(Boolean);
973
+ return { rules, noRules: values["no-rules"], agents, scope, yes: values.yes };
974
+ }
975
+ function projectRoot(cwd) {
976
+ const found = findRoot(cwd);
977
+ if (hasProject(found)) return found;
978
+ try {
979
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
980
+ cwd,
981
+ encoding: "utf8",
982
+ stdio: ["ignore", "pipe", "ignore"]
983
+ }).trim();
984
+ } catch {
985
+ return cwd;
986
+ }
987
+ }
988
+ async function readConfig(root) {
989
+ const text = await readSourceFile(root, CONFIG_FILE);
990
+ if (text === null) return { text: null, config: null };
991
+ let data;
992
+ try {
993
+ data = parse(text);
994
+ } catch (error) {
995
+ throw new Error(`${CONFIG_FILE}: ${errorMessage(error)} (fix it, then run rulecast init again)`);
996
+ }
997
+ const parsed = parseConfig(data ?? {});
998
+ if (!parsed.ok) throw new Error(`${parsed.message} (fix it, then run rulecast init again)`);
999
+ return { text, config: parsed.value };
1000
+ }
1001
+ async function loadCatalog(url, rev, home, registry) {
1002
+ try {
1003
+ let pinned = rev;
1004
+ if (pinned === void 0) {
1005
+ const tag = latestTag(await remoteTags(url));
1006
+ if (tag === null) return `${url} has no version tags`;
1007
+ pinned = tag.name;
1008
+ }
1009
+ const dir = await ensureRepo(home, url, pinned);
1010
+ const { rules } = await compileManifest(dir, registry);
1011
+ return { url, rev: pinned, label: repoLabel(url, pinned), rules };
1012
+ } catch (error) {
1013
+ return errorMessage(error);
1014
+ }
1015
+ }
1016
+ var idOf = (entry) => {
1017
+ const id = entry?.id;
1018
+ return typeof id === "string" ? id : null;
1019
+ };
1020
+ var groupOf = (id) => id.includes("/") ? id.slice(0, id.indexOf("/")) : "general";
1021
+ async function chooseRules(catalog, installed, files, flags, ui) {
1022
+ const available = catalog.rules.filter((rule) => !installed.has(rule.id));
1023
+ if (flags.noRules) return [];
1024
+ if (flags.rules !== null) {
1025
+ const known = new Set(catalog.rules.map((rule) => rule.id));
1026
+ const unknown = flags.rules.find((id) => !known.has(id));
1027
+ if (unknown !== void 0) throw new UsageError(`"${unknown}" is not a rule in ${catalog.label}`);
1028
+ return available.filter((rule) => flags.rules.includes(rule.id));
1029
+ }
1030
+ const preselected = available.filter((rule) => rule.detector?.kind !== "llm" && files.some((file) => rule.matches(file))).map((rule) => rule.id);
1031
+ if (ui.prompter === null) return available.filter((rule) => preselected.includes(rule.id));
1032
+ if (available.length === 0) {
1033
+ ui.say(`Every rule from ${catalog.label} is already configured.`, "Rules");
1034
+ return [];
1035
+ }
1036
+ const groups = {};
1037
+ for (const rule of catalog.rules) {
1038
+ const group = groupOf(rule.id);
1039
+ const done = installed.has(rule.id);
1040
+ const choices = groups[group] ?? [];
1041
+ choices.push({
1042
+ value: rule.id,
1043
+ label: group === "general" ? rule.id : rule.id.slice(group.length + 1),
1044
+ hint: done ? "installed" : hintFor(rule),
1045
+ disabled: done
1046
+ });
1047
+ groups[group] = choices;
1048
+ }
1049
+ const chosen = await ui.prompter.groupMultiselect({
1050
+ message: `Rules from ${catalog.label}`,
1051
+ groups,
1052
+ initialValues: preselected
1053
+ });
1054
+ return available.filter((rule) => chosen.includes(rule.id));
1055
+ }
1056
+ function hintFor(rule) {
1057
+ const description = rule.description ?? rule.name;
1058
+ if (rule.detector?.kind !== "llm") return description;
1059
+ const model = rule.detector.config.model ?? "a model";
1060
+ return `llm \xB7 ${model} \xB7 sends file contents to your provider \u2014 ${description}`;
1061
+ }
1062
+ async function chooseConventions(rules, detection, ui) {
1063
+ const choices = detection.docs.flatMap(docChoices);
1064
+ const selections = [];
1065
+ for (const rule of rules) {
1066
+ if (ui.prompter === null || rule.context.length === 0 || choices.length === 0) {
1067
+ selections.push({ id: rule.id, context: null });
1068
+ continue;
1069
+ }
1070
+ const keep = {
1071
+ value: "",
1072
+ label: "keep the package's doc",
1073
+ hint: rule.context.map((spec) => spec.ref).join(", ")
1074
+ };
1075
+ const answer = await ui.prompter.select({
1076
+ message: `Conventions for ${rule.id}`,
1077
+ choices: [keep, ...choices],
1078
+ initialValue: ""
1079
+ });
1080
+ selections.push({ id: rule.id, context: answer === "" ? null : [answer] });
1081
+ }
1082
+ return selections;
1083
+ }
1084
+ async function chooseAgents(detection, flags, ui) {
1085
+ const detected = detection.agents.filter((agent) => agent.adapter !== null).map((agent) => agent.name);
1086
+ let chosen;
1087
+ if (flags.agents.length > 0) chosen = flags.agents;
1088
+ else if (ui.prompter === null) chosen = installable().filter((adapter) => detected.includes(adapter.name));
1089
+ else {
1090
+ const choices = installable().map((adapter) => ({
1091
+ value: adapter.name,
1092
+ label: adapter.label,
1093
+ hint: detected.includes(adapter.name) ? "detected" : void 0
1094
+ }));
1095
+ for (const agent of detection.agents) {
1096
+ if (agent.adapter === null) {
1097
+ choices.push({
1098
+ value: `unsupported:${agent.name}`,
1099
+ label: agent.label,
1100
+ hint: "not supported yet",
1101
+ disabled: true
1102
+ });
1103
+ }
1104
+ }
1105
+ const names = await ui.prompter.multiselect({ message: "Install hooks for", choices, initialValues: detected });
1106
+ chosen = installable().filter((adapter) => names.includes(adapter.name));
1107
+ }
1108
+ if (chosen.length === 0) {
1109
+ ui.say("No agent hooks will be installed. Add them later with rulecast install --agent <name>.", "Agents");
1110
+ }
1111
+ const agents = [];
1112
+ for (const adapter of chosen) {
1113
+ let scope = flags.scope ?? "shared";
1114
+ if (flags.scope === null && ui.prompter !== null) {
1115
+ scope = await ui.prompter.select({
1116
+ message: `${adapter.label} hooks in`,
1117
+ choices: adapter.install.scopes.map((entry) => ({
1118
+ value: entry.scope,
1119
+ label: `${entry.scope} (${entry.file})`
1120
+ })),
1121
+ initialValue: "shared"
1122
+ });
1123
+ }
1124
+ agents.push({ adapter, scope });
1125
+ }
1126
+ return agents;
1127
+ }
1128
+ async function write(root, changes) {
1129
+ for (const change of changes) {
1130
+ const file = path6.join(root, change.file);
1131
+ await mkdir2(path6.dirname(file), { recursive: true });
1132
+ await writeFile3(file, change.content);
1133
+ }
1134
+ }
1135
+ async function validate(root, home, registry, ui) {
1136
+ const project = await compile({ root, registry, repos: fetchingRepos(home) });
1137
+ const errors = project.diagnostics.filter((diagnostic) => diagnostic.level === "error");
1138
+ const lines = project.diagnostics.map(
1139
+ (diagnostic) => `${diagnostic.level === "warning" ? "warning: " : ""}${diagnostic.source}${diagnostic.rule ? ` (${diagnostic.rule})` : ""}: ${diagnostic.message}`
1140
+ );
1141
+ if (errors.length === 0) {
1142
+ const count = project.rules.length;
1143
+ lines.push(`rulecast validate: ${count} ${count === 1 ? "rule" : "rules"} valid`);
1144
+ }
1145
+ ui.say(lines.join("\n"), "Validate");
1146
+ return errors.length;
1147
+ }
1148
+ async function run(root, flags, registry, io, ui) {
1149
+ const home = cacheHome(io.env);
1150
+ if (ui.prompter !== null) ui.prompter.intro(`rulecast init \xB7 ${root}`);
1151
+ else io.stdout(`rulecast init \xB7 ${root}
1152
+
1153
+ `);
1154
+ const { text: configText, config } = await readConfig(root);
1155
+ const files = await allFiles(root);
1156
+ const detection = await detectProject({
1157
+ files,
1158
+ read: (file) => readSourceFile(root, file),
1159
+ exists: markerExists(root),
1160
+ adapters: ADAPTERS
1161
+ });
1162
+ ui.say(detectionSummary(detection), "Detected");
1163
+ const url = io.env.RULECAST_CATALOG || DEFAULT_CATALOG;
1164
+ const configured = config?.repos.find((repo) => repo.repo === url);
1165
+ const loaded = await loadCatalog(url, configured?.rev, home, registry);
1166
+ const catalog = typeof loaded === "string" ? null : loaded;
1167
+ if (typeof loaded === "string") ui.say(`${loaded}. Continuing without catalog rules.`, "Catalog unavailable");
1168
+ const installed = new Set((configured?.rules ?? []).map(idOf).filter((id) => id !== null));
1169
+ const rules = catalog === null ? [] : await chooseRules(catalog, installed, files, flags, ui);
1170
+ const selections = await chooseConventions(rules, detection, ui);
1171
+ const agents = await chooseAgents(detection, flags, ui);
1172
+ const changes = await planInit(
1173
+ { catalog: catalog === null ? null : { url: catalog.url, rev: catalog.rev }, rules: selections, agents },
1174
+ {
1175
+ configText,
1176
+ verifyMs: (config ?? defaultConfig()).timeouts.verifyMs,
1177
+ local: existsSync4(path6.join(root, "node_modules", ".bin", "rulecast")),
1178
+ readText: (file) => readSourceFile(root, file)
1179
+ }
1180
+ );
1181
+ if (changes.length === 0) ui.say("Nothing to change.", "Review");
1182
+ else {
1183
+ ui.say(reviewText(changes), "Review");
1184
+ if (ui.prompter !== null) {
1185
+ if (!await ui.prompter.confirm({ message: "Write?", initialValue: true })) {
1186
+ ui.prompter.outro("Nothing written.");
1187
+ return 0;
1188
+ }
1189
+ } else if (!flags.yes) {
1190
+ io.stdout("Nothing written. Rerun with --yes to write these changes, or run rulecast init in a terminal.\n");
1191
+ return 2;
1192
+ }
1193
+ await write(root, changes);
1194
+ }
1195
+ const errors = await validate(root, home, registry, ui);
1196
+ const prompt = draftPrompt(catalog?.rev ?? `v${VERSION}`, detection.primaryDoc);
1197
+ const toCommit = changes.filter((change) => change.commit).map((change) => change.file);
1198
+ const closing = toCommit.length === 0 ? "Done." : `Commit ${toCommit.join(" and ")}.`;
1199
+ if (ui.prompter !== null) {
1200
+ ui.say(prompt, "Draft rules for your own conventions. Paste this into your coding agent:");
1201
+ if (await ui.prompter.confirm({ message: "Copy to clipboard?", initialValue: true })) {
1202
+ ui.say(await io.copyToClipboard(prompt) ? "Copied." : "No clipboard command found: copy it from above.");
1203
+ }
1204
+ ui.prompter.outro(closing);
1205
+ } else {
1206
+ ui.say(prompt, "Draft rules for your own conventions. Paste this into your coding agent:");
1207
+ io.stdout(`${closing}
1208
+ `);
1209
+ }
1210
+ return errors > 0 ? 2 : 0;
1211
+ }
1212
+ async function initCommand(args, registry, io) {
1213
+ const flags = parseFlags(args);
1214
+ const root = projectRoot(io.cwd);
1215
+ const prompter = io.interactive && !flags.yes ? await io.prompter() : null;
1216
+ const ui = {
1217
+ prompter,
1218
+ say: (message, title) => {
1219
+ if (prompter !== null) prompter.note(message, title);
1220
+ else io.stdout(`${title === void 0 ? "" : `${title}
1221
+ `}${message}
1222
+
1223
+ `);
1224
+ }
1225
+ };
1226
+ try {
1227
+ return await run(root, flags, registry, io, ui);
1228
+ } catch (error) {
1229
+ if (!(error instanceof Cancelled)) throw error;
1230
+ io.stderr("rulecast: init cancelled\n");
1231
+ return 130;
1232
+ }
1233
+ }
1234
+
1235
+ // src/commands/run.ts
1236
+ import { parseArgs as parseArgs5 } from "util";
1237
+
1238
+ // src/adapters/cli/format.ts
1239
+ var CLI_FORMATS = ["terminal", "agent", "json", "sarif"];
1240
+ var plural3 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
1241
+ function terminal(delivery, options) {
1242
+ const out = [];
1243
+ const shown = /* @__PURE__ */ new Map();
1244
+ const hidden = /* @__PURE__ */ new Map();
1245
+ for (const finding of delivery.findings) {
1246
+ const count = shown.get(finding.rule) ?? 0;
1247
+ if (count >= options.maxMatchesPerRule) {
1248
+ hidden.set(finding.rule, [...hidden.get(finding.rule) ?? [], finding]);
1249
+ continue;
1250
+ }
1251
+ shown.set(finding.rule, count + 1);
1252
+ const repeat = finding.count > 1 ? ` (\xD7${finding.count})` : "";
1253
+ out.push(
1254
+ `${finding.file}:${finding.line}:${finding.column} ${finding.severity.padEnd(7)} ${finding.rule} ${finding.message.replace(/\s*\n\s*/g, " ")}${repeat}`
1255
+ );
1256
+ }
1257
+ for (const [rule, rest] of hidden) out.push(`\u2026and ${rest.length} more for ${rule}`);
1258
+ if (out.length > 0) out.push("");
1259
+ for (const summary of delivery.preexistingSummary) {
1260
+ out.push(`pre-existing (not blocking): ${summary.rule} \xD7${summary.count} in ${summary.file}`);
1261
+ }
1262
+ if (delivery.references.length > 0) {
1263
+ out.push(`conventions: ${delivery.references.map((reference) => reference.ref).join(", ")}`);
1264
+ }
1265
+ if (delivery.preexistingSummary.length > 0 || delivery.references.length > 0) out.push("");
1266
+ if (delivery.warnings.length > 0) {
1267
+ out.push("warnings:", ...delivery.warnings.map((warning) => ` - ${warning}`), "");
1268
+ }
1269
+ const errors = delivery.findings.filter((finding) => finding.severity === "error").length;
1270
+ const warningsCount = delivery.findings.length - errors;
1271
+ out.push(
1272
+ delivery.findings.length === 0 ? "no findings" : `${plural3(errors, "error")}, ${plural3(warningsCount, "warning")}`
1273
+ );
1274
+ return out.join("\n");
1275
+ }
1276
+ function sarif(delivery) {
1277
+ const rules = [...new Set(delivery.findings.map((finding) => finding.rule))].map((id) => ({ id }));
1278
+ return JSON.stringify(
1279
+ {
1280
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
1281
+ version: "2.1.0",
1282
+ runs: [
1283
+ {
1284
+ tool: { driver: { name: "rulecast", rules } },
1285
+ results: delivery.findings.map((finding) => ({
1286
+ ruleId: finding.rule,
1287
+ level: finding.severity,
1288
+ message: { text: finding.message },
1289
+ locations: [
1290
+ {
1291
+ physicalLocation: {
1292
+ artifactLocation: { uri: finding.file },
1293
+ region: { startLine: finding.line, startColumn: finding.column }
1294
+ }
1295
+ }
1296
+ ]
1297
+ }))
1298
+ }
1299
+ ]
1300
+ },
1301
+ null,
1302
+ 2
1303
+ );
1304
+ }
1305
+ function formatDelivery(delivery, format, options) {
1306
+ switch (format) {
1307
+ case "terminal":
1308
+ return terminal(delivery, options);
1309
+ case "agent":
1310
+ return renderAgentText(delivery, options);
1311
+ case "json":
1312
+ return JSON.stringify(delivery, null, 2);
1313
+ case "sarif":
1314
+ return sarif(delivery);
1315
+ }
1316
+ }
1317
+ function exitCodeFor(delivery, failed) {
1318
+ if (failed) return 2;
1319
+ return delivery.findings.some((finding) => finding.severity === "error") ? 1 : 0;
1320
+ }
1321
+
1322
+ // src/commands/run.ts
1323
+ function parseRunArgs(args) {
1324
+ const { values, tokens } = parseArgs5({
1325
+ args,
1326
+ allowPositionals: true,
1327
+ tokens: true,
1328
+ options: {
1329
+ files: { type: "boolean", default: false },
1330
+ "all-files": { type: "boolean", default: false },
1331
+ "from-ref": { type: "string" },
1332
+ "to-ref": { type: "string" },
1333
+ format: { type: "string", default: "terminal" },
1334
+ session: { type: "string" },
1335
+ "no-llm": { type: "boolean", default: false },
1336
+ ref: { type: "string" }
1337
+ }
1338
+ });
1339
+ const format = values.format;
1340
+ if (!CLI_FORMATS.includes(format)) {
1341
+ throw new UsageError(`unknown format "${values.format}" (use ${CLI_FORMATS.join(", ")})`);
1342
+ }
1343
+ let filesAt = Number.POSITIVE_INFINITY;
1344
+ for (const token of tokens) {
1345
+ if (token.kind === "option" && token.name === "files") filesAt = Math.min(filesAt, token.index);
1346
+ }
1347
+ const leading = [];
1348
+ const files = [];
1349
+ for (const token of tokens) {
1350
+ if (token.kind !== "positional") continue;
1351
+ if (token.index > filesAt) files.push(token.value);
1352
+ else leading.push(token.value);
1353
+ }
1354
+ const fromRef = values["from-ref"] ?? null;
1355
+ const toRef = values["to-ref"] ?? null;
1356
+ if (values.files && files.length === 0) throw new UsageError("--files needs at least one file");
1357
+ if (values.files && values["all-files"]) throw new UsageError("--all-files and --files cannot be combined");
1358
+ if (toRef !== null && fromRef === null) throw new UsageError("--to-ref needs --from-ref");
1359
+ if (fromRef !== null && (values.files || values["all-files"])) {
1360
+ throw new UsageError("--from-ref cannot be combined with --all-files or --files");
1361
+ }
1362
+ return {
1363
+ leading,
1364
+ files: values.files ? files : null,
1365
+ allFiles: values["all-files"],
1366
+ fromRef,
1367
+ toRef,
1368
+ format,
1369
+ session: values.session ?? null,
1370
+ noLlm: values["no-llm"],
1371
+ ref: values.ref ?? null
1372
+ };
1373
+ }
1374
+ async function selectFiles(root, cwd, run2) {
1375
+ if (run2.files !== null) {
1376
+ const files = run2.files.map((file) => toProjectPath(root, cwd, file)).filter((file) => file !== null);
1377
+ return { files, baseCommit: null };
1378
+ }
1379
+ if (run2.fromRef !== null) {
1380
+ const base = await mergeBase(root, run2.fromRef, run2.toRef ?? "HEAD");
1381
+ const files = run2.toRef === null ? await changedFilesSince(root, base) : await changedFilesBetween(root, base, run2.toRef);
1382
+ return { files, baseCommit: base };
1383
+ }
1384
+ if (run2.allFiles) return { files: await allFiles(root), baseCommit: null };
1385
+ if (run2.session !== null) return { files: [], baseCommit: null };
1386
+ return { files: await stagedFiles(root), baseCommit: null };
1387
+ }
1388
+ async function executeRun({ project, ruleId, run: run2, registry, io }) {
1389
+ const root = project.root;
1390
+ if (ruleId !== null && !project.rules.some((rule) => rule.id === ruleId)) {
1391
+ throw new UsageError(`no rule "${ruleId}" (see rulecast validate)`);
1392
+ }
1393
+ const selected = await selectFiles(root, io.cwd, run2);
1394
+ const result = await runPipeline({
1395
+ project,
1396
+ stateDir: ensureProjectState(cacheHome(io.env), root),
1397
+ event: {
1398
+ kind: "verify",
1399
+ files: selected.files,
1400
+ baseCommit: selected.baseCommit ?? void 0,
1401
+ session: run2.session === null ? void 0 : { id: run2.session },
1402
+ cwd: root
1403
+ },
1404
+ registry,
1405
+ maxContextChars: null,
1406
+ skipDetectorKinds: run2.noLlm ? /* @__PURE__ */ new Set(["llm"]) : void 0,
1407
+ onlyRules: ruleId === null ? void 0 : /* @__PURE__ */ new Set([ruleId])
1408
+ });
1409
+ const text = formatDelivery(result.delivery, run2.format, { maxMatchesPerRule: project.config.maxMatchesPerRule });
1410
+ if (text) io.stdout(`${text}
1411
+ `);
1412
+ return exitCodeFor(result.delivery, result.failed);
1413
+ }
1414
+ async function runCommand(root, args, registry, io) {
1415
+ const run2 = parseRunArgs(args);
1416
+ if (run2.ref !== null) throw new UsageError("--ref only applies to try-repo");
1417
+ if (run2.leading.length > 1) throw new UsageError(`unexpected arguments: ${run2.leading.slice(1).join(" ")}`);
1418
+ if (!hasProject(root)) throw new Error(`no ${CONFIG_FILE} in ${io.cwd} or its parents`);
1419
+ const project = await compile({ root, registry, repos: fetchingRepos(cacheHome(io.env)) });
1420
+ return executeRun({ project, ruleId: run2.leading[0] ?? null, run: run2, registry, io });
1421
+ }
1422
+
1423
+ // src/commands/try-repo.ts
1424
+ import { existsSync as existsSync5, statSync as statSync2 } from "fs";
1425
+ import { mkdtemp, rm as rm2 } from "fs/promises";
1426
+ import { tmpdir as tmpdir2 } from "os";
1427
+ import path7 from "path";
1428
+ var isDirectory = (file) => existsSync5(file) && statSync2(file).isDirectory();
1429
+ var isGitRepo = (dir) => existsSync5(path7.join(dir, ".git")) || existsSync5(path7.join(dir, "HEAD"));
1430
+ async function baseConfigData(root) {
1431
+ if (!hasProject(root)) return {};
1432
+ const data = await readConfigData(root);
1433
+ if (!data.ok) throw new Error(data.message);
1434
+ return typeof data.value === "object" && data.value !== null && !Array.isArray(data.value) ? data.value : {};
1435
+ }
1436
+ async function tryRepoCommand(root, args, registry, io) {
1437
+ const run2 = parseRunArgs(args);
1438
+ const [repo, ruleId, ...extra] = run2.leading;
1439
+ if (repo === void 0) throw new UsageError("try-repo needs a repository path or URL");
1440
+ if (extra.length > 0) throw new UsageError(`unexpected arguments: ${extra.join(" ")}`);
1441
+ const local = path7.resolve(io.cwd, repo);
1442
+ const directory = isDirectory(local);
1443
+ if (directory && run2.ref !== null && !isGitRepo(local)) throw new UsageError("--ref only applies to URLs");
1444
+ const fromDirectory = directory && run2.ref === null;
1445
+ const rev = fromDirectory ? "working-tree" : run2.ref ?? "HEAD";
1446
+ const temp = fromDirectory ? null : await mkdtemp(path7.join(tmpdir2(), "rulecast-try-repo-"));
1447
+ try {
1448
+ const dir = temp ?? local;
1449
+ if (temp !== null) await fetchCheckout(directory ? local : repo, rev, temp);
1450
+ const label = fromDirectory ? `${path7.basename(local)}@${rev}` : repoLabel(repo, rev);
1451
+ const manifest = await readManifest(dir);
1452
+ if (!manifest.ok) throw new Error(`${label}: ${manifest.message}`);
1453
+ const ids = manifest.value.flatMap((rule) => {
1454
+ const id = rule?.id;
1455
+ return typeof id === "string" ? [id] : [];
1456
+ });
1457
+ if (ruleId !== void 0 && !ids.includes(ruleId)) throw new UsageError(`no rule "${ruleId}" in ${label}`);
1458
+ const configData = {
1459
+ ...await baseConfigData(root),
1460
+ repos: [{ repo, rev, rules: (ruleId === void 0 ? ids : [ruleId]).map((id) => ({ id })) }]
1461
+ };
1462
+ const project = await compile({ root, registry, repos: fixedRepo(dir, label), configData });
1463
+ return await executeRun({ project, ruleId: null, run: run2, registry, io });
1464
+ } finally {
1465
+ if (temp !== null) await rm2(temp, { recursive: true, force: true });
1466
+ }
1467
+ }
1468
+
1469
+ // src/commands/validate.ts
1470
+ import { existsSync as existsSync6 } from "fs";
1471
+ import path8 from "path";
1472
+ function formatDiagnostic(diagnostic) {
1473
+ return `${diagnostic.level === "warning" ? "warning: " : ""}${diagnosticText(diagnostic)}`;
1474
+ }
1475
+ async function validateCommand(root, args, registry, io) {
1476
+ 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) }));
1477
+ if (targets.length === 0) throw new Error(`nothing to validate: no ${CONFIG_FILE} or ${MANIFEST_FILE}`);
1478
+ for (const { shown, full } of targets) {
1479
+ const name = path8.basename(full);
1480
+ if (name !== CONFIG_FILE && name !== MANIFEST_FILE) {
1481
+ throw new UsageError(`${shown}: not a ${CONFIG_FILE} or ${MANIFEST_FILE}`);
1482
+ }
1483
+ }
1484
+ let failed = false;
1485
+ for (const { shown, full } of targets) {
1486
+ const dir = path8.dirname(full);
1487
+ const result = path8.basename(full) === CONFIG_FILE ? await compile({ root: dir, registry, repos: fetchingRepos(cacheHome(io.env)) }) : await compileManifest(dir, registry);
1488
+ for (const diagnostic of result.diagnostics) io.stdout(`${shown}: ${formatDiagnostic(diagnostic)}
1489
+ `);
1490
+ if (result.diagnostics.some((diagnostic) => diagnostic.level === "error")) {
1491
+ failed = true;
1492
+ continue;
1493
+ }
1494
+ const count = result.rules.length;
1495
+ io.stdout(`${shown}: ${count} ${count === 1 ? "rule" : "rules"} valid
1496
+ `);
1497
+ }
1498
+ return failed ? 2 : 0;
1499
+ }
1500
+
1501
+ // src/commands/warm.ts
1502
+ import { parseArgs as parseArgs6 } from "util";
1503
+ var WARM_TIMEOUT_MS = 5 * 6e4;
1504
+ async function warmCommand(root, args, registry, io) {
1505
+ const { values } = parseArgs6({ args, options: { detector: { type: "string", multiple: true } } });
1506
+ if (!hasProject(root)) throw new Error(`no .rulecast-config.yaml in ${root} or its parents (run rulecast init)`);
1507
+ const home = cacheHome(io.env);
1508
+ const stateDir = ensureProjectState(home, root);
1509
+ const log = debugLogger(stateDir);
1510
+ const result = await warmDetectors({
1511
+ root,
1512
+ stateDir,
1513
+ // Hooks start warm-ups detached, so like hooks it never fetches rule repos.
1514
+ project: await compile({ root, registry, repos: cachedRepos(home) }),
1515
+ registry,
1516
+ kinds: values.detector ?? null,
1517
+ timeoutMs: WARM_TIMEOUT_MS
1518
+ });
1519
+ if (result.warmed.length > 0) io.stdout(`rulecast: warmed ${result.warmed.join(", ")}
1520
+ `);
1521
+ if (result.skipped.length > 0) io.stdout(`rulecast: already warming ${result.skipped.join(", ")}
1522
+ `);
1523
+ for (const error of result.errors) {
1524
+ log(`warm ${error.kind} failed: ${error.message}`);
1525
+ io.stderr(`rulecast: warm ${error.kind} failed: ${error.message}
1526
+ `);
1527
+ }
1528
+ if (result.warmed.length + result.skipped.length + result.errors.length === 0) {
1529
+ io.stdout("rulecast: nothing to warm\n");
1530
+ }
1531
+ return result.errors.length > 0 ? 2 : 0;
1532
+ }
1533
+
1534
+ // src/commands/main.ts
1535
+ var USAGE = `usage:
1536
+ rulecast init [--rules id,id | --no-rules] [--agent <name>]... [--scope shared|personal] [--yes]
1537
+ rulecast install [--agent <name>]... [--scope shared|personal]
1538
+ rulecast uninstall [--agent <name>]...
1539
+ rulecast run [RULE_ID] [--all-files | --files F...] [--from-ref A [--to-ref B]] [--format terminal|agent|json|sarif] [--session <id>] [--no-llm]
1540
+ rulecast autoupdate [--freeze] [--repo URL]...
1541
+ rulecast try-repo <path|url> [RULE_ID] [--ref REV] [run flags]
1542
+ rulecast validate [file...]
1543
+ rulecast clean [--project]
1544
+ rulecast hook <adapter>
1545
+ rulecast warm [--detector <kind>]...
1546
+ rulecast doctor
1547
+ `;
1548
+ async function main(argv, io) {
1549
+ const [command, ...args] = argv;
1550
+ const registry = createRegistry([...builtinDetectors]);
1551
+ const root = findRoot(io.cwd);
1552
+ try {
1553
+ switch (command) {
1554
+ case "init":
1555
+ return await initCommand(args, registry, io);
1556
+ case "install":
1557
+ return await installCommand(root, args, io);
1558
+ case "uninstall":
1559
+ return await uninstallCommand(root, args, io);
1560
+ case "run":
1561
+ return await runCommand(root, args, registry, io);
1562
+ case "autoupdate":
1563
+ return await autoupdateCommand(root, args, io);
1564
+ case "try-repo":
1565
+ return await tryRepoCommand(root, args, registry, io);
1566
+ case "validate":
1567
+ return await validateCommand(root, args, registry, io);
1568
+ case "clean":
1569
+ return await cleanCommand(root, args, io);
1570
+ case "hook":
1571
+ return await hookCommand(args, registry, io);
1572
+ case "warm":
1573
+ return await warmCommand(root, args, registry, io);
1574
+ case "doctor":
1575
+ return await doctorCommand(root, args, registry, io);
1576
+ case void 0:
1577
+ case "help":
1578
+ case "--help":
1579
+ io.stdout(USAGE);
1580
+ return command === void 0 ? 2 : 0;
1581
+ default:
1582
+ throw new UsageError(`unknown command "${command}"`);
1583
+ }
1584
+ } catch (error) {
1585
+ io.stderr(`rulecast: ${errorMessage(error)}
1586
+ `);
1587
+ if (error instanceof UsageError || error.code?.startsWith("ERR_PARSE_ARGS")) {
1588
+ io.stderr(USAGE);
1589
+ }
1590
+ return 2;
1591
+ }
1592
+ }
1593
+
1594
+ // src/commands/spawn.ts
1595
+ import { spawn } from "child_process";
1596
+ function spawnDetached(command, args, cwd) {
1597
+ const child = spawn(command, args, { cwd, detached: true, stdio: "ignore" });
1598
+ child.on("error", () => {
1599
+ });
1600
+ child.unref();
1601
+ }
1602
+
1603
+ // src/init/clipboard.ts
1604
+ import { spawn as spawn2 } from "child_process";
1605
+ import { accessSync, constants, statSync as statSync3 } from "fs";
1606
+ import path9 from "path";
1607
+ var CANDIDATES = [
1608
+ { command: "pbcopy", args: [] },
1609
+ { command: "wl-copy", args: [] },
1610
+ { command: "xclip", args: ["-selection", "clipboard"] },
1611
+ { command: "clip.exe", args: [] }
1612
+ ];
1613
+ function isExecutable(file) {
1614
+ try {
1615
+ if (!statSync3(file).isFile()) return false;
1616
+ accessSync(file, constants.X_OK);
1617
+ return true;
1618
+ } catch {
1619
+ return false;
1620
+ }
1621
+ }
1622
+ function findOnPath(name, env) {
1623
+ for (const dir of (env.PATH ?? "").split(path9.delimiter)) {
1624
+ if (dir === "") continue;
1625
+ const candidate = path9.join(dir, name);
1626
+ if (isExecutable(candidate)) return candidate;
1627
+ }
1628
+ return null;
1629
+ }
1630
+ function clipboardCommand(env) {
1631
+ for (const candidate of CANDIDATES) {
1632
+ const found = findOnPath(candidate.command, env);
1633
+ if (found !== null) return { command: found, args: candidate.args };
1634
+ }
1635
+ return null;
1636
+ }
1637
+ function copyWith(clipboard, text) {
1638
+ return new Promise((resolve) => {
1639
+ const child = spawn2(clipboard.command, clipboard.args, { stdio: ["pipe", "ignore", "ignore"] });
1640
+ child.on("error", () => resolve(false));
1641
+ child.on("close", (code) => resolve(code === 0));
1642
+ child.stdin.on("error", () => {
1643
+ });
1644
+ child.stdin.end(text);
1645
+ });
1646
+ }
1647
+
1648
+ // src/cli.ts
1649
+ var script = process.argv[1];
1650
+ process.exitCode = await main(process.argv.slice(2), {
1651
+ cwd: process.cwd(),
1652
+ env: process.env,
1653
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
1654
+ stdout: (text) => process.stdout.write(text),
1655
+ stderr: (text) => process.stderr.write(text),
1656
+ readStdin: () => readAll(process.stdin),
1657
+ startWarm: (root, kinds) => spawnDetached(process.execPath, [script, "warm", ...kinds.flatMap((kind) => ["--detector", kind])], root),
1658
+ // Only init prompts; a dynamic import keeps @clack/prompts out of every hook's startup.
1659
+ prompter: async () => (await import("./clack-YPV4ZOQK.js")).clackPrompter(),
1660
+ copyToClipboard: async (text) => {
1661
+ const clipboard = clipboardCommand(process.env);
1662
+ return clipboard !== null && await copyWith(clipboard, text);
1663
+ }
1664
+ });