@jaybeeuu/agent-cortex 1.40.0 → 1.41.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/AGENTS.md +21 -8
- package/CHANGELOG.md +48 -0
- package/README.md +78 -38
- package/agents/README.md +5 -3
- package/bin/agent-cortex.mjs +36 -4
- package/bin/installers/claude.mjs +171 -21
- package/bin/installers/copilot.mjs +2 -17
- package/bin/installers/pi.mjs +261 -22
- package/claude/settings.json +4 -0
- package/claude-extras/scripts/statusline-command.sh +32 -4
- package/docs/claude-hooks.md +12 -0
- package/docs/ideas/ban-content-assertion-tests.md +73 -0
- package/docs/ideas/bead-progress-notes-for-session-resumption.md +75 -0
- package/docs/ideas/classify-beads-via-deterministic-script-with-agent-fallback.md +70 -0
- package/docs/ideas/ctx-alias-version-and-update-commands.md +65 -0
- package/docs/ideas/ctx-feedback-command-for-capturing-ideas-from-any-machine.md +67 -0
- package/docs/ideas/deployment-beads-for-post-merge-pipelines.md +36 -0
- package/docs/ideas/design-and-delivery-as-separate-bead-trees/design-and-delivery-as-separate-bead-trees.md +99 -0
- package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +33 -0
- package/docs/ideas/extension-manifests.md +55 -14
- package/docs/ideas/improve-pi-system-prompt.md +5 -2
- package/docs/ideas/map-every-cortex-tool-to-a-real-pi-equivalent.md +95 -0
- package/docs/ideas/record-idea-implementation-questions.md +68 -0
- package/docs/ideas/self-flushing-session.md +72 -0
- package/docs/ideas/state-in-beads-not-md.md +69 -0
- package/docs/ideas/task-session-context.local.md +95 -0
- package/docs/ideas/test-data-creation-helpers.md +92 -0
- package/docs/ideas/test-planning-stage-bead-in-feature-planning.md +68 -0
- package/docs/ideas/worktree-housekeeping-for-closed-beads.md +39 -0
- package/docs/technical-direction/harness-config.md +161 -0
- package/docs/technical-direction/migration-execution-labels.md +60 -23
- package/extensions/agent-guardrails/README.md +45 -0
- package/extensions/agent-guardrails/guardrails.test.ts +42 -0
- package/extensions/agent-guardrails/guardrails.ts +66 -0
- package/extensions/agent-guardrails/index.ts +22 -0
- package/extensions/agent-guardrails/node_modules/.bin/pi +21 -0
- package/extensions/agent-guardrails/node_modules/.bin/tsc +21 -0
- package/extensions/agent-guardrails/node_modules/.bin/tsserver +21 -0
- package/extensions/agent-guardrails/node_modules/.bin/tsx +21 -0
- package/extensions/agent-guardrails/package.json +19 -0
- package/extensions/agent-guardrails/tsconfig.json +12 -0
- package/extensions/agent-modes/README.md +9 -3
- package/extensions/agent-modes/index.test.ts +208 -0
- package/extensions/agent-modes/index.ts +23 -6
- package/extensions/agent-modes/package.json +1 -1
- package/lib/cli.mjs +8 -2
- package/lib/pi-packages.mjs +169 -0
- package/package.json +5 -1
- package/pi/settings.json +23 -12
- package/plugin.json +1 -1
- package/scripts/lib/fs.mjs +31 -0
- package/skills/planning/classify-bead/REFERENCE.md +85 -0
- package/skills/planning/classify-bead/SKILL.md +32 -36
- package/skills/planning/classify-bead/scripts/classify-bead.mjs +292 -0
- package/skills/planning/create-task/SKILL.md +19 -10
- package/skills/planning/write-a-prd/SKILL.md +2 -2
- package/skills/style/style-code/REFERENCE.md +32 -2
- package/skills/style/style-code/SKILL.md +5 -21
- package/skills/style/style-tests/SKILL.md +69 -17
- package/skills/workflow/run-pipeline-stage/SKILL.md +13 -1
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +12 -0
- package/test/classify-bead.test.mjs +277 -0
- package/test/cli.test.mjs +55 -11
- package/test/fs.test.mjs +76 -0
- package/test/installer.test.mjs +237 -4
- package/test/pi-installer.test.mjs +343 -4
- package/test/pi-packages.test.mjs +295 -0
- package/token-map.README.md +4 -2
- package/token-map.json +3 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { mkdtemp, mkdir, writeFile, rm, readFile, readdir, stat } from "node:fs/promises";
|
|
4
|
-
import { join, dirname } from "node:path";
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm, readFile, readdir, stat, lstat, symlink } from "node:fs/promises";
|
|
4
|
+
import { join, dirname, resolve } from "node:path";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { installPi } from "../bin/installers/pi.mjs";
|
|
@@ -63,10 +63,29 @@ async function writeFixture(fx, relPath, content) {
|
|
|
63
63
|
await writeFile(p, content);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
/**
|
|
66
|
+
/** Template pi settings the fixture package ships (defaults the installer merges). */
|
|
67
|
+
function defaultPiSettings() {
|
|
68
|
+
return {
|
|
69
|
+
packages: [{ source: "../../src/agent-cortex", skills: [] }, "npm:pi-web-access@0.10.7"],
|
|
70
|
+
defaultModel: "template/model",
|
|
71
|
+
theme: "dark",
|
|
72
|
+
tinyModel: { model: "template/tiny", maxNameLength: 40 },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function defaultPiKeybindings() {
|
|
77
|
+
return { "tui.input.newLine": ["shift+enter"] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Seed a minimal but realistic package tree (agents + token-map + skills + pi templates). */
|
|
67
81
|
async function seedPackage(fx, opts = {}) {
|
|
68
|
-
const { agents = {}, skills = {}, tokenMap: map, version } = opts;
|
|
82
|
+
const { agents = {}, skills = {}, tokenMap: map, version, packages } = opts;
|
|
69
83
|
await writeFixture(fx, "token-map.json", JSON.stringify(map ?? tokenMap(version ? { version } : {})));
|
|
84
|
+
if (packages) {
|
|
85
|
+
await writeFixture(fx, "package.json", JSON.stringify({ name: "fixture", pi: { packages } }));
|
|
86
|
+
}
|
|
87
|
+
await writeFixture(fx, "pi/settings.json", JSON.stringify(opts.piSettings ?? defaultPiSettings(), null, 2));
|
|
88
|
+
await writeFixture(fx, "pi/keybindings.json", JSON.stringify(opts.piKeybindings ?? defaultPiKeybindings(), null, 2));
|
|
70
89
|
for (const [name, def] of Object.entries(agents)) {
|
|
71
90
|
await writeFixture(fx, `agents/${name}/agent.md`, def.body ?? `# ${name}\nBody of ${name}.`);
|
|
72
91
|
await writeFixture(
|
|
@@ -243,6 +262,105 @@ describe("installPi — agent composition", () => {
|
|
|
243
262
|
});
|
|
244
263
|
});
|
|
245
264
|
|
|
265
|
+
// ─── Third-party package provisioning ────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
describe("installPi — package provisioning", () => {
|
|
268
|
+
function recordingRunner() {
|
|
269
|
+
const calls = [];
|
|
270
|
+
return {
|
|
271
|
+
calls,
|
|
272
|
+
runInstall: async (source) => {
|
|
273
|
+
calls.push(source);
|
|
274
|
+
return { ok: true, error: null };
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
it("does not touch the pi package store unless provisioning is requested", async () => {
|
|
280
|
+
const fx = await makeFixture();
|
|
281
|
+
try {
|
|
282
|
+
await seedPackage(fx, {
|
|
283
|
+
agents: { alpha: { body: "# alpha" } },
|
|
284
|
+
packages: ["npm:pi-questions"],
|
|
285
|
+
});
|
|
286
|
+
const { calls, runInstall } = recordingRunner();
|
|
287
|
+
|
|
288
|
+
const result = await installPi({ root: fx.root, output: fx.output, runInstall });
|
|
289
|
+
|
|
290
|
+
assert.deepEqual(calls, []);
|
|
291
|
+
assert.equal(result.packages, null);
|
|
292
|
+
} finally {
|
|
293
|
+
await fx.cleanup();
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("installs the packages declared in the package manifest when enabled", async () => {
|
|
298
|
+
const fx = await makeFixture();
|
|
299
|
+
try {
|
|
300
|
+
await seedPackage(fx, {
|
|
301
|
+
agents: { alpha: { body: "# alpha" } },
|
|
302
|
+
packages: ["npm:pi-questions", "npm:pi-web-access@0.10.7"],
|
|
303
|
+
});
|
|
304
|
+
const { calls, runInstall } = recordingRunner();
|
|
305
|
+
|
|
306
|
+
const result = await installPi({ root: fx.root, output: fx.output, provisionPackages: true, runInstall });
|
|
307
|
+
|
|
308
|
+
assert.deepEqual(calls, ["npm:pi-questions", "npm:pi-web-access@0.10.7"]);
|
|
309
|
+
assert.deepEqual(result.packages.installed, ["npm:pi-questions", "npm:pi-web-access@0.10.7"]);
|
|
310
|
+
assert.deepEqual(result.packages.failed, []);
|
|
311
|
+
} finally {
|
|
312
|
+
await fx.cleanup();
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("dry-run reports the package plan without installing", async () => {
|
|
317
|
+
const fx = await makeFixture();
|
|
318
|
+
try {
|
|
319
|
+
await seedPackage(fx, {
|
|
320
|
+
agents: { alpha: { body: "# alpha" } },
|
|
321
|
+
packages: ["npm:pi-questions"],
|
|
322
|
+
});
|
|
323
|
+
const { calls, runInstall } = recordingRunner();
|
|
324
|
+
|
|
325
|
+
const result = await installPi({
|
|
326
|
+
root: fx.root,
|
|
327
|
+
output: fx.output,
|
|
328
|
+
provisionPackages: true,
|
|
329
|
+
dryRun: true,
|
|
330
|
+
runInstall,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
assert.deepEqual(calls, []);
|
|
334
|
+
assert.deepEqual(result.packages.planned, ["npm:pi-questions"]);
|
|
335
|
+
} finally {
|
|
336
|
+
await fx.cleanup();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("warns but still installs agents when a package install fails", async () => {
|
|
341
|
+
const fx = await makeFixture();
|
|
342
|
+
try {
|
|
343
|
+
await seedPackage(fx, {
|
|
344
|
+
agents: { alpha: { body: "# alpha" } },
|
|
345
|
+
packages: ["npm:pi-questions"],
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const result = await installPi({
|
|
349
|
+
root: fx.root,
|
|
350
|
+
output: fx.output,
|
|
351
|
+
provisionPackages: true,
|
|
352
|
+
runInstall: async () => ({ ok: false, error: "offline" }),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
assert.ok(await pathExists(join(fx.output, "agents", "alpha.agent.md")), "agents still installed");
|
|
356
|
+
assert.deepEqual(result.packages.failed, [{ source: "npm:pi-questions", error: "offline" }]);
|
|
357
|
+
assert.ok(result.warnings.some((w) => w.includes("npm:pi-questions")), "failure surfaced as a warning");
|
|
358
|
+
} finally {
|
|
359
|
+
await fx.cleanup();
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
246
364
|
// ─── Skills ──────────────────────────────────────────────────────────────────
|
|
247
365
|
|
|
248
366
|
describe("installPi — skills", () => {
|
|
@@ -319,6 +437,227 @@ describe("installPi — output & dry-run", () => {
|
|
|
319
437
|
});
|
|
320
438
|
});
|
|
321
439
|
|
|
440
|
+
// ─── Pi settings.json ────────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
describe("installPi — pi settings.json", () => {
|
|
443
|
+
it("writes a real settings.json with the repo path package and template keys", async () => {
|
|
444
|
+
const fx = await makeFixture();
|
|
445
|
+
try {
|
|
446
|
+
await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
|
|
447
|
+
|
|
448
|
+
const result = await installPi({ root: fx.root, output: fx.output });
|
|
449
|
+
|
|
450
|
+
const settingsPath = join(fx.output, "settings.json");
|
|
451
|
+
assert.equal((await lstat(settingsPath)).isSymbolicLink(), false, "settings.json is a real file");
|
|
452
|
+
const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
|
|
453
|
+
|
|
454
|
+
const repoEntry = settings.packages.find((p) => !(typeof p === "string" ? p : p.source).startsWith("npm:"));
|
|
455
|
+
assert.deepEqual(repoEntry.skills, [], "repo path package keeps the package filter shape");
|
|
456
|
+
assert.equal(resolve(dirname(settingsPath), repoEntry.source), fx.root, "repo path package resolves to the package root");
|
|
457
|
+
assert.ok(settings.packages.includes("npm:pi-web-access@0.10.7"), "template npm deps preserved");
|
|
458
|
+
assert.equal(settings.defaultModel, "template/model", "template key present");
|
|
459
|
+
assert.match(settings["//"], /GENERATED from pi\/settings\.json/);
|
|
460
|
+
assert.equal(result.settings.action, "written");
|
|
461
|
+
} finally {
|
|
462
|
+
await fx.cleanup();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("preserves personal values and unknown keys while filling template defaults", async () => {
|
|
467
|
+
const fx = await makeFixture();
|
|
468
|
+
try {
|
|
469
|
+
await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
|
|
470
|
+
await writeFixture(
|
|
471
|
+
fx,
|
|
472
|
+
"out/settings.json",
|
|
473
|
+
JSON.stringify({
|
|
474
|
+
defaultModel: "personal/model",
|
|
475
|
+
theme: "light",
|
|
476
|
+
personalOnly: { nested: true },
|
|
477
|
+
tinyModel: { model: "personal/tiny" },
|
|
478
|
+
}),
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
await installPi({ root: fx.root, output: fx.output });
|
|
482
|
+
|
|
483
|
+
const settings = JSON.parse(await readFile(join(fx.output, "settings.json"), "utf-8"));
|
|
484
|
+
assert.equal(settings.defaultModel, "personal/model", "personal value wins over the template default");
|
|
485
|
+
assert.equal(settings.theme, "light", "personal value wins over the template default");
|
|
486
|
+
assert.deepEqual(settings.personalOnly, { nested: true }, "unknown key preserved");
|
|
487
|
+
assert.equal(settings.tinyModel.model, "personal/tiny", "nested personal leaf retained");
|
|
488
|
+
assert.equal(settings.tinyModel.maxNameLength, 40, "nested template default filled in");
|
|
489
|
+
assert.ok(
|
|
490
|
+
settings.packages.some((p) => !(typeof p === "string" ? p : p.source).startsWith("npm:")),
|
|
491
|
+
"packages stays CLI-managed",
|
|
492
|
+
);
|
|
493
|
+
} finally {
|
|
494
|
+
await fx.cleanup();
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("replaces a legacy symlink with a real managed file", async () => {
|
|
499
|
+
const fx = await makeFixture();
|
|
500
|
+
try {
|
|
501
|
+
await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
|
|
502
|
+
await mkdir(fx.output, { recursive: true });
|
|
503
|
+
await symlink(join(fx.root, "pi", "settings.json"), join(fx.output, "settings.json"));
|
|
504
|
+
|
|
505
|
+
const result = await installPi({ root: fx.root, output: fx.output });
|
|
506
|
+
|
|
507
|
+
assert.equal(result.settings.action, "removed-symlink");
|
|
508
|
+
const settingsPath = join(fx.output, "settings.json");
|
|
509
|
+
assert.equal((await lstat(settingsPath)).isSymbolicLink(), false, "symlink replaced by a real file");
|
|
510
|
+
const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
|
|
511
|
+
assert.ok(settings.packages.length > 0, "managed packages written");
|
|
512
|
+
} finally {
|
|
513
|
+
await fx.cleanup();
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("re-running leaves settings.json unchanged", async () => {
|
|
518
|
+
const fx = await makeFixture();
|
|
519
|
+
try {
|
|
520
|
+
await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
|
|
521
|
+
await installPi({ root: fx.root, output: fx.output });
|
|
522
|
+
const first = await readFile(join(fx.output, "settings.json"), "utf-8");
|
|
523
|
+
|
|
524
|
+
const result = await installPi({ root: fx.root, output: fx.output });
|
|
525
|
+
|
|
526
|
+
assert.equal(result.settings.action, "unchanged");
|
|
527
|
+
assert.equal(await readFile(join(fx.output, "settings.json"), "utf-8"), first);
|
|
528
|
+
} finally {
|
|
529
|
+
await fx.cleanup();
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("dry-run plans the settings write without touching the filesystem", async () => {
|
|
534
|
+
const fx = await makeFixture();
|
|
535
|
+
try {
|
|
536
|
+
await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
|
|
537
|
+
|
|
538
|
+
const result = await installPi({ root: fx.root, output: fx.output, dryRun: true });
|
|
539
|
+
|
|
540
|
+
assert.equal(result.settings.action, "would-write");
|
|
541
|
+
assert.equal(await pathExists(join(fx.output, "settings.json")), false, "no file written in dry-run");
|
|
542
|
+
} finally {
|
|
543
|
+
await fx.cleanup();
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("dry-run reports would-remove-symlink for a legacy symlink", async () => {
|
|
548
|
+
const fx = await makeFixture();
|
|
549
|
+
try {
|
|
550
|
+
await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
|
|
551
|
+
await mkdir(fx.output, { recursive: true });
|
|
552
|
+
const link = join(fx.output, "settings.json");
|
|
553
|
+
await symlink(join(fx.root, "pi", "settings.json"), link);
|
|
554
|
+
|
|
555
|
+
const result = await installPi({ root: fx.root, output: fx.output, dryRun: true });
|
|
556
|
+
|
|
557
|
+
assert.equal(result.settings.action, "would-remove-symlink");
|
|
558
|
+
assert.equal(result.settings.removedSymlink, true);
|
|
559
|
+
assert.equal((await lstat(link)).isSymbolicLink(), true, "symlink untouched in dry-run");
|
|
560
|
+
} finally {
|
|
561
|
+
await fx.cleanup();
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// ─── Pi keybindings.json ─────────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
describe("installPi — pi keybindings.json", () => {
|
|
569
|
+
it("writes the template bindings as a real generated file", async () => {
|
|
570
|
+
const fx = await makeFixture();
|
|
571
|
+
try {
|
|
572
|
+
await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
|
|
573
|
+
|
|
574
|
+
const result = await installPi({ root: fx.root, output: fx.output });
|
|
575
|
+
|
|
576
|
+
const keybindingsPath = join(fx.output, "keybindings.json");
|
|
577
|
+
assert.equal((await lstat(keybindingsPath)).isSymbolicLink(), false, "keybindings.json is a real file");
|
|
578
|
+
const keybindings = JSON.parse(await readFile(keybindingsPath, "utf-8"));
|
|
579
|
+
assert.deepEqual(keybindings["tui.input.newLine"], ["shift+enter"], "template bindings copied");
|
|
580
|
+
assert.match(keybindings["//"], /GENERATED from pi\/keybindings\.json/);
|
|
581
|
+
assert.equal(result.keybindings.action, "written");
|
|
582
|
+
} finally {
|
|
583
|
+
await fx.cleanup();
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("leaves a user-modified keybindings file alone", async () => {
|
|
588
|
+
const fx = await makeFixture();
|
|
589
|
+
try {
|
|
590
|
+
await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
|
|
591
|
+
await writeFixture(
|
|
592
|
+
fx,
|
|
593
|
+
"out/keybindings.json",
|
|
594
|
+
JSON.stringify({ "tui.input.newLine": ["ctrl+j"] }, null, 2),
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
const result = await installPi({ root: fx.root, output: fx.output });
|
|
598
|
+
|
|
599
|
+
assert.equal(result.keybindings.action, "skipped");
|
|
600
|
+
const keybindings = JSON.parse(await readFile(join(fx.output, "keybindings.json"), "utf-8"));
|
|
601
|
+
assert.deepEqual(keybindings["tui.input.newLine"], ["ctrl+j"], "user edit preserved");
|
|
602
|
+
} finally {
|
|
603
|
+
await fx.cleanup();
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("refreshes an unmodified install when the template changes", async () => {
|
|
608
|
+
const fx = await makeFixture();
|
|
609
|
+
try {
|
|
610
|
+
await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
|
|
611
|
+
await installPi({ root: fx.root, output: fx.output });
|
|
612
|
+
await writeFixture(
|
|
613
|
+
fx,
|
|
614
|
+
"pi/keybindings.json",
|
|
615
|
+
JSON.stringify({ "tui.input.newLine": ["ctrl+j"] }, null, 2),
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
const result = await installPi({ root: fx.root, output: fx.output });
|
|
619
|
+
|
|
620
|
+
assert.equal(result.keybindings.action, "written");
|
|
621
|
+
const keybindings = JSON.parse(await readFile(join(fx.output, "keybindings.json"), "utf-8"));
|
|
622
|
+
assert.deepEqual(keybindings["tui.input.newLine"], ["ctrl+j"], "updated template applied");
|
|
623
|
+
} finally {
|
|
624
|
+
await fx.cleanup();
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("replaces a legacy symlink with a real file", async () => {
|
|
629
|
+
const fx = await makeFixture();
|
|
630
|
+
try {
|
|
631
|
+
await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
|
|
632
|
+
await mkdir(fx.output, { recursive: true });
|
|
633
|
+
await symlink(join(fx.root, "pi", "keybindings.json"), join(fx.output, "keybindings.json"));
|
|
634
|
+
|
|
635
|
+
const result = await installPi({ root: fx.root, output: fx.output });
|
|
636
|
+
|
|
637
|
+
assert.equal(result.keybindings.action, "removed-symlink");
|
|
638
|
+
assert.equal((await lstat(join(fx.output, "keybindings.json"))).isSymbolicLink(), false);
|
|
639
|
+
} finally {
|
|
640
|
+
await fx.cleanup();
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("re-running leaves keybindings.json unchanged", async () => {
|
|
645
|
+
const fx = await makeFixture();
|
|
646
|
+
try {
|
|
647
|
+
await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
|
|
648
|
+
await installPi({ root: fx.root, output: fx.output });
|
|
649
|
+
const first = await readFile(join(fx.output, "keybindings.json"), "utf-8");
|
|
650
|
+
|
|
651
|
+
const result = await installPi({ root: fx.root, output: fx.output });
|
|
652
|
+
|
|
653
|
+
assert.equal(result.keybindings.action, "unchanged");
|
|
654
|
+
assert.equal(await readFile(join(fx.output, "keybindings.json"), "utf-8"), first);
|
|
655
|
+
} finally {
|
|
656
|
+
await fx.cleanup();
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
322
661
|
// ─── Integration against the real repo ───────────────────────────────────────
|
|
323
662
|
|
|
324
663
|
describe("installPi — real repo integration", () => {
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm, chmod } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import {
|
|
7
|
+
loadRequiredPackages,
|
|
8
|
+
npmSourceName,
|
|
9
|
+
planPackageInstalls,
|
|
10
|
+
provisionPiPackages,
|
|
11
|
+
runPiInstall,
|
|
12
|
+
} from "../lib/pi-packages.mjs";
|
|
13
|
+
|
|
14
|
+
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
async function makePiRoot({ declared = [], installed = [], settingsRaw } = {}) {
|
|
17
|
+
const root = await mkdtemp(join(tmpdir(), "pi-packages-"));
|
|
18
|
+
if (settingsRaw !== undefined) {
|
|
19
|
+
await writeFile(join(root, "settings.json"), settingsRaw);
|
|
20
|
+
} else if (declared.length > 0) {
|
|
21
|
+
await writeFile(join(root, "settings.json"), JSON.stringify({ packages: declared }));
|
|
22
|
+
}
|
|
23
|
+
for (const name of installed) {
|
|
24
|
+
await mkdir(join(root, "npm", "node_modules", name), { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
return { root, cleanup: async () => rm(root, { recursive: true, force: true }) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A runner that records every source it is asked to install. */
|
|
30
|
+
function recordingRunner({ results = {} } = {}) {
|
|
31
|
+
const calls = [];
|
|
32
|
+
const runner = async (source) => {
|
|
33
|
+
calls.push(source);
|
|
34
|
+
return results[source] ?? { ok: true, error: null };
|
|
35
|
+
};
|
|
36
|
+
return { runner, calls };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Run `fn` with PATH set to `pathValue`, restoring the previous PATH afterwards. */
|
|
40
|
+
async function withPath(pathValue, fn) {
|
|
41
|
+
const original = process.env.PATH;
|
|
42
|
+
process.env.PATH = pathValue;
|
|
43
|
+
try {
|
|
44
|
+
return await fn();
|
|
45
|
+
} finally {
|
|
46
|
+
process.env.PATH = original;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Write an executable `pi` stub with the given shell body; returns its dir and cleanup. */
|
|
51
|
+
async function makePiStub(body) {
|
|
52
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-stub-"));
|
|
53
|
+
const file = join(dir, "pi");
|
|
54
|
+
await writeFile(file, `#!/bin/sh\n${body}\n`);
|
|
55
|
+
await chmod(file, 0o755);
|
|
56
|
+
return { dir, cleanup: async () => rm(dir, { recursive: true, force: true }) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A directory with nothing on PATH, so any external command lookup fails. */
|
|
60
|
+
async function makeEmptyPathDir() {
|
|
61
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-stub-empty-"));
|
|
62
|
+
return { dir, cleanup: async () => rm(dir, { recursive: true, force: true }) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Manifest ────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
describe("loadRequiredPackages", () => {
|
|
68
|
+
it("reads the declared pi packages from the package manifest", async () => {
|
|
69
|
+
const root = await mkdtemp(join(tmpdir(), "pi-packages-manifest-"));
|
|
70
|
+
try {
|
|
71
|
+
await writeFile(
|
|
72
|
+
join(root, "package.json"),
|
|
73
|
+
JSON.stringify({ name: "x", pi: { extensions: ["./extensions"], packages: ["npm:pi-questions"] } }),
|
|
74
|
+
);
|
|
75
|
+
assert.deepEqual(await loadRequiredPackages(root), ["npm:pi-questions"]);
|
|
76
|
+
} finally {
|
|
77
|
+
await rm(root, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns no packages when the manifest does not declare any", async () => {
|
|
82
|
+
const root = await mkdtemp(join(tmpdir(), "pi-packages-manifest-"));
|
|
83
|
+
try {
|
|
84
|
+
await writeFile(join(root, "package.json"), JSON.stringify({ name: "x", pi: { skills: ["./skills"] } }));
|
|
85
|
+
assert.deepEqual(await loadRequiredPackages(root), []);
|
|
86
|
+
} finally {
|
|
87
|
+
await rm(root, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ─── Source parsing ──────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
describe("npmSourceName", () => {
|
|
95
|
+
it("extracts the package name from npm sources, including scoped and pinned specs", () => {
|
|
96
|
+
assert.equal(npmSourceName("npm:pi-questions"), "pi-questions");
|
|
97
|
+
assert.equal(npmSourceName("npm:pi-web-access@0.10.7"), "pi-web-access");
|
|
98
|
+
assert.equal(npmSourceName("npm:@scope/pkg@1.2.3"), "@scope/pkg");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("returns null for non-npm sources it cannot verify locally", () => {
|
|
102
|
+
assert.equal(npmSourceName("git:github.com/user/repo"), null);
|
|
103
|
+
assert.equal(npmSourceName("./local/path"), null);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ─── Install plan ────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
describe("planPackageInstalls", () => {
|
|
110
|
+
it("plans nothing when every required package is declared and installed", () => {
|
|
111
|
+
assert.deepEqual(
|
|
112
|
+
planPackageInstalls(["npm:pi-questions"], {
|
|
113
|
+
declaredSources: ["npm:pi-questions"],
|
|
114
|
+
installedNames: ["pi-questions"],
|
|
115
|
+
}),
|
|
116
|
+
[],
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("plans a package that is declared but not installed", () => {
|
|
121
|
+
assert.deepEqual(
|
|
122
|
+
planPackageInstalls(["npm:pi-web-access@0.10.7"], {
|
|
123
|
+
declaredSources: ["npm:pi-web-access@0.10.7"],
|
|
124
|
+
installedNames: [],
|
|
125
|
+
}),
|
|
126
|
+
["npm:pi-web-access@0.10.7"],
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("plans a package that is installed but not declared, so pi will load it", () => {
|
|
131
|
+
assert.deepEqual(
|
|
132
|
+
planPackageInstalls(["npm:pi-questions"], {
|
|
133
|
+
declaredSources: [],
|
|
134
|
+
installedNames: ["pi-questions"],
|
|
135
|
+
}),
|
|
136
|
+
["npm:pi-questions"],
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ─── Provisioning ────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
describe("runPiInstall", () => {
|
|
144
|
+
it("degrades to a result when the pi CLI is not on PATH", async () => {
|
|
145
|
+
const empty = await makeEmptyPathDir();
|
|
146
|
+
try {
|
|
147
|
+
const result = await withPath(empty.dir, () => runPiInstall("npm:pi-questions"));
|
|
148
|
+
assert.deepEqual(result, { ok: false, error: "pi CLI not found on PATH" });
|
|
149
|
+
} finally {
|
|
150
|
+
await empty.cleanup();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("maps a successful pi install to ok", async () => {
|
|
155
|
+
const stub = await makePiStub("exit 0");
|
|
156
|
+
try {
|
|
157
|
+
const result = await withPath(stub.dir, () => runPiInstall("npm:pi-questions"));
|
|
158
|
+
assert.deepEqual(result, { ok: true, error: null });
|
|
159
|
+
} finally {
|
|
160
|
+
await stub.cleanup();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("maps a failing pi install to its stderr", async () => {
|
|
165
|
+
const stub = await makePiStub('echo "offline: no registry" >&2\nexit 1');
|
|
166
|
+
try {
|
|
167
|
+
const result = await withPath(stub.dir, () => runPiInstall("npm:pi-questions"));
|
|
168
|
+
assert.deepEqual(result, { ok: false, error: "offline: no registry" });
|
|
169
|
+
} finally {
|
|
170
|
+
await stub.cleanup();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("provisionPiPackages", () => {
|
|
176
|
+
it("installs missing packages and skips the ones already present", async () => {
|
|
177
|
+
const fx = await makePiRoot({ declared: ["npm:pi-questions"], installed: ["pi-questions"] });
|
|
178
|
+
try {
|
|
179
|
+
const { runner, calls } = await recordingRunner();
|
|
180
|
+
|
|
181
|
+
const result = await provisionPiPackages({
|
|
182
|
+
required: ["npm:pi-questions", "npm:pi-web-access@0.10.7"],
|
|
183
|
+
piRoot: fx.root,
|
|
184
|
+
runInstall: runner,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
assert.deepEqual(calls, ["npm:pi-web-access@0.10.7"], "only the missing package is installed");
|
|
188
|
+
assert.deepEqual(result.installed, ["npm:pi-web-access@0.10.7"]);
|
|
189
|
+
assert.deepEqual(result.failed, []);
|
|
190
|
+
} finally {
|
|
191
|
+
await fx.cleanup();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("is idempotent — a second run with everything present installs nothing", async () => {
|
|
196
|
+
const fx = await makePiRoot({
|
|
197
|
+
declared: ["npm:pi-questions", "npm:pi-web-access@0.10.7"],
|
|
198
|
+
installed: ["pi-questions", "pi-web-access"],
|
|
199
|
+
});
|
|
200
|
+
try {
|
|
201
|
+
const { runner, calls } = await recordingRunner();
|
|
202
|
+
const result = await provisionPiPackages({
|
|
203
|
+
required: ["npm:pi-questions", "npm:pi-web-access@0.10.7"],
|
|
204
|
+
piRoot: fx.root,
|
|
205
|
+
runInstall: runner,
|
|
206
|
+
});
|
|
207
|
+
assert.deepEqual(calls, []);
|
|
208
|
+
assert.deepEqual(result.installed, []);
|
|
209
|
+
} finally {
|
|
210
|
+
await fx.cleanup();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("reports a failed install and continues with the rest", async () => {
|
|
215
|
+
const fx = await makePiRoot();
|
|
216
|
+
try {
|
|
217
|
+
const warnings = [];
|
|
218
|
+
const { runner, calls } = await recordingRunner({
|
|
219
|
+
results: { "npm:pi-questions": { ok: false, error: "offline" } },
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const result = await provisionPiPackages({
|
|
223
|
+
required: ["npm:pi-questions", "npm:pi-web-access@0.10.7"],
|
|
224
|
+
piRoot: fx.root,
|
|
225
|
+
runInstall: runner,
|
|
226
|
+
warn: (msg) => warnings.push(msg),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
assert.deepEqual(calls, ["npm:pi-questions", "npm:pi-web-access@0.10.7"], "failure does not abort the rest");
|
|
230
|
+
assert.deepEqual(result.installed, ["npm:pi-web-access@0.10.7"]);
|
|
231
|
+
assert.deepEqual(result.failed, [{ source: "npm:pi-questions", error: "offline" }]);
|
|
232
|
+
assert.ok(warnings.some((w) => w.includes("npm:pi-questions")), "failure is surfaced as a warning");
|
|
233
|
+
} finally {
|
|
234
|
+
await fx.cleanup();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("uses the pi CLI runner by default and warns when it is missing", async () => {
|
|
239
|
+
const fx = await makePiRoot();
|
|
240
|
+
const empty = await makeEmptyPathDir();
|
|
241
|
+
try {
|
|
242
|
+
const warnings = [];
|
|
243
|
+
const result = await withPath(empty.dir, () =>
|
|
244
|
+
provisionPiPackages({
|
|
245
|
+
required: ["npm:pi-questions"],
|
|
246
|
+
piRoot: fx.root,
|
|
247
|
+
warn: (msg) => warnings.push(msg),
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
assert.deepEqual(result.failed, [{ source: "npm:pi-questions", error: "pi CLI not found on PATH" }]);
|
|
252
|
+
assert.ok(
|
|
253
|
+
warnings.some((w) => w.includes("pi CLI not found on PATH")),
|
|
254
|
+
"missing CLI is surfaced as a warning, not a thrown error",
|
|
255
|
+
);
|
|
256
|
+
} finally {
|
|
257
|
+
await empty.cleanup();
|
|
258
|
+
await fx.cleanup();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("dry-run reports the plan without installing anything", async () => {
|
|
263
|
+
const fx = await makePiRoot();
|
|
264
|
+
try {
|
|
265
|
+
const { runner, calls } = await recordingRunner();
|
|
266
|
+
const result = await provisionPiPackages({
|
|
267
|
+
required: ["npm:pi-questions"],
|
|
268
|
+
piRoot: fx.root,
|
|
269
|
+
runInstall: runner,
|
|
270
|
+
dryRun: true,
|
|
271
|
+
});
|
|
272
|
+
assert.deepEqual(calls, []);
|
|
273
|
+
assert.deepEqual(result.planned, ["npm:pi-questions"]);
|
|
274
|
+
assert.deepEqual(result.installed, []);
|
|
275
|
+
} finally {
|
|
276
|
+
await fx.cleanup();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("treats an unreadable settings file as no declarations rather than failing the install", async () => {
|
|
281
|
+
const fx = await makePiRoot({ settingsRaw: "{ not json" });
|
|
282
|
+
try {
|
|
283
|
+
const { runner, calls } = await recordingRunner();
|
|
284
|
+
const result = await provisionPiPackages({
|
|
285
|
+
required: ["npm:pi-questions"],
|
|
286
|
+
piRoot: fx.root,
|
|
287
|
+
runInstall: runner,
|
|
288
|
+
});
|
|
289
|
+
assert.deepEqual(calls, ["npm:pi-questions"], "still attempts the install");
|
|
290
|
+
assert.deepEqual(result.installed, ["npm:pi-questions"]);
|
|
291
|
+
} finally {
|
|
292
|
+
await fx.cleanup();
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
});
|
package/token-map.README.md
CHANGED
|
@@ -131,8 +131,10 @@ The full contract is embedded in `token-map.json` under `contract`. In summary,
|
|
|
131
131
|
and `agent-cortex install copilot` run.
|
|
132
132
|
- `bin/installers/claude.mjs` — the single Claude generator: plain
|
|
133
133
|
`agent-cortex install claude` materialises the plugin into `~/.agent-cortex/claude`
|
|
134
|
-
with token-substituted skills
|
|
135
|
-
|
|
134
|
+
with token-substituted skills, merges the committed `claude/settings.json` into
|
|
135
|
+
`~/.claude/settings.json`, and registers it with Claude Code; `--output <dir>` is the
|
|
136
|
+
generate-only form (tests/CI structural checks). The only committed `claude/` file is the
|
|
137
|
+
settings template.
|
|
136
138
|
- `bin/installers/pi.mjs` — the pi harness installer (`agent-cortex install pi`), an
|
|
137
139
|
install-time consumer of the map's contract.
|
|
138
140
|
- `lib/cli.mjs` — `SUPPORTED_HARNESSES`, the install command surface the harness ids come
|