@jaybeeuu/agent-cortex 1.42.0 → 2.0.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.
Files changed (83) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +78 -8
  2. package/.github/workflows/ci.yml +51 -38
  3. package/AGENTS.md +32 -17
  4. package/CHANGELOG.md +182 -0
  5. package/README.md +167 -126
  6. package/agents/README.md +26 -27
  7. package/agents/ralph/agent.md +7 -10
  8. package/agents/ralph/claude/polling.md +1 -1
  9. package/bin/agent-cortex.mjs +46 -44
  10. package/bin/installers/claude.mjs +2 -2
  11. package/bin/installers/pi.mjs +885 -237
  12. package/docs/claude-hooks.md +5 -4
  13. package/docs/commit-signing.md +231 -0
  14. package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +10 -1
  15. package/docs/ideas/extension-manifests.md +5 -0
  16. package/docs/research/disable-model-invocation.md +82 -0
  17. package/docs/research/skill-design-research.md +2 -2
  18. package/docs/skills/skill-anatomy.md +5 -2
  19. package/docs/technical-direction/harness-config.md +30 -14
  20. package/docs/technical-direction/migration-execution-labels.md +17 -9
  21. package/docs/user-preferences.md +2 -2
  22. package/extensions/agent-modes/README.md +3 -3
  23. package/extensions/agent-modes/discover.test.ts +19 -0
  24. package/extensions/agent-modes/discover.ts +31 -9
  25. package/extensions/session-start/README.md +38 -0
  26. package/extensions/session-start/index.test.ts +122 -0
  27. package/extensions/session-start/index.ts +27 -0
  28. package/extensions/session-start/package.json +19 -0
  29. package/extensions/session-start/tsconfig.json +12 -0
  30. package/extensions/skill-stats/pnpm-lock.yaml +329 -0
  31. package/extensions/subagent/README.md +3 -3
  32. package/extensions/subagent/agents.test.ts +176 -0
  33. package/extensions/subagent/agents.ts +101 -11
  34. package/extensions/subagent/index.ts +2 -1
  35. package/extensions/subagent/package.json +1 -1
  36. package/lib/cli.mjs +40 -16
  37. package/package.json +8 -9
  38. package/pi/settings.json +1 -7
  39. package/pnpm-workspace.yaml +6 -0
  40. package/scripts/check-changeset.mjs +42 -0
  41. package/scripts/lib/compose-agent.mjs +10 -10
  42. package/scripts/lib/release-gates.mjs +61 -0
  43. package/scripts/lib/verify-worker-tree.mjs +107 -0
  44. package/scripts/resolve-publish-command.mjs +17 -0
  45. package/skills/planning/create-task/scripts/pnpm-lock.yaml +342 -0
  46. package/skills/productivity/validate-agent-dir/scripts/pnpm-lock.yaml +329 -0
  47. package/skills/workflow/git-workflow/SKILL.md +35 -1
  48. package/skills/workflow/ralph/REFERENCE.md +20 -4
  49. package/skills/workflow/ralph/SKILL.md +9 -6
  50. package/skills/workflow/run-pipeline-stage/SKILL.md +11 -4
  51. package/skills/workflow/run-pipeline-stage/playbooks/code.md +2 -0
  52. package/skills/workflow/run-pipeline-stage/playbooks/document.md +3 -0
  53. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -0
  54. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +11 -0
  55. package/skills/workflow/run-pipeline-stage/scripts/pnpm-lock.yaml +342 -0
  56. package/test/cli.test.mjs +37 -39
  57. package/test/helpers/fake-pi.mjs +132 -0
  58. package/test/pi-installer.test.mjs +345 -286
  59. package/test/pi-register.test.mjs +342 -0
  60. package/test/pi-sweep.test.mjs +619 -0
  61. package/test/release-gates.test.mjs +190 -0
  62. package/test/verify-worker-tree.test.mjs +120 -0
  63. package/token-map.README.md +16 -18
  64. package/token-map.json +29 -33
  65. package/agents/plan/copilot/frontmatter.json +0 -6
  66. package/agents/plan.agent.md +0 -59
  67. package/agents/ralph/copilot/frontmatter.json +0 -6
  68. package/agents/ralph/copilot/polling.md +0 -62
  69. package/agents/ralph-plan/copilot/frontmatter.json +0 -6
  70. package/agents/ralph-plan.agent.md +0 -126
  71. package/agents/ralph.agent.md +0 -331
  72. package/agents/strategy/copilot/frontmatter.json +0 -6
  73. package/agents/strategy.agent.md +0 -112
  74. package/bin/installers/copilot.mjs +0 -124
  75. package/extensions/session-start.ts +0 -44
  76. package/hooks.json +0 -10
  77. package/lib/pi-packages.mjs +0 -169
  78. package/plugin.json +0 -35
  79. package/scripts/build-copilot-agents.mjs +0 -30
  80. package/scripts/install-instructions.sh +0 -82
  81. package/scripts/sync-plugin-version.sh +0 -17
  82. package/test/copilot-installer.test.mjs +0 -308
  83. package/test/pi-packages.test.mjs +0 -295
@@ -1,308 +0,0 @@
1
- // Integration tests for `agent-cortex install copilot`: the install-time
2
- // generator for the Copilot CLI flat agent files (agents/*.agent.md). All tests
3
- // install into mkdtemp dirs so the real repo agents/ is never touched.
4
-
5
- import { describe, it } from "node:test";
6
- import assert from "node:assert/strict";
7
- import { execFile } from "node:child_process";
8
- import { mkdtemp, mkdir, writeFile, rm, readFile, readdir, stat } from "node:fs/promises";
9
- import { tmpdir } from "node:os";
10
- import { fileURLToPath } from "node:url";
11
- import { join, dirname } from "node:path";
12
- import { installCopilot } from "../bin/installers/copilot.mjs";
13
-
14
- const __dirname = dirname(fileURLToPath(import.meta.url));
15
- const ROOT = join(__dirname, "..");
16
- const CLI_PATH = join(ROOT, "bin", "agent-cortex.mjs");
17
-
18
- /** True when a path exists (stat succeeds). */
19
- async function pathExists(p) {
20
- try {
21
- await stat(p);
22
- return true;
23
- } catch {
24
- return false;
25
- }
26
- }
27
-
28
- function runCli(args) {
29
- return new Promise((resolve) => {
30
- execFile(process.execPath, [CLI_PATH, ...args], (error, stdout, stderr) => {
31
- resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
32
- });
33
- });
34
- }
35
-
36
- // ─── Fixtures ────────────────────────────────────────────────────────────────
37
-
38
- function tokenMap() {
39
- return {
40
- name: "fixture token map",
41
- version: 1,
42
- harnesses: ["copilot"],
43
- tools: {
44
- bash: { copilot: "bash" },
45
- view: { copilot: "view" },
46
- task: { copilot: "task" },
47
- ask_user: { copilot: "ask_user" },
48
- },
49
- paths: {
50
- plugin_root: { copilot: "~/.copilot/installed-plugins/_direct/agent-cortex" },
51
- agents_dir: { copilot: { base: "plugin_root", relative: "agents" } },
52
- },
53
- };
54
- }
55
-
56
- async function makeFixture() {
57
- const root = await mkdtemp(join(tmpdir(), "copilot-installer-"));
58
- return {
59
- root,
60
- output: join(root, "generated"),
61
- cleanup: async () => rm(root, { recursive: true, force: true }),
62
- };
63
- }
64
-
65
- async function writeFixture(fx, relPath, content) {
66
- const p = join(fx.root, relPath);
67
- await mkdir(dirname(p), { recursive: true });
68
- await writeFile(p, content);
69
- }
70
-
71
- /** Seed a minimal but realistic package tree (agents + token-map). */
72
- async function seedCopilotPackage(fx, opts = {}) {
73
- const { agents = {} } = opts;
74
- await writeFixture(fx, "token-map.json", JSON.stringify(tokenMap()));
75
- for (const [name, def] of Object.entries(agents)) {
76
- await writeFixture(fx, `agents/${name}/agent.md`, def.body ?? `# ${name}\nBody of ${name}.`);
77
- await writeFixture(
78
- fx,
79
- `agents/${name}/copilot/frontmatter.json`,
80
- JSON.stringify(
81
- def.frontmatter ?? {
82
- name: `agent-cortex:${name}`,
83
- description: `Description of ${name}`,
84
- tools: ["bash", "view"],
85
- argumentHint: `Hint for ${name}`,
86
- },
87
- ),
88
- );
89
- for (const [section, content] of Object.entries(def.sections ?? {})) {
90
- await writeFixture(fx, `agents/${name}/copilot/${section}.md`, content);
91
- }
92
- }
93
- }
94
-
95
- /** Deterministic snapshot of a directory: relative paths + byte content. */
96
- async function snapshot(dir) {
97
- const entries = [];
98
- const walk = async (p) => {
99
- for (const e of (await readdir(p, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
100
- const full = join(p, e.name);
101
- if (e.isDirectory()) {
102
- entries.push([e.name, "dir"]);
103
- await walk(full);
104
- } else entries.push([e.name, "file", await readFile(full, "utf-8")]);
105
- }
106
- };
107
- await walk(dir);
108
- return entries;
109
- }
110
-
111
- // ─── Agent composition ───────────────────────────────────────────────────────
112
-
113
- describe("installCopilot — agent composition", () => {
114
- it("writes one flat <name>.agent.md per composable agent dir with copilot frontmatter and no leftover tokens", async () => {
115
- const fx = await makeFixture();
116
- try {
117
- await seedCopilotPackage(fx, {
118
- agents: {
119
- alpha: {
120
- body: "# alpha\nUse {{TOOL:view}} to read.\n{{SECTION:polling}}\nRead {{PATH:agents_dir}}.",
121
- sections: { polling: "Spawn with {{TOOL:task}}." },
122
- frontmatter: {
123
- name: "agent-cortex:alpha",
124
- description: "Reads things.",
125
- tools: ["bash", "view"],
126
- argumentHint: "Read something",
127
- },
128
- },
129
- },
130
- });
131
-
132
- const result = await installCopilot({ root: fx.root, output: fx.output });
133
-
134
- const file = join(fx.output, "alpha.agent.md");
135
- assert.ok(await pathExists(file), "flat agent file written");
136
- const content = await readFile(file, "utf-8");
137
- assert.ok(content.startsWith("---\n"), "yaml frontmatter");
138
- assert.ok(content.includes("# GENERATED from agents/alpha/ by scripts/build-copilot-agents.mjs — DO NOT EDIT."));
139
- // Committed flat-file field order: description, name, tools (YAML array), argument-hint.
140
- const order = ["description:", "name: \"agent-cortex:alpha\"", "tools: [", "argument-hint:"].map((s) => content.indexOf(s));
141
- assert.ok(order.every((i) => i !== -1), "all frontmatter fields present");
142
- assert.deepEqual([...order].sort((a, b) => a - b), order, "frontmatter fields in committed order");
143
- assert.match(content, /tools: \["bash", "view"\]/, "copilot tools kept as a YAML array (no translation)");
144
- assert.ok(content.includes("Use view to read."), "{{TOOL:view}} substituted");
145
- assert.ok(content.includes("Spawn with task."), "{{SECTION:polling}} composed from copilot/polling.md");
146
- assert.ok(!/{{/.test(content), "no literal tokens remain");
147
- assert.equal(result.agents.length, 1);
148
- assert.deepEqual(result.agents, ["alpha"]);
149
- assert.equal(result.output, fx.output);
150
- } finally {
151
- await fx.cleanup();
152
- }
153
- });
154
-
155
- it("skips non-composable entries in agents/ and never clobbers composable sources", async () => {
156
- const fx = await makeFixture();
157
- try {
158
- await seedCopilotPackage(fx, { agents: { alpha: { body: "# alpha" } } });
159
- await writeFixture(fx, "agents/README.md", "# agents dir docs\n");
160
-
161
- const result = await installCopilot({ root: fx.root, output: fx.output });
162
-
163
- assert.deepEqual((await readdir(fx.output)).sort(), ["alpha.agent.md"], "no README, no subdirectories written");
164
- assert.deepEqual(result.agents, ["alpha"]);
165
- // The composable source dir next to the generated file stays untouched.
166
- assert.ok(await pathExists(join(fx.root, "agents", "alpha", "agent.md")), "composable source preserved");
167
- assert.ok(await pathExists(join(fx.root, "agents", "alpha", "copilot", "frontmatter.json")), "harness dir preserved");
168
- } finally {
169
- await fx.cleanup();
170
- }
171
- });
172
-
173
- it("throws when a composable dir lacks copilot/frontmatter.json", async () => {
174
- const fx = await makeFixture();
175
- try {
176
- await seedCopilotPackage(fx, { agents: { alpha: { body: "# alpha" } } });
177
- await rm(join(fx.root, "agents", "alpha", "copilot"), { recursive: true, force: true });
178
- await assert.rejects(
179
- installCopilot({ root: fx.root, output: fx.output }),
180
- /without copilot\/frontmatter\.json/,
181
- );
182
- } finally {
183
- await fx.cleanup();
184
- }
185
- });
186
-
187
- it("defaults to the plugin-scanned agents/ dir of the package root (plugin.json \"agents\": \"agents/\")", async () => {
188
- const fx = await makeFixture();
189
- try {
190
- await seedCopilotPackage(fx, { agents: { alpha: { body: "# alpha" } } });
191
-
192
- const result = await installCopilot({ root: fx.root });
193
-
194
- assert.equal(result.output, join(fx.root, "agents"), "default output is the dir plugin.json scans");
195
- assert.ok(await pathExists(join(fx.root, "agents", "alpha.agent.md")), "flat file lands next to the composable dir");
196
- assert.ok(await pathExists(join(fx.root, "agents", "alpha", "agent.md")), "composable source untouched");
197
- } finally {
198
- await fx.cleanup();
199
- }
200
- });
201
-
202
- it("throws on an unknown tool token", async () => {
203
- const fx = await makeFixture();
204
- try {
205
- await seedCopilotPackage(fx, { agents: { alpha: { body: "# alpha\nUse {{TOOL:nope}} now." } } });
206
- await assert.rejects(installCopilot({ root: fx.root, output: fx.output }), /unknown tool token {{TOOL:nope}}/);
207
- } finally {
208
- await fx.cleanup();
209
- }
210
- });
211
- });
212
-
213
- // ─── Output / dry-run ────────────────────────────────────────────────────────
214
-
215
- describe("installCopilot — output & dry-run", () => {
216
- it("dry-run reports the plan without writing anything", async () => {
217
- const fx = await makeFixture();
218
- try {
219
- await seedCopilotPackage(fx, { agents: { alpha: { body: "# alpha" } } });
220
-
221
- const result = await installCopilot({ root: fx.root, output: fx.output, dryRun: true });
222
-
223
- assert.equal(result.dryRun, true);
224
- assert.equal(await pathExists(fx.output), false, "no output dir created in dry-run");
225
- assert.deepEqual(result.agents, ["alpha"], "planned files still reported");
226
- } finally {
227
- await fx.cleanup();
228
- }
229
- });
230
-
231
- it("is deterministic across repeated installs into the same output", async () => {
232
- const fx = await makeFixture();
233
- try {
234
- await seedCopilotPackage(fx, {
235
- agents: {
236
- alpha: { body: "# alpha\n{{SECTION:polling}}", sections: { polling: "Poll with {{TOOL:task}}." } },
237
- beta: { body: "# beta" },
238
- },
239
- });
240
-
241
- await installCopilot({ root: fx.root, output: fx.output });
242
- const first = await snapshot(fx.output);
243
- await installCopilot({ root: fx.root, output: fx.output });
244
- assert.deepEqual(await snapshot(fx.output), first);
245
- } finally {
246
- await fx.cleanup();
247
- }
248
- });
249
- });
250
-
251
- // ─── Integration against the real repo ───────────────────────────────────────
252
-
253
- describe("installCopilot — real repo integration", () => {
254
- it("regenerates byte-identical copies of the committed agents/*.agent.md files", async () => {
255
- const fx = await makeFixture();
256
- try {
257
- const result = await installCopilot({ root: ROOT, output: fx.output });
258
-
259
- assert.deepEqual(result.agents.sort(), ["plan", "ralph", "ralph-plan", "strategy"]);
260
- for (const name of result.agents) {
261
- const generated = await readFile(join(fx.output, `${name}.agent.md`), "utf-8");
262
- const committed = await readFile(join(ROOT, "agents", `${name}.agent.md`), "utf-8");
263
- assert.equal(generated, committed, `${name}.agent.md must match the committed flat file (CI drift check)`);
264
- }
265
- } finally {
266
- await fx.cleanup();
267
- }
268
- });
269
- });
270
-
271
- // ─── CLI wiring ──────────────────────────────────────────────────────────────
272
-
273
- describe("CLI integration", () => {
274
- it("exits 0 for install copilot --output <tmp> and writes the flat files", async () => {
275
- const fx = await makeFixture();
276
- try {
277
- const { exitCode, stdout } = await runCli(["install", "copilot", "--output", fx.output]);
278
- assert.equal(exitCode, 0);
279
- assert.ok(stdout.includes("copilot"), "prints the harness name");
280
- assert.ok(stdout.includes("Generated"), "prints the generation summary");
281
- assert.ok(await pathExists(join(fx.output, "ralph.agent.md")), "agent files written");
282
- assert.ok(await pathExists(join(fx.output, "plan.agent.md")));
283
- } finally {
284
- await fx.cleanup();
285
- }
286
- });
287
-
288
- it("exits 0 for install copilot --dry-run and writes nothing", async () => {
289
- const fx = await makeFixture();
290
- try {
291
- const { exitCode, stdout } = await runCli(["install", "copilot", "--output", fx.output, "--dry-run"]);
292
- assert.equal(exitCode, 0);
293
- assert.ok(/dry-run/i.test(stdout));
294
- assert.equal(await pathExists(fx.output), false, "no files written in dry-run");
295
- } finally {
296
- await fx.cleanup();
297
- }
298
- });
299
-
300
- it("exits 0 for install copilot (default output) without touching committed files", async () => {
301
- const before = await readFile(join(ROOT, "agents", "ralph.agent.md"), "utf-8");
302
- const { exitCode } = await runCli(["install", "copilot"]);
303
- assert.equal(exitCode, 0);
304
- // Default output regenerates the shared agents/ dir in place — output must
305
- // stay byte-identical to what is committed.
306
- assert.equal(await readFile(join(ROOT, "agents", "ralph.agent.md"), "utf-8"), before);
307
- });
308
- });
@@ -1,295 +0,0 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { mkdtemp, mkdir, writeFile, rm, chmod } from "node:fs/promises";
4
- import { join } from "node:path";
5
- import { tmpdir } from "node:os";
6
- import {
7
- loadRequiredPackages,
8
- npmSourceName,
9
- planPackageInstalls,
10
- provisionPiPackages,
11
- runPiInstall,
12
- } from "../lib/pi-packages.mjs";
13
-
14
- // ─── Fixtures ────────────────────────────────────────────────────────────────
15
-
16
- async function makePiRoot({ declared = [], installed = [], settingsRaw } = {}) {
17
- const root = await mkdtemp(join(tmpdir(), "pi-packages-"));
18
- if (settingsRaw !== undefined) {
19
- await writeFile(join(root, "settings.json"), settingsRaw);
20
- } else if (declared.length > 0) {
21
- await writeFile(join(root, "settings.json"), JSON.stringify({ packages: declared }));
22
- }
23
- for (const name of installed) {
24
- await mkdir(join(root, "npm", "node_modules", name), { recursive: true });
25
- }
26
- return { root, cleanup: async () => rm(root, { recursive: true, force: true }) };
27
- }
28
-
29
- /** A runner that records every source it is asked to install. */
30
- function recordingRunner({ results = {} } = {}) {
31
- const calls = [];
32
- const runner = async (source) => {
33
- calls.push(source);
34
- return results[source] ?? { ok: true, error: null };
35
- };
36
- return { runner, calls };
37
- }
38
-
39
- /** Run `fn` with PATH set to `pathValue`, restoring the previous PATH afterwards. */
40
- async function withPath(pathValue, fn) {
41
- const original = process.env.PATH;
42
- process.env.PATH = pathValue;
43
- try {
44
- return await fn();
45
- } finally {
46
- process.env.PATH = original;
47
- }
48
- }
49
-
50
- /** Write an executable `pi` stub with the given shell body; returns its dir and cleanup. */
51
- async function makePiStub(body) {
52
- const dir = await mkdtemp(join(tmpdir(), "pi-stub-"));
53
- const file = join(dir, "pi");
54
- await writeFile(file, `#!/bin/sh\n${body}\n`);
55
- await chmod(file, 0o755);
56
- return { dir, cleanup: async () => rm(dir, { recursive: true, force: true }) };
57
- }
58
-
59
- /** A directory with nothing on PATH, so any external command lookup fails. */
60
- async function makeEmptyPathDir() {
61
- const dir = await mkdtemp(join(tmpdir(), "pi-stub-empty-"));
62
- return { dir, cleanup: async () => rm(dir, { recursive: true, force: true }) };
63
- }
64
-
65
- // ─── Manifest ────────────────────────────────────────────────────────────────
66
-
67
- describe("loadRequiredPackages", () => {
68
- it("reads the declared pi packages from the package manifest", async () => {
69
- const root = await mkdtemp(join(tmpdir(), "pi-packages-manifest-"));
70
- try {
71
- await writeFile(
72
- join(root, "package.json"),
73
- JSON.stringify({ name: "x", pi: { extensions: ["./extensions"], packages: ["npm:pi-questions"] } }),
74
- );
75
- assert.deepEqual(await loadRequiredPackages(root), ["npm:pi-questions"]);
76
- } finally {
77
- await rm(root, { recursive: true, force: true });
78
- }
79
- });
80
-
81
- it("returns no packages when the manifest does not declare any", async () => {
82
- const root = await mkdtemp(join(tmpdir(), "pi-packages-manifest-"));
83
- try {
84
- await writeFile(join(root, "package.json"), JSON.stringify({ name: "x", pi: { skills: ["./skills"] } }));
85
- assert.deepEqual(await loadRequiredPackages(root), []);
86
- } finally {
87
- await rm(root, { recursive: true, force: true });
88
- }
89
- });
90
- });
91
-
92
- // ─── Source parsing ──────────────────────────────────────────────────────────
93
-
94
- describe("npmSourceName", () => {
95
- it("extracts the package name from npm sources, including scoped and pinned specs", () => {
96
- assert.equal(npmSourceName("npm:pi-questions"), "pi-questions");
97
- assert.equal(npmSourceName("npm:pi-web-access@0.10.7"), "pi-web-access");
98
- assert.equal(npmSourceName("npm:@scope/pkg@1.2.3"), "@scope/pkg");
99
- });
100
-
101
- it("returns null for non-npm sources it cannot verify locally", () => {
102
- assert.equal(npmSourceName("git:github.com/user/repo"), null);
103
- assert.equal(npmSourceName("./local/path"), null);
104
- });
105
- });
106
-
107
- // ─── Install plan ────────────────────────────────────────────────────────────
108
-
109
- describe("planPackageInstalls", () => {
110
- it("plans nothing when every required package is declared and installed", () => {
111
- assert.deepEqual(
112
- planPackageInstalls(["npm:pi-questions"], {
113
- declaredSources: ["npm:pi-questions"],
114
- installedNames: ["pi-questions"],
115
- }),
116
- [],
117
- );
118
- });
119
-
120
- it("plans a package that is declared but not installed", () => {
121
- assert.deepEqual(
122
- planPackageInstalls(["npm:pi-web-access@0.10.7"], {
123
- declaredSources: ["npm:pi-web-access@0.10.7"],
124
- installedNames: [],
125
- }),
126
- ["npm:pi-web-access@0.10.7"],
127
- );
128
- });
129
-
130
- it("plans a package that is installed but not declared, so pi will load it", () => {
131
- assert.deepEqual(
132
- planPackageInstalls(["npm:pi-questions"], {
133
- declaredSources: [],
134
- installedNames: ["pi-questions"],
135
- }),
136
- ["npm:pi-questions"],
137
- );
138
- });
139
- });
140
-
141
- // ─── Provisioning ────────────────────────────────────────────────────────────
142
-
143
- describe("runPiInstall", () => {
144
- it("degrades to a result when the pi CLI is not on PATH", async () => {
145
- const empty = await makeEmptyPathDir();
146
- try {
147
- const result = await withPath(empty.dir, () => runPiInstall("npm:pi-questions"));
148
- assert.deepEqual(result, { ok: false, error: "pi CLI not found on PATH" });
149
- } finally {
150
- await empty.cleanup();
151
- }
152
- });
153
-
154
- it("maps a successful pi install to ok", async () => {
155
- const stub = await makePiStub("exit 0");
156
- try {
157
- const result = await withPath(stub.dir, () => runPiInstall("npm:pi-questions"));
158
- assert.deepEqual(result, { ok: true, error: null });
159
- } finally {
160
- await stub.cleanup();
161
- }
162
- });
163
-
164
- it("maps a failing pi install to its stderr", async () => {
165
- const stub = await makePiStub('echo "offline: no registry" >&2\nexit 1');
166
- try {
167
- const result = await withPath(stub.dir, () => runPiInstall("npm:pi-questions"));
168
- assert.deepEqual(result, { ok: false, error: "offline: no registry" });
169
- } finally {
170
- await stub.cleanup();
171
- }
172
- });
173
- });
174
-
175
- describe("provisionPiPackages", () => {
176
- it("installs missing packages and skips the ones already present", async () => {
177
- const fx = await makePiRoot({ declared: ["npm:pi-questions"], installed: ["pi-questions"] });
178
- try {
179
- const { runner, calls } = await recordingRunner();
180
-
181
- const result = await provisionPiPackages({
182
- required: ["npm:pi-questions", "npm:pi-web-access@0.10.7"],
183
- piRoot: fx.root,
184
- runInstall: runner,
185
- });
186
-
187
- assert.deepEqual(calls, ["npm:pi-web-access@0.10.7"], "only the missing package is installed");
188
- assert.deepEqual(result.installed, ["npm:pi-web-access@0.10.7"]);
189
- assert.deepEqual(result.failed, []);
190
- } finally {
191
- await fx.cleanup();
192
- }
193
- });
194
-
195
- it("is idempotent — a second run with everything present installs nothing", async () => {
196
- const fx = await makePiRoot({
197
- declared: ["npm:pi-questions", "npm:pi-web-access@0.10.7"],
198
- installed: ["pi-questions", "pi-web-access"],
199
- });
200
- try {
201
- const { runner, calls } = await recordingRunner();
202
- const result = await provisionPiPackages({
203
- required: ["npm:pi-questions", "npm:pi-web-access@0.10.7"],
204
- piRoot: fx.root,
205
- runInstall: runner,
206
- });
207
- assert.deepEqual(calls, []);
208
- assert.deepEqual(result.installed, []);
209
- } finally {
210
- await fx.cleanup();
211
- }
212
- });
213
-
214
- it("reports a failed install and continues with the rest", async () => {
215
- const fx = await makePiRoot();
216
- try {
217
- const warnings = [];
218
- const { runner, calls } = await recordingRunner({
219
- results: { "npm:pi-questions": { ok: false, error: "offline" } },
220
- });
221
-
222
- const result = await provisionPiPackages({
223
- required: ["npm:pi-questions", "npm:pi-web-access@0.10.7"],
224
- piRoot: fx.root,
225
- runInstall: runner,
226
- warn: (msg) => warnings.push(msg),
227
- });
228
-
229
- assert.deepEqual(calls, ["npm:pi-questions", "npm:pi-web-access@0.10.7"], "failure does not abort the rest");
230
- assert.deepEqual(result.installed, ["npm:pi-web-access@0.10.7"]);
231
- assert.deepEqual(result.failed, [{ source: "npm:pi-questions", error: "offline" }]);
232
- assert.ok(warnings.some((w) => w.includes("npm:pi-questions")), "failure is surfaced as a warning");
233
- } finally {
234
- await fx.cleanup();
235
- }
236
- });
237
-
238
- it("uses the pi CLI runner by default and warns when it is missing", async () => {
239
- const fx = await makePiRoot();
240
- const empty = await makeEmptyPathDir();
241
- try {
242
- const warnings = [];
243
- const result = await withPath(empty.dir, () =>
244
- provisionPiPackages({
245
- required: ["npm:pi-questions"],
246
- piRoot: fx.root,
247
- warn: (msg) => warnings.push(msg),
248
- }),
249
- );
250
-
251
- assert.deepEqual(result.failed, [{ source: "npm:pi-questions", error: "pi CLI not found on PATH" }]);
252
- assert.ok(
253
- warnings.some((w) => w.includes("pi CLI not found on PATH")),
254
- "missing CLI is surfaced as a warning, not a thrown error",
255
- );
256
- } finally {
257
- await empty.cleanup();
258
- await fx.cleanup();
259
- }
260
- });
261
-
262
- it("dry-run reports the plan without installing anything", async () => {
263
- const fx = await makePiRoot();
264
- try {
265
- const { runner, calls } = await recordingRunner();
266
- const result = await provisionPiPackages({
267
- required: ["npm:pi-questions"],
268
- piRoot: fx.root,
269
- runInstall: runner,
270
- dryRun: true,
271
- });
272
- assert.deepEqual(calls, []);
273
- assert.deepEqual(result.planned, ["npm:pi-questions"]);
274
- assert.deepEqual(result.installed, []);
275
- } finally {
276
- await fx.cleanup();
277
- }
278
- });
279
-
280
- it("treats an unreadable settings file as no declarations rather than failing the install", async () => {
281
- const fx = await makePiRoot({ settingsRaw: "{ not json" });
282
- try {
283
- const { runner, calls } = await recordingRunner();
284
- const result = await provisionPiPackages({
285
- required: ["npm:pi-questions"],
286
- piRoot: fx.root,
287
- runInstall: runner,
288
- });
289
- assert.deepEqual(calls, ["npm:pi-questions"], "still attempts the install");
290
- assert.deepEqual(result.installed, ["npm:pi-questions"]);
291
- } finally {
292
- await fx.cleanup();
293
- }
294
- });
295
- });