skillrepo 3.2.0 → 4.1.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 (53) hide show
  1. package/README.md +137 -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-cohort-hooks.mjs +127 -0
  7. package/src/commands/init-session-sync.mjs +1 -1
  8. package/src/commands/init.mjs +480 -117
  9. package/src/commands/list.mjs +1 -1
  10. package/src/commands/remove.mjs +10 -2
  11. package/src/commands/uninstall.mjs +13 -2
  12. package/src/commands/update.mjs +112 -19
  13. package/src/lib/agent-hook-merge.mjs +203 -0
  14. package/src/lib/agent-registry.mjs +399 -0
  15. package/src/lib/artifact-registry.mjs +111 -2
  16. package/src/lib/cli-config.mjs +146 -44
  17. package/src/lib/detect-agents.mjs +112 -0
  18. package/src/lib/file-write.mjs +162 -77
  19. package/src/lib/fs-utils.mjs +16 -1
  20. package/src/lib/mcp-merge.mjs +17 -36
  21. package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
  22. package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
  23. package/src/lib/mergers/gitignore.mjs +55 -28
  24. package/src/lib/paths.mjs +27 -25
  25. package/src/lib/prompt-multiselect.mjs +324 -0
  26. package/src/lib/removers/agent-hooks.mjs +83 -0
  27. package/src/lib/sync.mjs +18 -19
  28. package/src/test/commands/add.test.mjs +18 -3
  29. package/src/test/commands/init-picker.test.mjs +144 -0
  30. package/src/test/commands/init.test.mjs +508 -41
  31. package/src/test/commands/remove.test.mjs +4 -1
  32. package/src/test/commands/update.test.mjs +148 -3
  33. package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
  34. package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
  35. package/src/test/e2e/cli-commands.test.mjs +39 -13
  36. package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
  37. package/src/test/integration/file-write.integration.test.mjs +31 -10
  38. package/src/test/lib/agent-hook-merge.test.mjs +172 -0
  39. package/src/test/lib/agent-registry.test.mjs +215 -0
  40. package/src/test/lib/artifact-registry.test.mjs +39 -0
  41. package/src/test/lib/cli-config.test.mjs +222 -38
  42. package/src/test/lib/detect-agents.test.mjs +336 -0
  43. package/src/test/lib/file-write-placement.test.mjs +264 -0
  44. package/src/test/lib/file-write.test.mjs +231 -30
  45. package/src/test/lib/mcp-merge.test.mjs +23 -15
  46. package/src/test/lib/paths.test.mjs +53 -17
  47. package/src/test/lib/prompt-multiselect.test.mjs +448 -0
  48. package/src/test/lib/sync.test.mjs +157 -0
  49. package/src/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
  50. package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
  51. package/src/test/removers/agent-hooks.test.mjs +206 -0
  52. package/src/lib/detect-ides.mjs +0 -44
  53. package/src/test/detect-ides.test.mjs +0 -65
@@ -0,0 +1,448 @@
1
+ /**
2
+ * Unit tests for src/lib/prompt-multiselect.mjs (#1236, Phase 3 of #876).
3
+ *
4
+ * Coverage scope:
5
+ *
6
+ * - Non-TTY mode (the comma-separated parser): empty input, single
7
+ * key, multi key, unknown-key rejection, dedup, override semantics.
8
+ *
9
+ * - TTY mode is exercised lightly. Full keypress simulation requires
10
+ * emitting parsed key events into a fake stdin, which is doable
11
+ * via the same `Readable` shape Node's keypress parser accepts.
12
+ * We exercise the renderer indirectly through one round-trip
13
+ * (toggle → enter) to catch wiring regressions, but most TTY
14
+ * behavior is covered by the integration test in init.test.mjs
15
+ * once the picker is wired in.
16
+ */
17
+
18
+ import { describe, it } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { Readable, Writable } from "node:stream";
21
+
22
+ import { promptMultiSelect } from "../../lib/prompt-multiselect.mjs";
23
+
24
+ function makeFakeStdin(input) {
25
+ // A minimal Readable that emits the input as a single chunk and
26
+ // ends. Node's readline.emitKeypressEvents will not be used in
27
+ // non-TTY mode — runNonTtyPicker reads raw chunks.
28
+ const stream = Readable.from([input], { objectMode: false });
29
+ stream.isTTY = false;
30
+ return stream;
31
+ }
32
+
33
+ function makeFakeStdout() {
34
+ const chunks = [];
35
+ const stream = new Writable({
36
+ write(chunk, _enc, cb) {
37
+ chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf-8"));
38
+ cb();
39
+ },
40
+ });
41
+ stream.isTTY = false;
42
+ stream.text = () => chunks.join("");
43
+ return stream;
44
+ }
45
+
46
+ const ITEMS = [
47
+ { key: "claude", label: "Claude Code", hint: ".claude/skills/", preChecked: true },
48
+ { key: "agents", label: "Other agents", hint: ".agents/skills/", preChecked: true },
49
+ { key: "none", label: "None — I'll configure manually", preChecked: false },
50
+ ];
51
+
52
+ // ── Non-TTY: empty input ────────────────────────────────────────────
53
+
54
+ describe("promptMultiSelect — non-TTY: empty input", () => {
55
+ it("returns the pre-checked items when stdin is empty (just newline)", async () => {
56
+ const stdin = makeFakeStdin("\n");
57
+ const stdout = makeFakeStdout();
58
+ const result = await promptMultiSelect(
59
+ { question: "Pick targets:", items: ITEMS },
60
+ { stdin, stdout, forceTty: false },
61
+ );
62
+ assert.deepEqual(result, ["claude", "agents"]);
63
+ });
64
+
65
+ it("returns the pre-checked items when stdin is just whitespace", async () => {
66
+ const stdin = makeFakeStdin(" \n");
67
+ const stdout = makeFakeStdout();
68
+ const result = await promptMultiSelect(
69
+ { question: "Pick targets:", items: ITEMS },
70
+ { stdin, stdout, forceTty: false },
71
+ );
72
+ assert.deepEqual(result, ["claude", "agents"]);
73
+ });
74
+
75
+ it("returns empty array when no items are pre-checked and input is empty", async () => {
76
+ const stdin = makeFakeStdin("\n");
77
+ const stdout = makeFakeStdout();
78
+ const items = ITEMS.map((it) => ({ ...it, preChecked: false }));
79
+ const result = await promptMultiSelect(
80
+ { question: "?", items },
81
+ { stdin, stdout, forceTty: false },
82
+ );
83
+ assert.deepEqual(result, []);
84
+ });
85
+ });
86
+
87
+ // ── Non-TTY: explicit input ────────────────────────────────────────
88
+
89
+ describe("promptMultiSelect — non-TTY: explicit input", () => {
90
+ it("returns just the requested key for a single-key input", async () => {
91
+ const stdin = makeFakeStdin("claude\n");
92
+ const stdout = makeFakeStdout();
93
+ const result = await promptMultiSelect(
94
+ { question: "?", items: ITEMS },
95
+ { stdin, stdout, forceTty: false },
96
+ );
97
+ assert.deepEqual(result, ["claude"]);
98
+ });
99
+
100
+ it("parses comma-separated input", async () => {
101
+ const stdin = makeFakeStdin("claude,agents\n");
102
+ const stdout = makeFakeStdout();
103
+ const result = await promptMultiSelect(
104
+ { question: "?", items: ITEMS },
105
+ { stdin, stdout, forceTty: false },
106
+ );
107
+ assert.deepEqual(result, ["claude", "agents"]);
108
+ });
109
+
110
+ it("trims whitespace around comma-separated tokens", async () => {
111
+ const stdin = makeFakeStdin(" claude , agents \n");
112
+ const stdout = makeFakeStdout();
113
+ const result = await promptMultiSelect(
114
+ { question: "?", items: ITEMS },
115
+ { stdin, stdout, forceTty: false },
116
+ );
117
+ assert.deepEqual(result, ["claude", "agents"]);
118
+ });
119
+
120
+ it("explicit input overrides pre-checked defaults", async () => {
121
+ // Both `claude` and `agents` are pre-checked, but the user types
122
+ // only `none` — the result must be `["none"]`, not the union.
123
+ const stdin = makeFakeStdin("none\n");
124
+ const stdout = makeFakeStdout();
125
+ const result = await promptMultiSelect(
126
+ { question: "?", items: ITEMS },
127
+ { stdin, stdout, forceTty: false },
128
+ );
129
+ assert.deepEqual(result, ["none"]);
130
+ });
131
+
132
+ it("dedupes repeated keys in input", async () => {
133
+ const stdin = makeFakeStdin("claude,claude,agents\n");
134
+ const stdout = makeFakeStdout();
135
+ const result = await promptMultiSelect(
136
+ { question: "?", items: ITEMS },
137
+ { stdin, stdout, forceTty: false },
138
+ );
139
+ assert.deepEqual(result, ["claude", "agents"]);
140
+ });
141
+
142
+ it("rejects unknown keys with a clear error listing valid keys", async () => {
143
+ const stdin = makeFakeStdin("foo\n");
144
+ const stdout = makeFakeStdout();
145
+ await assert.rejects(
146
+ () =>
147
+ promptMultiSelect(
148
+ { question: "?", items: ITEMS },
149
+ { stdin, stdout, forceTty: false },
150
+ ),
151
+ (err) =>
152
+ err instanceof Error &&
153
+ /Unknown key/.test(err.message) &&
154
+ /claude/.test(err.message) &&
155
+ /agents/.test(err.message) &&
156
+ /none/.test(err.message),
157
+ );
158
+ });
159
+
160
+ it("rejects when input contains a single unknown alongside valid keys", async () => {
161
+ const stdin = makeFakeStdin("claude,foo\n");
162
+ const stdout = makeFakeStdout();
163
+ await assert.rejects(
164
+ () =>
165
+ promptMultiSelect(
166
+ { question: "?", items: ITEMS },
167
+ { stdin, stdout, forceTty: false },
168
+ ),
169
+ (err) => err instanceof Error && /foo/.test(err.message),
170
+ );
171
+ });
172
+
173
+ it("handles \\r\\n line endings (Windows-style)", async () => {
174
+ // Cross-platform check: Windows piped input often arrives with
175
+ // CRLF. The parser must tolerate both \n and \r\n.
176
+ const stdin = makeFakeStdin("claude\r\n");
177
+ const stdout = makeFakeStdout();
178
+ const result = await promptMultiSelect(
179
+ { question: "?", items: ITEMS },
180
+ { stdin, stdout, forceTty: false },
181
+ );
182
+ assert.deepEqual(result, ["claude"]);
183
+ });
184
+ });
185
+
186
+ // ── Non-TTY: rendered output ────────────────────────────────────────
187
+
188
+ describe("promptMultiSelect — non-TTY: rendering", () => {
189
+ it("prints the question and every item", async () => {
190
+ const stdin = makeFakeStdin("\n");
191
+ const stdout = makeFakeStdout();
192
+ await promptMultiSelect(
193
+ { question: "Pick targets:", items: ITEMS },
194
+ { stdin, stdout, forceTty: false },
195
+ );
196
+ const text = stdout.text();
197
+ assert.match(text, /Pick targets:/);
198
+ assert.match(text, /claude/);
199
+ assert.match(text, /Claude Code/);
200
+ assert.match(text, /agents/);
201
+ assert.match(text, /Other agents/);
202
+ assert.match(text, /none/);
203
+ });
204
+
205
+ it("includes hints in the rendered output", async () => {
206
+ const stdin = makeFakeStdin("\n");
207
+ const stdout = makeFakeStdout();
208
+ await promptMultiSelect(
209
+ { question: "?", items: ITEMS },
210
+ { stdin, stdout, forceTty: false },
211
+ );
212
+ const text = stdout.text();
213
+ assert.match(text, /\.claude\/skills\//);
214
+ assert.match(text, /\.agents\/skills\//);
215
+ });
216
+
217
+ it("renders pre-checked items with [x] and unchecked with [ ]", async () => {
218
+ const stdin = makeFakeStdin("\n");
219
+ const stdout = makeFakeStdout();
220
+ await promptMultiSelect(
221
+ { question: "?", items: ITEMS },
222
+ { stdin, stdout, forceTty: false },
223
+ );
224
+ const text = stdout.text();
225
+ // Two pre-checked items: [x] should appear at least twice.
226
+ const checkedMatches = text.match(/\[x\]/g) ?? [];
227
+ assert.ok(checkedMatches.length >= 2, "two items should render as [x]");
228
+ // One unchecked: [ ] should appear at least once.
229
+ assert.match(text, /\[ \]/);
230
+ });
231
+ });
232
+
233
+ // ── TTY mode: keypress simulation ───────────────────────────────────
234
+ //
235
+ // We construct a fake stdin that satisfies the picker's expectations
236
+ // (isTTY=true, setRawMode, pause/resume, on/removeAllListeners) and
237
+ // directly emit "keypress" events. This bypasses readline's keypress
238
+ // parser — which is fine, because we're testing the picker's reaction
239
+ // to parsed events, not the parser itself.
240
+
241
+ import { EventEmitter } from "node:events";
242
+
243
+ function makeTtyStdin() {
244
+ const stdin = new EventEmitter();
245
+ stdin.isTTY = true;
246
+ stdin.isRaw = false;
247
+ stdin.setRawMode = function (raw) {
248
+ this.isRaw = raw;
249
+ return this;
250
+ };
251
+ stdin.pause = function () {};
252
+ stdin.resume = function () {};
253
+ // Drive a sequence of keypress events on the next tick so the picker
254
+ // has time to register its listener via `stdin.on("keypress", ...)`.
255
+ stdin.send = (events) => {
256
+ queueMicrotask(() => {
257
+ for (const [str, key] of events) {
258
+ stdin.emit("keypress", str, key);
259
+ }
260
+ });
261
+ };
262
+ return stdin;
263
+ }
264
+
265
+ function makeTtyStdout() {
266
+ const chunks = [];
267
+ const stream = new Writable({
268
+ write(chunk, _enc, cb) {
269
+ chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf-8"));
270
+ cb();
271
+ },
272
+ });
273
+ stream.isTTY = true;
274
+ stream.text = () => chunks.join("");
275
+ return stream;
276
+ }
277
+
278
+ describe("promptMultiSelect — TTY: keypress handling", () => {
279
+ it("returns pre-checked items when user presses enter immediately", async () => {
280
+ const stdin = makeTtyStdin();
281
+ const stdout = makeTtyStdout();
282
+ stdin.send([[null, { name: "return" }]]);
283
+ const result = await promptMultiSelect(
284
+ { question: "?", items: ITEMS },
285
+ { stdin, stdout, forceTty: true },
286
+ );
287
+ assert.deepEqual(result, ["claude", "agents"]);
288
+ });
289
+
290
+ it("space toggles the active row off; enter confirms", async () => {
291
+ const stdin = makeTtyStdin();
292
+ const stdout = makeTtyStdout();
293
+ // Cursor starts on index 0 (claude). Space → uncheck claude. Enter.
294
+ stdin.send([
295
+ [" ", { name: "space" }],
296
+ [null, { name: "return" }],
297
+ ]);
298
+ const result = await promptMultiSelect(
299
+ { question: "?", items: ITEMS },
300
+ { stdin, stdout, forceTty: true },
301
+ );
302
+ assert.deepEqual(result, ["agents"]);
303
+ });
304
+
305
+ it("down arrow then space toggles the cohort row off", async () => {
306
+ const stdin = makeTtyStdin();
307
+ const stdout = makeTtyStdout();
308
+ // Down → cursor on agents. Space → uncheck. Enter.
309
+ stdin.send([
310
+ [null, { name: "down" }],
311
+ [" ", { name: "space" }],
312
+ [null, { name: "return" }],
313
+ ]);
314
+ const result = await promptMultiSelect(
315
+ { question: "?", items: ITEMS },
316
+ { stdin, stdout, forceTty: true },
317
+ );
318
+ assert.deepEqual(result, ["claude"]);
319
+ });
320
+
321
+ it("up arrow wraps from index 0 to the last row", async () => {
322
+ const stdin = makeTtyStdin();
323
+ const stdout = makeTtyStdout();
324
+ // Up from index 0 → wraps to index 2 (none). Space → check none. Enter.
325
+ stdin.send([
326
+ [null, { name: "up" }],
327
+ [" ", { name: "space" }],
328
+ [null, { name: "return" }],
329
+ ]);
330
+ const result = await promptMultiSelect(
331
+ { question: "?", items: ITEMS },
332
+ { stdin, stdout, forceTty: true },
333
+ );
334
+ // claude + agents pre-checked + none toggled on
335
+ assert.deepEqual(result, ["claude", "agents", "none"]);
336
+ });
337
+
338
+ it("'a' toggles all items on when at least one is unchecked", async () => {
339
+ const stdin = makeTtyStdin();
340
+ const stdout = makeTtyStdout();
341
+ // ITEMS has none pre-checked false. 'a' → check all. Enter.
342
+ stdin.send([
343
+ ["a", { name: "a" }],
344
+ [null, { name: "return" }],
345
+ ]);
346
+ const result = await promptMultiSelect(
347
+ { question: "?", items: ITEMS },
348
+ { stdin, stdout, forceTty: true },
349
+ );
350
+ assert.deepEqual(result, ["claude", "agents", "none"]);
351
+ });
352
+
353
+ it("'a' clears all when every item is already checked", async () => {
354
+ const stdin = makeTtyStdin();
355
+ const stdout = makeTtyStdout();
356
+ const allPreChecked = ITEMS.map((it) => ({ ...it, preChecked: true }));
357
+ stdin.send([
358
+ ["a", { name: "a" }],
359
+ [null, { name: "return" }],
360
+ ]);
361
+ const result = await promptMultiSelect(
362
+ { question: "?", items: allPreChecked },
363
+ { stdin, stdout, forceTty: true },
364
+ );
365
+ assert.deepEqual(result, []);
366
+ });
367
+
368
+ it("Ctrl+C cancels with a rejection", async () => {
369
+ const stdin = makeTtyStdin();
370
+ const stdout = makeTtyStdout();
371
+ stdin.send([[null, { name: "c", ctrl: true }]]);
372
+ await assert.rejects(
373
+ () =>
374
+ promptMultiSelect(
375
+ { question: "?", items: ITEMS },
376
+ { stdin, stdout, forceTty: true },
377
+ ),
378
+ /cancelled/i,
379
+ );
380
+ });
381
+
382
+ it("Ctrl+D cancels with a rejection (does not hang the process)", async () => {
383
+ // Round-2 review fix: Ctrl+D in raw mode does not close the stream
384
+ // automatically; without explicit handling the process hangs.
385
+ const stdin = makeTtyStdin();
386
+ const stdout = makeTtyStdout();
387
+ stdin.send([[null, { name: "d", ctrl: true }]]);
388
+ await assert.rejects(
389
+ () =>
390
+ promptMultiSelect(
391
+ { question: "?", items: ITEMS },
392
+ { stdin, stdout, forceTty: true },
393
+ ),
394
+ /cancelled/i,
395
+ );
396
+ });
397
+
398
+ it("Esc cancels with a rejection", async () => {
399
+ const stdin = makeTtyStdin();
400
+ const stdout = makeTtyStdout();
401
+ stdin.send([[null, { name: "escape" }]]);
402
+ await assert.rejects(
403
+ () =>
404
+ promptMultiSelect(
405
+ { question: "?", items: ITEMS },
406
+ { stdin, stdout, forceTty: true },
407
+ ),
408
+ /cancelled/i,
409
+ );
410
+ });
411
+
412
+ it("restores raw mode to its prior value after enter", async () => {
413
+ const stdin = makeTtyStdin();
414
+ const stdout = makeTtyStdout();
415
+ stdin.isRaw = false;
416
+ stdin.send([[null, { name: "return" }]]);
417
+ await promptMultiSelect(
418
+ { question: "?", items: ITEMS },
419
+ { stdin, stdout, forceTty: true },
420
+ );
421
+ assert.equal(stdin.isRaw, false, "raw mode must be restored");
422
+ });
423
+ });
424
+
425
+ // ── Argument validation ─────────────────────────────────────────────
426
+
427
+ describe("promptMultiSelect — argument validation", () => {
428
+ it("throws TypeError when items is missing", async () => {
429
+ await assert.rejects(
430
+ () => promptMultiSelect({ question: "?" }, {}),
431
+ TypeError,
432
+ );
433
+ });
434
+
435
+ it("throws TypeError when items is empty", async () => {
436
+ await assert.rejects(
437
+ () => promptMultiSelect({ question: "?", items: [] }, {}),
438
+ TypeError,
439
+ );
440
+ });
441
+
442
+ it("throws TypeError when options is missing", async () => {
443
+ await assert.rejects(
444
+ () => promptMultiSelect(undefined, {}),
445
+ TypeError,
446
+ );
447
+ });
448
+ });
@@ -528,3 +528,160 @@ describe("runSync — orphan cleanup", () => {
528
528
  assert.ok(existsSync(join(root, "recoverable.tmp", "SKILL.md")));
529
529
  });
530
530
  });
531
+
532
+ // ── runSync — cohort placement classification ──────────────────────────
533
+ //
534
+ // Coverage gap from the QA cross-PR review (#1252): the cohort vendors
535
+ // (cursor / windsurf / gemini / codex / cline / copilot) all share the
536
+ // `agentsProject` placement target via the registry. Cross-vendor
537
+ // re-syncs and partial-vendor re-syncs were unverified — these tests
538
+ // lock the `isAnyTargetPresent` resolution that distinguishes added-vs-
539
+ // updated for cohort-only writes, plus tombstone scoping when only
540
+ // part of a multi-vendor original write is asked to be removed.
541
+
542
+ describe("runSync — cohort placement classification", () => {
543
+ beforeEach(setupServer);
544
+ afterEach(teardownServer);
545
+
546
+ it("re-sync with a different cohort vendor classifies as updated, not added", async () => {
547
+ // First sync writes via vendors: ["cursor"] → the cohort target
548
+ // resolves to `agentsProject` → `.agents/skills/<name>/`.
549
+ server.setLibraryResponse({
550
+ skills: [makeSkill("shared")],
551
+ removals: [],
552
+ syncedAt: "2025-01-01T00:00:00Z",
553
+ });
554
+ const first = await runSync({
555
+ serverUrl,
556
+ apiKey: VALID_KEY,
557
+ vendors: ["cursor"],
558
+ });
559
+ assert.equal(first.added, 1);
560
+
561
+ // Confirm the cohort dir was actually written (not a coincidental
562
+ // pass via some other path).
563
+ const cohortDir = resolvePlacementDir("agentsProject", "shared");
564
+ assert.ok(existsSync(join(cohortDir, "SKILL.md")), "cohort dir must exist after first sync");
565
+
566
+ // Clear the .last-sync state so the second sync isn't 304-short-
567
+ // circuited — we want the writeSkillDir path to actually run so
568
+ // isAnyTargetPresent classifies the existing dir as "updated".
569
+ rmSync(globalLastSyncPath(), { force: true });
570
+
571
+ // Second sync via vendors: ["windsurf"]. Both cursor and windsurf
572
+ // resolve to `agentsProject` per the registry, so the existing
573
+ // `.agents/skills/shared/` should be detected as already-on-disk
574
+ // and the summary must report updated:1, added:0.
575
+ server.setLibraryResponse({
576
+ skills: [makeSkill("shared", "v2")],
577
+ removals: [],
578
+ syncedAt: "2025-01-02T00:00:00Z",
579
+ });
580
+ const second = await runSync({
581
+ serverUrl,
582
+ apiKey: VALID_KEY,
583
+ vendors: ["windsurf"],
584
+ });
585
+ assert.equal(
586
+ second.updated,
587
+ 1,
588
+ "cross-cohort vendor re-sync must classify as updated (both resolve to .agents/skills/)",
589
+ );
590
+ assert.equal(second.added, 0, "must NOT classify as added when target dir already exists");
591
+ });
592
+
593
+ it("claudeCode + cohort then claudeCode-only leaves the cohort copy untouched", async () => {
594
+ // First sync writes to BOTH .claude/skills/ and .agents/skills/
595
+ server.setLibraryResponse({
596
+ skills: [makeSkill("dual")],
597
+ removals: [],
598
+ syncedAt: "2025-01-01T00:00:00Z",
599
+ });
600
+ await runSync({
601
+ serverUrl,
602
+ apiKey: VALID_KEY,
603
+ vendors: ["claudeCode", "cursor"],
604
+ });
605
+
606
+ const claudeDir = resolvePlacementDir("claudeProject", "dual");
607
+ const cohortDir = resolvePlacementDir("agentsProject", "dual");
608
+ assert.ok(existsSync(join(claudeDir, "SKILL.md")), "claude dir must exist");
609
+ assert.ok(existsSync(join(cohortDir, "SKILL.md")), "cohort dir must exist");
610
+
611
+ // Capture the cohort SKILL.md mtime + content before the second
612
+ // sync so we can confirm it was NOT touched. Use content rather
613
+ // than mtime because mtime granularity is filesystem-dependent
614
+ // and can be a flake source on fast machines.
615
+ const cohortBefore = readFileSync(join(cohortDir, "SKILL.md"), "utf-8");
616
+
617
+ rmSync(globalLastSyncPath(), { force: true });
618
+
619
+ // Second sync targets ONLY claudeCode. The cohort copy is not
620
+ // re-written and is not removed — vendor-scoped writes don't
621
+ // touch other vendors' targets.
622
+ server.setLibraryResponse({
623
+ skills: [makeSkill("dual", "claude-only-update")],
624
+ removals: [],
625
+ syncedAt: "2025-01-02T00:00:00Z",
626
+ });
627
+ await runSync({
628
+ serverUrl,
629
+ apiKey: VALID_KEY,
630
+ vendors: ["claudeCode"],
631
+ });
632
+
633
+ // Cohort dir content unchanged — it survives the partial-vendor
634
+ // re-sync untouched.
635
+ assert.ok(existsSync(join(cohortDir, "SKILL.md")), "cohort dir must survive partial re-sync");
636
+ const cohortAfter = readFileSync(join(cohortDir, "SKILL.md"), "utf-8");
637
+ assert.equal(cohortAfter, cohortBefore, "cohort SKILL.md must not be rewritten");
638
+
639
+ // Claude dir DID get updated.
640
+ const claudeAfter = readFileSync(join(claudeDir, "SKILL.md"), "utf-8");
641
+ assert.match(claudeAfter, /claude-only-update/);
642
+ });
643
+
644
+ it("tombstone via cohort vendor removes from .agents/skills/ but leaves .claude/skills/", async () => {
645
+ // Pre-write to BOTH targets via a multi-vendor first sync.
646
+ server.setLibraryResponse({
647
+ skills: [makeSkill("doomed")],
648
+ removals: [],
649
+ syncedAt: "2025-01-01T00:00:00Z",
650
+ });
651
+ await runSync({
652
+ serverUrl,
653
+ apiKey: VALID_KEY,
654
+ vendors: ["claudeCode", "cursor"],
655
+ });
656
+
657
+ const claudeDir = resolvePlacementDir("claudeProject", "doomed");
658
+ const cohortDir = resolvePlacementDir("agentsProject", "doomed");
659
+ assert.ok(existsSync(claudeDir));
660
+ assert.ok(existsSync(cohortDir));
661
+
662
+ rmSync(globalLastSyncPath(), { force: true });
663
+
664
+ // Apply a tombstone with vendors: ["cursor"] only. Vendor-scoped
665
+ // tombstones map to the cohort target (.agents/skills/) and must
666
+ // NOT delete the Claude target — only the explicit vendor scope
667
+ // is touched. This locks the "vendor-scoped removal isn't a
668
+ // footgun" behavior.
669
+ server.setLibraryResponse({
670
+ skills: [],
671
+ removals: [{ owner: "alice", name: "doomed", removedAt: "2025-01-02T00:00:00Z" }],
672
+ syncedAt: "2025-01-02T00:00:00Z",
673
+ });
674
+ const result = await runSync({
675
+ serverUrl,
676
+ apiKey: VALID_KEY,
677
+ vendors: ["cursor"],
678
+ });
679
+
680
+ assert.equal(result.removed, 1);
681
+ assert.ok(!existsSync(cohortDir), "cohort dir must be removed by the cursor-scoped tombstone");
682
+ assert.ok(
683
+ existsSync(claudeDir),
684
+ "Claude Code dir must NOT be removed by a cursor-scoped tombstone — vendor scoping protects untargeted dirs",
685
+ );
686
+ });
687
+ });