pi-rules-md 0.0.0-stage → 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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +163 -2
  3. package/RULE-SPEC.md +191 -0
  4. package/dist/cli.mjs +842 -0
  5. package/dist/extension.js +11297 -0
  6. package/index.ts +165 -0
  7. package/package.json +52 -3
  8. package/shell/collectors.test.ts +172 -0
  9. package/shell/collectors.ts +109 -0
  10. package/shell/discovery.test.ts +597 -0
  11. package/shell/discovery.ts +348 -0
  12. package/shell/injection.test.ts +164 -0
  13. package/shell/injection.ts +88 -0
  14. package/shell/pipeline.test.ts +243 -0
  15. package/shell/pipeline.ts +200 -0
  16. package/shell/read-rule.test.ts +57 -0
  17. package/shell/read-rule.ts +49 -0
  18. package/shell/rules-paths.test.ts +26 -0
  19. package/shell/rules-paths.ts +17 -0
  20. package/src/cli/constants.ts +1 -0
  21. package/src/cli/debug.ts +6 -0
  22. package/src/cli/doctor.test.ts +161 -0
  23. package/src/cli/doctor.ts +109 -0
  24. package/src/cli/install.test.ts +97 -0
  25. package/src/cli/install.ts +63 -0
  26. package/src/cli/interlock.test.ts +29 -0
  27. package/src/cli/interlock.ts +6 -0
  28. package/src/cli/main.test.ts +88 -0
  29. package/src/cli/main.ts +123 -0
  30. package/src/cli/match.test.ts +86 -0
  31. package/src/cli/match.ts +76 -0
  32. package/src/cli/packaging.test.ts +47 -0
  33. package/src/cli/paths.test.ts +54 -0
  34. package/src/cli/paths.ts +45 -0
  35. package/src/cli/pi-runner.test.ts +79 -0
  36. package/src/cli/pi-runner.ts +62 -0
  37. package/src/cli/registry.test.ts +68 -0
  38. package/src/cli/registry.ts +53 -0
  39. package/src/cli/scopes.ts +19 -0
  40. package/src/cli/settings.test.ts +76 -0
  41. package/src/cli/settings.ts +68 -0
  42. package/src/cli/status.test.ts +107 -0
  43. package/src/cli/status.ts +73 -0
  44. package/src/cli/uninstall.test.ts +149 -0
  45. package/src/cli/uninstall.ts +123 -0
  46. package/src/cli/update.test.ts +126 -0
  47. package/src/cli/update.ts +76 -0
  48. package/src/conditions/predicates.test.ts +118 -0
  49. package/src/conditions/predicates.ts +79 -0
  50. package/src/conditions/table.test.ts +314 -0
  51. package/src/conditions/table.ts +215 -0
  52. package/src/engine.test.ts +476 -0
  53. package/src/engine.ts +165 -0
  54. package/src/frontmatter.test.ts +376 -0
  55. package/src/frontmatter.ts +303 -0
  56. package/src/intent.test.ts +20 -0
  57. package/src/intent.ts +25 -0
  58. package/src/match.test.ts +336 -0
  59. package/src/match.ts +146 -0
  60. package/src/render.golden/multi-rule-mixed-reasons.md +18 -0
  61. package/src/render.golden/no-reasons.md +6 -0
  62. package/src/render.golden/single-rule.md +7 -0
  63. package/src/render.test.ts +162 -0
  64. package/src/render.ts +103 -0
  65. package/src/result.test.ts +27 -0
  66. package/src/result.ts +16 -0
  67. package/src/select.test.ts +280 -0
  68. package/src/select.ts +116 -0
  69. package/src/state.test.ts +205 -0
  70. package/src/state.ts +78 -0
  71. package/src/types.test.ts +291 -0
  72. package/src/types.ts +219 -0
package/dist/cli.mjs ADDED
@@ -0,0 +1,842 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { existsSync, readFileSync, realpathSync, rmSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+ import { readFile, realpath } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { spawn } from "node:child_process";
9
+
10
+ //#region src/cli/constants.ts
11
+ const PACKAGE_NAME = "pi-rules-md";
12
+
13
+ //#endregion
14
+ //#region src/cli/interlock.ts
15
+ function sandboxRefusal(opts) {
16
+ if ((opts.env.VITEST === "true" || opts.env.NODE_ENV === "test") && opts.agentDir === void 0) return "refusing to mutate Pi settings without an explicit agent directory under a test runtime; pass --agent-dir or set PI_RULES_AGENT_DIR";
17
+ return null;
18
+ }
19
+
20
+ //#endregion
21
+ //#region src/cli/match.ts
22
+ async function findMatchingEntries(packages, opts) {
23
+ const matches = [];
24
+ const unmatchedLocal = [];
25
+ for (const entry of packages) {
26
+ if (entry.kind === "malformed") continue;
27
+ const { source } = entry;
28
+ if (source.startsWith("npm:")) {
29
+ const spec = source.slice(4);
30
+ if (spec === "pi-rules-md") matches.push({
31
+ entry,
32
+ source,
33
+ kind: "npm"
34
+ });
35
+ else if (spec.startsWith(`${"pi-rules-md"}@`) && isVersion(spec.slice("pi-rules-md".length + 1))) matches.push({
36
+ entry,
37
+ source,
38
+ kind: "npm-pinned",
39
+ version: spec.slice(PACKAGE_NAME.length + 1)
40
+ });
41
+ continue;
42
+ }
43
+ if (source.startsWith("git:")) {
44
+ const repo = source.slice(4).split(/[?#]/, 1)[0] ?? "";
45
+ if (path.posix.basename(repo.replace(/\\/g, "/")).replace(/\.git$/i, "") === "pi-rules-md") matches.push({
46
+ entry,
47
+ source,
48
+ kind: "git"
49
+ });
50
+ continue;
51
+ }
52
+ const expanded = source.startsWith("~") ? path.join(homedir(), source.slice(1).replace(/^[/\\]/, "")) : source;
53
+ const target = path.resolve(path.isAbsolute(expanded) ? expanded : path.join(opts.settingsDir, expanded));
54
+ try {
55
+ const canonicalPath = await realpath(target);
56
+ const metadata = JSON.parse(await readFile(path.join(canonicalPath, "package.json"), "utf8"));
57
+ if (metadata && typeof metadata === "object" && !Array.isArray(metadata) && metadata.name === "pi-rules-md") matches.push({
58
+ entry,
59
+ source,
60
+ kind: "local",
61
+ canonicalPath
62
+ });
63
+ else unmatchedLocal.push(entry);
64
+ } catch {
65
+ unmatchedLocal.push(entry);
66
+ }
67
+ }
68
+ const groups = /* @__PURE__ */ new Map();
69
+ for (const match of matches) {
70
+ if (!match.canonicalPath) continue;
71
+ groups.set(match.canonicalPath, [...groups.get(match.canonicalPath) ?? [], match]);
72
+ }
73
+ return {
74
+ matches,
75
+ unmatchedLocal,
76
+ duplicates: new Map([...groups].filter(([, group]) => group.length > 1))
77
+ };
78
+ }
79
+ function isVersion(version) {
80
+ return /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version);
81
+ }
82
+
83
+ //#endregion
84
+ //#region src/cli/paths.ts
85
+ function resolveAgentDir(explicit, env) {
86
+ return explicit ?? env.PI_RULES_AGENT_DIR ?? path.join(homedir(), ".pi", "agent");
87
+ }
88
+ function resolvePersonalSettingsPath(agentDir) {
89
+ return path.join(agentDir, "settings.json");
90
+ }
91
+ function resolveProjectSettingsPath(opts) {
92
+ const cwd = path.resolve(opts.cwd);
93
+ const gitRoot = findGitRoot(cwd);
94
+ if (!gitRoot) return settingsPathIfPresent(cwd);
95
+ let current = cwd;
96
+ while (true) {
97
+ const settings = settingsPathIfPresent(current);
98
+ if (settings) return settings;
99
+ if (current === gitRoot) return void 0;
100
+ current = path.dirname(current);
101
+ }
102
+ }
103
+ function resolveNpmStorePath(agentDir) {
104
+ return path.join(agentDir, "npm", "node_modules", PACKAGE_NAME);
105
+ }
106
+ function settingsPathIfPresent(directory) {
107
+ const settings = path.join(directory, ".pi", "settings.json");
108
+ return existsSync(settings) ? settings : void 0;
109
+ }
110
+ function findGitRoot(start) {
111
+ let current = start;
112
+ while (true) {
113
+ if (existsSync(path.join(current, ".git"))) return current;
114
+ const parent = path.dirname(current);
115
+ if (parent === current) return void 0;
116
+ current = parent;
117
+ }
118
+ }
119
+
120
+ //#endregion
121
+ //#region src/cli/pi-runner.ts
122
+ function runPi({ argv, timeoutMs = 3e4, dryRun = false, capture = false, env = process.env, stdout = [] }) {
123
+ if (dryRun) {
124
+ stdout.push(`[pi-rules] [dry-run] Would run: pi ${argv.join(" ")}`);
125
+ return Promise.resolve({ exitCode: 0 });
126
+ }
127
+ return new Promise((resolve) => {
128
+ let child;
129
+ try {
130
+ child = spawn("pi", argv, {
131
+ shell: false,
132
+ env,
133
+ stdio: capture ? [
134
+ "ignore",
135
+ "pipe",
136
+ "pipe"
137
+ ] : "inherit"
138
+ });
139
+ } catch (error) {
140
+ resolve({
141
+ exitCode: 1,
142
+ error: error instanceof Error ? error.message : String(error)
143
+ });
144
+ return;
145
+ }
146
+ let out = "";
147
+ let err = "";
148
+ let timedOut = false;
149
+ let spawnError;
150
+ if (capture) {
151
+ child.stdout?.setEncoding("utf8").on("data", (chunk) => {
152
+ out += chunk;
153
+ });
154
+ child.stderr?.setEncoding("utf8").on("data", (chunk) => {
155
+ err += chunk;
156
+ });
157
+ }
158
+ child.once("error", (error) => {
159
+ spawnError = error;
160
+ });
161
+ const timer = setTimeout(() => {
162
+ timedOut = true;
163
+ child.kill("SIGKILL");
164
+ }, timeoutMs);
165
+ child.once("close", (code, signal) => {
166
+ clearTimeout(timer);
167
+ if (spawnError?.code === "ENOENT") {
168
+ resolve({
169
+ exitCode: 1,
170
+ error: "pi CLI not found on PATH",
171
+ ...capture ? {
172
+ stdout: out,
173
+ stderr: err
174
+ } : {}
175
+ });
176
+ return;
177
+ }
178
+ resolve({
179
+ exitCode: timedOut ? 1 : code ?? 1,
180
+ ...capture ? {
181
+ stdout: out,
182
+ stderr: err
183
+ } : {},
184
+ ...spawnError ? { error: spawnError.message } : {},
185
+ ...timedOut ? {
186
+ timedOut: true,
187
+ signal: "SIGKILL"
188
+ } : signal ? { signal } : {}
189
+ });
190
+ });
191
+ });
192
+ }
193
+
194
+ //#endregion
195
+ //#region src/cli/settings.ts
196
+ async function loadSettings(target, opts) {
197
+ const settingsPath = target === "personal" ? resolvePersonalSettingsPath(resolveAgentDir(opts.agentDir, {})) : target === "project" ? resolveProjectSettingsPath({ cwd: opts.cwd }) ?? path.join(opts.cwd, ".pi", "settings.json") : path.resolve(target);
198
+ let text;
199
+ try {
200
+ text = await readFile(settingsPath, "utf8");
201
+ } catch (error) {
202
+ if (isMissing(error)) return {
203
+ path: settingsPath,
204
+ exists: false,
205
+ packages: []
206
+ };
207
+ return {
208
+ path: settingsPath,
209
+ exists: true,
210
+ readError: errorMessage(error),
211
+ packages: []
212
+ };
213
+ }
214
+ let parsed;
215
+ try {
216
+ parsed = JSON.parse(text);
217
+ } catch (error) {
218
+ return {
219
+ path: settingsPath,
220
+ exists: true,
221
+ parseError: errorMessage(error),
222
+ packages: []
223
+ };
224
+ }
225
+ if (!isRecord(parsed) || !Array.isArray(parsed.packages)) return {
226
+ path: settingsPath,
227
+ exists: true,
228
+ shapeError: "\"packages\" must be an array",
229
+ packages: []
230
+ };
231
+ return {
232
+ path: settingsPath,
233
+ exists: true,
234
+ packages: parsed.packages.map((raw) => {
235
+ if (typeof raw === "string") return {
236
+ kind: "string",
237
+ source: raw
238
+ };
239
+ if (isRecord(raw) && typeof raw.source === "string") return {
240
+ kind: "object",
241
+ source: raw.source
242
+ };
243
+ return {
244
+ kind: "malformed",
245
+ raw
246
+ };
247
+ })
248
+ };
249
+ }
250
+ function isRecord(value) {
251
+ return value !== null && typeof value === "object" && !Array.isArray(value);
252
+ }
253
+ function isMissing(error) {
254
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
255
+ }
256
+ function errorMessage(error) {
257
+ return error instanceof Error ? error.message : String(error);
258
+ }
259
+
260
+ //#endregion
261
+ //#region src/cli/install.ts
262
+ function ioFor$4(options) {
263
+ return options.io ?? {
264
+ env: process.env,
265
+ stdout: { push: (...lines) => console.log(...lines) },
266
+ stderr: { push: (...lines) => console.error(...lines) }
267
+ };
268
+ }
269
+ function settingsProblem$4(settings) {
270
+ if (settings.readError) return settings.readError;
271
+ if (settings.parseError) return settings.parseError;
272
+ return settings.shapeError;
273
+ }
274
+ async function installFlow(options) {
275
+ const io = ioFor$4(options);
276
+ const refusal = sandboxRefusal({
277
+ agentDir: options.agentDir,
278
+ env: io.env
279
+ });
280
+ if (refusal) {
281
+ io.stderr.push(refusal);
282
+ return 3;
283
+ }
284
+ const agentDir = resolveAgentDir(options.agentDir, process.env);
285
+ const local = options.local === true;
286
+ let matches = [];
287
+ const settings = await loadSettings(local ? "project" : "personal", {
288
+ agentDir,
289
+ cwd: process.cwd()
290
+ });
291
+ const { path: settingsPath, exists, packages } = settings;
292
+ const problem = settingsProblem$4(settings);
293
+ if (problem) io.stderr.push(`warning: unable to read settings at ${settingsPath}: ${problem}`);
294
+ else if (exists) {
295
+ const { matches: foundMatches } = await findMatchingEntries(packages, { settingsDir: path.dirname(settingsPath) });
296
+ matches = foundMatches;
297
+ }
298
+ let removed = 0;
299
+ for (const match of matches) {
300
+ const result = await runPi({
301
+ argv: [
302
+ "remove",
303
+ match.source,
304
+ ...local ? ["--local"] : []
305
+ ],
306
+ dryRun: options.dryRun,
307
+ env: io.env,
308
+ stdout: io.stdout
309
+ });
310
+ if (result.exitCode !== 0) return result.exitCode;
311
+ removed++;
312
+ }
313
+ const result = await runPi({
314
+ argv: [
315
+ "install",
316
+ `npm:${PACKAGE_NAME}`,
317
+ ...local ? ["--local"] : []
318
+ ],
319
+ dryRun: options.dryRun,
320
+ env: io.env,
321
+ stdout: io.stdout
322
+ });
323
+ if (result.exitCode !== 0) return result.exitCode;
324
+ const stale = removed > 0 ? `, removed ${removed} stale entr${removed === 1 ? "y" : "ies"}` : "";
325
+ io.stdout.push(`✓ Installed ${PACKAGE_NAME} (npm:${PACKAGE_NAME})${stale}\ntip: Run pi-rules doctor`);
326
+ return 0;
327
+ }
328
+
329
+ //#endregion
330
+ //#region src/cli/scopes.ts
331
+ async function loadScopes(scopes, opts) {
332
+ const loaded = [];
333
+ for (const scope of scopes) loaded.push({
334
+ scope,
335
+ settings: await loadSettings(scope, opts)
336
+ });
337
+ return loaded;
338
+ }
339
+
340
+ //#endregion
341
+ //#region src/cli/uninstall.ts
342
+ function ioFor$3(options) {
343
+ return options.io ?? {
344
+ env: process.env,
345
+ stdout: { push: (...lines) => console.log(...lines) },
346
+ stderr: { push: (...lines) => console.error(...lines) }
347
+ };
348
+ }
349
+ function settingsProblem$3(settings) {
350
+ if (settings.readError) return settings.readError;
351
+ if (settings.parseError) return settings.parseError;
352
+ return settings.shapeError;
353
+ }
354
+ function isStrictlyInside(parent, child) {
355
+ const relative = path.relative(parent, child);
356
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
357
+ }
358
+ function purgeStore(agentDir, io, dryRun) {
359
+ const storePath = resolveNpmStorePath(agentDir);
360
+ let realAgent;
361
+ let npm;
362
+ let nodeModules;
363
+ try {
364
+ realAgent = realpathSync(agentDir);
365
+ npm = realpathSync(path.join(agentDir, "npm"));
366
+ nodeModules = realpathSync(path.join(agentDir, "npm", "node_modules"));
367
+ } catch (error) {
368
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
369
+ io.stdout.push("✓ package store already absent");
370
+ return 0;
371
+ }
372
+ io.stderr.push(`refusing to purge unsafe package store path: ${storePath}`);
373
+ return 1;
374
+ }
375
+ if (!isStrictlyInside(realAgent, npm) || !isStrictlyInside(npm, nodeModules)) {
376
+ io.stderr.push(`refusing to purge unsafe package store path: ${storePath}`);
377
+ return 1;
378
+ }
379
+ let store;
380
+ try {
381
+ store = realpathSync(storePath);
382
+ } catch (error) {
383
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
384
+ io.stdout.push("✓ package store already absent");
385
+ return 0;
386
+ }
387
+ io.stderr.push(`refusing to purge unsafe package store path: ${storePath}`);
388
+ return 1;
389
+ }
390
+ if (!isStrictlyInside(nodeModules, store) || path.basename(storePath) !== "pi-rules-md") {
391
+ io.stderr.push(`refusing to purge unsafe package store path: ${storePath}`);
392
+ return 1;
393
+ }
394
+ if (dryRun) {
395
+ io.stdout.push(`[pi-rules] [dry-run] Would remove ${storePath}`);
396
+ return 0;
397
+ }
398
+ rmSync(storePath, {
399
+ recursive: true,
400
+ force: true
401
+ });
402
+ io.stdout.push(`✓ removed package store ${storePath}`);
403
+ io.stderr.push("run pi update --extensions to reconcile the package store");
404
+ return 0;
405
+ }
406
+ async function uninstallFlow(options) {
407
+ const io = ioFor$3(options);
408
+ const refusal = sandboxRefusal({
409
+ agentDir: options.agentDir,
410
+ env: io.env
411
+ });
412
+ if (refusal) {
413
+ io.stderr.push(refusal);
414
+ return 3;
415
+ }
416
+ const agentDir = resolveAgentDir(options.agentDir, process.env);
417
+ const scopes = options.local ? ["project"] : ["personal", "project"];
418
+ const loaded = await loadScopes(scopes, {
419
+ agentDir,
420
+ cwd: process.cwd()
421
+ });
422
+ const matches = [];
423
+ for (const { scope, settings } of loaded) {
424
+ const problem = settingsProblem$3(settings);
425
+ if (problem) {
426
+ io.stderr.push(`error: unable to read settings at ${settings.path}: ${problem}`);
427
+ return 1;
428
+ }
429
+ if (!settings.exists) continue;
430
+ const result = await findMatchingEntries(settings.packages, { settingsDir: path.dirname(settings.path) });
431
+ for (const entry of result.unmatchedLocal) if (entry.kind !== "malformed") io.stderr.push(`warning: local entry does not look like pi-rules-md, leaving it: ${entry.source}`);
432
+ matches.push(...result.matches.map(({ source }) => ({
433
+ scope,
434
+ source
435
+ })));
436
+ }
437
+ const hasPersonalScope = scopes.includes("personal");
438
+ if (matches.length === 0) {
439
+ io.stdout.push("✓ pi-rules-md not installed");
440
+ if (options.purge && hasPersonalScope) return purgeStore(agentDir, io, options.dryRun);
441
+ if (options.purge && options.local) io.stdout.push("nothing to purge in project scope");
442
+ return 0;
443
+ }
444
+ let anyFailed = false;
445
+ for (const match of matches) if ((await runPi({
446
+ argv: [
447
+ "remove",
448
+ match.source,
449
+ ...match.scope === "project" ? ["--local"] : []
450
+ ],
451
+ dryRun: options.dryRun,
452
+ env: io.env,
453
+ stdout: io.stdout
454
+ })).exitCode !== 0) anyFailed = true;
455
+ if (options.purge) {
456
+ if (purgeStore(agentDir, io, options.dryRun) !== 0) anyFailed = true;
457
+ }
458
+ return anyFailed ? 1 : 0;
459
+ }
460
+
461
+ //#endregion
462
+ //#region src/cli/registry.ts
463
+ const REGISTRY_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
464
+ const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
465
+ async function fetchLatestVersion(fetchImpl = fetch, timeoutMs = 3e3) {
466
+ let timer;
467
+ const timeout = new Promise((resolve) => {
468
+ timer = setTimeout(() => resolve(null), timeoutMs);
469
+ });
470
+ const request = (async () => {
471
+ const response = await fetchImpl(REGISTRY_LATEST_URL, { signal: AbortSignal.timeout(timeoutMs) });
472
+ if (!response.ok) return null;
473
+ const body = await response.json();
474
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
475
+ const { version } = body;
476
+ return typeof version === "string" && VERSION_PATTERN.test(version) ? version : null;
477
+ })();
478
+ try {
479
+ return await Promise.race([request.catch(() => null), timeout]);
480
+ } catch {
481
+ return null;
482
+ } finally {
483
+ if (timer !== void 0) clearTimeout(timer);
484
+ }
485
+ }
486
+ function parseSemver(version) {
487
+ if (!VERSION_PATTERN.test(version)) return null;
488
+ const [core] = version.split("-");
489
+ const parts = core?.split(".").map(Number);
490
+ if (!parts || parts.length !== 3 || parts.some((part) => !Number.isSafeInteger(part))) return null;
491
+ return {
492
+ major: parts[0],
493
+ minor: parts[1],
494
+ patch: parts[2]
495
+ };
496
+ }
497
+ function compareSemver(a, b) {
498
+ if (a == null || b == null) return null;
499
+ const left = parseSemver(a);
500
+ const right = parseSemver(b);
501
+ if (!left || !right) return null;
502
+ return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
503
+ }
504
+ function isStale(installed, latest) {
505
+ const comparison = compareSemver(latest, installed);
506
+ return comparison !== null && comparison > 0;
507
+ }
508
+
509
+ //#endregion
510
+ //#region src/cli/update.ts
511
+ function ioFor$2(options) {
512
+ return options.io ?? {
513
+ env: process.env,
514
+ stdout: { push: (...lines) => console.log(...lines) },
515
+ stderr: { push: (...lines) => console.error(...lines) }
516
+ };
517
+ }
518
+ function settingsProblem$2(settings) {
519
+ return settings.readError ?? settings.parseError ?? settings.shapeError;
520
+ }
521
+ async function updateFlow(options) {
522
+ const io = ioFor$2(options);
523
+ const refusal = sandboxRefusal({
524
+ agentDir: options.agentDir,
525
+ env: io.env
526
+ });
527
+ if (refusal) {
528
+ io.stderr.push(refusal);
529
+ return 3;
530
+ }
531
+ const agentDir = resolveAgentDir(options.agentDir, process.env);
532
+ const scopes = options.local ? ["project"] : ["personal", "project"];
533
+ const loaded = await loadScopes(scopes, {
534
+ agentDir,
535
+ cwd: process.cwd()
536
+ });
537
+ const matches = [];
538
+ for (const { scope, settings } of loaded) {
539
+ const problem = settingsProblem$2(settings);
540
+ if (problem) {
541
+ io.stderr.push(`error: unable to read settings at ${settings.path}: ${problem}`);
542
+ continue;
543
+ }
544
+ if (!settings.exists) continue;
545
+ const result = await findMatchingEntries(settings.packages, { settingsDir: path.dirname(settings.path) });
546
+ matches.push(...result.matches.map(({ source, kind, version }) => ({
547
+ scope,
548
+ source,
549
+ kind,
550
+ ...version === void 0 ? {} : { version }
551
+ })));
552
+ }
553
+ if (matches.length === 0) {
554
+ io.stdout.push("nothing to update (no pi-rules-md entries found)");
555
+ return 0;
556
+ }
557
+ if (matches.some(({ kind }) => kind === "npm" || kind === "npm-pinned")) {
558
+ const latest = await fetchLatestVersion(options.fetchImpl ?? fetch);
559
+ if (latest) io.stdout.push(`latest: ${latest}`);
560
+ }
561
+ let anyFailed = false;
562
+ const seen = /* @__PURE__ */ new Set();
563
+ for (const match of matches) {
564
+ if (match.kind === "local") {
565
+ io.stdout.push(`${match.scope}: ${match.source} tracks the working tree (not updated)`);
566
+ continue;
567
+ }
568
+ if (match.kind === "npm-pinned") {
569
+ io.stdout.push(`${match.scope}: ${match.source} pinned to ${match.version}, not updating`);
570
+ continue;
571
+ }
572
+ if (match.kind !== "npm") continue;
573
+ const key = `${match.scope}:${match.source}`;
574
+ if (seen.has(key)) continue;
575
+ seen.add(key);
576
+ if ((await runPi({
577
+ argv: [
578
+ "update",
579
+ match.source,
580
+ ...match.scope === "project" ? ["--local"] : []
581
+ ],
582
+ dryRun: options.dryRun,
583
+ env: io.env,
584
+ stdout: io.stdout
585
+ })).exitCode !== 0) anyFailed = true;
586
+ }
587
+ return anyFailed ? 1 : 0;
588
+ }
589
+
590
+ //#endregion
591
+ //#region src/cli/status.ts
592
+ function ioFor$1(options) {
593
+ return options.io ?? {
594
+ env: process.env,
595
+ stdout: { push: (...lines) => console.log(...lines) },
596
+ stderr: { push: (...lines) => console.error(...lines) }
597
+ };
598
+ }
599
+ function settingsProblem$1(settings) {
600
+ return settings.readError ?? settings.parseError ?? settings.shapeError;
601
+ }
602
+ function readPackageVersion(pkgDir) {
603
+ try {
604
+ const metadata = JSON.parse(readFileSync(path.join(pkgDir, "package.json"), "utf8"));
605
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return void 0;
606
+ const { version } = metadata;
607
+ return typeof version === "string" ? version : void 0;
608
+ } catch {
609
+ return;
610
+ }
611
+ }
612
+ function installedVersion$1(match, agentDir) {
613
+ const packageDir = match.kind === "local" ? match.canonicalPath : resolveNpmStorePath(agentDir);
614
+ return packageDir ? readPackageVersion(packageDir) : void 0;
615
+ }
616
+ function kindLabel$1(kind) {
617
+ return kind === "npm" ? "npm" : kind === "npm-pinned" ? "npm-pinned" : kind;
618
+ }
619
+ async function statusFlow(options) {
620
+ const io = ioFor$1(options);
621
+ const agentDir = resolveAgentDir(options.agentDir, process.env);
622
+ const loaded = await loadScopes(["personal", "project"], {
623
+ agentDir,
624
+ cwd: process.cwd()
625
+ });
626
+ const matches = [];
627
+ for (const { scope, settings } of loaded) {
628
+ const problem = settingsProblem$1(settings);
629
+ if (problem) {
630
+ io.stderr.push(`error: unable to read settings at ${settings.path}: ${problem}`);
631
+ continue;
632
+ }
633
+ if (!settings.exists) continue;
634
+ const result = await findMatchingEntries(settings.packages, { settingsDir: path.dirname(settings.path) });
635
+ for (const match of result.matches) {
636
+ const installed = installedVersion$1(match, agentDir);
637
+ matches.push({
638
+ scope,
639
+ match,
640
+ installed
641
+ });
642
+ io.stdout.push(`${scope}: ${match.source} (${kindLabel$1(match.kind)}, installed ${installed ?? "unknown"})`);
643
+ }
644
+ }
645
+ if (matches.length === 0) {
646
+ io.stdout.push("pi-rules-md not installed");
647
+ return 0;
648
+ }
649
+ const latest = await fetchLatestVersion(options.fetchImpl ?? fetch);
650
+ if (latest) {
651
+ io.stdout.push(`latest: ${latest}`);
652
+ for (const { scope, match, installed } of matches) if ((match.kind === "npm" || match.kind === "npm-pinned") && installed && isStale(installed, latest)) io.stdout.push(`stale: ${scope} ${match.source} installed ${installed} < latest ${latest}`);
653
+ }
654
+ return 0;
655
+ }
656
+
657
+ //#endregion
658
+ //#region src/cli/doctor.ts
659
+ const MIN_NODE_VERSION = "22.19.0";
660
+ const DIST_FILES = ["dist/extension.js", "dist/cli.mjs"];
661
+ function ioFor(options) {
662
+ return options.io ?? {
663
+ env: process.env,
664
+ stdout: { push: (...lines) => console.log(...lines) },
665
+ stderr: { push: (...lines) => console.error(...lines) }
666
+ };
667
+ }
668
+ function settingsProblem(settings) {
669
+ return settings.readError ?? settings.parseError ?? settings.shapeError;
670
+ }
671
+ function checkNodeVersion(current) {
672
+ const comparison = compareSemver(current, MIN_NODE_VERSION);
673
+ if (comparison === null) return "unknown";
674
+ return comparison >= 0 ? "ok" : "outdated";
675
+ }
676
+ function installedVersion(match, agentDir) {
677
+ const packageDir = match.kind === "local" ? match.canonicalPath : resolveNpmStorePath(agentDir);
678
+ return packageDir ? readPackageVersion(packageDir) : void 0;
679
+ }
680
+ function kindLabel(kind) {
681
+ return kind === "npm-pinned" ? "npm-pinned" : kind;
682
+ }
683
+ async function doctorFlow(options) {
684
+ const io = ioFor(options);
685
+ const agentDir = resolveAgentDir(options.agentDir, process.env);
686
+ const loaded = await loadScopes(["personal", "project"], {
687
+ agentDir,
688
+ cwd: process.cwd()
689
+ });
690
+ const structural = [];
691
+ const advisory = [];
692
+ const matches = [];
693
+ for (const { scope, settings } of loaded) {
694
+ const problem = settingsProblem(settings);
695
+ if (problem) {
696
+ advisory.push(`advisory: ${scope} settings unreadable (${problem})`);
697
+ continue;
698
+ }
699
+ if (!settings.exists) {
700
+ io.stdout.push(`${scope}: no settings`);
701
+ continue;
702
+ }
703
+ const result = await findMatchingEntries(settings.packages, { settingsDir: path.dirname(settings.path) });
704
+ for (const match of result.matches) {
705
+ const installed = installedVersion(match, agentDir);
706
+ matches.push({
707
+ scope,
708
+ match,
709
+ installed
710
+ });
711
+ io.stdout.push(`${scope}: ${match.source} (${kindLabel(match.kind)}, installed ${installed ?? "unknown"})`);
712
+ if (match.kind === "local") {
713
+ for (const file of DIST_FILES) if (!match.canonicalPath || !existsSync(path.join(match.canonicalPath, file))) advisory.push(`advisory: ${scope} ${match.source} missing ${file} (run pnpm build in ${match.canonicalPath ?? "unknown"})`);
714
+ } else for (const file of DIST_FILES) if (!existsSync(path.join(resolveNpmStorePath(agentDir), file))) structural.push(`structural: ${scope} ${match.source} package store missing ${file}`);
715
+ }
716
+ for (const [canonicalPath, group] of result.duplicates) advisory.push(`advisory: duplicate entries resolve to ${canonicalPath} (${group.length} entries)`);
717
+ }
718
+ const nodeVersion = process.versions.node;
719
+ const nodeStatus = checkNodeVersion(nodeVersion);
720
+ if (nodeStatus === "outdated") structural.push(`structural: node ${nodeVersion} < ${MIN_NODE_VERSION} (engines requirement)`);
721
+ if (nodeStatus === "unknown") advisory.push(`advisory: unable to verify node version (${nodeVersion})`);
722
+ if (matches.length === 0) structural.push("structural: pi-rules-md is not installed (run pi-rules install)");
723
+ let latestLine;
724
+ if (matches.length > 0) {
725
+ const latest = await fetchLatestVersion(options.fetchImpl ?? fetch);
726
+ if (latest) {
727
+ latestLine = `latest: ${latest}`;
728
+ for (const { scope, match, installed } of matches) if (match.kind === "npm-pinned") advisory.push(`advisory: ${scope} ${match.source} pinned to ${match.version} — update manually if stale`);
729
+ else if (match.kind === "npm" && installed && isStale(installed, latest)) advisory.push(`advisory: ${scope} ${match.source} stale (installed ${installed} < latest ${latest})`);
730
+ } else advisory.push("advisory: registry unreachable, staleness skipped");
731
+ }
732
+ io.stdout.push(...structural);
733
+ if (latestLine) io.stdout.push(latestLine);
734
+ io.stdout.push(...advisory);
735
+ const structuralCount = structural.length;
736
+ io.stdout.push(structuralCount === 0 ? "summary: healthy" : `summary: ${structuralCount} structural issue(s)`);
737
+ return structuralCount === 0 ? 0 : 1;
738
+ }
739
+
740
+ //#endregion
741
+ //#region src/cli/main.ts
742
+ const USAGE = `Usage: pi-rules <command> [options]
743
+
744
+ Commands:
745
+ install Install pi-rules-md
746
+ uninstall Remove pi-rules-md
747
+ update Update pi-rules-md
748
+ status Show installation status
749
+ doctor Diagnose installation health
750
+
751
+ Options:
752
+ --local Use the project scope (install/uninstall/update)
753
+ --purge Remove the package directory (uninstall)
754
+ --agent-dir <dir> Use a specific Pi agent directory
755
+ --dry-run Print actions without changing anything
756
+ --help Show this help`;
757
+ var NotImplementedFlowError = class extends Error {
758
+ constructor(name) {
759
+ super(`${name} flow not implemented`);
760
+ this.name = "NotImplementedFlowError";
761
+ }
762
+ };
763
+ const defaultFlows = {
764
+ install: installFlow,
765
+ uninstall: uninstallFlow,
766
+ update: updateFlow,
767
+ status: statusFlow,
768
+ doctor: doctorFlow
769
+ };
770
+ async function runCli(args, io, flows = defaultFlows) {
771
+ const [command, ...commandArgs] = args;
772
+ if (command === "--help") {
773
+ io.stdout.push(USAGE);
774
+ return 0;
775
+ }
776
+ if (!command) {
777
+ io.stderr.push(USAGE);
778
+ return 2;
779
+ }
780
+ if (!(command in defaultFlows)) {
781
+ io.stderr.push(USAGE);
782
+ return 2;
783
+ }
784
+ let options;
785
+ try {
786
+ const { values } = parseArgs({
787
+ args: commandArgs,
788
+ strict: true,
789
+ allowPositionals: false,
790
+ options: {
791
+ ...command === "install" || command === "uninstall" || command === "update" ? { local: { type: "boolean" } } : {},
792
+ ...command === "uninstall" ? { purge: { type: "boolean" } } : {},
793
+ "agent-dir": { type: "string" },
794
+ "dry-run": { type: "boolean" },
795
+ help: { type: "boolean" }
796
+ }
797
+ });
798
+ if (values.help) {
799
+ io.stdout.push(USAGE);
800
+ return 0;
801
+ }
802
+ const agentDir = values["agent-dir"] ?? io.env.PI_RULES_AGENT_DIR;
803
+ if (agentDir !== void 0 && agentDir.trim() === "") {
804
+ io.stderr.push(`error: --agent-dir requires a non-empty path\n${USAGE}`);
805
+ return 2;
806
+ }
807
+ options = {
808
+ ...agentDir === void 0 ? {} : { agentDir },
809
+ local: values.local === true,
810
+ purge: values.purge === true,
811
+ dryRun: values["dry-run"] ?? false
812
+ };
813
+ } catch (error) {
814
+ io.stderr.push(`${error instanceof Error ? error.message : String(error)}\n${USAGE}`);
815
+ return 2;
816
+ }
817
+ try {
818
+ return await flows[command]({
819
+ ...options,
820
+ io
821
+ });
822
+ } catch (error) {
823
+ if (error instanceof NotImplementedFlowError) {
824
+ io.stderr.push(`error: ${error.message} in this build; it lands in an upcoming release.\n${USAGE}`);
825
+ return 2;
826
+ }
827
+ throw error;
828
+ }
829
+ }
830
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
831
+ const io = {
832
+ env: process.env,
833
+ stdout: [],
834
+ stderr: []
835
+ };
836
+ process.exitCode = await runCli(process.argv.slice(2), io);
837
+ for (const line of io.stdout) console.log(line);
838
+ for (const line of io.stderr) console.error(line);
839
+ }
840
+
841
+ //#endregion
842
+ export { NotImplementedFlowError, USAGE, runCli };