@jaybeeuu/agent-cortex 1.41.1 → 1.43.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 (115) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +73 -2
  2. package/.github/workflows/ci.yml +52 -32
  3. package/AGENTS.md +23 -2
  4. package/CHANGELOG.md +189 -0
  5. package/README.md +125 -73
  6. package/agents/README.md +17 -4
  7. package/agents/ralph-plan/agent.md +7 -3
  8. package/agents/ralph-plan.agent.md +7 -3
  9. package/bin/agent-cortex.mjs +48 -30
  10. package/bin/installers/pi.mjs +885 -237
  11. package/claude-extras/scripts/statusline-command.sh +0 -0
  12. package/docs/claude-hooks.md +5 -4
  13. package/docs/commit-signing.md +231 -0
  14. package/docs/ideas/ban-content-assertion-tests.md +3 -1
  15. package/docs/ideas/dedicated-review-tests-skill.md +71 -0
  16. package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +10 -1
  17. package/docs/ideas/extension-manifests.md +5 -0
  18. package/docs/ideas/unify-the-ticket-and-task-decomposition-workflows.md +71 -0
  19. package/docs/research/disable-model-invocation.md +103 -0
  20. package/docs/research/skill-design-research.md +2 -2
  21. package/docs/skills/skill-anatomy.md +5 -1
  22. package/docs/technical-direction/harness-config.md +25 -1
  23. package/docs/technical-direction/migration-execution-labels.md +17 -9
  24. package/extensions/agent-modes/discover.test.ts +19 -0
  25. package/extensions/agent-modes/discover.ts +25 -3
  26. package/extensions/session-start/README.md +38 -0
  27. package/extensions/session-start/index.test.ts +122 -0
  28. package/extensions/session-start/index.ts +27 -0
  29. package/extensions/session-start/package.json +19 -0
  30. package/extensions/session-start/tsconfig.json +12 -0
  31. package/extensions/subagent/README.md +1 -1
  32. package/extensions/subagent/agents.test.ts +176 -0
  33. package/extensions/subagent/agents.ts +102 -11
  34. package/extensions/subagent/index.ts +2 -1
  35. package/extensions/subagent/package.json +1 -1
  36. package/lib/cli.mjs +35 -9
  37. package/package.json +10 -6
  38. package/pi/settings.json +1 -7
  39. package/plugin.json +1 -1
  40. package/pnpm-workspace.yaml +6 -0
  41. package/scripts/check-changeset.mjs +42 -0
  42. package/scripts/install-instructions.sh +0 -0
  43. package/scripts/lib/release-gates.mjs +61 -0
  44. package/scripts/lib/verify-worker-tree.mjs +107 -0
  45. package/scripts/resolve-publish-command.mjs +17 -0
  46. package/scripts/sync-plugin-version.sh +0 -0
  47. package/skills/engineering/grill-with-docs/SKILL.md +3 -2
  48. package/skills/engineering/improve-codebase-architecture/REFERENCE.md +5 -0
  49. package/skills/engineering/improve-codebase-architecture/SKILL.md +2 -1
  50. package/skills/planning/create-task/SKILL.md +8 -3
  51. package/skills/planning/create-task/scripts/create-chores.ts +4 -1
  52. package/skills/planning/prd-to-tasks/REFERENCE.md +2 -2
  53. package/skills/planning/prd-to-tasks/SKILL.md +9 -4
  54. package/skills/planning/request-refactor-plan/FORMAT.md +5 -0
  55. package/skills/planning/request-refactor-plan/SKILL.md +3 -2
  56. package/skills/planning/spec-to-tickets/REFERENCE.md +82 -0
  57. package/skills/planning/spec-to-tickets/SKILL.md +143 -0
  58. package/skills/planning/write-a-prd/FORMAT.md +6 -0
  59. package/skills/planning/write-a-prd/SKILL.md +9 -6
  60. package/skills/planning/write-a-ticket/EXAMPLES.md +133 -0
  61. package/skills/planning/write-a-ticket/SKILL.md +77 -59
  62. package/skills/productivity/bd-tool/SKILL.md +27 -2
  63. package/skills/productivity/grill-me/SKILL.md +11 -0
  64. package/skills/review/review-security/scripts/install-gitleaks.sh +0 -0
  65. package/skills/workflow/git-workflow/SKILL.md +35 -1
  66. package/skills/workflow/plan/SKILL.md +20 -8
  67. package/skills/workflow/ralph/REFERENCE.md +20 -2
  68. package/skills/workflow/ralph/SKILL.md +4 -1
  69. package/skills/workflow/run-pipeline-stage/SKILL.md +17 -5
  70. package/skills/workflow/run-pipeline-stage/playbooks/code.md +2 -0
  71. package/skills/workflow/run-pipeline-stage/playbooks/document.md +3 -0
  72. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -0
  73. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +11 -0
  74. package/skills/workflow/using-agent-skills/SKILL.md +1 -0
  75. package/test/cli.test.mjs +26 -29
  76. package/test/create-chores.test.mjs +12 -0
  77. package/test/fake-bd.test.mjs +72 -0
  78. package/test/helpers/fake-bd.mjs +26 -6
  79. package/test/helpers/fake-pi.mjs +132 -0
  80. package/test/pi-installer.test.mjs +345 -286
  81. package/test/pi-register.test.mjs +342 -0
  82. package/test/pi-sweep.test.mjs +619 -0
  83. package/test/release-gates.test.mjs +190 -0
  84. package/test/verify-worker-tree.test.mjs +120 -0
  85. package/token-map.README.md +7 -6
  86. package/token-map.json +6 -5
  87. package/extensions/agent-guardrails/node_modules/.bin/pi +0 -21
  88. package/extensions/agent-guardrails/node_modules/.bin/tsc +0 -21
  89. package/extensions/agent-guardrails/node_modules/.bin/tsserver +0 -21
  90. package/extensions/agent-guardrails/node_modules/.bin/tsx +0 -21
  91. package/extensions/agent-modes/node_modules/.bin/pi +0 -21
  92. package/extensions/agent-modes/node_modules/.bin/tsc +0 -21
  93. package/extensions/agent-modes/node_modules/.bin/tsserver +0 -21
  94. package/extensions/agent-modes/node_modules/.bin/tsx +0 -21
  95. package/extensions/session-start.ts +0 -44
  96. package/extensions/skill-stats/node_modules/.bin/pi +0 -21
  97. package/extensions/skill-stats/node_modules/.bin/tsc +0 -21
  98. package/extensions/skill-stats/node_modules/.bin/tsserver +0 -21
  99. package/extensions/skill-stats/node_modules/.bin/tsx +0 -21
  100. package/extensions/subagent/node_modules/.bin/pi +0 -21
  101. package/extensions/subagent/node_modules/.bin/pi-ai +0 -21
  102. package/extensions/subagent/node_modules/.bin/tsc +0 -21
  103. package/extensions/subagent/node_modules/.bin/tsserver +0 -21
  104. package/extensions/subagent/node_modules/.bin/tsx +0 -21
  105. package/lib/pi-packages.mjs +0 -169
  106. package/skills/planning/create-task/scripts/node_modules/.bin/tsc +0 -21
  107. package/skills/planning/create-task/scripts/node_modules/.bin/tsserver +0 -21
  108. package/skills/planning/create-task/scripts/node_modules/.bin/tsx +0 -21
  109. package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsc +0 -21
  110. package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsserver +0 -21
  111. package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsx +0 -21
  112. package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsc +0 -21
  113. package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsserver +0 -21
  114. package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsx +0 -21
  115. package/test/pi-packages.test.mjs +0 -295
package/test/cli.test.mjs CHANGED
@@ -123,11 +123,6 @@ describe("parseArgs", () => {
123
123
  assert.deepStrictEqual(result, { command: "install", harness: "pi", pluginRoot: "/tmp/plugin" });
124
124
  });
125
125
 
126
- it("parses --no-provision for install", async () => {
127
- const result = parseArgs(["install", "pi", "--no-provision"]);
128
- assert.deepStrictEqual(result, { command: "install", harness: "pi", noProvision: true });
129
- });
130
-
131
126
  it("combines multiple install options", async () => {
132
127
  const result = parseArgs(["install", "pi", "--dry-run", "--output", "/tmp/out", "--plugin-root", "/tmp/plugin"]);
133
128
  assert.deepStrictEqual(result, {
@@ -236,14 +231,6 @@ describe("CLI integration", () => {
236
231
  }
237
232
  });
238
233
 
239
- it("exits 0 for install pi --no-provision", async () => {
240
- // --no-provision keeps this hermetic: without it the CLI would install the
241
- // third-party packages declared in the package manifest.
242
- const { exitCode, stdout } = await runCli(["install", "pi", "--no-provision"]);
243
- assert.equal(exitCode, 0);
244
- assert.ok(stdout.includes("pi"));
245
- });
246
-
247
234
  it("exits 1 and prints error for install with no harness", async () => {
248
235
  const { exitCode, stderr } = await runCli(["install"]);
249
236
  assert.equal(exitCode, 1);
@@ -256,6 +243,12 @@ describe("CLI integration", () => {
256
243
  assert.ok(stderr.includes("--bogus"));
257
244
  });
258
245
 
246
+ it("exits 1 for the removed --no-provision flag", async () => {
247
+ const { exitCode, stderr } = await runCli(["install", "pi", "--no-provision"]);
248
+ assert.equal(exitCode, 1);
249
+ assert.ok(stderr.includes("--no-provision"), "the removed flag is named in the error");
250
+ });
251
+
259
252
  it("exits 1 and prints error for install with a missing option value", async () => {
260
253
  const { exitCode, stderr } = await runCli(["install", "pi", "--output"]);
261
254
  assert.equal(exitCode, 1);
@@ -263,30 +256,33 @@ describe("CLI integration", () => {
263
256
  });
264
257
 
265
258
  it("exits 0 for install pi --dry-run and writes nothing", async () => {
266
- const { exitCode, stdout } = await runCli(["install", "pi", "--dry-run"]);
267
- assert.equal(exitCode, 0);
268
- assert.ok(stdout.includes("ralph.agent.md"));
269
- assert.ok(stdout.includes("dry-run"));
259
+ // A throwaway HOME keeps the registration plan (which reads pi's settings)
260
+ // off the developer's real ~/.pi/agent.
261
+ const fakeHome = await mkdtemp(join(tmpdir(), "cli-pi-dry-run-home-"));
262
+ try {
263
+ const { exitCode, stdout } = await runCli(["install", "pi", "--dry-run"], { HOME: fakeHome });
264
+ assert.equal(exitCode, 0);
265
+ assert.ok(stdout.includes("ralph.agent.md"));
266
+ assert.ok(stdout.includes("dry-run"));
267
+ } finally {
268
+ await rm(fakeHome, { recursive: true, force: true });
269
+ }
270
270
  });
271
271
 
272
- it("install pi --dry-run lists the pi packages it would install", async () => {
273
- // Fake HOME keeps the pi user scope hermetic: both manifest packages are
274
- // missing, so the plan contains exactly the declared sources.
272
+ it("install pi --dry-run reports the bundled pi packages it would stage", async () => {
275
273
  const fakeHome = await mkdtemp(join(tmpdir(), "cli-pi-home-"));
276
274
  try {
277
275
  const { exitCode, stdout } = await runCli(["install", "pi", "--dry-run"], { HOME: fakeHome });
278
276
  assert.equal(exitCode, 0);
279
- assert.ok(stdout.includes("Would install pi package npm:pi-questions"), "planned pi-questions shown");
280
- assert.ok(
281
- stdout.includes("Would install pi package npm:pi-web-access@0.10.7"),
282
- "planned pi-web-access shown",
283
- );
277
+ assert.ok(stdout.includes("Bundled pi package pi-questions"), "pi-questions staged");
278
+ assert.ok(stdout.includes("Bundled pi package pi-web-access"), "pi-web-access staged");
279
+ assert.ok(stdout.includes(join(fakeHome, ".agent-cortex", "pi")), "default output is the home staging dir");
284
280
  } finally {
285
281
  await rm(fakeHome, { recursive: true, force: true });
286
282
  }
287
283
  });
288
284
 
289
- it("exits 0 for install pi --output <tmp> and writes agent and config files", async () => {
285
+ it("exits 0 for install pi --output <tmp> and stages a loadable pi package", async () => {
290
286
  const outDir = await mkdtemp(join(tmpdir(), "cli-pi-install-"));
291
287
  try {
292
288
  const { exitCode, stdout } = await runCli(["install", "pi", "--output", outDir]);
@@ -294,9 +290,10 @@ describe("CLI integration", () => {
294
290
  assert.ok(stdout.includes("ralph.agent.md"));
295
291
  const ralph = await readFile(join(outDir, "agents", "ralph.agent.md"), "utf-8");
296
292
  assert.ok(ralph.includes("agent-cortex:ralph"));
297
- const settings = JSON.parse(await readFile(join(outDir, "settings.json"), "utf-8"));
298
- assert.ok(Array.isArray(settings.packages));
299
- assert.ok(await readFile(join(outDir, "keybindings.json"), "utf-8"));
293
+ const manifest = JSON.parse(await readFile(join(outDir, "package.json"), "utf-8"));
294
+ assert.deepEqual(manifest.keywords, ["pi-package"]);
295
+ assert.ok(manifest.pi.extensions.includes("./extensions"));
296
+ assert.ok(manifest.pi.skills.includes("./skills"));
300
297
  } finally {
301
298
  await rm(outDir, { recursive: true, force: true });
302
299
  }
@@ -70,4 +70,16 @@ describe("create-chores", () => {
70
70
  ["implementation-type:afk", "stage:code"],
71
71
  );
72
72
  });
73
+
74
+ it("validates the PR gate task but not the stage chores", async () => {
75
+ const { ids, beads } = await createChoresAgainstFakeBd(["implementation-type:afk"]);
76
+
77
+ const gate = beads.find((b) => b.id === ids.featurePrReview);
78
+ assert.equal(gate.type, "task");
79
+ assert.equal(gate.validated, true);
80
+ assert.match(gate.description, /## Acceptance Criteria/);
81
+
82
+ const codeChore = beads.find((b) => b.id === ids.code);
83
+ assert.equal(codeChore.validated, false);
84
+ });
73
85
  });
@@ -0,0 +1,72 @@
1
+ // Tests for the stateful fake `bd` helper's `--validate` model.
2
+ // The fake mirrors `bd create --validate`: a substantive bead whose description lacks the
3
+ // required section(s) for its type is rejected; chores have no requirements.
4
+
5
+ import { describe, it } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { execFile } from "node:child_process";
8
+ import { mkdtemp, rm } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import { makeFakeBd } from "./helpers/fake-bd.mjs";
13
+
14
+ /** Run the fake `bd` binary with the given args. */
15
+ function runFake(bin, state, args) {
16
+ return new Promise((resolve) => {
17
+ execFile(bin, args, { env: { ...process.env, FAKE_BD_STATE: state } }, (error, stdout, stderr) => {
18
+ resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
19
+ });
20
+ });
21
+ }
22
+
23
+ /** Create a fresh fake workspace, run `fn`, then remove it. */
24
+ async function withFake(fn) {
25
+ const dir = await mkdtemp(join(tmpdir(), "fake-bd-"));
26
+ try {
27
+ const { bin, state } = await makeFakeBd(dir, { beads: [] });
28
+ await fn(bin, state);
29
+ } finally {
30
+ await rm(dir, { recursive: true, force: true });
31
+ }
32
+ }
33
+
34
+ describe("fake bd --validate", () => {
35
+ it("rejects a section-less task", async () => {
36
+ await withFake(async (bin, state) => {
37
+ const { exitCode, stderr } = await runFake(bin, state, ["create", "empty", "--type", "task", "--validate"]);
38
+ assert.equal(exitCode, 1);
39
+ assert.match(stderr, /Acceptance Criteria/);
40
+ });
41
+ });
42
+
43
+ it("accepts a task carrying ## Acceptance Criteria (case-insensitive)", async () => {
44
+ await withFake(async (bin, state) => {
45
+ const { exitCode } = await runFake(bin, state, [
46
+ "create", "ok", "--type", "task",
47
+ "--description", "## Acceptance criteria\n- [ ] done",
48
+ "--validate",
49
+ ]);
50
+ assert.equal(exitCode, 0);
51
+ });
52
+ });
53
+
54
+ it("requires ## Success Criteria for an epic", async () => {
55
+ await withFake(async (bin, state) => {
56
+ const { exitCode, stderr } = await runFake(bin, state, [
57
+ "create", "ep", "--type", "epic",
58
+ "--description", "## Acceptance Criteria\n- [ ] x",
59
+ "--validate",
60
+ ]);
61
+ assert.equal(exitCode, 1);
62
+ assert.match(stderr, /Success Criteria/);
63
+ });
64
+ });
65
+
66
+ it("does not require sections for a chore", async () => {
67
+ await withFake(async (bin, state) => {
68
+ const { exitCode } = await runFake(bin, state, ["create", "c", "--type", "chore", "--validate"]);
69
+ assert.equal(exitCode, 0);
70
+ });
71
+ });
72
+ });
@@ -1,10 +1,10 @@
1
1
  // Shared test helper: a stateful fake `bd` binary for create-chores.ts.
2
2
  // `bd` is a genuine external system (its real database must never be touched
3
3
  // by tests), so the script is driven against this fake via the BD_PATH
4
- // override. The fake reproduces the one real behaviour the test depends on —
5
- // `bd create --parent` inherits the parent's labels unless
6
- // `--no-inherit-labels` is passed — and records every created bead to a JSON
7
- // state file the test can read back.
4
+ // override. The fake reproduces the real behaviours the tests depend on —
5
+ // `bd create --parent` label inheritance (unless `--no-inherit-labels` is
6
+ // passed) and `bd create --validate` section enforcement — and records every
7
+ // created bead to a JSON state file the test can read back.
8
8
  //
9
9
  // State lives in `state.json` next to the binary; the test seeds it with the
10
10
  // parent bead and reads the children back after the script runs. Written async
@@ -20,7 +20,7 @@ const FAKE_BD_SCRIPT = [
20
20
  "",
21
21
  "const stateFile = process.env.FAKE_BD_STATE;",
22
22
  "const VALUE_FLAGS = new Set(['--type', '--description', '--priority', '--labels', '--parent']);",
23
- "const BOOL_FLAGS = new Set(['--silent', '--no-inherit-labels']);",
23
+ "const BOOL_FLAGS = new Set(['--silent', '--no-inherit-labels', '--validate']);",
24
24
  "",
25
25
  "(async () => {",
26
26
  " const argv = process.argv.slice(2);",
@@ -37,13 +37,33 @@ const FAKE_BD_SCRIPT = [
37
37
  " }",
38
38
  "",
39
39
  " const state = JSON.parse(await fs.readFile(stateFile, 'utf8'));",
40
+ "",
41
+ " // Model `bd create --validate`: reject a substantive bead whose description lacks the",
42
+ " // required section(s) for its type. Chores have no requirements and always pass.",
43
+ " if (bools.has('--validate')) {",
44
+ " const type = flags['--type'] || 'task';",
45
+ " const required = {",
46
+ " task: ['## Acceptance Criteria'],",
47
+ " feature: ['## Acceptance Criteria'],",
48
+ " bug: ['## Steps to Reproduce', '## Acceptance Criteria'],",
49
+ " epic: ['## Success Criteria'],",
50
+ " chore: [],",
51
+ " }[type] || ['## Acceptance Criteria'];",
52
+ " const description = flags['--description'] || '';",
53
+ " const missing = required.filter((section) => !description.toLowerCase().includes(section.toLowerCase()));",
54
+ " if (missing.length > 0) {",
55
+ " console.error('missing required sections for ' + type + ': ' + missing.join(', '));",
56
+ " process.exit(1);",
57
+ " }",
58
+ " }",
59
+ "",
40
60
  " const explicit = (flags['--labels'] || '').split(',').filter(Boolean);",
41
61
  " const parent = state.beads.find((b) => b.id === flags['--parent']);",
42
62
  " const inherited = parent && !bools.has('--no-inherit-labels') ? parent.labels : [];",
43
63
  " const labels = [...new Set([...inherited, ...explicit])];",
44
64
  "",
45
65
  " const id = 'bd-' + state.nextId++;",
46
- " state.beads.push({ id, title, labels, parent: flags['--parent'] ?? null });",
66
+ " state.beads.push({ id, title, labels, parent: flags['--parent'] ?? null, type: flags['--type'] || 'task', description: flags['--description'] || '', validated: bools.has('--validate') });",
47
67
  " await fs.writeFile(stateFile, JSON.stringify(state));",
48
68
  " process.stdout.write(id + '\\n');",
49
69
  "})().catch((err) => {",
@@ -0,0 +1,132 @@
1
+ // Shared test helper: a stateful fake `pi` binary for pi's package commands.
2
+ // The real CLI is a genuine external system (and its user settings must never
3
+ // be touched by tests), so tests drive this fake instead. It records every
4
+ // invocation to `calls.log` (argv, space-joined, one line each) and simulates
5
+ // just enough real behaviour to assert registration state:
6
+ //
7
+ // --version → exit 0 (availability probe)
8
+ // list → configured package sources
9
+ // install <source> → adds <source> to settings.json `packages`, persisted
10
+ // relative to the settings file's directory (what real
11
+ // pi does) unless an entry already resolves to it
12
+ // remove <source> → drops the matching entry; exit 1 when none matches
13
+ //
14
+ // Local sources are resolved the way pi resolves them: relative to the
15
+ // directory holding settings.json. `failWhen` makes any invocation whose argv
16
+ // contains the substring exit 1 (command failures).
17
+ //
18
+ // Written async throughout (the fake is a child process and the helper's own
19
+ // I/O is test code) — the repo's check-no-sync lint scans test/ for *Sync.
20
+
21
+ import { chmod, writeFile } from "node:fs/promises";
22
+ import { join } from "node:path";
23
+
24
+ const FAKE_PI_SCRIPT = [
25
+ "#!/usr/bin/env node",
26
+ "// Stateful fake for the `pi` package CLI (test/helpers/fake-pi.mjs).",
27
+ "const fs = require('node:fs/promises');",
28
+ "const path = require('node:path');",
29
+ "const os = require('node:os');",
30
+ "",
31
+ "const dir = __dirname;",
32
+ "const settingsFile = path.join(dir, 'settings.json');",
33
+ "const logFile = path.join(dir, 'calls.log');",
34
+ "const failWhenFile = path.join(dir, 'failWhen.txt');",
35
+ "",
36
+ "const expand = (p) => (p === '~' ? os.homedir() : p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p);",
37
+ "const sourceOf = (entry) => (typeof entry === 'string' ? entry : entry && entry.source);",
38
+ "// pi resolves a configured local source against the settings file's directory.",
39
+ "const resolvedSource = (entry) => path.resolve(dir, expand(sourceOf(entry)));",
40
+ "const resolvedArgument = (arg) => path.resolve(dir, expand(arg));",
41
+ "",
42
+ "(async () => {",
43
+ " const argv = process.argv.slice(2);",
44
+ " const joined = argv.join(' ');",
45
+ " await fs.appendFile(logFile, joined + '\\n');",
46
+ "",
47
+ " const failWhen = await fs.readFile(failWhenFile, 'utf8').catch(() => null);",
48
+ " if (failWhen && joined.includes(failWhen)) process.exit(1);",
49
+ "",
50
+ " if (argv[0] === '--version' || argv[0] === '-v') {",
51
+ " console.log('1.4.0');",
52
+ " process.exit(0);",
53
+ " }",
54
+ "",
55
+ " const readSettings = async () => {",
56
+ " try {",
57
+ " return JSON.parse(await fs.readFile(settingsFile, 'utf8'));",
58
+ " } catch {",
59
+ " return {};",
60
+ " }",
61
+ " };",
62
+ " const writePackages = async (settings, packages) =>",
63
+ " fs.writeFile(settingsFile, JSON.stringify({ ...settings, packages }, null, 2) + '\\n');",
64
+ "",
65
+ " const [command, argument] = argv;",
66
+ " const settings = await readSettings();",
67
+ " const packages = settings.packages ?? [];",
68
+ "",
69
+ " if (command === 'list') {",
70
+ " if (packages.length === 0) {",
71
+ " console.log('No packages installed.');",
72
+ " process.exit(0);",
73
+ " }",
74
+ " console.log('User packages:');",
75
+ " for (const entry of packages) console.log(' ' + sourceOf(entry));",
76
+ " process.exit(0);",
77
+ " }",
78
+ "",
79
+ " if (command === 'install') {",
80
+ " const target = resolvedArgument(argument);",
81
+ " const persisted = path.relative(dir, target) || '.';",
82
+ " const index = packages.findIndex((entry) => resolvedSource(entry) === target);",
83
+ " if (index === -1) {",
84
+ " packages.push(persisted);",
85
+ " } else if (typeof packages[index] === 'string') {",
86
+ " packages[index] = persisted;",
87
+ " } else {",
88
+ " packages[index] = { ...packages[index], source: persisted };",
89
+ " }",
90
+ " await writePackages(settings, packages);",
91
+ " console.log('Installed ' + argument);",
92
+ " process.exit(0);",
93
+ " }",
94
+ "",
95
+ " if (command === 'remove') {",
96
+ " const target = resolvedArgument(argument);",
97
+ " const next = packages.filter((entry) => resolvedSource(entry) !== target);",
98
+ " if (next.length === packages.length) {",
99
+ " console.error('No matching package found for ' + argument);",
100
+ " process.exit(1);",
101
+ " }",
102
+ " await writePackages(settings, next);",
103
+ " console.log('Removed ' + argument);",
104
+ " process.exit(0);",
105
+ " }",
106
+ "",
107
+ " process.exit(0);",
108
+ "})().catch((err) => {",
109
+ " console.error(err);",
110
+ " process.exit(1);",
111
+ "});",
112
+ ].join("\n");
113
+
114
+ /** Create a stateful fake `pi` binary in `dir`.
115
+ *
116
+ * The binary's own directory doubles as pi's settings directory, so a local
117
+ * package source is persisted relative to it exactly as real pi does.
118
+ * @param {string} dir Directory to hold bin, calls.log, settings.json
119
+ * @param {{ failWhen?: string|null, packages?: unknown[] }} [options]
120
+ * failWhen: invocations whose argv contains the substring exit 1;
121
+ * packages: initial `packages` array for settings.json
122
+ * @returns {Promise<{ bin: string, log: string, settingsPath: string }>} */
123
+ export async function makeFakePi(dir, { failWhen = null, packages = [] } = {}) {
124
+ const bin = join(dir, "pi");
125
+ const log = join(dir, "calls.log");
126
+ const settingsPath = join(dir, "settings.json");
127
+ await writeFile(bin, FAKE_PI_SCRIPT + "\n");
128
+ await chmod(bin, 0o755);
129
+ await writeFile(settingsPath, `${JSON.stringify({ packages }, null, 2)}\n`);
130
+ if (failWhen) await writeFile(join(dir, "failWhen.txt"), failWhen);
131
+ return { bin, log, settingsPath };
132
+ }