@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
@@ -1,8 +1,9 @@
1
1
  // Install-time generator + registrar for the Claude Code plugin. A plain
2
2
  // `agent-cortex install claude` MATERIALISES the plugin into a home-scoped
3
- // directory (~/.agent-cortex/claude); there is no committed claude/ output in
4
- // the repo to regenerate — claude-extras/ and the gitignored $HOME target are
5
- // the only stores. The materialised plugin is:
3
+ // directory (~/.agent-cortex/claude) and merges the committed
4
+ // claude/settings.json template into ~/.claude/settings.json; the generated
5
+ // plugin subtree is never committed — only that template, claude-extras/, and
6
+ // the gitignored $HOME target are stores. The materialised plugin is:
6
7
  //
7
8
  // <output>/.claude-plugin/plugin.json plugin manifest (version tracks the
8
9
  // package — never stale)
@@ -52,12 +53,13 @@
52
53
  //
53
54
  // Zero dependencies so it runs on the CI Node and local Node alike.
54
55
 
55
- import { readFile, writeFile, mkdir, rm, copyFile, stat, readdir, chmod } from "node:fs/promises";
56
+ import { readFile, writeFile, mkdir, rm, copyFile, stat, readdir, chmod, rename, lstat } from "node:fs/promises";
56
57
  import { spawn } from "node:child_process";
57
58
  import { join, dirname, basename, resolve } from "node:path";
58
59
  import { fileURLToPath } from "node:url";
59
60
  import { homedir } from "node:os";
60
61
  import { composeAgent, loadTokenMap, substituteTokens } from "../../scripts/lib/compose-agent.mjs";
62
+ import { isDirectory, isFile } from "../../scripts/lib/fs.mjs";
61
63
 
62
64
  const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
63
65
  const PACKAGE_ROOT = join(MODULE_DIR, "..", "..");
@@ -69,6 +71,18 @@ const PACKAGE_ROOT = join(MODULE_DIR, "..", "..");
69
71
  // os.homedir() honours $HOME on POSIX).
70
72
  const DEFAULT_OUTPUT = join(homedir(), ".agent-cortex", "claude");
71
73
 
74
+ // Claude Code's user settings file: the default install merges the committed
75
+ // claude/settings.json template into it. `--output` leaves it alone; an
76
+ // explicit settingsPath is the programmatic (test/CI) seam that requests it.
77
+ const DEFAULT_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
78
+
79
+ // The committed user-settings template.
80
+ const SETTINGS_TEMPLATE = "claude/settings.json";
81
+
82
+ // The only keys the installer owns outright: everything else in the live file
83
+ // is personal config the merge must preserve.
84
+ const SETTINGS_OWNED_KEYS = ["enabledPlugins", "extraKnownMarketplaces"];
85
+
72
86
  // The token-map contract version this installer implements (token-map.json
73
87
  // "version" field). A higher map version is rejected: the contract must be
74
88
  // extended before this installer can trust it.
@@ -89,22 +103,6 @@ const SKILL_EXCLUDE = new Set(["ralph", "run-pipeline-stage"]);
89
103
 
90
104
  const NAME_PREFIX = "agent-cortex:";
91
105
 
92
- async function isFile(p) {
93
- try {
94
- return (await stat(p)).isFile();
95
- } catch {
96
- return false;
97
- }
98
- }
99
-
100
- async function isDirectory(p) {
101
- try {
102
- return (await stat(p)).isDirectory();
103
- } catch {
104
- return false;
105
- }
106
- }
107
-
108
106
  function byName(a, b) {
109
107
  return a.localeCompare(b);
110
108
  }
@@ -301,6 +299,141 @@ async function buildHandAuthored(root) {
301
299
  return items;
302
300
  }
303
301
 
302
+ // ─── Claude user settings ────────────────────────────────────────────────────
303
+
304
+ /**
305
+ * Materialise ~/.claude/settings.json from the committed claude/settings.json
306
+ * template with merge-keep-personal semantics (the same contract as the pi
307
+ * installer's installSettings):
308
+ *
309
+ * - template keys fill in defaults the live file lacks; every value already
310
+ * present in the live file wins (deeply), and unknown keys are preserved,
311
+ * so personal config survives re-install;
312
+ * - `enabledPlugins` and `extraKnownMarketplaces` are CLI-owned: they are
313
+ * rewritten from the template outright. `permissions`, `hooks`, `env`,
314
+ * `statusLine` and every other key are never touched.
315
+ */
316
+ async function installSettings({ root, target, dryRun, warn }) {
317
+ const template = await readTemplateJson(join(root, SETTINGS_TEMPLATE), warn);
318
+ if (template === null) return null;
319
+
320
+ const merged = mergeSettings(template, await readJsonObject(target, warn));
321
+ for (const key of SETTINGS_OWNED_KEYS) {
322
+ if (key in template) merged[key] = template[key];
323
+ else delete merged[key];
324
+ }
325
+
326
+ const content = `${JSON.stringify(merged, null, 2)}\n`;
327
+ return writeManagedJson({ target, content, dryRun });
328
+ }
329
+
330
+ /**
331
+ * Merge the committed template over the live file. Template keys fill in
332
+ * defaults, live values win (deeply), and keys the template does not know about
333
+ * are preserved — re-installing never discards personal or Claude-written config.
334
+ */
335
+ function mergeSettings(template, existing) {
336
+ const merged = {};
337
+ for (const key of Object.keys(template)) {
338
+ const templateValue = template[key];
339
+ const existingValue = existing[key];
340
+ if (isPlainObject(templateValue) && isPlainObject(existingValue)) {
341
+ merged[key] = mergeSettings(templateValue, existingValue);
342
+ } else {
343
+ merged[key] = key in existing ? existingValue : templateValue;
344
+ }
345
+ }
346
+ for (const key of Object.keys(existing)) {
347
+ if (!(key in merged)) merged[key] = existing[key];
348
+ }
349
+ return merged;
350
+ }
351
+
352
+ /**
353
+ * Write the managed settings file. A legacy symlink is replaced with a real
354
+ * file by writing a sibling temp file and renaming it over the target — rename
355
+ * replaces the link itself, so the committed template is never written through.
356
+ * Returns the action taken so the CLI can report the plan (dry-run included).
357
+ */
358
+ async function writeManagedJson({ target, content, dryRun }) {
359
+ const symlink = await isSymlink(target);
360
+ const current = await readFileMaybe(target);
361
+
362
+ if (dryRun) {
363
+ return {
364
+ path: target,
365
+ action: symlink ? "would-remove-symlink" : current === content ? "unchanged" : "would-write",
366
+ removedSymlink: symlink,
367
+ };
368
+ }
369
+ if (!symlink && current === content) {
370
+ return { path: target, action: "unchanged", removedSymlink: false };
371
+ }
372
+
373
+ await mkdir(dirname(target), { recursive: true });
374
+ const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
375
+ try {
376
+ await writeFile(tmp, content);
377
+ await rename(tmp, target);
378
+ } catch (err) {
379
+ await rm(tmp, { force: true });
380
+ throw err;
381
+ }
382
+ return { path: target, action: symlink ? "removed-symlink" : "written", removedSymlink: symlink };
383
+ }
384
+
385
+ async function readTemplateJson(filePath, warn) {
386
+ const raw = await readFileMaybe(filePath);
387
+ if (raw === null) {
388
+ warn(`no ${filePath} template in the package — skipping`);
389
+ return null;
390
+ }
391
+ const parsed = tryParseJsonObject(raw);
392
+ if (parsed === null) throw new Error(`${filePath} is not a JSON object`);
393
+ return parsed;
394
+ }
395
+
396
+ async function readJsonObject(filePath, warn) {
397
+ const raw = await readFileMaybe(filePath);
398
+ if (raw === null) return {};
399
+ const parsed = tryParseJsonObject(raw);
400
+ if (parsed === null) {
401
+ warn(`${filePath} is not a JSON object — ignoring its contents`);
402
+ return {};
403
+ }
404
+ return parsed;
405
+ }
406
+
407
+ function tryParseJsonObject(raw) {
408
+ try {
409
+ const parsed = JSON.parse(raw);
410
+ return isPlainObject(parsed) ? parsed : null;
411
+ } catch {
412
+ return null;
413
+ }
414
+ }
415
+
416
+ function isPlainObject(value) {
417
+ return typeof value === "object" && value !== null && !Array.isArray(value);
418
+ }
419
+
420
+ async function readFileMaybe(filePath) {
421
+ try {
422
+ return await readFile(filePath, "utf-8");
423
+ } catch (err) {
424
+ if (err.code === "ENOENT") return null;
425
+ throw err;
426
+ }
427
+ }
428
+
429
+ async function isSymlink(filePath) {
430
+ try {
431
+ return (await lstat(filePath)).isSymbolicLink();
432
+ } catch {
433
+ return false;
434
+ }
435
+ }
436
+
304
437
  /**
305
438
  * Generate the Claude Code plugin subtree.
306
439
  *
@@ -313,14 +446,19 @@ async function buildHandAuthored(root) {
313
446
  * @param {string} [options.pluginRoot] Override plugin_root used for {{PATH:...}} resolution
314
447
  * (default: token-map.json's claude value)
315
448
  * @param {boolean} [options.dryRun] Plan and report without writing anything.
449
+ * @param {string} [options.settingsPath] Claude user-settings target
450
+ * (default ~/.claude/settings.json). Passing it
451
+ * explicitly materialises settings even with
452
+ * --output — the test/CI seam.
316
453
  * @param {(msg: string) => void} [options.warn] Warning sink, also collected in `warnings`.
317
454
  * @returns {{ output: string, marketplaceRoot: string|null, manifestPath: string|null,
318
455
  * marketplaceManifest: object|null, agents: string[], natives: string[],
319
456
  * skills: {names: string[], md: number, files: number, dir: string},
320
457
  * hooks: boolean, hookFiles: string[], handAuthored: string[],
458
+ * settings: {path: string, action: string, removedSymlink: boolean}|null,
321
459
  * warnings: string[], dryRun: boolean }}
322
460
  */
323
- export async function installClaude({ root = PACKAGE_ROOT, output, dryRun = false, pluginRoot, warn = DEFAULT_WARN } = {}) {
461
+ export async function installClaude({ root = PACKAGE_ROOT, output, dryRun = false, pluginRoot, settingsPath, warn = DEFAULT_WARN } = {}) {
324
462
  const tokenMap = await loadTokenMap(root);
325
463
  if (typeof tokenMap.version === "number" && tokenMap.version > CONTRACT_VERSION) {
326
464
  throw new Error(
@@ -360,6 +498,15 @@ export async function installClaude({ root = PACKAGE_ROOT, output, dryRun = fals
360
498
  await mkdir(out, { recursive: true });
361
499
  }
362
500
 
501
+ // The default install owns ~/.claude/settings.json; --output is the
502
+ // generate-only form and leaves the user's Claude config alone. An explicit
503
+ // settingsPath requests the write regardless (the test/CI seam).
504
+ const settingsTarget = settingsPath ?? DEFAULT_SETTINGS_PATH;
505
+ const settings =
506
+ isDefaultInstall || settingsPath !== undefined
507
+ ? await installSettings({ root, target: settingsTarget, dryRun, warn: warnSink })
508
+ : null;
509
+
363
510
  const agents = await buildAgents(root);
364
511
  const natives = await buildNatives(root, join(root, "agents-native"));
365
512
  const skillOut = join(out, "skills");
@@ -382,6 +529,7 @@ export async function installClaude({ root = PACKAGE_ROOT, output, dryRun = fals
382
529
  hooks: hooksJson !== null,
383
530
  hookFiles: hookFiles.map((f) => f.rel),
384
531
  handAuthored: handAuthored.map((f) => f.rel),
532
+ settings,
385
533
  warnings,
386
534
  dryRun,
387
535
  };
@@ -402,10 +550,12 @@ export async function installClaude({ root = PACKAGE_ROOT, output, dryRun = fals
402
550
  console.log(` hook scripts: ${summary.hookFiles.join(", ")} (bundled into ${join(out, "hooks")}/)`);
403
551
  if (summary.handAuthored.length)
404
552
  console.log(` hand-authored files: ${summary.handAuthored.join(", ")} (copied from repo claude-extras/)`);
553
+ if (summary.settings) console.log(` settings → ${summary.settings.path} (${summary.settings.action})`);
405
554
  return summary;
406
555
  }
407
556
 
408
557
  // Write phase — the cleanup before buildSkills already cleared stale children.
558
+ if (settings) console.log(`Materialised Claude settings → ${settings.path} (${settings.action})`);
409
559
  const agentOut = join(out, "agents");
410
560
  await mkdir(agentOut, { recursive: true });
411
561
  for (const { file, content } of [...agents.files, ...natives]) {
@@ -30,30 +30,15 @@
30
30
  //
31
31
  // Zero dependencies so it runs on the CI Node and local Node alike.
32
32
 
33
- import { writeFile, mkdir, readdir, stat } from "node:fs/promises";
33
+ import { writeFile, mkdir, readdir } from "node:fs/promises";
34
34
  import { join, dirname } from "node:path";
35
35
  import { fileURLToPath } from "node:url";
36
36
  import { composeAgent } from "../../scripts/lib/compose-agent.mjs";
37
+ import { isDirectory, isFile } from "../../scripts/lib/fs.mjs";
37
38
 
38
39
  const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
39
40
  const DEFAULT_ROOT = join(MODULE_DIR, "..", "..");
40
41
 
41
- async function isDirectory(p) {
42
- try {
43
- return (await stat(p)).isDirectory();
44
- } catch {
45
- return false;
46
- }
47
- }
48
-
49
- async function isFile(p) {
50
- try {
51
- return (await stat(p)).isFile();
52
- } catch {
53
- return false;
54
- }
55
- }
56
-
57
42
  function byName(a, b) {
58
43
  return a.localeCompare(b);
59
44
  }
@@ -4,6 +4,8 @@
4
4
  //
5
5
  // <output>/agents/<slug>.agent.md composed agents (default ~/.pi/agent/agents)
6
6
  // <output>/skills/<group>/<name>/ token-substituted skill copies (default ~/.pi/agent/skills)
7
+ // <output>/settings.json merged pi settings — CLI owns `packages` (default ~/.pi/agent/settings.json)
8
+ // <output>/keybindings.json generated pi keybindings (default ~/.pi/agent/keybindings.json)
7
9
  //
8
10
  // Composition per the token-map.json contract:
9
11
  // 1. {{SECTION:name}} is resolved from the agent's pi/<name>.md section file
@@ -19,17 +21,26 @@
19
21
  // Skill markdown files get the same TOOL/PATH substitution so pi never serves
20
22
  // literal token syntax; other skill files are copied verbatim.
21
23
  //
22
- // Skills install into ~/.pi/agent/skills (pi's user-global skill dir), which pi
23
- // loads before package skills, so the substituted copies shadow the raw package
24
- // skills of the same name (pi dedupes by name, user wins).
24
+ // Skills install into ~/.pi/agent/skills (pi's user-global skill dir), which is
25
+ // the sole pi skill source: the checkout's raw package skills are not loaded
26
+ // locally because pi/settings.json registers agent-cortex with an object-form
27
+ // packages filter { source, "skills": [] } (extensions stay enabled). The
28
+ // filter exists so pi never sees the raw, un-substituted package skills and
29
+ // reports a name collision per skill (pi warns on collisions, user dir wins).
30
+ // Consumers who install the npm package without that filter still rely on pi's
31
+ // shadowing order (user dir loads before package skills). Re-run this installer
32
+ // after any skill edit so the substituted copy in the user dir stays in sync.
25
33
  //
26
34
  // Zero dependencies so it runs on the CI Node and local Node alike.
27
35
 
28
- import { readFile, writeFile, mkdir, copyFile, readdir, stat } from "node:fs/promises";
29
- import { join, dirname } from "node:path";
36
+ import { readFile, writeFile, mkdir, copyFile, readdir, lstat, rename, rm } from "node:fs/promises";
37
+ import { createHash } from "node:crypto";
38
+ import { join, dirname, basename, relative } from "node:path";
30
39
  import { fileURLToPath } from "node:url";
31
40
  import { homedir } from "node:os";
32
41
  import { composeAgent, loadTokenMap, substituteTokens, translateToolList } from "../../scripts/lib/compose-agent.mjs";
42
+ import { loadRequiredPackages, provisionPiPackages } from "../../lib/pi-packages.mjs";
43
+ import { isDirectory, isFile } from "../../scripts/lib/fs.mjs";
33
44
 
34
45
  const PI = "pi";
35
46
 
@@ -41,23 +52,17 @@ const CONTRACT_VERSION = 1;
41
52
  const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
42
53
  const DEFAULT_OUTPUT = join(homedir(), ".pi", "agent");
43
54
 
44
- const DEFAULT_WARN = (msg) => console.warn(`[pi-installer] ${msg}`);
55
+ const SETTINGS_TEMPLATE = "pi/settings.json";
56
+ const KEYBINDINGS_TEMPLATE = "pi/keybindings.json";
45
57
 
46
- async function isDirectory(p) {
47
- try {
48
- return (await stat(p)).isDirectory();
49
- } catch {
50
- return false;
51
- }
52
- }
58
+ // pi parses settings.json / keybindings.json with a bare JSON.parse (see pi's
59
+ // SettingsManager / KeybindingsManager), so JSONC comments are not an option —
60
+ // a `//` line would make the file unreadable. The GENERATED marker therefore
61
+ // lives in a top-level "//" key, the conventional JSON comment stand-in, which
62
+ // pi ignores on load and preserves on write.
63
+ const GENERATED_KEY = "//";
53
64
 
54
- async function isFile(p) {
55
- try {
56
- return (await stat(p)).isFile();
57
- } catch {
58
- return false;
59
- }
60
- }
65
+ const DEFAULT_WARN = (msg) => console.warn(`[pi-installer] ${msg}`);
61
66
 
62
67
  /**
63
68
  * Install the agent-cortex pi harness into the pi runtime.
@@ -70,8 +75,18 @@ async function isFile(p) {
70
75
  * @param {boolean} [options.dryRun] Compute and report without writing anything
71
76
  * @param {string} [options.pluginRoot] Override plugin_root used for {{PATH:...}}
72
77
  * resolution (default: token-map pi value)
78
+ * @param {boolean} [options.provisionPackages] Install the third-party pi packages
79
+ * declared in the package manifest (package.json
80
+ * pi.packages) into the pi user scope — e.g.
81
+ * pi-questions (ask_questions) and pi-web-access
82
+ * (fetch_content). Off by default; `agent-cortex
83
+ * install pi` turns it on for a real install.
84
+ * @param {(source: string) => Promise<{ok: boolean, error: string | null}>} [options.runInstall]
85
+ * Installer for one package source (default: pi install)
73
86
  * @param {(msg: string) => void} [options.warn] Warning sink, also collected in `warnings`
74
87
  * @returns {{ agents: {name:string, filePath:string}[], skills: {skills:number, md:number, files:number, dir:string},
88
+ * packages: {planned:string[], installed:string[], failed:{source:string, error:string}[]} | null,
89
+ * settings: ManagedFileResult|null, keybindings: ManagedFileResult|null,
75
90
  * warnings: string[], dryRun: boolean, agentsDir: string, skillsDir: string }}
76
91
  */
77
92
  export async function installPi(options = {}) {
@@ -97,8 +112,22 @@ export async function installPi(options = {}) {
97
112
 
98
113
  const agents = await installAgents({ root, agentsDir, dryRun, pluginRoot, tokenMap, warn });
99
114
  const skills = await installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, warn });
115
+ const config = await installConfig({ root, output, dryRun, warn });
116
+
117
+ // Third-party packages give the composed agents their pi tools (ask_questions
118
+ // from pi-questions, fetch_content from pi-web-access). They are declared in
119
+ // the package manifest and only provisioned on a real install.
120
+ const packages = options.provisionPackages
121
+ ? await provisionPiPackages({
122
+ required: await loadRequiredPackages(root),
123
+ piRoot: output,
124
+ dryRun,
125
+ runInstall: options.runInstall,
126
+ warn,
127
+ })
128
+ : null;
100
129
 
101
- return { agents, skills, warnings, dryRun, agentsDir, skillsDir };
130
+ return { agents, skills, packages, ...config, warnings, dryRun, agentsDir, skillsDir };
102
131
  }
103
132
 
104
133
  // ─── Agents ──────────────────────────────────────────────────────────────────
@@ -197,4 +226,214 @@ async function copyTree(src, dest, transform, dryRun) {
197
226
  }
198
227
  }
199
228
  return { md, files };
200
- }
229
+ }
230
+ // ─── Pi config files ─────────────────────────────────────────────────────────
231
+
232
+ /**
233
+ * Materialise the pi config files into the agent dir.
234
+ *
235
+ * `settings.json` is merged: the committed template supplies defaults for keys
236
+ * the live file lacks, while every value already present in the live file wins,
237
+ * so personal config (and anything pi itself wrote) survives re-install. The
238
+ * one key the CLI owns outright is `packages`: it is rewritten from the
239
+ * template with the repo path entry resolved against the target settings file.
240
+ *
241
+ * `keybindings.json` follows a checksum rule: a missing file or a legacy
242
+ * symlink is materialised from the template, an unmodified install is refreshed
243
+ * when the template changes, and a file edited after install is left untouched.
244
+ */
245
+ async function installConfig({ root, output, dryRun, warn }) {
246
+ return {
247
+ settings: await installSettings({ root, output, dryRun, warn }),
248
+ keybindings: await installKeybindings({ root, output, dryRun, warn }),
249
+ };
250
+ }
251
+
252
+ async function installSettings({ root, output, dryRun, warn }) {
253
+ const template = await readTemplateJson(join(root, SETTINGS_TEMPLATE), warn);
254
+ if (template === null) return null;
255
+
256
+ const target = join(output, "settings.json");
257
+ const merged = mergeSettings(template, await readJsonObject(target, warn));
258
+ merged.packages = resolvePackages(template.packages, root, dirname(target));
259
+ delete merged[GENERATED_KEY];
260
+
261
+ const content = `${JSON.stringify(
262
+ { [GENERATED_KEY]: generatedMarker(SETTINGS_TEMPLATE), ...merged },
263
+ null,
264
+ 2,
265
+ )}\n`;
266
+ return writeManagedJson({ target, content, dryRun });
267
+ }
268
+
269
+ async function installKeybindings({ root, output, dryRun, warn }) {
270
+ const template = await readTemplateJson(join(root, KEYBINDINGS_TEMPLATE), warn);
271
+ if (template === null) return null;
272
+
273
+ const target = join(output, "keybindings.json");
274
+ const content = `${JSON.stringify(
275
+ { [GENERATED_KEY]: generatedMarker(KEYBINDINGS_TEMPLATE, checksum(template)), ...template },
276
+ null,
277
+ 2,
278
+ )}\n`;
279
+
280
+ const current = await readFileMaybe(target);
281
+ if (current !== null && !(await isSymlink(target)) && current !== content) {
282
+ const parsed = tryParseJsonObject(current);
283
+ // An install we wrote carries the checksum it recorded; a body that no
284
+ // longer matches was edited by hand and must not be clobbered. A body
285
+ // byte-identical to the template is a legacy copy and safe to refresh.
286
+ const recorded = parsed === null ? null : readRecordedChecksum(parsed[GENERATED_KEY]);
287
+ const unmodified = parsed !== null && (checksum(parsed) === checksum(template) || recorded === checksum(parsed));
288
+ if (!unmodified) {
289
+ warn(`${target} was modified after install — leaving it untouched (delete it to re-adopt the template)`);
290
+ return { path: target, action: "skipped", removedSymlink: false };
291
+ }
292
+ }
293
+ return writeManagedJson({ target, content, dryRun });
294
+ }
295
+
296
+ /** Human-readable marker written as the `//` key of every generated config file. */
297
+ function generatedMarker(from, templateChecksum) {
298
+ const base = `GENERATED from ${from} by \`agent-cortex install pi\` — DO NOT EDIT; re-run the installer to update.`;
299
+ return templateChecksum ? `${base} template-checksum:${templateChecksum}` : base;
300
+ }
301
+
302
+ /** SHA-256 of a config body with the generated marker stripped, so it is stable across writes. */
303
+ function checksum(obj) {
304
+ const { [GENERATED_KEY]: _marker, ...body } = obj;
305
+ return createHash("sha256").update(JSON.stringify(body, null, 2)).digest("hex");
306
+ }
307
+
308
+ function readRecordedChecksum(marker) {
309
+ if (typeof marker !== "string") return null;
310
+ const match = /template-checksum:([0-9a-f]{64})/.exec(marker);
311
+ return match ? match[1] : null;
312
+ }
313
+
314
+ /**
315
+ * Merge the committed template over the live file. Template keys fill in
316
+ * defaults, live values win (deeply), and keys the template does not know about
317
+ * are preserved — re-installing never discards personal or pi-written config.
318
+ */
319
+ function mergeSettings(template, existing) {
320
+ const merged = {};
321
+ for (const key of Object.keys(template)) {
322
+ const templateValue = template[key];
323
+ const existingValue = existing[key];
324
+ if (isPlainObject(templateValue) && isPlainObject(existingValue)) {
325
+ merged[key] = mergeSettings(templateValue, existingValue);
326
+ } else {
327
+ merged[key] = key in existing ? existingValue : templateValue;
328
+ }
329
+ }
330
+ for (const key of Object.keys(existing)) {
331
+ if (!(key in merged)) merged[key] = existing[key];
332
+ }
333
+ return merged;
334
+ }
335
+
336
+ /**
337
+ * Build the CLI-managed packages list: the template entries with the repo path
338
+ * package resolved relative to the target settings file. pi resolves user-scope
339
+ * local package paths against its agent dir, so this keeps the checkout (or npm
340
+ * install) registered wherever the settings file lands.
341
+ */
342
+ function resolvePackages(packages, root, settingsDir) {
343
+ if (!Array.isArray(packages)) return packages;
344
+ const repoSource = relative(settingsDir, root) || ".";
345
+ let resolved = false;
346
+ const out = packages.map((entry) => {
347
+ const source = typeof entry === "string" ? entry : entry?.source;
348
+ if (resolved || typeof source !== "string" || source.startsWith("npm:")) return entry;
349
+ resolved = true;
350
+ return typeof entry === "string" ? repoSource : { ...entry, source: repoSource };
351
+ });
352
+ if (!resolved) out.unshift({ source: repoSource, skills: [] });
353
+ return out;
354
+ }
355
+
356
+ /**
357
+ * Write a managed config file. A legacy symlink is replaced with a real file by
358
+ * writing a sibling temp file and renaming it over the target — rename replaces
359
+ * the link itself, so the repo template is never written through. Returns the
360
+ * action taken so the CLI can report the plan (dry-run included).
361
+ */
362
+ async function writeManagedJson({ target, content, dryRun }) {
363
+ const symlink = await isSymlink(target);
364
+ const current = await readFileMaybe(target);
365
+
366
+ if (dryRun) {
367
+ return {
368
+ path: target,
369
+ action: symlink ? "would-remove-symlink" : current === content ? "unchanged" : "would-write",
370
+ removedSymlink: symlink,
371
+ };
372
+ }
373
+ if (!symlink && current === content) {
374
+ return { path: target, action: "unchanged", removedSymlink: false };
375
+ }
376
+
377
+ await mkdir(dirname(target), { recursive: true });
378
+ const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
379
+ try {
380
+ await writeFile(tmp, content);
381
+ await rename(tmp, target);
382
+ } catch (err) {
383
+ await rm(tmp, { force: true });
384
+ throw err;
385
+ }
386
+ return { path: target, action: symlink ? "removed-symlink" : "written", removedSymlink: symlink };
387
+ }
388
+
389
+ async function readTemplateJson(filePath, warn) {
390
+ const raw = await readFileMaybe(filePath);
391
+ if (raw === null) {
392
+ warn(`no ${filePath} template in the package — skipping`);
393
+ return null;
394
+ }
395
+ const parsed = tryParseJsonObject(raw);
396
+ if (parsed === null) throw new Error(`${filePath} is not a JSON object`);
397
+ return parsed;
398
+ }
399
+
400
+ async function readJsonObject(filePath, warn) {
401
+ const raw = await readFileMaybe(filePath);
402
+ if (raw === null) return {};
403
+ const parsed = tryParseJsonObject(raw);
404
+ if (parsed === null) {
405
+ warn(`${filePath} is not a JSON object — ignoring its contents`);
406
+ return {};
407
+ }
408
+ return parsed;
409
+ }
410
+
411
+ function tryParseJsonObject(raw) {
412
+ try {
413
+ const parsed = JSON.parse(raw);
414
+ return isPlainObject(parsed) ? parsed : null;
415
+ } catch {
416
+ return null;
417
+ }
418
+ }
419
+
420
+ function isPlainObject(value) {
421
+ return typeof value === "object" && value !== null && !Array.isArray(value);
422
+ }
423
+
424
+ async function readFileMaybe(filePath) {
425
+ try {
426
+ return await readFile(filePath, "utf-8");
427
+ } catch (err) {
428
+ if (err.code === "ENOENT") return null;
429
+ throw err;
430
+ }
431
+ }
432
+
433
+ async function isSymlink(filePath) {
434
+ try {
435
+ return (await lstat(filePath)).isSymbolicLink();
436
+ } catch {
437
+ return false;
438
+ }
439
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "enabledPlugins": {},
3
+ "extraKnownMarketplaces": {}
4
+ }