@jaybeeuu/agent-cortex 1.40.0 → 1.41.1

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 (74) hide show
  1. package/AGENTS.md +21 -8
  2. package/CHANGELOG.md +85 -0
  3. package/README.md +78 -38
  4. package/agents/README.md +5 -3
  5. package/bin/agent-cortex.mjs +36 -4
  6. package/bin/installers/claude.mjs +171 -21
  7. package/bin/installers/copilot.mjs +2 -17
  8. package/bin/installers/pi.mjs +261 -22
  9. package/claude/settings.json +4 -0
  10. package/claude-extras/scripts/statusline-command.sh +32 -4
  11. package/docs/claude-hooks.md +12 -0
  12. package/docs/ideas/ban-content-assertion-tests.md +73 -0
  13. package/docs/ideas/bead-progress-notes-for-session-resumption.md +75 -0
  14. package/docs/ideas/classify-beads-via-deterministic-script-with-agent-fallback.md +70 -0
  15. package/docs/ideas/ctx-alias-version-and-update-commands.md +65 -0
  16. package/docs/ideas/ctx-feedback-command-for-capturing-ideas-from-any-machine.md +67 -0
  17. package/docs/ideas/deployment-beads-for-post-merge-pipelines.md +36 -0
  18. package/docs/ideas/design-and-delivery-as-separate-bead-trees/design-and-delivery-as-separate-bead-trees.md +99 -0
  19. package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +33 -0
  20. package/docs/ideas/extension-manifests.md +55 -14
  21. package/docs/ideas/improve-pi-system-prompt.md +5 -2
  22. package/docs/ideas/map-every-cortex-tool-to-a-real-pi-equivalent.md +95 -0
  23. package/docs/ideas/record-idea-implementation-questions.md +68 -0
  24. package/docs/ideas/self-flushing-session.md +72 -0
  25. package/docs/ideas/state-in-beads-not-md.md +69 -0
  26. package/docs/ideas/task-session-context.local.md +95 -0
  27. package/docs/ideas/test-data-creation-helpers.md +92 -0
  28. package/docs/ideas/test-planning-stage-bead-in-feature-planning.md +68 -0
  29. package/docs/ideas/worktree-housekeeping-for-closed-beads.md +39 -0
  30. package/docs/technical-direction/harness-config.md +161 -0
  31. package/docs/technical-direction/migration-execution-labels.md +60 -23
  32. package/extensions/agent-guardrails/README.md +45 -0
  33. package/extensions/agent-guardrails/guardrails.test.ts +42 -0
  34. package/extensions/agent-guardrails/guardrails.ts +66 -0
  35. package/extensions/agent-guardrails/index.ts +22 -0
  36. package/extensions/agent-guardrails/node_modules/.bin/pi +21 -0
  37. package/extensions/agent-guardrails/node_modules/.bin/tsc +21 -0
  38. package/extensions/agent-guardrails/node_modules/.bin/tsserver +21 -0
  39. package/extensions/agent-guardrails/node_modules/.bin/tsx +21 -0
  40. package/extensions/agent-guardrails/package.json +19 -0
  41. package/extensions/agent-guardrails/tsconfig.json +12 -0
  42. package/extensions/agent-modes/README.md +9 -3
  43. package/extensions/agent-modes/index.test.ts +208 -0
  44. package/extensions/agent-modes/index.ts +23 -6
  45. package/extensions/agent-modes/package.json +1 -1
  46. package/lib/cli.mjs +8 -2
  47. package/lib/pi-packages.mjs +169 -0
  48. package/package.json +5 -1
  49. package/pi/settings.json +23 -12
  50. package/plugin.json +1 -1
  51. package/scripts/lib/fs.mjs +31 -0
  52. package/skills/planning/classify-bead/REFERENCE.md +85 -0
  53. package/skills/planning/classify-bead/SKILL.md +32 -36
  54. package/skills/planning/classify-bead/scripts/classify-bead.mjs +292 -0
  55. package/skills/planning/create-task/REFERENCE.md +1 -1
  56. package/skills/planning/create-task/SKILL.md +20 -11
  57. package/skills/planning/create-task/scripts/create-chores.ts +4 -0
  58. package/skills/planning/write-a-prd/SKILL.md +2 -2
  59. package/skills/style/style-code/REFERENCE.md +32 -2
  60. package/skills/style/style-code/SKILL.md +9 -21
  61. package/skills/style/style-tests/EXAMPLES.md +548 -0
  62. package/skills/style/style-tests/SKILL.md +84 -33
  63. package/skills/workflow/run-pipeline-stage/SKILL.md +13 -1
  64. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +12 -0
  65. package/test/classify-bead.test.mjs +277 -0
  66. package/test/cli.test.mjs +55 -11
  67. package/test/create-chores.test.mjs +73 -0
  68. package/test/fs.test.mjs +76 -0
  69. package/test/helpers/fake-bd.mjs +67 -0
  70. package/test/installer.test.mjs +237 -4
  71. package/test/pi-installer.test.mjs +343 -4
  72. package/test/pi-packages.test.mjs +295 -0
  73. package/token-map.README.md +4 -2
  74. package/token-map.json +3 -3
@@ -0,0 +1,73 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { execFile } from "node:child_process";
4
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+ import { dirname, join } from "node:path";
8
+
9
+ import { makeFakeBd } from "./helpers/fake-bd.mjs";
10
+
11
+ const testDir = dirname(fileURLToPath(import.meta.url));
12
+ const CREATE_CHORES = join(testDir, "..", "skills", "planning", "create-task", "scripts", "create-chores.ts");
13
+
14
+ /** Run create-chores.ts against the fake `bd` with a parent that already
15
+ * carries implementation-type:afk — the label `bd create --parent` inherits.
16
+ * @param {string[]} parentLabels Labels seeded on the parent bead
17
+ * @returns {Promise<{ ids: Record<string, string>, beads: Array<{ id: string, title: string, labels: string[] }> }>} */
18
+ async function createChoresAgainstFakeBd(parentLabels) {
19
+ const dir = await mkdtemp(join(tmpdir(), "create-chores-"));
20
+ const { bin, state } = await makeFakeBd(dir, {
21
+ beads: [{ id: "parent-1", title: "Feature", labels: parentLabels }],
22
+ });
23
+
24
+ try {
25
+ const { exitCode, stdout, stderr } = await runScript(
26
+ ["--parent", "parent-1"],
27
+ { BD_PATH: bin, FAKE_BD_STATE: state },
28
+ );
29
+ assert.equal(exitCode, 0, stderr);
30
+
31
+ const ids = JSON.parse(stdout);
32
+ const { beads } = JSON.parse(await readFile(state, "utf8"));
33
+ return { ids, beads };
34
+ } finally {
35
+ await rm(dir, { recursive: true, force: true });
36
+ }
37
+ }
38
+
39
+ /** Run create-chores.ts with the given env overrides. */
40
+ function runScript(args, env) {
41
+ return new Promise((resolve) => {
42
+ execFile(
43
+ process.execPath,
44
+ [CREATE_CHORES, ...args],
45
+ { env: { ...process.env, ...env } },
46
+ (error, stdout, stderr) => {
47
+ resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
48
+ },
49
+ );
50
+ });
51
+ }
52
+
53
+ describe("create-chores", () => {
54
+ it("gives the PR gate exactly its declared labels, without the parent's inherited implementation-type:afk", async () => {
55
+ const { ids, beads } = await createChoresAgainstFakeBd(["implementation-type:afk"]);
56
+
57
+ const gate = beads.find((b) => b.id === ids.featurePrReview);
58
+ assert.deepEqual(
59
+ [...gate.labels].sort(),
60
+ ["implementation-type:hitl", "lifecycle:feature-pr"],
61
+ );
62
+ });
63
+
64
+ it("still lets stage chores inherit implementation-type:afk from the parent", async () => {
65
+ const { ids, beads } = await createChoresAgainstFakeBd(["implementation-type:afk"]);
66
+
67
+ const codeChore = beads.find((b) => b.id === ids.code);
68
+ assert.deepEqual(
69
+ [...codeChore.labels].sort(),
70
+ ["implementation-type:afk", "stage:code"],
71
+ );
72
+ });
73
+ });
@@ -0,0 +1,76 @@
1
+ // Unit tests for scripts/lib/fs.mjs — the shared async stat wrappers. The
2
+ // helpers are exercised through their public API only; each case creates its
3
+ // own throwaway fixture under os.tmpdir().
4
+
5
+ import { describe, it } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { isDirectory, isFile } from "../scripts/lib/fs.mjs";
11
+
12
+ async function makeTmp() {
13
+ return await mkdtemp(join(tmpdir(), "agent-cortex-fs-"));
14
+ }
15
+
16
+ describe("isFile", () => {
17
+ it("is true for an existing regular file", async () => {
18
+ const dir = await makeTmp();
19
+ try {
20
+ const file = join(dir, "file.txt");
21
+ await writeFile(file, "hello");
22
+ assert.equal(await isFile(file), true);
23
+ } finally {
24
+ await rm(dir, { recursive: true, force: true });
25
+ }
26
+ });
27
+
28
+ it("is false for a directory", async () => {
29
+ const dir = await makeTmp();
30
+ try {
31
+ assert.equal(await isFile(dir), false);
32
+ } finally {
33
+ await rm(dir, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ it("is false for a missing path", async () => {
38
+ const dir = await makeTmp();
39
+ try {
40
+ assert.equal(await isFile(join(dir, "nope.txt")), false);
41
+ } finally {
42
+ await rm(dir, { recursive: true, force: true });
43
+ }
44
+ });
45
+ });
46
+
47
+ describe("isDirectory", () => {
48
+ it("is true for an existing directory", async () => {
49
+ const dir = await makeTmp();
50
+ try {
51
+ assert.equal(await isDirectory(dir), true);
52
+ } finally {
53
+ await rm(dir, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ it("is false for a regular file", async () => {
58
+ const dir = await makeTmp();
59
+ try {
60
+ const file = join(dir, "file.txt");
61
+ await writeFile(file, "hello");
62
+ assert.equal(await isDirectory(file), false);
63
+ } finally {
64
+ await rm(dir, { recursive: true, force: true });
65
+ }
66
+ });
67
+
68
+ it("is false for a missing path", async () => {
69
+ const dir = await makeTmp();
70
+ try {
71
+ assert.equal(await isDirectory(join(dir, "nope")), false);
72
+ } finally {
73
+ await rm(dir, { recursive: true, force: true });
74
+ }
75
+ });
76
+ });
@@ -0,0 +1,67 @@
1
+ // Shared test helper: a stateful fake `bd` binary for create-chores.ts.
2
+ // `bd` is a genuine external system (its real database must never be touched
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.
8
+ //
9
+ // State lives in `state.json` next to the binary; the test seeds it with the
10
+ // parent bead and reads the children back after the script runs. Written async
11
+ // throughout — the repo's check-no-sync lint scans test/ (including helpers).
12
+
13
+ import { chmod, writeFile } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+
16
+ const FAKE_BD_SCRIPT = [
17
+ "#!/usr/bin/env node",
18
+ "// Stateful fake for `bd create` (test/helpers/fake-bd.mjs).",
19
+ "const fs = require('node:fs/promises');",
20
+ "",
21
+ "const stateFile = process.env.FAKE_BD_STATE;",
22
+ "const VALUE_FLAGS = new Set(['--type', '--description', '--priority', '--labels', '--parent']);",
23
+ "const BOOL_FLAGS = new Set(['--silent', '--no-inherit-labels']);",
24
+ "",
25
+ "(async () => {",
26
+ " const argv = process.argv.slice(2);",
27
+ " if (argv[0] === 'dep') process.exit(0);",
28
+ " if (argv[0] !== 'create') process.exit(1);",
29
+ "",
30
+ " const title = argv[1];",
31
+ " const flags = {};",
32
+ " const bools = new Set();",
33
+ " for (let i = 2; i < argv.length; i++) {",
34
+ " const arg = argv[i];",
35
+ " if (BOOL_FLAGS.has(arg)) { bools.add(arg); continue; }",
36
+ " if (VALUE_FLAGS.has(arg)) { flags[arg] = argv[++i]; continue; }",
37
+ " }",
38
+ "",
39
+ " const state = JSON.parse(await fs.readFile(stateFile, 'utf8'));",
40
+ " const explicit = (flags['--labels'] || '').split(',').filter(Boolean);",
41
+ " const parent = state.beads.find((b) => b.id === flags['--parent']);",
42
+ " const inherited = parent && !bools.has('--no-inherit-labels') ? parent.labels : [];",
43
+ " const labels = [...new Set([...inherited, ...explicit])];",
44
+ "",
45
+ " const id = 'bd-' + state.nextId++;",
46
+ " state.beads.push({ id, title, labels, parent: flags['--parent'] ?? null });",
47
+ " await fs.writeFile(stateFile, JSON.stringify(state));",
48
+ " process.stdout.write(id + '\\n');",
49
+ "})().catch((err) => {",
50
+ " console.error(err);",
51
+ " process.exit(1);",
52
+ "});",
53
+ ].join("\n");
54
+
55
+ /** Create a stateful fake `bd` binary in `dir`.
56
+ * @param {string} dir Directory to hold the binary and its state.json
57
+ * @param {{ beads: Array<{ id: string, title: string, labels: string[] }> }} seed
58
+ * Pre-existing beads (the parent) the fake inherits labels from
59
+ * @returns {Promise<{ bin: string, state: string }>} */
60
+ export async function makeFakeBd(dir, { beads }) {
61
+ const bin = join(dir, "bd");
62
+ const state = join(dir, "state.json");
63
+ await writeFile(bin, FAKE_BD_SCRIPT + "\n");
64
+ await chmod(bin, 0o755);
65
+ await writeFile(state, JSON.stringify({ beads, nextId: 1 }));
66
+ return { bin, state };
67
+ }
@@ -18,6 +18,7 @@ import {
18
18
  stat,
19
19
  lstat,
20
20
  readlink,
21
+ symlink,
21
22
  } from "node:fs/promises";
22
23
  import { tmpdir } from "node:os";
23
24
  import { fileURLToPath } from "node:url";
@@ -294,14 +295,15 @@ describe("install claude", () => {
294
295
  assert.equal(await pathExists(join(out, ".claude-plugin")), false);
295
296
  });
296
297
 
297
- it("leaves the canonical sources untouched (no committed claude/ output)", async () => {
298
+ it("leaves the canonical sources untouched (no generated claude/ output)", async () => {
298
299
  const out = await makeTmp();
299
300
  const beforeExtras = (await readdir(join(ROOT, "claude-extras"))).sort();
300
301
  const beforeExtrasScripts = (await readdir(join(ROOT, "claude-extras", "scripts"))).sort();
301
302
  await runCli(["install", "claude", "--output", out]);
302
- // no committed claude/ output remains, and installs never write into the
303
- // canonical sources the plugin materialises from
304
- assert.equal(await pathExists(join(ROOT, "claude")), false);
303
+ // the committed claude/ store carries only the settings template — the
304
+ // generated plugin subtree (agents/, skills/, .claude-plugin/, hooks.json)
305
+ // is never committed, and installs never write into the canonical sources
306
+ assert.deepStrictEqual((await readdir(join(ROOT, "claude"))).sort(), ["settings.json"]);
305
307
  assert.deepStrictEqual((await readdir(join(ROOT, "claude-extras"))).sort(), beforeExtras);
306
308
  assert.deepStrictEqual((await readdir(join(ROOT, "claude-extras", "scripts"))).sort(), beforeExtrasScripts);
307
309
  });
@@ -346,6 +348,11 @@ describe("install claude", () => {
346
348
  "plugin install agent-cortex@jaybeeuu -y",
347
349
  ]);
348
350
  assert.ok(stdout.includes("Registering plugin"), "CLI reports registration");
351
+
352
+ // the default install also materialises ~/.claude/settings.json from the
353
+ // committed template, owning only the two declared keys
354
+ const settings = JSON.parse(await readFile(join(fakeHome, ".claude", "settings.json"), "utf-8"));
355
+ assert.deepEqual(Object.keys(settings).sort(), ["enabledPlugins", "extraKnownMarketplaces"]);
349
356
  });
350
357
 
351
358
  it("re-running a plain install is the update path: no marketplace re-add, no double-install", async () => {
@@ -407,8 +414,24 @@ describe("install claude", () => {
407
414
  const { exitCode, stdout } = await runCli(["install", "claude", "--dry-run"], env);
408
415
  assert.equal(exitCode, 0);
409
416
  assert.ok(stdout.includes(join(fakeHome, ".agent-cortex", "claude")), "dry-run names the home target");
417
+ assert.ok(stdout.includes(join(fakeHome, ".claude", "settings.json")), "dry-run names the settings target");
410
418
  assert.ok(/copied/i.test(stdout), "dry-run describes copied skills");
411
419
  assert.equal(await pathExists(join(fakeHome, ".agent-cortex")), false, "nothing written in dry-run");
420
+ assert.equal(await pathExists(join(fakeHome, ".claude")), false, "no settings written in dry-run");
421
+ });
422
+
423
+ it("--output stays generate-only and never touches ~/.claude/settings.json", async () => {
424
+ const fakeHome = await makeTmp();
425
+ const out = await makeTmp();
426
+ try {
427
+ const { exitCode } = await runCli(["install", "claude", "--output", out], { HOME: fakeHome });
428
+ assert.equal(exitCode, 0);
429
+ assert.ok(await pathExists(join(out, ".claude-plugin", "plugin.json")), "plugin subtree still generated");
430
+ assert.equal(await pathExists(join(fakeHome, ".claude")), false, "no user settings written by --output");
431
+ } finally {
432
+ await rm(fakeHome, { recursive: true, force: true });
433
+ await rm(out, { recursive: true, force: true });
434
+ }
412
435
  });
413
436
 
414
437
  it("rejects token-map.json versions newer than the implemented contract", async () => {
@@ -428,4 +451,214 @@ describe("install claude", () => {
428
451
  await rm(fixture, { recursive: true, force: true });
429
452
  }
430
453
  });
454
+ });
455
+
456
+ // ─── installClaude — ~/.claude/settings.json ─────────────────────────────────
457
+
458
+ /** A claude/settings.json template carrying the two CLI-owned keys. */
459
+ function settingsTemplate(overrides = {}) {
460
+ return { enabledPlugins: {}, extraKnownMarketplaces: {}, ...overrides };
461
+ }
462
+
463
+ /** A realistic personal ~/.claude/settings.json (keys the installer must never own). */
464
+ function personalSettings(overrides = {}) {
465
+ return {
466
+ permissions: { allow: ["Bash(pnpm test:*)"] },
467
+ hooks: { SessionStart: [{ hooks: [{ type: "command", command: "echo personal" }] }] },
468
+ env: { PERSONAL_FLAG: "1" },
469
+ statusLine: { type: "command", command: "~/.claude/statusline.sh" },
470
+ ...overrides,
471
+ };
472
+ }
473
+
474
+ /** Minimal package root with a claude/settings.json template, enough for
475
+ * installClaude to materialise the plugin subtree into a throwaway output. */
476
+ async function makeSettingsFixture({ template = settingsTemplate(), settings } = {}) {
477
+ const root = await makeTmp();
478
+ await writeFile(
479
+ join(root, "package.json"),
480
+ JSON.stringify({ name: "agent-cortex", version: "1.2.3", license: "MIT" }),
481
+ );
482
+ await writeFile(
483
+ join(root, "token-map.json"),
484
+ JSON.stringify({ tools: {}, paths: { plugin_root: { claude: "PLUGIN_ROOT" } } }),
485
+ );
486
+ await mkdir(join(root, "agents"));
487
+ await mkdir(join(root, "skills"));
488
+ if (template !== null) {
489
+ await mkdir(join(root, "claude"), { recursive: true });
490
+ await writeFile(join(root, "claude", "settings.json"), JSON.stringify(template, null, 2) + "\n");
491
+ }
492
+ const output = join(root, "out");
493
+ const settingsPath = join(root, "claude-home", "settings.json");
494
+ if (settings !== undefined) {
495
+ await mkdir(dirname(settingsPath), { recursive: true });
496
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
497
+ }
498
+ return { root, output, settingsPath, cleanup: () => rm(root, { recursive: true, force: true }) };
499
+ }
500
+
501
+ describe("install claude — settings.json", () => {
502
+ it("materialises a real settings.json carrying the CLI-owned keys from the committed template", async () => {
503
+ const fx = await makeSettingsFixture({
504
+ template: settingsTemplate({
505
+ enabledPlugins: { "context7@claude-plugins-official": true },
506
+ extraKnownMarketplaces: {
507
+ "claude-plugins-official": { source: { source: "github", repo: "anthropics/claude-plugins" } },
508
+ },
509
+ }),
510
+ });
511
+ try {
512
+ const result = await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
513
+
514
+ assert.equal(result.settings.path, fx.settingsPath);
515
+ assert.equal(result.settings.action, "written");
516
+ assert.equal((await lstat(fx.settingsPath)).isSymbolicLink(), false, "settings.json is a real file");
517
+ const settings = JSON.parse(await readFile(fx.settingsPath, "utf-8"));
518
+ assert.deepEqual(settings.enabledPlugins, { "context7@claude-plugins-official": true });
519
+ assert.deepEqual(settings.extraKnownMarketplaces, {
520
+ "claude-plugins-official": { source: { source: "github", repo: "anthropics/claude-plugins" } },
521
+ });
522
+ assert.deepEqual(
523
+ Object.keys(settings).sort(),
524
+ ["enabledPlugins", "extraKnownMarketplaces"],
525
+ "only the two CLI-owned keys are written",
526
+ );
527
+ } finally {
528
+ await fx.cleanup();
529
+ }
530
+ });
531
+
532
+ it("preserves personal and unknown keys on re-install (merge-keep-personal)", async () => {
533
+ const fx = await makeSettingsFixture({
534
+ template: settingsTemplate({
535
+ theme: { name: "dark", accent: "blue" },
536
+ enabledPlugins: { "team@official": true },
537
+ }),
538
+ settings: personalSettings({
539
+ theme: { name: "light" },
540
+ personalOnly: { nested: true },
541
+ }),
542
+ });
543
+ try {
544
+ await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
545
+ const settings = JSON.parse(await readFile(fx.settingsPath, "utf-8"));
546
+
547
+ assert.deepEqual(settings.permissions, { allow: ["Bash(pnpm test:*)"] });
548
+ assert.deepEqual(settings.hooks, { SessionStart: [{ hooks: [{ type: "command", command: "echo personal" }] }] });
549
+ assert.deepEqual(settings.env, { PERSONAL_FLAG: "1" });
550
+ assert.deepEqual(settings.statusLine, { type: "command", command: "~/.claude/statusline.sh" });
551
+ assert.deepEqual(settings.personalOnly, { nested: true }, "unknown key preserved");
552
+ assert.equal(settings.theme.name, "light", "personal value wins over the template default");
553
+ assert.equal(settings.theme.accent, "blue", "template default fills the unset nested key");
554
+ } finally {
555
+ await fx.cleanup();
556
+ }
557
+ });
558
+
559
+ it("owns only enabledPlugins + extraKnownMarketplaces — personal entries under them are replaced by the template", async () => {
560
+ const fx = await makeSettingsFixture({
561
+ template: settingsTemplate({
562
+ enabledPlugins: { "team@official": true },
563
+ extraKnownMarketplaces: {
564
+ official: { source: { source: "github", repo: "anthropics/plugins" } },
565
+ },
566
+ }),
567
+ settings: personalSettings({
568
+ enabledPlugins: { "personal@mine": true },
569
+ extraKnownMarketplaces: { mine: { source: { source: "github", repo: "me/mine" } } },
570
+ }),
571
+ });
572
+ try {
573
+ await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
574
+ const settings = JSON.parse(await readFile(fx.settingsPath, "utf-8"));
575
+
576
+ assert.deepEqual(settings.enabledPlugins, { "team@official": true }, "template owns enabledPlugins");
577
+ assert.deepEqual(
578
+ settings.extraKnownMarketplaces,
579
+ { official: { source: { source: "github", repo: "anthropics/plugins" } } },
580
+ "template owns extraKnownMarketplaces",
581
+ );
582
+ assert.ok(!("personal@mine" in settings.enabledPlugins));
583
+ } finally {
584
+ await fx.cleanup();
585
+ }
586
+ });
587
+
588
+ it("dry-run plans the settings write without touching the filesystem", async () => {
589
+ const fx = await makeSettingsFixture({
590
+ template: settingsTemplate({ enabledPlugins: { "team@official": true } }),
591
+ });
592
+ try {
593
+ const result = await installClaude({
594
+ root: fx.root,
595
+ output: fx.output,
596
+ settingsPath: fx.settingsPath,
597
+ dryRun: true,
598
+ });
599
+
600
+ assert.equal(result.settings.action, "would-write");
601
+ assert.equal(await pathExists(fx.settingsPath), false, "nothing written in dry-run");
602
+ } finally {
603
+ await fx.cleanup();
604
+ }
605
+ });
606
+
607
+ it("re-running is idempotent — the second install leaves settings.json unchanged", async () => {
608
+ const fx = await makeSettingsFixture({
609
+ template: settingsTemplate({ enabledPlugins: { "team@official": true } }),
610
+ });
611
+ try {
612
+ await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
613
+ const first = await readFile(fx.settingsPath, "utf-8");
614
+
615
+ const second = await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
616
+ assert.equal(second.settings.action, "unchanged");
617
+ assert.equal(await readFile(fx.settingsPath, "utf-8"), first);
618
+ } finally {
619
+ await fx.cleanup();
620
+ }
621
+ });
622
+
623
+ it("replaces a legacy symlinked settings.json with a real file, never writing through the template", async () => {
624
+ const fx = await makeSettingsFixture({
625
+ template: settingsTemplate({ enabledPlugins: { "team@official": true } }),
626
+ });
627
+ try {
628
+ const templatePath = join(fx.root, "claude", "settings.json");
629
+ const templateBefore = await readFile(templatePath, "utf-8");
630
+ await mkdir(dirname(fx.settingsPath), { recursive: true });
631
+ await symlink(templatePath, fx.settingsPath);
632
+
633
+ const result = await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
634
+
635
+ assert.equal(result.settings.action, "removed-symlink");
636
+ assert.equal(result.settings.removedSymlink, true);
637
+ assert.equal((await lstat(fx.settingsPath)).isSymbolicLink(), false, "symlink replaced by a real file");
638
+ const settings = JSON.parse(await readFile(fx.settingsPath, "utf-8"));
639
+ assert.deepEqual(settings.enabledPlugins, { "team@official": true });
640
+ assert.equal(await readFile(templatePath, "utf-8"), templateBefore, "template never written through");
641
+ } finally {
642
+ await fx.cleanup();
643
+ }
644
+ });
645
+
646
+ it("warns and skips when the package ships no claude/settings.json template", async () => {
647
+ const fx = await makeSettingsFixture({ template: null });
648
+ try {
649
+ const warnings = [];
650
+ const result = await installClaude({
651
+ root: fx.root,
652
+ output: fx.output,
653
+ settingsPath: fx.settingsPath,
654
+ warn: (m) => warnings.push(m),
655
+ });
656
+
657
+ assert.equal(result.settings, null);
658
+ assert.ok(warnings.some((w) => w.includes("claude/settings.json")), "missing template is warned about");
659
+ assert.equal(await pathExists(fx.settingsPath), false, "nothing written without a template");
660
+ } finally {
661
+ await fx.cleanup();
662
+ }
663
+ });
431
664
  });