skillrepo 3.2.0 → 4.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 (39) hide show
  1. package/README.md +90 -27
  2. package/bin/skillrepo.mjs +5 -5
  3. package/package.json +1 -1
  4. package/src/commands/add.mjs +21 -6
  5. package/src/commands/get.mjs +20 -4
  6. package/src/commands/init-session-sync.mjs +1 -1
  7. package/src/commands/init.mjs +435 -111
  8. package/src/commands/list.mjs +1 -1
  9. package/src/commands/remove.mjs +10 -2
  10. package/src/commands/uninstall.mjs +1 -1
  11. package/src/commands/update.mjs +15 -3
  12. package/src/lib/agent-registry.mjs +215 -0
  13. package/src/lib/cli-config.mjs +146 -44
  14. package/src/lib/detect-agents.mjs +112 -0
  15. package/src/lib/file-write.mjs +162 -77
  16. package/src/lib/mcp-merge.mjs +17 -36
  17. package/src/lib/mergers/gitignore.mjs +55 -28
  18. package/src/lib/paths.mjs +27 -25
  19. package/src/lib/prompt-multiselect.mjs +324 -0
  20. package/src/lib/sync.mjs +18 -19
  21. package/src/test/commands/add.test.mjs +18 -3
  22. package/src/test/commands/init-picker.test.mjs +144 -0
  23. package/src/test/commands/init.test.mjs +228 -42
  24. package/src/test/commands/remove.test.mjs +4 -1
  25. package/src/test/commands/update.test.mjs +13 -3
  26. package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
  27. package/src/test/e2e/cli-commands.test.mjs +39 -13
  28. package/src/test/integration/file-write.integration.test.mjs +31 -10
  29. package/src/test/lib/agent-registry.test.mjs +215 -0
  30. package/src/test/lib/cli-config.test.mjs +222 -38
  31. package/src/test/lib/detect-agents.test.mjs +336 -0
  32. package/src/test/lib/file-write-placement.test.mjs +264 -0
  33. package/src/test/lib/file-write.test.mjs +231 -30
  34. package/src/test/lib/mcp-merge.test.mjs +23 -15
  35. package/src/test/lib/paths.test.mjs +53 -17
  36. package/src/test/lib/prompt-multiselect.test.mjs +448 -0
  37. package/src/test/lib/sync.test.mjs +157 -0
  38. package/src/lib/detect-ides.mjs +0 -44
  39. package/src/test/detect-ides.test.mjs +0 -65
@@ -0,0 +1,631 @@
1
+ /**
2
+ * E2E permutation matrix for the `--agent` flag (placement epic
3
+ * #876, #1234, #1235, #1236).
4
+ *
5
+ * These tests verify USER INTENT, not implementation internals. Each
6
+ * case spawns the real CLI binary, then asserts on the user-visible
7
+ * post-state: which directories exist on disk, what their SKILL.md
8
+ * frontmatter actually says, what `.gitignore` contains, and the
9
+ * process exit code + stderr text.
10
+ *
11
+ * Unit tests under `src/test/lib/file-write-placement.test.mjs`
12
+ * already cover the data layer (`placementTargetsFor` return values).
13
+ * The gap this file closes is the user-layer: "after I run init
14
+ * with these flags, what do I see in my project on disk?"
15
+ *
16
+ * The harness pattern is the same as the read-commands E2E file —
17
+ * shared `createMockServer`, per-test mkdtemp for cwd + HOME, and a
18
+ * `runCli` helper that always resolves with stdout/stderr/status.
19
+ */
20
+
21
+ import { describe, it, before, after, beforeEach, afterEach } from "node:test";
22
+ import assert from "node:assert/strict";
23
+ import {
24
+ mkdtempSync,
25
+ rmSync,
26
+ existsSync,
27
+ readFileSync,
28
+ } from "node:fs";
29
+ import { join, dirname, resolve } from "node:path";
30
+ import { tmpdir } from "node:os";
31
+ import { execFile } from "node:child_process";
32
+ import { fileURLToPath } from "node:url";
33
+
34
+ import { createMockServer } from "./mock-server.mjs";
35
+
36
+ const __dirname = dirname(fileURLToPath(import.meta.url));
37
+ const CLI_BIN = resolve(__dirname, "../../../bin/skillrepo.mjs");
38
+ const VALID_KEY = "sk_live_test_e2e";
39
+
40
+ let server;
41
+ let port;
42
+ let tempDir;
43
+ let tempHome;
44
+
45
+ /**
46
+ * Build a `SyncSkill`-shaped object the mock server's library route
47
+ * returns. The SKILL.md content is a real frontmatter block so the
48
+ * frontmatter-validity assertions in each test are meaningful.
49
+ */
50
+ function makeSkill(owner, name, content = `body of ${name}`) {
51
+ return {
52
+ owner,
53
+ name,
54
+ version: "1.0.0",
55
+ description: `${name} description`,
56
+ files: [
57
+ {
58
+ path: "SKILL.md",
59
+ content: `---\nname: ${name}\ndescription: ${name} description\n---\n\n${content}\n`,
60
+ sha256: "x",
61
+ size: 100,
62
+ contentType: "text/markdown",
63
+ },
64
+ ],
65
+ updatedAt: "2025-01-01T00:00:00Z",
66
+ };
67
+ }
68
+
69
+ function runCli(args, extraEnv = {}) {
70
+ return new Promise((resolve) => {
71
+ execFile(
72
+ process.execPath,
73
+ [CLI_BIN, ...args],
74
+ {
75
+ cwd: tempDir,
76
+ encoding: "utf-8",
77
+ timeout: 15_000,
78
+ env: {
79
+ ...process.env,
80
+ HOME: tempHome,
81
+ NO_COLOR: "1",
82
+ NODE_NO_WARNINGS: "1",
83
+ SKILLREPO_ACCESS_KEY: "",
84
+ SKILLREPO_TIMEOUT_MS: "5000",
85
+ ...extraEnv,
86
+ },
87
+ },
88
+ (err, stdout, stderr) => {
89
+ resolve({
90
+ stdout: stdout ?? "",
91
+ stderr: stderr ?? "",
92
+ status: err ? err.code ?? 1 : 0,
93
+ });
94
+ },
95
+ );
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Seed two skills the mock server's library endpoint returns. Two
101
+ * skills (rather than one) makes sure the assertions catch the
102
+ * common bug of "first skill writes correctly, second falls through
103
+ * to a wrong target."
104
+ */
105
+ function seedTwoSkills() {
106
+ server.setLibraryResponse({
107
+ skills: [
108
+ makeSkill("alice", "pdf-helper"),
109
+ makeSkill("bob", "code-review"),
110
+ ],
111
+ removals: [],
112
+ syncedAt: "x",
113
+ });
114
+ }
115
+
116
+ const SEEDED_NAMES = ["pdf-helper", "code-review"];
117
+
118
+ /** Read a SKILL.md file and assert it has a valid `name:`/`description:` frontmatter block. */
119
+ function assertValidSkillFile(filePath, expectedName) {
120
+ assert.ok(existsSync(filePath), `expected skill file at ${filePath}`);
121
+ const content = readFileSync(filePath, "utf-8");
122
+ // Frontmatter fence must be present.
123
+ assert.match(content, /^---\r?\n/, `frontmatter fence missing in ${filePath}`);
124
+ // `name:` must match the seeded skill.
125
+ assert.match(
126
+ content,
127
+ new RegExp(`\\nname:\\s*${expectedName}\\b`),
128
+ `name field missing or wrong in ${filePath}`,
129
+ );
130
+ // `description:` must be present.
131
+ assert.match(
132
+ content,
133
+ /\ndescription:\s*\S/,
134
+ `description field missing in ${filePath}`,
135
+ );
136
+ }
137
+
138
+ describe("CLI E2E — --agent permutation matrix", () => {
139
+ before(async () => {
140
+ server = createMockServer({});
141
+ port = await server.start();
142
+ });
143
+
144
+ after(async () => {
145
+ if (server) await server.stop();
146
+ });
147
+
148
+ beforeEach(() => {
149
+ tempDir = mkdtempSync(join(tmpdir(), "cli-e2e-agent-"));
150
+ tempHome = mkdtempSync(join(tmpdir(), "cli-e2e-home-"));
151
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
152
+ server.setEtag(null);
153
+ server.clearSkillResponses();
154
+ server.setSearchResponse({ skills: [], pagination: { total: 0, limit: 20, offset: 0 } });
155
+ server.clearAddResponses();
156
+ server.clearRemoveResponses();
157
+ });
158
+
159
+ afterEach(() => {
160
+ if (tempDir) rmSync(tempDir, { recursive: true, force: true });
161
+ if (tempHome) rmSync(tempHome, { recursive: true, force: true });
162
+ });
163
+
164
+ // ── init permutations (project scope) ──────────────────────────────
165
+
166
+ it("init --agent claude writes Claude-only placement", async () => {
167
+ seedTwoSkills();
168
+ const r = await runCli([
169
+ "init",
170
+ "--key", VALID_KEY,
171
+ "--url", `http://127.0.0.1:${port}`,
172
+ "--yes",
173
+ "--agent", "claude",
174
+ ]);
175
+ assert.equal(r.status, 0, `stderr: ${r.stderr}`);
176
+
177
+ // Config saved under tempHome
178
+ assert.ok(existsSync(join(tempHome, ".claude", "skillrepo", "config.json")));
179
+
180
+ // Each seeded skill is on disk under .claude/skills/<name>/SKILL.md
181
+ for (const name of SEEDED_NAMES) {
182
+ assertValidSkillFile(join(tempDir, ".claude", "skills", name, "SKILL.md"), name);
183
+ }
184
+
185
+ // .gitignore protects the Claude path but NOT the agents path
186
+ const gi = readFileSync(join(tempDir, ".gitignore"), "utf-8");
187
+ assert.match(gi, /(^|\n)\.claude\/skills\/(\r?\n|$)/, `.gitignore missing .claude/skills/: ${gi}`);
188
+ assert.doesNotMatch(gi, /(^|\n)\.agents\/skills\/(\r?\n|$)/, `.gitignore should not contain .agents/skills/: ${gi}`);
189
+
190
+ // No agents-cohort directory created
191
+ assert.ok(!existsSync(join(tempDir, ".agents", "skills")), ".agents/skills/ should not exist for claude-only");
192
+ });
193
+
194
+ it("init --agent agents writes agents-cohort placement only", async () => {
195
+ seedTwoSkills();
196
+ const r = await runCli([
197
+ "init",
198
+ "--key", VALID_KEY,
199
+ "--url", `http://127.0.0.1:${port}`,
200
+ "--yes",
201
+ "--agent", "agents",
202
+ ]);
203
+ assert.equal(r.status, 0, `stderr: ${r.stderr}`);
204
+
205
+ for (const name of SEEDED_NAMES) {
206
+ assertValidSkillFile(join(tempDir, ".agents", "skills", name, "SKILL.md"), name);
207
+ }
208
+
209
+ const gi = readFileSync(join(tempDir, ".gitignore"), "utf-8");
210
+ assert.match(gi, /(^|\n)\.agents\/skills\/(\r?\n|$)/, `.gitignore missing .agents/skills/: ${gi}`);
211
+ assert.doesNotMatch(gi, /(^|\n)\.claude\/skills\/(\r?\n|$)/, `.gitignore should not contain .claude/skills/: ${gi}`);
212
+
213
+ assert.ok(!existsSync(join(tempDir, ".claude", "skills")), ".claude/skills/ should not exist for agents-only");
214
+ });
215
+
216
+ it("init --agent claude,agents writes both placements with identical content", async () => {
217
+ seedTwoSkills();
218
+ const r = await runCli([
219
+ "init",
220
+ "--key", VALID_KEY,
221
+ "--url", `http://127.0.0.1:${port}`,
222
+ "--yes",
223
+ "--agent", "claude,agents",
224
+ ]);
225
+ assert.equal(r.status, 0, `stderr: ${r.stderr}`);
226
+
227
+ for (const name of SEEDED_NAMES) {
228
+ const claudePath = join(tempDir, ".claude", "skills", name, "SKILL.md");
229
+ const agentsPath = join(tempDir, ".agents", "skills", name, "SKILL.md");
230
+ assertValidSkillFile(claudePath, name);
231
+ assertValidSkillFile(agentsPath, name);
232
+ // Identical content across the two targets
233
+ assert.equal(
234
+ readFileSync(claudePath, "utf-8"),
235
+ readFileSync(agentsPath, "utf-8"),
236
+ `SKILL.md content should be identical between claude and agents targets for ${name}`,
237
+ );
238
+ }
239
+
240
+ const gi = readFileSync(join(tempDir, ".gitignore"), "utf-8");
241
+ assert.match(gi, /(^|\n)\.claude\/skills\/(\r?\n|$)/);
242
+ assert.match(gi, /(^|\n)\.agents\/skills\/(\r?\n|$)/);
243
+ });
244
+
245
+ it("init --agent none configures credentials but writes no skills", async () => {
246
+ seedTwoSkills();
247
+ const r = await runCli([
248
+ "init",
249
+ "--key", VALID_KEY,
250
+ "--url", `http://127.0.0.1:${port}`,
251
+ "--yes",
252
+ "--agent", "none",
253
+ ]);
254
+ assert.equal(r.status, 0, `stderr: ${r.stderr}`);
255
+
256
+ // Config still happens
257
+ assert.ok(existsSync(join(tempHome, ".claude", "skillrepo", "config.json")));
258
+ // Gitignore still happens — at minimum, .env.local must be in it
259
+ const giPath = join(tempDir, ".gitignore");
260
+ assert.ok(existsSync(giPath), ".gitignore should exist");
261
+ const gi = readFileSync(giPath, "utf-8");
262
+ assert.match(gi, /(^|\n)\.env\.local(\r?\n|$)/, `.gitignore missing .env.local: ${gi}`);
263
+
264
+ // No skill placement directories created
265
+ assert.ok(!existsSync(join(tempDir, ".claude", "skills")), ".claude/skills/ must not be written for --agent none");
266
+ assert.ok(!existsSync(join(tempDir, ".agents", "skills")), ".agents/skills/ must not be written for --agent none");
267
+ });
268
+
269
+ it("init --agent cursor (vendor alias) writes to .agents/skills, not .cursor/skills", async () => {
270
+ seedTwoSkills();
271
+ const r = await runCli([
272
+ "init",
273
+ "--key", VALID_KEY,
274
+ "--url", `http://127.0.0.1:${port}`,
275
+ "--yes",
276
+ "--agent", "cursor",
277
+ ]);
278
+ assert.equal(r.status, 0, `stderr: ${r.stderr}`);
279
+
280
+ for (const name of SEEDED_NAMES) {
281
+ assertValidSkillFile(join(tempDir, ".agents", "skills", name, "SKILL.md"), name);
282
+ }
283
+ // The product decision: Cursor uses the cohort `.agents/skills/`,
284
+ // NOT a per-vendor `.cursor/skills/` directory.
285
+ assert.ok(
286
+ !existsSync(join(tempDir, ".cursor", "skills")),
287
+ ".cursor/skills/ must not exist — Cursor placement uses .agents/skills/",
288
+ );
289
+ });
290
+
291
+ it("init --agent claude-code (kebab alias) writes to .claude/skills", async () => {
292
+ seedTwoSkills();
293
+ const r = await runCli([
294
+ "init",
295
+ "--key", VALID_KEY,
296
+ "--url", `http://127.0.0.1:${port}`,
297
+ "--yes",
298
+ "--agent", "claude-code",
299
+ ]);
300
+ assert.equal(r.status, 0, `stderr: ${r.stderr}`);
301
+
302
+ for (const name of SEEDED_NAMES) {
303
+ assertValidSkillFile(join(tempDir, ".claude", "skills", name, "SKILL.md"), name);
304
+ }
305
+ assert.ok(!existsSync(join(tempDir, ".agents", "skills")), ".agents/skills/ should not exist for claude-code alias");
306
+ });
307
+
308
+ it("init --agent gemini,codex,cline dedupes to a single .agents/skills/<name> per skill", async () => {
309
+ seedTwoSkills();
310
+ const r = await runCli([
311
+ "init",
312
+ "--key", VALID_KEY,
313
+ "--url", `http://127.0.0.1:${port}`,
314
+ "--yes",
315
+ "--agent", "gemini,codex,cline",
316
+ ]);
317
+ assert.equal(r.status, 0, `stderr: ${r.stderr}`);
318
+
319
+ for (const name of SEEDED_NAMES) {
320
+ assertValidSkillFile(join(tempDir, ".agents", "skills", name, "SKILL.md"), name);
321
+ }
322
+ // No per-vendor directories — these three vendors all collapse
323
+ // onto the cohort `.agents/skills/` target.
324
+ assert.ok(!existsSync(join(tempDir, ".gemini", "skills")), ".gemini/skills/ should not exist");
325
+ assert.ok(!existsSync(join(tempDir, ".codex", "skills")), ".codex/skills/ should not exist");
326
+ assert.ok(!existsSync(join(tempDir, ".cline", "skills")), ".cline/skills/ should not exist");
327
+ });
328
+
329
+ // ── init --global permutations (personal scope) ────────────────────
330
+
331
+ it("init --global --agent claude writes to ~/.claude/skills/, not project", async () => {
332
+ seedTwoSkills();
333
+ const r = await runCli([
334
+ "init",
335
+ "--key", VALID_KEY,
336
+ "--url", `http://127.0.0.1:${port}`,
337
+ "--yes",
338
+ "--global",
339
+ "--agent", "claude",
340
+ "--json",
341
+ ]);
342
+ assert.equal(r.status, 0, `stderr: ${r.stderr}`);
343
+
344
+ for (const name of SEEDED_NAMES) {
345
+ assertValidSkillFile(
346
+ join(tempHome, ".claude", "skills", name, "SKILL.md"),
347
+ name,
348
+ );
349
+ }
350
+ // Global writes do not pollute the project directory
351
+ assert.ok(
352
+ !existsSync(join(tempDir, ".claude", "skills")),
353
+ "project .claude/skills/ should not exist when init runs with --global",
354
+ );
355
+ // Positive: --global --agent claude IS Claude-targeted, so the
356
+ // session-sync flow must run (the action is anything BUT
357
+ // "not-applicable"). Whether the hook merge succeeds depends on
358
+ // whether a global skillrepo is on PATH (environment-dependent —
359
+ // CI runners don't have one; local dev machines often do), so we
360
+ // assert on the attempt, not the file. Pairs with the windsurf
361
+ // negative assertion that locks action === "not-applicable".
362
+ const summary = JSON.parse(r.stdout);
363
+ assert.notEqual(
364
+ summary.sessionSync.action,
365
+ "not-applicable",
366
+ "--global --agent claude is Claude-targeted; sessionSync must attempt the install",
367
+ );
368
+ });
369
+
370
+ it("init --global --agent windsurf writes to ~/.codeium/windsurf/skills (vendor-specific)", async () => {
371
+ seedTwoSkills();
372
+ const r = await runCli([
373
+ "init",
374
+ "--key", VALID_KEY,
375
+ "--url", `http://127.0.0.1:${port}`,
376
+ "--yes",
377
+ "--global",
378
+ "--agent", "windsurf",
379
+ ]);
380
+ assert.equal(r.status, 0, `stderr: ${r.stderr}`);
381
+
382
+ // Windsurf has a vendor-specific personal skills path.
383
+ for (const name of SEEDED_NAMES) {
384
+ assertValidSkillFile(
385
+ join(tempHome, ".codeium", "windsurf", "skills", name, "SKILL.md"),
386
+ name,
387
+ );
388
+ }
389
+ // Windsurf's globalTarget is windsurfGlobal, NOT agentsGlobal.
390
+ assert.ok(
391
+ !existsSync(join(tempHome, ".agents", "skills")),
392
+ "~/.agents/skills/ should not exist — Windsurf personal scope is ~/.codeium/windsurf/skills/",
393
+ );
394
+ // The Claude SessionStart hook is Claude Code-specific. A user
395
+ // running `--global --agent windsurf` did NOT pick Claude — the
396
+ // hook must not install. Locks the fix from manual E2E sweep.
397
+ // Assert via --json so the test is environment-independent (file
398
+ // presence depends on whether a global skillrepo is on PATH;
399
+ // sessionSync.action === "not-applicable" is the load-bearing
400
+ // signal that the install path was correctly skipped).
401
+ assert.ok(
402
+ !existsSync(join(tempHome, ".claude", "settings.local.json")),
403
+ "~/.claude/settings.local.json must NOT be installed for --global --agent windsurf",
404
+ );
405
+ });
406
+
407
+ it("init --global --agent copilot rejects (no personal scope)", async () => {
408
+ seedTwoSkills();
409
+ const r = await runCli([
410
+ "init",
411
+ "--key", VALID_KEY,
412
+ "--url", `http://127.0.0.1:${port}`,
413
+ "--yes",
414
+ "--global",
415
+ "--agent", "copilot",
416
+ ]);
417
+ // The user passed an invalid combination; init must fail loudly.
418
+ assert.equal(r.status, 5, `expected exit 5 for copilot+global, got ${r.status}; stderr=${r.stderr}; stdout=${r.stdout}`);
419
+ assert.match(
420
+ r.stderr,
421
+ /does not support global scope|copilot/i,
422
+ `stderr should explain copilot has no global scope: ${r.stderr}`,
423
+ );
424
+ // No skill files should land anywhere when the combination is rejected.
425
+ assert.ok(!existsSync(join(tempHome, ".claude", "skills")), "no skills should be written for rejected combination");
426
+ assert.ok(!existsSync(join(tempHome, ".agents", "skills")), "no skills should be written for rejected combination");
427
+ assert.ok(!existsSync(join(tempHome, ".codeium", "windsurf", "skills")), "no skills should be written for rejected combination");
428
+ });
429
+
430
+ // ── update permutations (after a seeded init) ──────────────────────
431
+
432
+ it("update --agent claude is idempotent after init --agent claude", async () => {
433
+ seedTwoSkills();
434
+ const initR = await runCli([
435
+ "init",
436
+ "--key", VALID_KEY,
437
+ "--url", `http://127.0.0.1:${port}`,
438
+ "--yes",
439
+ "--agent", "claude",
440
+ ]);
441
+ assert.equal(initR.status, 0, `init stderr: ${initR.stderr}`);
442
+
443
+ // Snapshot the SKILL.md content after init.
444
+ const skillPath = join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md");
445
+ const before = readFileSync(skillPath, "utf-8");
446
+
447
+ // Re-run update — should be a no-op (or at worst, a re-write with
448
+ // identical bytes). In either case: exit 0 and content unchanged.
449
+ const updateR = await runCli([
450
+ "update",
451
+ "--key", VALID_KEY,
452
+ "--url", `http://127.0.0.1:${port}`,
453
+ "--agent", "claude",
454
+ ]);
455
+ assert.equal(updateR.status, 0, `update stderr: ${updateR.stderr}`);
456
+
457
+ const after = readFileSync(skillPath, "utf-8");
458
+ assert.equal(after, before, "SKILL.md content should be unchanged after re-running update");
459
+ assertValidSkillFile(skillPath, "pdf-helper");
460
+ });
461
+
462
+ it("update --agent agents after init --agent claude adds the agents placement without disturbing claude", async () => {
463
+ seedTwoSkills();
464
+ const initR = await runCli([
465
+ "init",
466
+ "--key", VALID_KEY,
467
+ "--url", `http://127.0.0.1:${port}`,
468
+ "--yes",
469
+ "--agent", "claude",
470
+ ]);
471
+ assert.equal(initR.status, 0, `init stderr: ${initR.stderr}`);
472
+
473
+ // Pre-state: claude path exists, agents path does not.
474
+ assert.ok(existsSync(join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md")));
475
+ assert.ok(!existsSync(join(tempDir, ".agents", "skills")));
476
+
477
+ const claudeBefore = readFileSync(
478
+ join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md"),
479
+ "utf-8",
480
+ );
481
+
482
+ // ETag served from a fresh state may make update return 304;
483
+ // clear it so update fetches fresh and writes to the new target.
484
+ server.setEtag(null);
485
+
486
+ const updateR = await runCli([
487
+ "update",
488
+ "--key", VALID_KEY,
489
+ "--url", `http://127.0.0.1:${port}`,
490
+ "--agent", "agents",
491
+ ]);
492
+ assert.equal(updateR.status, 0, `update stderr: ${updateR.stderr}`);
493
+
494
+ // agents placement now exists for both seeded skills.
495
+ for (const name of SEEDED_NAMES) {
496
+ assertValidSkillFile(join(tempDir, ".agents", "skills", name, "SKILL.md"), name);
497
+ }
498
+ // claude placement is unchanged.
499
+ const claudeAfter = readFileSync(
500
+ join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md"),
501
+ "utf-8",
502
+ );
503
+ assert.equal(
504
+ claudeAfter,
505
+ claudeBefore,
506
+ "claude SKILL.md must remain unchanged when update targets a different cohort",
507
+ );
508
+ });
509
+
510
+ // ── failure-mode tests ─────────────────────────────────────────────
511
+
512
+ it("init --agent foo rejects unknown agent token", async () => {
513
+ seedTwoSkills();
514
+ const r = await runCli([
515
+ "init",
516
+ "--key", VALID_KEY,
517
+ "--url", `http://127.0.0.1:${port}`,
518
+ "--yes",
519
+ "--agent", "foo",
520
+ ]);
521
+ assert.equal(r.status, 5, `expected exit 5; stderr=${r.stderr}`);
522
+ assert.match(
523
+ r.stderr,
524
+ /Unknown --agent target|foo/i,
525
+ `stderr should mention the unknown token: ${r.stderr}`,
526
+ );
527
+ // No skill files written.
528
+ assert.ok(!existsSync(join(tempDir, ".claude", "skills")));
529
+ assert.ok(!existsSync(join(tempDir, ".agents", "skills")));
530
+ });
531
+
532
+ it("init --agent claude,none rejects mutually-exclusive tokens", async () => {
533
+ seedTwoSkills();
534
+ const r = await runCli([
535
+ "init",
536
+ "--key", VALID_KEY,
537
+ "--url", `http://127.0.0.1:${port}`,
538
+ "--yes",
539
+ "--agent", "claude,none",
540
+ ]);
541
+ assert.equal(r.status, 5, `expected exit 5; stderr=${r.stderr}`);
542
+ assert.match(
543
+ r.stderr,
544
+ /cannot mix.*none|none.*cannot/i,
545
+ `stderr should explain none is mutually exclusive: ${r.stderr}`,
546
+ );
547
+ assert.ok(!existsSync(join(tempDir, ".claude", "skills")));
548
+ assert.ok(!existsSync(join(tempDir, ".agents", "skills")));
549
+ });
550
+
551
+ it("init --agent '' rejects empty value", async () => {
552
+ seedTwoSkills();
553
+ const r = await runCli([
554
+ "init",
555
+ "--key", VALID_KEY,
556
+ "--url", `http://127.0.0.1:${port}`,
557
+ "--yes",
558
+ "--agent", "",
559
+ ]);
560
+ assert.equal(r.status, 5, `expected exit 5; stderr=${r.stderr}`);
561
+ // The CLI should call out that --agent received nothing useful.
562
+ assert.match(
563
+ r.stderr,
564
+ /agent/i,
565
+ `stderr should mention --agent: ${r.stderr}`,
566
+ );
567
+ assert.ok(!existsSync(join(tempDir, ".claude", "skills")));
568
+ assert.ok(!existsSync(join(tempDir, ".agents", "skills")));
569
+ });
570
+
571
+ it("init --ide claude rejects the legacy flag with a v2→v3 migration hint", async () => {
572
+ seedTwoSkills();
573
+ const r = await runCli([
574
+ "init",
575
+ "--key", VALID_KEY,
576
+ "--url", `http://127.0.0.1:${port}`,
577
+ "--yes",
578
+ "--ide", "claude",
579
+ ]);
580
+ assert.equal(r.status, 5, `expected exit 5; stderr=${r.stderr}`);
581
+ assert.match(
582
+ r.stderr,
583
+ /--ide was renamed to --agent/,
584
+ `stderr should explain the v2→v3 rename: ${r.stderr}`,
585
+ );
586
+ assert.ok(!existsSync(join(tempDir, ".claude", "skills")));
587
+ assert.ok(!existsSync(join(tempDir, ".agents", "skills")));
588
+ });
589
+
590
+ // ── re-run idempotency ─────────────────────────────────────────────
591
+
592
+ it("init --agent claude twice in a row leaves a clean tree on disk", async () => {
593
+ seedTwoSkills();
594
+
595
+ const first = await runCli([
596
+ "init",
597
+ "--key", VALID_KEY,
598
+ "--url", `http://127.0.0.1:${port}`,
599
+ "--yes",
600
+ "--agent", "claude",
601
+ ]);
602
+ assert.equal(first.status, 0, `first init stderr: ${first.stderr}`);
603
+
604
+ const skillPath = join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md");
605
+ const before = readFileSync(skillPath, "utf-8");
606
+
607
+ const second = await runCli([
608
+ "init",
609
+ "--key", VALID_KEY,
610
+ "--url", `http://127.0.0.1:${port}`,
611
+ "--yes",
612
+ "--agent", "claude",
613
+ ]);
614
+ assert.equal(second.status, 0, `second init stderr: ${second.stderr}`);
615
+
616
+ // SKILL.md content unchanged after the second run.
617
+ const after = readFileSync(skillPath, "utf-8");
618
+ assert.equal(after, before, "second init must not alter SKILL.md content");
619
+
620
+ // No leftover .tmp / .old siblings under the placement root.
621
+ const placementRoot = join(tempDir, ".claude", "skills");
622
+ const { readdirSync } = await import("node:fs");
623
+ const entries = readdirSync(placementRoot);
624
+ const stale = entries.filter((e) => e.endsWith(".tmp") || e.endsWith(".old"));
625
+ assert.deepEqual(
626
+ stale,
627
+ [],
628
+ `placement root should have no .tmp/.old leftovers; found: ${stale.join(", ")}`,
629
+ );
630
+ });
631
+ });