@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.
- package/AGENTS.md +21 -8
- package/CHANGELOG.md +85 -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/REFERENCE.md +1 -1
- package/skills/planning/create-task/SKILL.md +20 -11
- package/skills/planning/create-task/scripts/create-chores.ts +4 -0
- 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 +9 -21
- package/skills/style/style-tests/EXAMPLES.md +548 -0
- package/skills/style/style-tests/SKILL.md +84 -33
- 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/create-chores.test.mjs +73 -0
- package/test/fs.test.mjs +76 -0
- package/test/helpers/fake-bd.mjs +67 -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,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)
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
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
|
}
|
package/bin/installers/pi.mjs
CHANGED
|
@@ -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
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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,
|
|
29
|
-
import {
|
|
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
|
|
55
|
+
const SETTINGS_TEMPLATE = "pi/settings.json";
|
|
56
|
+
const KEYBINDINGS_TEMPLATE = "pi/keybindings.json";
|
|
45
57
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
+
}
|