@jaybeeuu/agent-cortex 1.42.0 → 1.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/style-versioning/SKILL.md +73 -2
- package/.github/workflows/ci.yml +47 -27
- package/AGENTS.md +23 -2
- package/CHANGELOG.md +166 -0
- package/README.md +125 -73
- package/agents/README.md +17 -4
- package/bin/agent-cortex.mjs +48 -30
- package/bin/installers/pi.mjs +885 -237
- package/docs/claude-hooks.md +5 -4
- package/docs/commit-signing.md +231 -0
- package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +10 -1
- package/docs/ideas/extension-manifests.md +5 -0
- package/docs/research/disable-model-invocation.md +103 -0
- package/docs/research/skill-design-research.md +2 -2
- package/docs/skills/skill-anatomy.md +5 -1
- package/docs/technical-direction/harness-config.md +25 -1
- package/docs/technical-direction/migration-execution-labels.md +17 -9
- package/extensions/agent-modes/discover.test.ts +19 -0
- package/extensions/agent-modes/discover.ts +25 -3
- package/extensions/session-start/README.md +38 -0
- package/extensions/session-start/index.test.ts +122 -0
- package/extensions/session-start/index.ts +27 -0
- package/extensions/session-start/package.json +19 -0
- package/extensions/session-start/tsconfig.json +12 -0
- package/extensions/skill-stats/pnpm-lock.yaml +329 -0
- package/extensions/subagent/README.md +1 -1
- package/extensions/subagent/agents.test.ts +176 -0
- package/extensions/subagent/agents.ts +102 -11
- package/extensions/subagent/index.ts +2 -1
- package/extensions/subagent/package.json +1 -1
- package/lib/cli.mjs +35 -9
- package/package.json +6 -5
- package/pi/settings.json +1 -7
- package/plugin.json +1 -1
- package/pnpm-workspace.yaml +6 -0
- package/scripts/check-changeset.mjs +42 -0
- package/scripts/lib/release-gates.mjs +61 -0
- package/scripts/lib/verify-worker-tree.mjs +107 -0
- package/scripts/resolve-publish-command.mjs +17 -0
- package/skills/planning/create-task/scripts/pnpm-lock.yaml +342 -0
- package/skills/productivity/validate-agent-dir/scripts/pnpm-lock.yaml +329 -0
- package/skills/workflow/git-workflow/SKILL.md +35 -1
- package/skills/workflow/ralph/REFERENCE.md +17 -1
- package/skills/workflow/ralph/SKILL.md +4 -1
- package/skills/workflow/run-pipeline-stage/SKILL.md +11 -4
- package/skills/workflow/run-pipeline-stage/playbooks/code.md +2 -0
- package/skills/workflow/run-pipeline-stage/playbooks/document.md +3 -0
- package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -0
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +11 -0
- package/skills/workflow/run-pipeline-stage/scripts/pnpm-lock.yaml +342 -0
- package/test/cli.test.mjs +26 -29
- package/test/helpers/fake-pi.mjs +132 -0
- package/test/pi-installer.test.mjs +345 -286
- package/test/pi-register.test.mjs +342 -0
- package/test/pi-sweep.test.mjs +619 -0
- package/test/release-gates.test.mjs +190 -0
- package/test/verify-worker-tree.test.mjs +120 -0
- package/token-map.README.md +7 -6
- package/token-map.json +6 -5
- package/extensions/session-start.ts +0 -44
- package/lib/pi-packages.mjs +0 -169
- package/test/pi-packages.test.mjs +0 -295
package/bin/installers/pi.mjs
CHANGED
|
@@ -1,45 +1,116 @@
|
|
|
1
1
|
// Pi harness installer: agent-cortex install pi
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// MATERIALISES agent-cortex as a self-contained pi package in a home-scoped
|
|
4
|
+
// directory (~/.agent-cortex/pi) instead of writing into pi's own user
|
|
5
|
+
// directories (~/.pi/agent/…) — the pi analogue of the Claude installer's
|
|
6
|
+
// ~/.agent-cortex/claude — and then REGISTERS it with pi through pi's own
|
|
7
|
+
// `pi install <path>` / `pi remove <source>` package commands. Staging only
|
|
8
|
+
// writes below the output directory; registration never writes pi's settings
|
|
9
|
+
// itself, it drives the CLI that owns them.
|
|
4
10
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// <output>/
|
|
8
|
-
//
|
|
11
|
+
// Generated tree:
|
|
12
|
+
//
|
|
13
|
+
// <output>/package.json pi manifest — keywords ["pi-package"],
|
|
14
|
+
// pi.extensions + pi.skills (conventional
|
|
15
|
+
// dirs plus the bundled third-party packages)
|
|
16
|
+
// <output>/agents/<slug>.agent.md composed flat agents (agents/<name>/
|
|
17
|
+
// agent.md + pi/ harness dirs, {{TOOL:...}} /
|
|
18
|
+
// {{PATH:...}} substituted against
|
|
19
|
+
// token-map.json's pi column). pi package
|
|
20
|
+
// rules have no agent resource type: the
|
|
21
|
+
// shipped agent-modes extension resolves
|
|
22
|
+
// <extension>/../../agents, i.e. this dir
|
|
23
|
+
// <output>/skills/<group>/<name>/ token-substituted skill copies — real
|
|
24
|
+
// files, never symlinks
|
|
25
|
+
// <output>/extensions/ copy of the repo extensions/ tree (loaded
|
|
26
|
+
// by pi's package discovery)
|
|
27
|
+
// <output>/token-map.json the map agent-modes reads from the package
|
|
28
|
+
// root when composing agents
|
|
29
|
+
// <output>/node_modules/<pkg>/ the required third-party pi packages
|
|
30
|
+
// (ask_questions from pi-questions,
|
|
31
|
+
// fetch_content from pi-web-access) and
|
|
32
|
+
// their transitive closure
|
|
9
33
|
//
|
|
10
34
|
// Composition per the token-map.json contract:
|
|
11
35
|
// 1. {{SECTION:name}} is resolved from the agent's pi/<name>.md section file
|
|
12
36
|
// (shared composer, scripts/lib/compose-agent.mjs).
|
|
13
|
-
// 2. {{TOOL:key}} is substituted with the pi column
|
|
14
|
-
//
|
|
15
|
-
// token from prose with a warning.
|
|
37
|
+
// 2. {{TOOL:key}} is substituted with the pi column; a null pi mapping drops
|
|
38
|
+
// the token from prose with a warning.
|
|
16
39
|
// 3. {{PATH:key}} named keys resolve through the paths table; bare relative
|
|
17
|
-
// paths resolve against the plugin root (default: token-map.json's pi
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
40
|
+
// paths resolve against the plugin root (default: token-map.json's pi
|
|
41
|
+
// value, which points at this staged package; overridable with
|
|
42
|
+
// --plugin-root for checkout/symlinked installs).
|
|
43
|
+
// Frontmatter tool lists are translated the same way agent-modes does at
|
|
44
|
+
// runtime: canonical → pi column, null → omitted, unknown (native pi tool) →
|
|
45
|
+
// pass through.
|
|
46
|
+
//
|
|
47
|
+
// Third-party packages: the agents need tools that live in other pi packages.
|
|
48
|
+
// They are ordinary runtime dependencies of this package, and the staged tree
|
|
49
|
+
// ships its own copy of each (plus its closure) under node_modules/ — pi loads
|
|
50
|
+
// a local directory and resolves nothing itself, so a staged package without
|
|
51
|
+
// them would hand the agents a model with no ask_questions/fetch_content.
|
|
52
|
+
// Resolution goes through Node (createRequire(import.meta.url).resolve), so it
|
|
53
|
+
// is hoist-agnostic: isolated, hoisted, nested, and global installs all work.
|
|
54
|
+
// A dependency that cannot be resolved fails the install.
|
|
55
|
+
//
|
|
56
|
+
// Re-staging is idempotent: the installer clears the children it owns before
|
|
57
|
+
// writing, so a re-run never accumulates stale copies.
|
|
58
|
+
//
|
|
59
|
+
// pi's own user settings (settings.json, keybindings.json) are NOT written by
|
|
60
|
+
// the installer: they are pi's config, not the package's, and `pi install` is
|
|
61
|
+
// the command that owns them. They are READ (never written) to decide
|
|
62
|
+
// install-vs-no-op and to find stale raw-repo sources; the committed pi/*.json
|
|
63
|
+
// templates remain reference copies.
|
|
23
64
|
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
65
|
+
// LEGACY SWEEP (sweepLegacyPiUserDirs): the pre-staged-model installer rendered
|
|
66
|
+
// agents and skills into pi's own user directory (~/.pi/agent). Those copies are
|
|
67
|
+
// now a second source of the same resource — the collision diagnostic the staged
|
|
68
|
+
// model exists to remove — so once the staged package is registered, the
|
|
69
|
+
// installer deletes the copies it can attribute by name: `agents/<name>.agent.md`
|
|
70
|
+
// for a composable agent this package ships or one it once shipped (below), and
|
|
71
|
+
// `skills/<group>/<name>` for a skill it ships or once shipped.
|
|
72
|
+
//
|
|
73
|
+
// It is a ONE-SHOT migration, not a standing rule. Name attribution cannot tell
|
|
74
|
+
// a rendered copy from a file the user wrote under the same name, so the sweep
|
|
75
|
+
// records a finished migration (DEFAULT_LEGACY_SWEEP_RECORD, keyed by the pi
|
|
76
|
+
// user directory) and never attributes anything in that directory again — after
|
|
77
|
+
// the migration the directory is the user's. The record lives under the home
|
|
78
|
+
// install root, beside the staged package, never inside the directory it
|
|
79
|
+
// describes: ~/.pi/agent is left with no agent-cortex artifact in it. pi's
|
|
80
|
+
// extensions directory is never entered: no installer on this flow has ever
|
|
81
|
+
// written there, so everything in it is the user's.
|
|
82
|
+
//
|
|
83
|
+
// Registration (registerPi) is idempotent by STATE, not by exit code: the
|
|
84
|
+
// configured package sources decide install-vs-no-op, so a re-run after
|
|
85
|
+
// `git pull && agent-cortex install pi` is a true no-op (a local package source
|
|
86
|
+
// is read from disk on every pi start — there is nothing to refresh). Sources
|
|
87
|
+
// pointing at a raw agent-cortex checkout (this package's own repo name) are
|
|
88
|
+
// removed first, so exactly one agent-cortex source stays configured; a source
|
|
89
|
+
// that survives its removal fails the run, because two configured sources both
|
|
90
|
+
// load. If the `pi` CLI is missing, the installer warns and prints the exact
|
|
91
|
+
// commands, exiting non-zero only with --require-register.
|
|
92
|
+
//
|
|
93
|
+
// node bin/agent-cortex.mjs install pi
|
|
94
|
+
// materialises into ~/.agent-cortex/pi AND
|
|
95
|
+
// registers that directory with pi (user scope)
|
|
96
|
+
// then sweeps the artifacts the old installer
|
|
97
|
+
// left in ~/.pi/agent (see LEGACY SWEEP above)
|
|
98
|
+
// node bin/agent-cortex.mjs install pi --output <dir>
|
|
99
|
+
// stages into <dir> only — no registration, no
|
|
100
|
+
// sweep (tests/CI)
|
|
101
|
+
// node bin/agent-cortex.mjs install pi --dry-run
|
|
102
|
+
// reports the staging and registration plan
|
|
103
|
+
// without writing or spawning anything
|
|
33
104
|
//
|
|
34
105
|
// Zero dependencies so it runs on the CI Node and local Node alike.
|
|
35
106
|
|
|
36
|
-
import { readFile, writeFile, mkdir, copyFile, readdir,
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
107
|
+
import { readFile, writeFile, mkdir, copyFile, readdir, rm, stat, lstat } from "node:fs/promises";
|
|
108
|
+
import { spawn } from "node:child_process";
|
|
109
|
+
import { createRequire } from "node:module";
|
|
110
|
+
import { join, dirname, resolve } from "node:path";
|
|
39
111
|
import { fileURLToPath } from "node:url";
|
|
40
112
|
import { homedir } from "node:os";
|
|
41
113
|
import { composeAgent, loadTokenMap, substituteTokens, translateToolList } from "../../scripts/lib/compose-agent.mjs";
|
|
42
|
-
import { loadRequiredPackages, provisionPiPackages } from "../../lib/pi-packages.mjs";
|
|
43
114
|
import { isDirectory, isFile } from "../../scripts/lib/fs.mjs";
|
|
44
115
|
|
|
45
116
|
const PI = "pi";
|
|
@@ -50,55 +121,100 @@ const PI = "pi";
|
|
|
50
121
|
const CONTRACT_VERSION = 1;
|
|
51
122
|
|
|
52
123
|
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
53
|
-
const DEFAULT_OUTPUT = join(homedir(), ".pi", "agent");
|
|
54
124
|
|
|
55
|
-
|
|
56
|
-
|
|
125
|
+
// Home-scoped staged package. pi loads it by path once registered, so nothing
|
|
126
|
+
// is written into ~/.pi/agent. Overridable via --output (tests/CI) or HOME
|
|
127
|
+
// (node's os.homedir() honours $HOME on POSIX).
|
|
128
|
+
const DEFAULT_OUTPUT = join(homedir(), ".agent-cortex", "pi");
|
|
129
|
+
|
|
130
|
+
// Name of the generated package. `private` because it is a staged build
|
|
131
|
+
// artifact, never published.
|
|
132
|
+
const STAGED_NAME = "@jaybeeuu/agent-cortex-pi";
|
|
57
133
|
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// a
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
134
|
+
// Children of the output directory this installer owns. They are removed
|
|
135
|
+
// before every write so re-staging cannot leave stale copies behind (a skill
|
|
136
|
+
// deleted in the repo, a package dropped from the manifest). Anything else in
|
|
137
|
+
// the output directory is left alone.
|
|
138
|
+
const OWNED_CHILDREN = ["package.json", "token-map.json", "agents", "skills", "extensions", "node_modules"];
|
|
139
|
+
|
|
140
|
+
const GENERATED_MARKER =
|
|
141
|
+
"GENERATED by `agent-cortex install pi` — DO NOT EDIT; re-run the installer to update.";
|
|
142
|
+
|
|
143
|
+
// The `pi` CLI that owns package registration. Overridable in tests via piBin.
|
|
144
|
+
const DEFAULT_PI_BIN = "pi";
|
|
145
|
+
|
|
146
|
+
// pi's user settings file — READ ONLY here (registration writes through the
|
|
147
|
+
// CLI). Its directory is also the base pi resolves relative package sources
|
|
148
|
+
// against. Overridable in tests via settingsPath.
|
|
149
|
+
const DEFAULT_SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
|
|
150
|
+
|
|
151
|
+
// Source prefixes pi does not treat as local directories (packages.md:
|
|
152
|
+
// npm:, git:, https/github URLs, and built-in extension names).
|
|
153
|
+
const NON_LOCAL_PREFIXES = ["npm:", "git:", "github:", "http:", "https:", "ssh:", "builtin:"];
|
|
64
154
|
|
|
65
155
|
const DEFAULT_WARN = (msg) => console.warn(`[pi-installer] ${msg}`);
|
|
66
156
|
|
|
157
|
+
// Artifacts the pre-staged-model installer wrote into pi's own user directory
|
|
158
|
+
// that the package root can no longer name on its own: the orphaned
|
|
159
|
+
// `pipeline-worker` agent (an older render with no canonical source today) and
|
|
160
|
+
// the `migration` skill (authored on a branch this epic does not carry).
|
|
161
|
+
// Everything else is attributed from the package root — see
|
|
162
|
+
// sweepLegacyPiUserDirs. Drop an entry once it is either shipped again (the
|
|
163
|
+
// root-derived rules then cover it) or gone for good.
|
|
164
|
+
const LEGACY_ORPHAN_AGENTS = ["pipeline-worker"];
|
|
165
|
+
const LEGACY_ORPHAN_SKILLS = ["productivity/migration"];
|
|
166
|
+
|
|
167
|
+
// A path record some install flows leave in pi's user directory, listing the
|
|
168
|
+
// copies they wrote there. It describes the same tree the sweep removes, so it
|
|
169
|
+
// goes with the artifacts it lists — but only as part of the one-shot migration.
|
|
170
|
+
const LEGACY_INSTALL_MANIFEST = ".agent-cortex-install.json";
|
|
171
|
+
|
|
172
|
+
// Record of the one-shot migration, keyed by the resolved pi user directory:
|
|
173
|
+
// { "<piUserDir>": { "version": 1, "migratedAt": "<ISO>" } }
|
|
174
|
+
// It sits under the home install root (~/.agent-cortex), not in the directory it
|
|
175
|
+
// describes — after the migration that directory holds no agent-cortex artifact.
|
|
176
|
+
// An entry ends the sweep for its directory: from then on nothing there is
|
|
177
|
+
// attributed to agent-cortex, so an agent or skill the user writes under a
|
|
178
|
+
// shipped name survives every later install. Only a fully successful migration
|
|
179
|
+
// gets an entry, so a copy that could not be removed is retried next install.
|
|
180
|
+
const DEFAULT_LEGACY_SWEEP_RECORD = join(homedir(), ".agent-cortex", "legacy-sweep.json");
|
|
181
|
+
|
|
67
182
|
/**
|
|
68
|
-
*
|
|
183
|
+
* Stage the agent-cortex pi package.
|
|
69
184
|
*
|
|
70
185
|
* @param {object} options
|
|
71
186
|
* @param {string} [options.root] Package root (defaults to this file's repo)
|
|
72
|
-
* @param {string} [options.output]
|
|
73
|
-
*
|
|
74
|
-
* (default ~/.pi/agent)
|
|
75
|
-
* @param {boolean} [options.dryRun] Compute and report without writing anything
|
|
187
|
+
* @param {string} [options.output] Staged package dir (default ~/.agent-cortex/pi)
|
|
188
|
+
* @param {boolean} [options.dryRun] Report the plan without writing anything
|
|
76
189
|
* @param {string} [options.pluginRoot] Override plugin_root used for {{PATH:...}}
|
|
77
190
|
* resolution (default: token-map pi value)
|
|
78
|
-
* @param {
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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)
|
|
191
|
+
* @param {(name: string, fromDir?: string) => string | Promise<string>} [options.resolvePackage]
|
|
192
|
+
* Resolve an installed dependency to its
|
|
193
|
+
* directory (default: Node resolution from
|
|
194
|
+
* this package's own install)
|
|
86
195
|
* @param {(msg: string) => void} [options.warn] Warning sink, also collected in `warnings`
|
|
87
|
-
* @returns {{
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
196
|
+
* @returns {Promise<{ output: string, agents: {name: string, filePath: string}[],
|
|
197
|
+
* skills: {skills: number, md: number, files: number, dir: string},
|
|
198
|
+
* extensions: {files: number, dir: string},
|
|
199
|
+
* packages: {dir: string, bundled: string[], staged: string[], resources: {extensions: string[], skills: string[]}},
|
|
200
|
+
* manifest: {path: string, manifest: object},
|
|
201
|
+
* tokenMap: string, warnings: string[], dryRun: boolean }>}
|
|
91
202
|
*/
|
|
92
203
|
export async function installPi(options = {}) {
|
|
93
204
|
const root = options.root ?? PACKAGE_ROOT;
|
|
94
205
|
const output = options.output ?? DEFAULT_OUTPUT;
|
|
95
206
|
const dryRun = options.dryRun ?? false;
|
|
207
|
+
const resolvePackage = options.resolvePackage ?? resolveFromNode;
|
|
96
208
|
const warnings = [];
|
|
97
209
|
const warn = (msg) => {
|
|
98
210
|
warnings.push(msg);
|
|
99
211
|
(options.warn ?? DEFAULT_WARN)(msg);
|
|
100
212
|
};
|
|
101
213
|
|
|
214
|
+
if (resolve(output) === resolve(root)) {
|
|
215
|
+
throw new Error(`refusing to stage the pi package into the package root ${output} — the installer clears its own output tree`);
|
|
216
|
+
}
|
|
217
|
+
|
|
102
218
|
const tokenMap = await loadTokenMap(root);
|
|
103
219
|
if (typeof tokenMap.version === "number" && tokenMap.version > CONTRACT_VERSION) {
|
|
104
220
|
throw new Error(
|
|
@@ -107,27 +223,43 @@ export async function installPi(options = {}) {
|
|
|
107
223
|
}
|
|
108
224
|
|
|
109
225
|
const pluginRoot = options.pluginRoot ?? tokenMap.paths.plugin_root?.[PI];
|
|
110
|
-
const agentsDir = join(output, "agents");
|
|
111
|
-
const skillsDir = join(output, "skills");
|
|
112
226
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
227
|
+
// Resolve the bundled packages before clearing the output: an unresolvable
|
|
228
|
+
// dependency must fail the install without destroying the previous staging.
|
|
229
|
+
const dependencies = await resolvePiPackages({ root, resolvePackage });
|
|
116
230
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
piRoot: output,
|
|
124
|
-
dryRun,
|
|
125
|
-
runInstall: options.runInstall,
|
|
126
|
-
warn,
|
|
127
|
-
})
|
|
128
|
-
: null;
|
|
231
|
+
if (!dryRun) {
|
|
232
|
+
for (const child of OWNED_CHILDREN) {
|
|
233
|
+
await rm(join(output, child), { recursive: true, force: true });
|
|
234
|
+
}
|
|
235
|
+
await mkdir(output, { recursive: true });
|
|
236
|
+
}
|
|
129
237
|
|
|
130
|
-
|
|
238
|
+
const agents = await installAgents({ root, agentsDir: join(output, "agents"), dryRun, pluginRoot, tokenMap, warn });
|
|
239
|
+
const skills = await installSkills({ root, skillsDir: join(output, "skills"), dryRun, pluginRoot, tokenMap, warn });
|
|
240
|
+
const extensions = await installExtensions({ root, output, dryRun });
|
|
241
|
+
const tokenMapFile = await installTokenMap({ output, tokenMap, dryRun });
|
|
242
|
+
const packages = await stagePiPackages({ dependencies, output, resolvePackage, dryRun, warn });
|
|
243
|
+
const manifestObject = await buildManifest({ root, dependencies });
|
|
244
|
+
// Verify before writing the manifest: a package whose declared resources are
|
|
245
|
+
// missing is not loadable, so it must never be reported as staged.
|
|
246
|
+
if (!dryRun) {
|
|
247
|
+
await verifyManifestResources({ output, manifest: manifestObject });
|
|
248
|
+
await writeManifest({ output, manifest: manifestObject });
|
|
249
|
+
}
|
|
250
|
+
const manifest = { path: join(output, "package.json"), manifest: manifestObject };
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
output,
|
|
254
|
+
agents,
|
|
255
|
+
skills,
|
|
256
|
+
extensions,
|
|
257
|
+
packages,
|
|
258
|
+
manifest,
|
|
259
|
+
tokenMap: tokenMapFile,
|
|
260
|
+
warnings,
|
|
261
|
+
dryRun,
|
|
262
|
+
};
|
|
131
263
|
}
|
|
132
264
|
|
|
133
265
|
// ─── Agents ──────────────────────────────────────────────────────────────────
|
|
@@ -169,7 +301,12 @@ function renderPiAgent(name, fm, tools) {
|
|
|
169
301
|
`# GENERATED from agents/${name}/ by bin/installers/pi.mjs — DO NOT EDIT.`,
|
|
170
302
|
`name: ${JSON.stringify(fm.name)}`,
|
|
171
303
|
`description: ${JSON.stringify(fm.description)}`,
|
|
172
|
-
|
|
304
|
+
// pi's parseFrontmatter hands back the scalar as one string, so the tool
|
|
305
|
+
// list must use the separator its consumers split on. The subagent extension
|
|
306
|
+
// (extensions/subagent/agents.ts) turns this into `pi --tools`, whose
|
|
307
|
+
// parser is comma-separated — a space-separated list collapsed to a single
|
|
308
|
+
// bogus name and produced an allowlist matching no tool at all.
|
|
309
|
+
`tools: ${JSON.stringify(tools.join(", "))}`,
|
|
173
310
|
];
|
|
174
311
|
if (fm.model) lines.push(`model: ${JSON.stringify(fm.model)}`);
|
|
175
312
|
if (fm.argumentHint) lines.push(`argument-hint: ${JSON.stringify(fm.argumentHint)}`);
|
|
@@ -186,7 +323,6 @@ async function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, wa
|
|
|
186
323
|
const transform = (content) =>
|
|
187
324
|
substituteTokens(content, PI, tokenMap, { dropNullTools: true, pluginRoot, resolveRelativePaths: true, warn });
|
|
188
325
|
|
|
189
|
-
if (!dryRun) await mkdir(skillsDir, { recursive: true });
|
|
190
326
|
let skills = 0;
|
|
191
327
|
let md = 0;
|
|
192
328
|
let files = 0;
|
|
@@ -196,7 +332,7 @@ async function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, wa
|
|
|
196
332
|
for (const name of (await readdir(groupDir)).sort()) {
|
|
197
333
|
if (!(await isFile(join(groupDir, name, "SKILL.md")))) continue;
|
|
198
334
|
skills += 1;
|
|
199
|
-
const stats = await copyTree(join(groupDir, name), join(skillsDir, group, name),
|
|
335
|
+
const stats = await copyTree(join(groupDir, name), join(skillsDir, group, name), { dryRun, transform });
|
|
200
336
|
md += stats.md;
|
|
201
337
|
files += stats.files;
|
|
202
338
|
}
|
|
@@ -204,235 +340,747 @@ async function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, wa
|
|
|
204
340
|
return { skills, md, files, dir: skillsDir };
|
|
205
341
|
}
|
|
206
342
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
343
|
+
// ─── Extensions ──────────────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Copy the repo's extensions/ tree into the staged package verbatim.
|
|
347
|
+
*
|
|
348
|
+
* The extensions import `@earendil-works/*`, which pi's loader supplies, so no
|
|
349
|
+
* dependency tree is needed. `node_modules` (pnpm workspace links) and symlinks
|
|
350
|
+
* are never copied: the staged package owns its dependencies and must contain
|
|
351
|
+
* real files only.
|
|
352
|
+
*/
|
|
353
|
+
async function installExtensions({ root, output, dryRun }) {
|
|
354
|
+
const src = join(root, "extensions");
|
|
355
|
+
const dir = join(output, "extensions");
|
|
356
|
+
if (!(await isDirectory(src))) return { files: 0, dir };
|
|
357
|
+
const { files } = await copyTree(src, dir, { dryRun });
|
|
358
|
+
return { files, dir };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ─── Token map ───────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Ship token-map.json alongside agents/ — agent-modes loads it from the package
|
|
365
|
+
* root, so the staged extensions keep the same source of truth as the checkout.
|
|
366
|
+
*/
|
|
367
|
+
async function installTokenMap({ output, tokenMap, dryRun }) {
|
|
368
|
+
const path = join(output, "token-map.json");
|
|
369
|
+
if (!dryRun) await writeFile(path, `${JSON.stringify(tokenMap, null, 2)}\n`);
|
|
370
|
+
return path;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ─── Third-party pi packages ─────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Resolve the pi packages this package depends on.
|
|
377
|
+
*
|
|
378
|
+
* Every declared runtime dependency is resolved and inspected: one that ships
|
|
379
|
+
* pi resources (a `pi` manifest key) is bundled into the staged package, and
|
|
380
|
+
* its declared resource paths are rewritten relative to node_modules/ so the
|
|
381
|
+
* generated manifest loads them. Ordinary dependencies are left to the usual
|
|
382
|
+
* module resolution.
|
|
383
|
+
*
|
|
384
|
+
* @returns {Promise<{packages: {name: string, dir: string}[], resources: {extensions: string[], skills: string[]}}>}
|
|
385
|
+
*/
|
|
386
|
+
async function resolvePiPackages({ root, resolvePackage }) {
|
|
387
|
+
const packages = [];
|
|
388
|
+
const resources = { extensions: [], skills: [] };
|
|
389
|
+
|
|
390
|
+
for (const name of await readDirectDependencies(root)) {
|
|
391
|
+
const dir = await resolvePackage(name);
|
|
392
|
+
const manifest = await readJsonObject(join(dir, "package.json"));
|
|
393
|
+
if (manifest === null || manifest.pi === undefined) continue;
|
|
394
|
+
|
|
395
|
+
packages.push({ name, dir });
|
|
396
|
+
for (const type of ["extensions", "skills"]) {
|
|
397
|
+
for (const entry of asStringList(manifest.pi[type])) {
|
|
398
|
+
resources[type].push(`node_modules/${name}/${stripRelative(entry)}`);
|
|
399
|
+
}
|
|
226
400
|
}
|
|
227
401
|
}
|
|
228
|
-
|
|
402
|
+
|
|
403
|
+
return { packages, resources };
|
|
229
404
|
}
|
|
230
|
-
// ─── Pi config files ─────────────────────────────────────────────────────────
|
|
231
405
|
|
|
232
406
|
/**
|
|
233
|
-
*
|
|
407
|
+
* Copy the resolved packages and their transitive closure into <output>/node_modules.
|
|
234
408
|
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
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.
|
|
409
|
+
* The walk resolves every dependency from its dependent's own directory, so the
|
|
410
|
+
* source layout is irrelevant. Copies are real files (never symlinks) and stay
|
|
411
|
+
* flat, matching what pi loads from a local package; a package that resolves to
|
|
412
|
+
* two different versions keeps the first and warns.
|
|
244
413
|
*/
|
|
245
|
-
async function
|
|
414
|
+
async function stagePiPackages({ dependencies, output, resolvePackage, dryRun, warn }) {
|
|
415
|
+
const staged = new Map();
|
|
416
|
+
const pending = dependencies.packages.map(({ name, dir }) => ({ name, dir }));
|
|
417
|
+
|
|
418
|
+
while (pending.length > 0) {
|
|
419
|
+
const { name, dir } = pending.shift();
|
|
420
|
+
const seen = staged.get(name);
|
|
421
|
+
if (seen !== undefined) {
|
|
422
|
+
if (seen !== dir) warn(`pi package "${name}" resolves to two directories (${seen}, ${dir}) — keeping the first`);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
staged.set(name, dir);
|
|
426
|
+
for (const dependency of await readDependencyNames(dir)) {
|
|
427
|
+
pending.push({ name: dependency, dir: await resolvePackage(dependency, dir) });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const nodeModulesDir = join(output, "node_modules");
|
|
432
|
+
if (!dryRun) {
|
|
433
|
+
for (const [name, dir] of staged) await copyTree(dir, join(nodeModulesDir, name), {});
|
|
434
|
+
}
|
|
246
435
|
return {
|
|
247
|
-
|
|
248
|
-
|
|
436
|
+
dir: nodeModulesDir,
|
|
437
|
+
bundled: dependencies.packages.map(({ name }) => name),
|
|
438
|
+
staged: [...staged.keys()],
|
|
439
|
+
resources: dependencies.resources,
|
|
249
440
|
};
|
|
250
441
|
}
|
|
251
442
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
{
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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 };
|
|
443
|
+
/**
|
|
444
|
+
* Node's own resolution from this package's install — hoist-agnostic by
|
|
445
|
+
* construction. A package's `package.json` is not always exported, so the
|
|
446
|
+
* package directory is found by resolving an entry point and walking up to the
|
|
447
|
+
* enclosing manifest that names it.
|
|
448
|
+
*/
|
|
449
|
+
async function resolveFromNode(name, fromDir = PACKAGE_ROOT) {
|
|
450
|
+
const req = createRequire(join(fromDir, "package.json"));
|
|
451
|
+
let entry = null;
|
|
452
|
+
for (const specifier of [name, `${name}/package.json`]) {
|
|
453
|
+
try {
|
|
454
|
+
entry = req.resolve(specifier);
|
|
455
|
+
break;
|
|
456
|
+
} catch {
|
|
457
|
+
// try the next form — `exports` may hide either subpath
|
|
291
458
|
}
|
|
292
459
|
}
|
|
293
|
-
|
|
294
|
-
}
|
|
460
|
+
if (entry === null) {
|
|
461
|
+
throw new Error(`cannot resolve pi package "${name}" from ${fromDir} — run \`pnpm install\` first`);
|
|
462
|
+
}
|
|
295
463
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
464
|
+
let dir = dirname(entry);
|
|
465
|
+
while (true) {
|
|
466
|
+
const manifest = await readJsonObject(join(dir, "package.json"));
|
|
467
|
+
if (manifest?.name === name) return dir;
|
|
468
|
+
const parent = dirname(dir);
|
|
469
|
+
if (parent === dir) throw new Error(`cannot locate the package directory of "${name}" resolved from ${fromDir}`);
|
|
470
|
+
dir = parent;
|
|
471
|
+
}
|
|
300
472
|
}
|
|
301
473
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return createHash("sha256").update(JSON.stringify(body, null, 2)).digest("hex");
|
|
474
|
+
async function readDirectDependencies(root) {
|
|
475
|
+
const manifest = await readJsonObject(join(root, "package.json"));
|
|
476
|
+
return manifest === null ? [] : Object.keys(manifest.dependencies ?? {});
|
|
306
477
|
}
|
|
307
478
|
|
|
308
|
-
function
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
return match ? match[1] : null;
|
|
479
|
+
async function readDependencyNames(dir) {
|
|
480
|
+
const manifest = await readJsonObject(join(dir, "package.json"));
|
|
481
|
+
return manifest === null ? [] : Object.keys(manifest.dependencies ?? {});
|
|
312
482
|
}
|
|
313
483
|
|
|
484
|
+
// ─── Registration ────────────────────────────────────────────────────────────
|
|
485
|
+
|
|
314
486
|
/**
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
487
|
+
* Register the staged package with pi so pi loads its resources.
|
|
488
|
+
*
|
|
489
|
+
* Registration goes through pi's own package commands — `pi install <path>`
|
|
490
|
+
* appends a local-path entry to pi's user `settings.json` (user scope, no
|
|
491
|
+
* copying) and `pi remove <source>` drops one — because pi owns that file. The
|
|
492
|
+
* installer only READS it, to decide the plan by state:
|
|
493
|
+
*
|
|
494
|
+
* `packages` already holds the staged path → no install (local package
|
|
495
|
+
* sources are read from disk on every pi start, so a re-run after
|
|
496
|
+
* `git pull && agent-cortex install pi` is a true no-op)
|
|
497
|
+
* a source resolves to a raw agent-cortex checkout (this package's own name)
|
|
498
|
+
* → `pi remove` it first, so exactly one agent-cortex source stays
|
|
499
|
+
*
|
|
500
|
+
* A superseded source that survives its removal fails the run: pi would load
|
|
501
|
+
* both sources side by side, so reporting success would leave the very state
|
|
502
|
+
* this step exists to prevent. Nothing is installed on that path, and the
|
|
503
|
+
* error names the source still configured.
|
|
504
|
+
*
|
|
505
|
+
* When the `pi` CLI cannot be run, the staged package is left unregistered:
|
|
506
|
+
* the installer warns, prints the exact commands, and fails only with
|
|
507
|
+
* `requireRegister` (the file side of the install already succeeded).
|
|
508
|
+
*
|
|
509
|
+
* @param {object} options
|
|
510
|
+
* @param {string} [options.root] Package root whose package.json name
|
|
511
|
+
* identifies stale raw-repo sources
|
|
512
|
+
* @param {string} [options.output] Staged package dir (default ~/.agent-cortex/pi)
|
|
513
|
+
* @param {string} [options.settingsPath] pi user settings (read only)
|
|
514
|
+
* @param {string} [options.piBin] The `pi` executable (default: `pi` from PATH)
|
|
515
|
+
* @param {boolean} [options.dryRun] Plan and report without spawning anything
|
|
516
|
+
* @param {boolean} [options.requireRegister] Throw when registration cannot run
|
|
517
|
+
* @param {(msg: string) => void} [options.warn] Warning sink (default: console.warn)
|
|
518
|
+
* @returns {Promise<{ output: string, piBin: string, settingsPath: string, registered: boolean,
|
|
519
|
+
* removed: string[], commands: string[][], dryRun: boolean }>}
|
|
318
520
|
*/
|
|
319
|
-
function
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
521
|
+
export async function registerPi({
|
|
522
|
+
root = PACKAGE_ROOT,
|
|
523
|
+
output = DEFAULT_OUTPUT,
|
|
524
|
+
settingsPath = DEFAULT_SETTINGS_PATH,
|
|
525
|
+
piBin = DEFAULT_PI_BIN,
|
|
526
|
+
dryRun = false,
|
|
527
|
+
requireRegister = false,
|
|
528
|
+
warn = DEFAULT_WARN,
|
|
529
|
+
} = {}) {
|
|
530
|
+
const stagedPath = resolve(output);
|
|
531
|
+
const rawPackageName = (await readJsonObject(join(root, "package.json")))?.name;
|
|
532
|
+
const sources = await readConfiguredPackages(settingsPath);
|
|
533
|
+
const { registered, stale } = await classifySources({ sources, settingsPath, stagedPath, rawPackageName });
|
|
534
|
+
|
|
535
|
+
// The same sequence a human would run by hand, in the order the installer
|
|
536
|
+
// runs it: drop the superseded sources, then register the staged package.
|
|
537
|
+
const manualCommands = [...stale.map(({ path }) => `${piBin} remove ${path}`), `${piBin} install ${stagedPath}`];
|
|
538
|
+
|
|
539
|
+
const summary = { output: stagedPath, piBin, settingsPath, registered: false, removed: [], commands: [], dryRun };
|
|
540
|
+
for (const { path } of stale) summary.commands.push(["remove", path]);
|
|
541
|
+
if (!registered) summary.commands.push(["install", stagedPath]);
|
|
542
|
+
|
|
543
|
+
console.log(`Registering the staged pi package with pi (via ${piBin})…`);
|
|
544
|
+
|
|
545
|
+
if (dryRun) {
|
|
546
|
+
console.log(` would read: ${settingsPath} (install-vs-no-op and stale raw-repo sources)`);
|
|
547
|
+
for (const { path } of stale) console.log(` would run: ${piBin} remove ${path}`);
|
|
548
|
+
if (registered) console.log(` already configured: ${stagedPath}`);
|
|
549
|
+
else console.log(` would run: ${piBin} install ${stagedPath}`);
|
|
550
|
+
console.log("(dry-run — no pi state written)");
|
|
551
|
+
summary.registered = registered;
|
|
552
|
+
return summary;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Nothing to change: do not spawn the CLI at all (a re-run is a true no-op).
|
|
556
|
+
if (summary.commands.length === 0) {
|
|
557
|
+
console.log(` ✓ Already configured — ${stagedPath} (no-op)`);
|
|
558
|
+
summary.registered = true;
|
|
559
|
+
return summary;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const probe = await probePi(piBin);
|
|
563
|
+
if (!probe.ok) {
|
|
564
|
+
warn(
|
|
565
|
+
`pi CLI "${piBin}" ${probe.detail} — the package is staged at ${stagedPath} but NOT registered with pi. ` +
|
|
566
|
+
`Complete registration manually with:\n` +
|
|
567
|
+
manualCommands.map((command) => ` ${command}`).join("\n"),
|
|
568
|
+
);
|
|
569
|
+
if (requireRegister) {
|
|
570
|
+
throw new Error("registration required (--require-register) but the pi CLI cannot drive it — run the commands above and re-run");
|
|
328
571
|
}
|
|
572
|
+
return summary;
|
|
329
573
|
}
|
|
330
|
-
|
|
331
|
-
|
|
574
|
+
|
|
575
|
+
for (const { path } of stale) {
|
|
576
|
+
try {
|
|
577
|
+
console.log(` ${piBin} remove ${path}`);
|
|
578
|
+
await runPiCommand(piBin, ["remove", path]);
|
|
579
|
+
summary.removed.push(path);
|
|
580
|
+
} catch (err) {
|
|
581
|
+
// Two configured sources both load, so a source we cannot drop means the
|
|
582
|
+
// registration failed — installing now would leave pi with a raw repo
|
|
583
|
+
// checkout and the staged package at once, and every re-run would repeat
|
|
584
|
+
// the same non-converging cycle. Fail instead of reporting success.
|
|
585
|
+
throw new Error(
|
|
586
|
+
`${err.message} — the superseded source is still configured: ${path}. ` +
|
|
587
|
+
`Remove it with "${piBin} remove ${path}" and re-run`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
332
590
|
}
|
|
333
|
-
|
|
591
|
+
|
|
592
|
+
// Execute exactly the plan built above: an already-configured staged path is
|
|
593
|
+
// not re-installed (it may still reach here because a stale source had to be
|
|
594
|
+
// removed first), so the commands in `summary` are the commands that ran.
|
|
595
|
+
if (!registered) {
|
|
596
|
+
if (!(await isDirectory(stagedPath))) {
|
|
597
|
+
throw new Error(`nothing staged at ${stagedPath} — run "agent-cortex install pi" without --dry-run first`);
|
|
598
|
+
}
|
|
599
|
+
console.log(` ${piBin} install ${stagedPath}`);
|
|
600
|
+
await runPiCommand(piBin, ["install", stagedPath]);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
summary.registered = true;
|
|
604
|
+
console.log(
|
|
605
|
+
registered
|
|
606
|
+
? `Superseded source(s) removed — ${stagedPath} stays registered (restart pi to apply).`
|
|
607
|
+
: `Registered — verify with "${piBin} list" and "${piBin} config" (restart pi to apply).`,
|
|
608
|
+
);
|
|
609
|
+
return summary;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Local package sources configured in pi's settings file, in order. pi stores
|
|
614
|
+
* each entry as a source string or a `{ source, … }` filter object.
|
|
615
|
+
*/
|
|
616
|
+
async function readConfiguredPackages(settingsPath) {
|
|
617
|
+
const settings = await readJsonObject(settingsPath);
|
|
618
|
+
const packages = settings?.packages;
|
|
619
|
+
if (!Array.isArray(packages)) return [];
|
|
620
|
+
return packages.map(packageSource).filter((source) => source !== undefined);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function packageSource(entry) {
|
|
624
|
+
if (typeof entry === "string") return entry;
|
|
625
|
+
return typeof entry?.source === "string" ? entry.source : undefined;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function isLocalSource(source) {
|
|
629
|
+
return !NON_LOCAL_PREFIXES.some((prefix) => source.trim().startsWith(prefix));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Resolve a configured source the way pi does: relative to the directory
|
|
634
|
+
* holding the settings file, with `~` expanded.
|
|
635
|
+
*/
|
|
636
|
+
function resolveSourcePath(source, settingsDir) {
|
|
637
|
+
const trimmed = source.trim();
|
|
638
|
+
const expanded = trimmed === "~" ? homedir() : trimmed.startsWith("~/") ? join(homedir(), trimmed.slice(2)) : trimmed;
|
|
639
|
+
return resolve(settingsDir, expanded);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Split the configured local sources into "the staged package is already
|
|
644
|
+
* registered" and "a raw agent-cortex checkout superseded by it". A source
|
|
645
|
+
* whose directory is gone is left alone: a missing path cannot be told apart
|
|
646
|
+
* from a temporarily absent one, and removing it could drop a package the user
|
|
647
|
+
* still wants.
|
|
648
|
+
*/
|
|
649
|
+
async function classifySources({ sources, settingsPath, stagedPath, rawPackageName }) {
|
|
650
|
+
const settingsDir = dirname(settingsPath);
|
|
651
|
+
const stale = [];
|
|
652
|
+
let registered = false;
|
|
653
|
+
for (const source of sources) {
|
|
654
|
+
if (!isLocalSource(source)) continue;
|
|
655
|
+
const path = resolveSourcePath(source, settingsDir);
|
|
656
|
+
if (path === stagedPath) {
|
|
657
|
+
registered = true;
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
if (rawPackageName === undefined) continue;
|
|
661
|
+
if ((await readJsonObject(join(path, "package.json")))?.name === rawPackageName) stale.push({ path });
|
|
662
|
+
}
|
|
663
|
+
return { registered, stale };
|
|
334
664
|
}
|
|
335
665
|
|
|
336
666
|
/**
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
* local package paths against its agent dir, so this keeps the checkout (or npm
|
|
340
|
-
* install) registered wherever the settings file lands.
|
|
667
|
+
* Probe whether the `pi` CLI can be run at all, with output suppressed.
|
|
668
|
+
* Resolves `{ ok: true }` or `{ ok: false, detail }` naming the failure.
|
|
341
669
|
*/
|
|
342
|
-
function
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
670
|
+
function probePi(piBin) {
|
|
671
|
+
return new Promise((resolve) => {
|
|
672
|
+
const child = spawn(piBin, ["--version"], { stdio: "ignore" });
|
|
673
|
+
child.on("error", (err) =>
|
|
674
|
+
resolve({ ok: false, detail: `cannot be run (${err.message}) — is pi installed and on PATH?` }),
|
|
675
|
+
);
|
|
676
|
+
child.on("exit", (code, signal) =>
|
|
677
|
+
resolve(
|
|
678
|
+
code === 0
|
|
679
|
+
? { ok: true }
|
|
680
|
+
: { ok: false, detail: code === null ? `was terminated by ${signal}` : `"${piBin} --version" exited ${code}` },
|
|
681
|
+
),
|
|
682
|
+
);
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Run one pi package command, inheriting its output; rejects on failure. */
|
|
687
|
+
function runPiCommand(piBin, args) {
|
|
688
|
+
const label = `${piBin} ${args.join(" ")}`;
|
|
689
|
+
return new Promise((resolve, reject) => {
|
|
690
|
+
const child = spawn(piBin, args, { stdio: "inherit" });
|
|
691
|
+
child.on("error", (err) => reject(new Error(`failed to run "${label}": ${err.message}`)));
|
|
692
|
+
child.on("exit", (code, signal) => {
|
|
693
|
+
if (code === 0) resolve();
|
|
694
|
+
else if (code !== null) reject(new Error(`"${label}" exited ${code}`));
|
|
695
|
+
else reject(new Error(`"${label}" was terminated by ${signal}`));
|
|
696
|
+
});
|
|
351
697
|
});
|
|
352
|
-
if (!resolved) out.unshift({ source: repoSource, skills: [] });
|
|
353
|
-
return out;
|
|
354
698
|
}
|
|
355
699
|
|
|
700
|
+
// ─── Legacy sweep ────────────────────────────────────────────────────────────
|
|
701
|
+
|
|
356
702
|
/**
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
703
|
+
* Delete the artifacts the pre-staged-model installer rendered into pi's own
|
|
704
|
+
* user directory (~/.pi/agent), now that the staged package is the single
|
|
705
|
+
* source.
|
|
706
|
+
*
|
|
707
|
+
* This is a one-shot migration: it runs once per pi user directory and records
|
|
708
|
+
* the finished migration in `recordPath` — outside that directory — after which
|
|
709
|
+
* it reports nothing and removes nothing. Only artifacts this package can name
|
|
710
|
+
* are removed, and only on that single run:
|
|
711
|
+
*
|
|
712
|
+
* agents/<name>.agent.md `<name>` is a composable agent the package
|
|
713
|
+
* root ships, or a LEGACY_ORPHAN_AGENTS entry
|
|
714
|
+
* skills/<group>/<name>/ the package root ships `skills/<group>/<name>`,
|
|
715
|
+
* or it is a LEGACY_ORPHAN_SKILLS entry
|
|
716
|
+
* skills/<group>/ a group dir this run emptied
|
|
717
|
+
* .agent-cortex-install.json a path record listing the copies removed
|
|
718
|
+
*
|
|
719
|
+
* pi's extensions directory is not touched at all: no installer on this flow
|
|
720
|
+
* wrote there, so its contents are the user's. Everything not removed — config
|
|
721
|
+
* files, the user's own agents and skills — is reported as `kept`.
|
|
722
|
+
*
|
|
723
|
+
* Nothing is deleted and no record is written when `dryRun` is set; the plan is
|
|
724
|
+
* reported instead. A pi user directory that does not exist is left uncreated.
|
|
725
|
+
*
|
|
726
|
+
* The migration is recorded only when every artifact was removed: an entry
|
|
727
|
+
* written over a partial migration would claim a directory is the user's while
|
|
728
|
+
* the copies it exists to delete are still in it, so the removal failures are
|
|
729
|
+
* warned about and the next install retries them. A directory the sweep cannot
|
|
730
|
+
* read is a failure of the same kind, not an empty one — it cannot attribute
|
|
731
|
+
* what it cannot see, so it warns and leaves the migration unrecorded rather
|
|
732
|
+
* than claiming a directory it never looked at. Recording is best-effort — a
|
|
733
|
+
* record that cannot be written warns rather than failing an install whose
|
|
734
|
+
* registration already succeeded.
|
|
735
|
+
*
|
|
736
|
+
* @param {object} options
|
|
737
|
+
* @param {string} [options.root] Package root that names the rendered copies
|
|
738
|
+
* @param {string} options.piUserDir pi's own user directory (~/.pi/agent)
|
|
739
|
+
* @param {string} [options.recordPath] Migration record (default ~/.agent-cortex/legacy-sweep.json)
|
|
740
|
+
* @param {boolean} [options.dryRun] Report the plan without deleting anything
|
|
741
|
+
* @param {(msg: string) => void} [options.warn] Warning sink
|
|
742
|
+
* @returns {Promise<{ removed: string[], kept: string[] }>} Absolute paths
|
|
361
743
|
*/
|
|
362
|
-
async function
|
|
363
|
-
|
|
364
|
-
|
|
744
|
+
export async function sweepLegacyPiUserDirs({
|
|
745
|
+
root = PACKAGE_ROOT,
|
|
746
|
+
piUserDir,
|
|
747
|
+
recordPath = DEFAULT_LEGACY_SWEEP_RECORD,
|
|
748
|
+
dryRun = false,
|
|
749
|
+
warn = DEFAULT_WARN,
|
|
750
|
+
} = {}) {
|
|
751
|
+
const removed = [];
|
|
752
|
+
const kept = [];
|
|
753
|
+
const failed = [];
|
|
365
754
|
|
|
366
|
-
if (
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
755
|
+
if (!(await isDirectory(piUserDir))) return { removed, kept };
|
|
756
|
+
if (await hasMigrated({ recordPath, piUserDir, warn })) return { removed, kept };
|
|
757
|
+
|
|
758
|
+
// Commands that record what happened and either delete the path or keep it.
|
|
759
|
+
const sweep = async (path, isLegacy) => {
|
|
760
|
+
if (!isLegacy) {
|
|
761
|
+
kept.push(path);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (dryRun) {
|
|
765
|
+
removed.push(path);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
await rm(path, { recursive: true, force: true });
|
|
770
|
+
removed.push(path);
|
|
771
|
+
} catch (err) {
|
|
772
|
+
warn(`could not remove the legacy artifact ${path}: ${err.message}`);
|
|
773
|
+
kept.push(path);
|
|
774
|
+
failed.push(path);
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
const shippedAgents = await shippedAgentNames(root);
|
|
779
|
+
const shippedSkills = await shippedSkillPaths(root);
|
|
780
|
+
|
|
781
|
+
// A directory this run cannot read is not an empty one. The sweep cannot
|
|
782
|
+
// attribute what it cannot see, so it warns and counts the directory as a
|
|
783
|
+
// failure — which is what keeps the migration unrecorded, so the next install
|
|
784
|
+
// retries once the permissions allow it.
|
|
785
|
+
const unreadable = (dir) => {
|
|
786
|
+
warn(
|
|
787
|
+
`could not list ${dir} — the legacy sweep cannot tell its contents from the user's, ` +
|
|
788
|
+
`so the migration is not recorded and runs again on the next install`,
|
|
789
|
+
);
|
|
790
|
+
failed.push(dir);
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
// Entries of a sweep site: none for a directory that is absent, and a recorded
|
|
794
|
+
// failure for one that exists but cannot be read.
|
|
795
|
+
const list = async (dir) => {
|
|
796
|
+
const entries = await listDir(dir);
|
|
797
|
+
if (entries === null) unreadable(dir);
|
|
798
|
+
return entries ?? [];
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
if (shippedAgents === null || shippedSkills === null) {
|
|
802
|
+
// Attribution resolves from the package root, so an unreadable tree there
|
|
803
|
+
// makes every copy look like a file this package does not ship: sweeping now
|
|
804
|
+
// would record a migration that removed nothing.
|
|
805
|
+
unreadable(join(root, shippedAgents === null ? "agents" : "skills"));
|
|
806
|
+
return { removed, kept };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const agentsDir = join(piUserDir, "agents");
|
|
810
|
+
const agentFileSuffix = ".agent.md";
|
|
811
|
+
for (const entry of await list(agentsDir)) {
|
|
812
|
+
const name = entry.endsWith(agentFileSuffix) ? entry.slice(0, -agentFileSuffix.length) : null;
|
|
813
|
+
const isLegacy = name !== null && (shippedAgents.has(name) || LEGACY_ORPHAN_AGENTS.includes(name));
|
|
814
|
+
await sweep(join(agentsDir, entry), isLegacy);
|
|
372
815
|
}
|
|
373
|
-
|
|
374
|
-
|
|
816
|
+
|
|
817
|
+
const skillsDir = join(piUserDir, "skills");
|
|
818
|
+
for (const group of await list(skillsDir)) {
|
|
819
|
+
const groupDir = join(skillsDir, group);
|
|
820
|
+
// A symlinked group is a door out of pi's user directory: walking through
|
|
821
|
+
// it would delete the link target's contents, which is not this sweep's.
|
|
822
|
+
if (!(await isRealDirectory(groupDir))) {
|
|
823
|
+
kept.push(groupDir);
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
const removedBefore = removed.length;
|
|
827
|
+
for (const entry of await list(groupDir)) {
|
|
828
|
+
const skill = `${group}/${entry}`;
|
|
829
|
+
await sweep(join(groupDir, entry), shippedSkills.has(skill) || LEGACY_ORPHAN_SKILLS.includes(skill));
|
|
830
|
+
}
|
|
831
|
+
// A group dir this run emptied held the old rendering's copies, so it is
|
|
832
|
+
// part of that rendering. One that was already empty is not: the user may
|
|
833
|
+
// have made it. One that cannot be re-listed is not empty either — removing
|
|
834
|
+
// it would delete whatever is inside it unseen.
|
|
835
|
+
if (removed.length > removedBefore) {
|
|
836
|
+
const remaining = await listDir(groupDir);
|
|
837
|
+
if (remaining === null) unreadable(groupDir);
|
|
838
|
+
else if (remaining.length === 0) await sweep(groupDir, true);
|
|
839
|
+
}
|
|
375
840
|
}
|
|
376
841
|
|
|
377
|
-
await
|
|
378
|
-
|
|
842
|
+
await sweep(join(piUserDir, LEGACY_INSTALL_MANIFEST), await isFile(join(piUserDir, LEGACY_INSTALL_MANIFEST)));
|
|
843
|
+
|
|
844
|
+
if (!dryRun) {
|
|
845
|
+
if (failed.length === 0) {
|
|
846
|
+
await recordMigration({ recordPath, piUserDir, warn });
|
|
847
|
+
} else {
|
|
848
|
+
warn(
|
|
849
|
+
`the legacy sweep of ${piUserDir} is incomplete — ${failed.length} artifact(s) could not be removed, ` +
|
|
850
|
+
`so the migration is not recorded and runs again on the next install`,
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return { removed, kept };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Whether this pi user directory has already been migrated.
|
|
860
|
+
*
|
|
861
|
+
* A record that exists but cannot be read is treated as "already migrated":
|
|
862
|
+
* the state is ambiguous and a deletion is not recoverable, so the sweep never
|
|
863
|
+
* runs on an unreadable record.
|
|
864
|
+
*/
|
|
865
|
+
async function hasMigrated({ recordPath, piUserDir, warn }) {
|
|
866
|
+
let raw;
|
|
379
867
|
try {
|
|
380
|
-
await
|
|
381
|
-
await rename(tmp, target);
|
|
868
|
+
raw = await readFile(recordPath, "utf-8");
|
|
382
869
|
} catch (err) {
|
|
383
|
-
|
|
384
|
-
|
|
870
|
+
if (err.code === "ENOENT") return false;
|
|
871
|
+
warn(`could not read the legacy sweep record ${recordPath}: ${err.message} — the migration will not run again`);
|
|
872
|
+
return true;
|
|
385
873
|
}
|
|
386
|
-
|
|
874
|
+
const entries = parseRecordEntries(raw);
|
|
875
|
+
if (entries === null) {
|
|
876
|
+
warn(`ignoring the unreadable legacy sweep record ${recordPath} — the migration will not run again`);
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
return entries[resolve(piUserDir)] !== undefined;
|
|
387
880
|
}
|
|
388
881
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
882
|
+
/**
|
|
883
|
+
* Add this pi user directory to the migration record. Existing entries are kept
|
|
884
|
+
* — another pi config may have migrated already and must not be re-run. A write
|
|
885
|
+
* failure warns instead of throwing: registration already succeeded, so an
|
|
886
|
+
* unwritable record is not a reason to fail the install.
|
|
887
|
+
*/
|
|
888
|
+
async function recordMigration({ recordPath, piUserDir, warn }) {
|
|
889
|
+
const entries = await readRecordEntries(recordPath);
|
|
890
|
+
entries[resolve(piUserDir)] = { version: 1, migratedAt: new Date().toISOString() };
|
|
891
|
+
try {
|
|
892
|
+
await mkdir(dirname(recordPath), { recursive: true });
|
|
893
|
+
await writeFile(recordPath, `${JSON.stringify(entries, null, 2)}\n`);
|
|
894
|
+
} catch (err) {
|
|
895
|
+
warn(
|
|
896
|
+
`could not record the legacy sweep at ${recordPath}: ${err.message} — a later install may run the migration again`,
|
|
897
|
+
);
|
|
394
898
|
}
|
|
395
|
-
const parsed = tryParseJsonObject(raw);
|
|
396
|
-
if (parsed === null) throw new Error(`${filePath} is not a JSON object`);
|
|
397
|
-
return parsed;
|
|
398
899
|
}
|
|
399
900
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
warn(`${filePath} is not a JSON object — ignoring its contents`);
|
|
901
|
+
/** Record entries keyed by pi user directory; `{}` when it is absent or unusable. */
|
|
902
|
+
async function readRecordEntries(recordPath) {
|
|
903
|
+
try {
|
|
904
|
+
return parseRecordEntries(await readFile(recordPath, "utf-8")) ?? {};
|
|
905
|
+
} catch {
|
|
406
906
|
return {};
|
|
407
907
|
}
|
|
408
|
-
return parsed;
|
|
409
908
|
}
|
|
410
909
|
|
|
411
|
-
|
|
910
|
+
/** The record's `{ "<piUserDir>": entry }` object, or null when the text is not one. */
|
|
911
|
+
function parseRecordEntries(raw) {
|
|
912
|
+
let parsed;
|
|
412
913
|
try {
|
|
413
|
-
|
|
414
|
-
|
|
914
|
+
parsed = JSON.parse(raw);
|
|
915
|
+
} catch {
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** Whether `path` is a directory without following a symlink to one. */
|
|
922
|
+
async function isRealDirectory(path) {
|
|
923
|
+
try {
|
|
924
|
+
return (await lstat(path)).isDirectory();
|
|
415
925
|
} catch {
|
|
926
|
+
return false;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Composable agent names the package root ships (`agents/<name>/agent.md`), or null when it cannot be listed. */
|
|
931
|
+
async function shippedAgentNames(root) {
|
|
932
|
+
const entries = await listDir(join(root, "agents"));
|
|
933
|
+
if (entries === null) return null;
|
|
934
|
+
const names = new Set();
|
|
935
|
+
for (const entry of entries) {
|
|
936
|
+
if (await isFile(join(root, "agents", entry, "agent.md"))) names.add(entry);
|
|
937
|
+
}
|
|
938
|
+
return names;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** Skill paths the package root ships (`skills/<group>/<name>/SKILL.md`), or null when it cannot be listed. */
|
|
942
|
+
async function shippedSkillPaths(root) {
|
|
943
|
+
const groups = await listDir(join(root, "skills"));
|
|
944
|
+
if (groups === null) return null;
|
|
945
|
+
const paths = new Set();
|
|
946
|
+
for (const group of groups) {
|
|
947
|
+
const names = await listDir(join(root, "skills", group));
|
|
948
|
+
if (names === null) return null;
|
|
949
|
+
for (const name of names) {
|
|
950
|
+
if (await isFile(join(root, "skills", group, name, "SKILL.md"))) paths.add(`${group}/${name}`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return paths;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Sorted names in `dir`: none for a directory that does not exist, `null` for
|
|
958
|
+
* one that exists but cannot be read.
|
|
959
|
+
*
|
|
960
|
+
* The two must not collapse into one answer: the sweep records a finished
|
|
961
|
+
* migration, so a listing failure read as an empty directory would record one
|
|
962
|
+
* that removed nothing and never retry it.
|
|
963
|
+
*/
|
|
964
|
+
async function listDir(dir) {
|
|
965
|
+
try {
|
|
966
|
+
return (await readdir(dir)).sort();
|
|
967
|
+
} catch (err) {
|
|
968
|
+
if (err.code === "ENOENT" || err.code === "ENOTDIR") return [];
|
|
416
969
|
return null;
|
|
417
970
|
}
|
|
418
971
|
}
|
|
419
972
|
|
|
420
|
-
|
|
421
|
-
|
|
973
|
+
// ─── Manifest ────────────────────────────────────────────────────────────────
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Build the staged package's pi manifest.
|
|
977
|
+
*
|
|
978
|
+
* pi's package rules do the loading: conventional dirs are declared explicitly
|
|
979
|
+
* so the bundled packages' resources are included alongside ./extensions and
|
|
980
|
+
* ./skills (one pi-visible source, no tokenised repo copies).
|
|
981
|
+
*/
|
|
982
|
+
async function buildManifest({ root, dependencies }) {
|
|
983
|
+
const pkg = await readJsonObject(join(root, "package.json"));
|
|
984
|
+
return {
|
|
985
|
+
"//": GENERATED_MARKER,
|
|
986
|
+
name: STAGED_NAME,
|
|
987
|
+
version: typeof pkg?.version === "string" ? pkg.version : "0.0.0",
|
|
988
|
+
private: true,
|
|
989
|
+
description: "agent-cortex pi resources — generated by `agent-cortex install pi`",
|
|
990
|
+
type: "module",
|
|
991
|
+
keywords: ["pi-package"],
|
|
992
|
+
pi: {
|
|
993
|
+
extensions: ["./extensions", ...dependencies.resources.extensions],
|
|
994
|
+
skills: ["./skills", ...dependencies.resources.skills],
|
|
995
|
+
},
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function writeManifest({ output, manifest }) {
|
|
1000
|
+
const path = join(output, "package.json");
|
|
1001
|
+
await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
1002
|
+
return { path, manifest };
|
|
422
1003
|
}
|
|
423
1004
|
|
|
424
|
-
|
|
1005
|
+
/**
|
|
1006
|
+
* Prove the manifest references real resources before reporting a success: a
|
|
1007
|
+
* staged package whose declared extension or skill path does not exist would
|
|
1008
|
+
* load silently short of the tools the agents expect.
|
|
1009
|
+
*/
|
|
1010
|
+
async function verifyManifestResources({ output, manifest }) {
|
|
1011
|
+
const declared = [...manifest.pi.extensions, ...manifest.pi.skills];
|
|
1012
|
+
const missing = [];
|
|
1013
|
+
for (const rel of declared) {
|
|
1014
|
+
if (!(await pathExists(join(output, rel)))) missing.push(rel);
|
|
1015
|
+
}
|
|
1016
|
+
if (missing.length > 0) {
|
|
1017
|
+
throw new Error(`staged pi manifest references missing resources: ${missing.join(", ")}`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ─── Copying ─────────────────────────────────────────────────────────────────
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Recursively copy a tree, optionally transforming file contents. `.md` files
|
|
1025
|
+
* go through `transform` when supplied; everything else is copied verbatim.
|
|
1026
|
+
* `node_modules` and symlinks are never copied.
|
|
1027
|
+
*/
|
|
1028
|
+
async function copyTree(src, dest, { dryRun = false, transform = null } = {}) {
|
|
1029
|
+
let files = 0;
|
|
1030
|
+
let md = 0;
|
|
1031
|
+
for (const entry of await readdir(src, { withFileTypes: true })) {
|
|
1032
|
+
if (entry.name === "node_modules") continue;
|
|
1033
|
+
if (entry.isSymbolicLink()) continue;
|
|
1034
|
+
const from = join(src, entry.name);
|
|
1035
|
+
const to = join(dest, entry.name);
|
|
1036
|
+
if (entry.isDirectory()) {
|
|
1037
|
+
const stats = await copyTree(from, to, { dryRun, transform });
|
|
1038
|
+
files += stats.files;
|
|
1039
|
+
md += stats.md;
|
|
1040
|
+
} else if (entry.isFile()) {
|
|
1041
|
+
files += 1;
|
|
1042
|
+
const isMarkdown = entry.name.endsWith(".md");
|
|
1043
|
+
if (isMarkdown) md += 1;
|
|
1044
|
+
if (dryRun) continue;
|
|
1045
|
+
await mkdir(dest, { recursive: true });
|
|
1046
|
+
if (isMarkdown && transform !== null) await writeFile(to, transform(await readFile(from, "utf-8")));
|
|
1047
|
+
else await copyFile(from, to);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return { files, md };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// ─── Small helpers ───────────────────────────────────────────────────────────
|
|
1054
|
+
|
|
1055
|
+
/** `./skills/` → `skills` — a package resource path relative to its own root. */
|
|
1056
|
+
function stripRelative(entry) {
|
|
1057
|
+
return entry.replace(/^\.\//, "").replace(/\/$/, "");
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function asStringList(value) {
|
|
1061
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async function readJsonObject(filePath) {
|
|
1065
|
+
let raw;
|
|
425
1066
|
try {
|
|
426
|
-
|
|
1067
|
+
raw = await readFile(filePath, "utf-8");
|
|
427
1068
|
} catch (err) {
|
|
428
1069
|
if (err.code === "ENOENT") return null;
|
|
429
1070
|
throw err;
|
|
430
1071
|
}
|
|
1072
|
+
try {
|
|
1073
|
+
const parsed = JSON.parse(raw);
|
|
1074
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
1075
|
+
} catch {
|
|
1076
|
+
return null;
|
|
1077
|
+
}
|
|
431
1078
|
}
|
|
432
1079
|
|
|
433
|
-
async function
|
|
1080
|
+
async function pathExists(filePath) {
|
|
434
1081
|
try {
|
|
435
|
-
|
|
1082
|
+
await stat(filePath);
|
|
1083
|
+
return true;
|
|
436
1084
|
} catch {
|
|
437
1085
|
return false;
|
|
438
1086
|
}
|