skillrepo 4.5.0 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1018 @@
1
+ /**
2
+ * Cross-command integration: `update` → `list` contract.
3
+ *
4
+ * Why this file exists
5
+ * --------------------
6
+ * This is the test layer the v4.5.0 production-readiness review missed.
7
+ *
8
+ * The 4.5.0 staleness epic shipped two commands that share a contract:
9
+ *
10
+ * `update` WRITES skills to disk under every vendor it targets.
11
+ * `list` READS those placements to compute per-skill drift state.
12
+ *
13
+ * The shared contract is the set of "detected vendors." If `update`
14
+ * writes to a smaller set than `list` walks, the list rolls up as
15
+ * MISSING for every multi-vendor user — exactly the production bug
16
+ * that triggered the 4.5.1 hotfix.
17
+ *
18
+ * Per-command unit tests (update.test.mjs + list.test.mjs) cannot
19
+ * surface this. They each fix one half of the contract and mock the
20
+ * other. Only an end-to-end test that runs `update` THEN reads the
21
+ * resulting `list` output catches a contract divergence. That's what
22
+ * this file is — the test that would have caught 4.5.1 pre-shipping.
23
+ *
24
+ * Test isolation
25
+ * --------------
26
+ * Every test runs inside a sandbox HOME so detection env vars are
27
+ * controlled, on-disk placements live in temp dirs, and the
28
+ * `.last-sync` state file is sandbox-local. The mock server stands in
29
+ * for the real registry. Detection signals are toggled via env vars
30
+ * documented in `agent-registry.mjs`:
31
+ *
32
+ * - claudeCode: CLAUDECODE=1
33
+ * - cursor: CURSOR_AGENT=1
34
+ *
35
+ * Detection via `home`/`project` filesystem signals is also exercised
36
+ * implicitly when a test seeds `.claude/` or `.agents/` directories
37
+ * before invoking `update`.
38
+ */
39
+
40
+ import { describe, it, beforeEach, afterEach } from "node:test";
41
+ import assert from "node:assert/strict";
42
+ import {
43
+ mkdtempSync,
44
+ mkdirSync,
45
+ rmSync,
46
+ existsSync,
47
+ readFileSync,
48
+ writeFileSync,
49
+ } from "node:fs";
50
+ import { join } from "node:path";
51
+ import { tmpdir } from "node:os";
52
+
53
+ import { runUpdate } from "../../commands/update.mjs";
54
+ import { runList } from "../../commands/list.mjs";
55
+ import { runGet } from "../../commands/get.mjs";
56
+ import { runAdd } from "../../commands/add.mjs";
57
+ import { runRemove } from "../../commands/remove.mjs";
58
+ import { resolvePlacementDir } from "../../lib/file-write.mjs";
59
+ import { globalLastSyncPath } from "../../lib/paths.mjs";
60
+ import { readLastSync } from "../../lib/sync.mjs";
61
+ import { createMockServer } from "../e2e/mock-server.mjs";
62
+ import { createCaptureStream } from "../helpers/capture-stream.mjs";
63
+ import {
64
+ captureHome,
65
+ setSandboxHome,
66
+ restoreHome,
67
+ } from "../helpers/sandbox-home.mjs";
68
+
69
+ let sandbox;
70
+ let server;
71
+ let serverUrl;
72
+ let originalCwd;
73
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
74
+ let originalHomeEnv;
75
+ let stdout;
76
+ const VALID_KEY = "sk_live_test";
77
+
78
+ /**
79
+ * Build a registry skill payload with a single SKILL.md file. Same
80
+ * helper the unit tests use; replicated here so changes in either
81
+ * place don't silently break the other.
82
+ */
83
+ function makeSkill(owner, name, version = "1.0.0") {
84
+ const content = `---\nname: ${name}\ndescription: ${name} description\n---\n\nbody\n`;
85
+ return {
86
+ owner,
87
+ name,
88
+ version,
89
+ description: `${name} description`,
90
+ files: [
91
+ {
92
+ path: "SKILL.md",
93
+ content,
94
+ // Server-side SHAs are not used by the CLI for drift detection —
95
+ // the CLI re-computes from content on disk. Any non-empty
96
+ // placeholder works.
97
+ sha256: "x",
98
+ size: content.length,
99
+ contentType: "text/markdown",
100
+ },
101
+ ],
102
+ updatedAt: "2025-01-01T12:00:00Z",
103
+ };
104
+ }
105
+
106
+ async function setup() {
107
+ sandbox = mkdtempSync(join(tmpdir(), "cli-int-update-list-"));
108
+ mkdirSync(join(sandbox, "project"), { recursive: true });
109
+ mkdirSync(join(sandbox, "home"), { recursive: true });
110
+ originalCwd = process.cwd();
111
+ originalHomeEnv = captureHome();
112
+ process.chdir(join(sandbox, "project"));
113
+ setSandboxHome(join(sandbox, "home"));
114
+
115
+ // Strip any inherited credentials/detection that would otherwise
116
+ // pollute the test's controlled environment.
117
+ delete process.env.SKILLREPO_ACCESS_KEY;
118
+ delete process.env.SKILLREPO_URL;
119
+ delete process.env.CLAUDECODE;
120
+ delete process.env.CURSOR_AGENT;
121
+ delete process.env.CURSOR_CLI;
122
+
123
+ server = createMockServer({});
124
+ const port = await server.start();
125
+ serverUrl = `http://127.0.0.1:${port}`;
126
+
127
+ stdout = createCaptureStream();
128
+ }
129
+
130
+ async function teardown() {
131
+ if (server) await server.stop();
132
+ process.chdir(originalCwd);
133
+ restoreHome(originalHomeEnv);
134
+ delete process.env.CLAUDECODE;
135
+ delete process.env.CURSOR_AGENT;
136
+ delete process.env.CURSOR_CLI;
137
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
138
+ server = null;
139
+ }
140
+
141
+ // ───────────────────────────────────────────────────────────────────────
142
+ // The 4.5.1 regression suite
143
+ // ───────────────────────────────────────────────────────────────────────
144
+ //
145
+ // The bug: `effectiveVendors` defaulted to `["claudeCode"]` when no
146
+ // `--agent` was passed, so `skillrepo update` (with no flags) wrote
147
+ // ONLY to claudeCode's placement. Meanwhile, `skillrepo list` walked
148
+ // EVERY detected vendor's placement. Result for multi-agent users:
149
+ // every skill rolled up as MISSING for every vendor that wasn't
150
+ // Claude Code.
151
+ //
152
+ // The fix: `effectiveVendors` now defaults to every detected vendor
153
+ // (DI-injectable for testability). These tests prove the new contract
154
+ // end-to-end.
155
+
156
+ describe("update → list cross-command contract (#1574)", () => {
157
+ beforeEach(setup);
158
+ afterEach(teardown);
159
+
160
+ it("multi-vendor: update with no --agent writes to every detected vendor (no MISS in list)", async () => {
161
+ // ── Forcing detection of TWO vendors ─────────────────────────────
162
+ // This is the exact production scenario behind 4.5.1: a user with
163
+ // both Claude Code AND Cursor installed runs `skillrepo update`
164
+ // with no `--agent`. Pre-fix, only claudeCode got the files. We
165
+ // assert both placements receive the skill AND that `list` agrees.
166
+ process.env.CLAUDECODE = "1";
167
+ process.env.CURSOR_AGENT = "1";
168
+
169
+ server.setLibraryResponse({
170
+ skills: [makeSkill("alice", "shared-skill", "1.0.0")],
171
+ removals: [],
172
+ syncedAt: "2026-01-01T00:00:00Z",
173
+ });
174
+ server.setEtag('"v1"');
175
+
176
+ // ── Step 1: update with no --agent ───────────────────────────────
177
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
178
+
179
+ // Both placements must exist on disk — the contract claim.
180
+ const claudeDir = resolvePlacementDir("claudeProject", "shared-skill");
181
+ const agentsDir = resolvePlacementDir("agentsProject", "shared-skill");
182
+ assert.ok(existsSync(claudeDir), "claudeProject placement must be written");
183
+ assert.ok(existsSync(agentsDir), "agentsProject (cursor) placement must be written");
184
+
185
+ // Same bytes in both placements — sync writes identical content,
186
+ // not a stub. A future regression that wrote to multiple targets
187
+ // but produced divergent content would break list's drift check
188
+ // and surface here.
189
+ const claudeBody = readFileSync(join(claudeDir, "SKILL.md"), "utf-8");
190
+ const agentsBody = readFileSync(join(agentsDir, "SKILL.md"), "utf-8");
191
+ assert.equal(claudeBody, agentsBody, "writes to multiple targets must produce identical content");
192
+
193
+ // ── Step 2: list reads the same placements ───────────────────────
194
+ // Use --json so we can assert the per-placement state directly.
195
+ stdout = createCaptureStream();
196
+ await runList(
197
+ ["--key", VALID_KEY, "--url", serverUrl, "--json"],
198
+ { stdout },
199
+ );
200
+ const [item] = JSON.parse(stdout.text());
201
+ assert.equal(item.name, "shared-skill");
202
+ // The rollup: both placements are current → rollup is current.
203
+ // Pre-fix this was "missing" because cursor's placement was empty.
204
+ assert.equal(item.state, "current", "rollup must be `current` when every detected vendor has the skill");
205
+ // Lock in the EXACT vendor count. Without this assertion a
206
+ // regression that over-detects (e.g., a future change that
207
+ // defaults to ALL seven registry entries instead of detected-only)
208
+ // would still pass the per-vendor `state === "current"` checks
209
+ // below because both claudeCode and cursor would land in the
210
+ // list. The `.length === 2` check catches the over-detection
211
+ // direction; the test above already catches under-detection.
212
+ assert.equal(
213
+ item.placements.length,
214
+ 2,
215
+ "list must walk exactly the two detected vendors, not every registered one",
216
+ );
217
+ // Each vendor's placement entry reports current independently.
218
+ const byVendor = Object.fromEntries(item.placements.map((p) => [p.vendor, p]));
219
+ assert.ok(byVendor.claudeCode, "claudeCode placement must appear");
220
+ assert.ok(byVendor.cursor, "cursor placement must appear");
221
+ assert.equal(byVendor.claudeCode.state, "current");
222
+ assert.equal(byVendor.cursor.state, "current");
223
+ });
224
+
225
+ it("single-vendor: update with only cursor detected → list shows current for cursor only", async () => {
226
+ // The narrow contract: only the one detected vendor gets the
227
+ // placement, AND `list` afterwards reports `current` for that
228
+ // vendor — no phantom MISS for claudeCode.
229
+ //
230
+ // This test used to opt out of the `list` assertion because of a
231
+ // companion bug: writing `~/.claude/skillrepo/.last-sync`
232
+ // created `~/.claude/`, which then matched claudeCode's home
233
+ // detection signal. PR #1574's production-readiness audit
234
+ // narrowed that signal to `~/.claude/settings.json` (a file
235
+ // Claude Code itself creates, that SkillRepo never writes) so
236
+ // the false positive no longer fires. This test is now the
237
+ // regression guard: a Cursor-only user must see `list` reporting
238
+ // their skills as `current`, NOT all-MISS for a vendor they
239
+ // don't use.
240
+ process.env.CURSOR_AGENT = "1";
241
+
242
+ server.setLibraryResponse({
243
+ skills: [makeSkill("alice", "cursor-only", "1.0.0")],
244
+ removals: [],
245
+ syncedAt: "2026-01-01T00:00:00Z",
246
+ });
247
+ server.setEtag('"v1"');
248
+
249
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
250
+
251
+ // Only cursor's placement exists.
252
+ assert.ok(
253
+ existsSync(resolvePlacementDir("agentsProject", "cursor-only")),
254
+ "agentsProject placement should exist for cursor",
255
+ );
256
+ assert.equal(
257
+ existsSync(resolvePlacementDir("claudeProject", "cursor-only")),
258
+ false,
259
+ "claudeProject placement should NOT exist when claudeCode is not detected at update-time",
260
+ );
261
+
262
+ // `list` now correctly sees ONE detected vendor and reports
263
+ // `current`. The false-positive claudeCode detection that
264
+ // shipped in 4.5.0/4.5.1 produced all-MISS output here.
265
+ stdout = createCaptureStream();
266
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
267
+ const [item] = JSON.parse(stdout.text());
268
+ assert.equal(item.state, "current");
269
+ assert.equal(
270
+ item.placements.length,
271
+ 1,
272
+ "Cursor-only user must not see a phantom claudeCode placement entry",
273
+ );
274
+ assert.equal(item.placements[0].vendor, "cursor");
275
+ assert.equal(item.placements[0].state, "current");
276
+ });
277
+
278
+ it("no-detection fallback: update with no --agent uses the historical claudeCode default", async () => {
279
+ // The other edge of the fix: if NOTHING is detected (no env vars,
280
+ // no `.claude/` or `.agents/` dirs anywhere), the CLI falls back
281
+ // to writing claudeCode-only — preserving the pre-4.5.1 behavior
282
+ // for first-run users. Without this fallback, a brand-new
283
+ // checkout in a clean shell would silently no-op `update` because
284
+ // detection returned an empty list.
285
+ //
286
+ // We don't set ANY env vars. The sandbox HOME is brand-new
287
+ // (no .claude / no .cursor / no .agents).
288
+
289
+ server.setLibraryResponse({
290
+ skills: [makeSkill("alice", "fallback", "1.0.0")],
291
+ removals: [],
292
+ syncedAt: "2026-01-01T00:00:00Z",
293
+ });
294
+ server.setEtag('"v1"');
295
+
296
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
297
+
298
+ // claudeProject is the documented fallback target.
299
+ assert.ok(
300
+ existsSync(resolvePlacementDir("claudeProject", "fallback")),
301
+ "fallback to claudeProject must hold when nothing is detected",
302
+ );
303
+ });
304
+
305
+ it("explicit --agent narrows the write set even when more vendors are detected", async () => {
306
+ // The explicit-flag escape hatch: even if both vendors are
307
+ // detected, `--agent claude` (or `--agent cursor`) writes only
308
+ // to the named vendor. `effectiveVendors` returns the explicit
309
+ // list verbatim — detection is ONLY consulted when no --agent
310
+ // is passed.
311
+ process.env.CLAUDECODE = "1";
312
+ process.env.CURSOR_AGENT = "1";
313
+
314
+ server.setLibraryResponse({
315
+ skills: [makeSkill("alice", "narrowed", "1.0.0")],
316
+ removals: [],
317
+ syncedAt: "2026-01-01T00:00:00Z",
318
+ });
319
+ server.setEtag('"v1"');
320
+
321
+ await runUpdate(
322
+ ["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude"],
323
+ { stdout },
324
+ );
325
+
326
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", "narrowed")));
327
+ // cursor's placement must NOT be written, even though cursor
328
+ // is detected. Explicit > implicit.
329
+ assert.equal(
330
+ existsSync(resolvePlacementDir("agentsProject", "narrowed")),
331
+ false,
332
+ "--agent claude must NOT incidentally write to cursor's placement",
333
+ );
334
+ });
335
+
336
+ it("explicit --agent ALSO has the listed vendor appear in list when detected", async () => {
337
+ // Defense-in-depth check: a user can run `update --agent claude`
338
+ // on a multi-vendor machine and `list` will still walk EVERY
339
+ // detected vendor (including cursor) and surface cursor's
340
+ // placement as MISS — that's the correct user-visible signal that
341
+ // their explicit-narrow update left a vendor un-updated.
342
+ process.env.CLAUDECODE = "1";
343
+ process.env.CURSOR_AGENT = "1";
344
+
345
+ server.setLibraryResponse({
346
+ skills: [makeSkill("alice", "explicit", "1.0.0")],
347
+ removals: [],
348
+ syncedAt: "2026-01-01T00:00:00Z",
349
+ });
350
+ server.setEtag('"v1"');
351
+
352
+ await runUpdate(
353
+ ["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude"],
354
+ { stdout },
355
+ );
356
+
357
+ stdout = createCaptureStream();
358
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
359
+ const [item] = JSON.parse(stdout.text());
360
+ // Cursor's placement is empty → its per-vendor state is missing
361
+ // → rollup is missing (worst state wins). User sees a clear
362
+ // signal that one of their vendors didn't get the update.
363
+ assert.equal(item.state, "missing");
364
+ const cursor = item.placements.find((p) => p.vendor === "cursor");
365
+ assert.ok(cursor, "list must still include cursor's placement entry");
366
+ assert.equal(cursor.state, "missing");
367
+ });
368
+ });
369
+
370
+ // ───────────────────────────────────────────────────────────────────────
371
+ // .last-sync v1 → v2 migration round-trip
372
+ // ───────────────────────────────────────────────────────────────────────
373
+ //
374
+ // A user upgrading from 4.4.x to 4.5.x has a v1 `.last-sync` on disk.
375
+ // The new CLI must:
376
+ // 1. Read it without crashing.
377
+ // 2. Preserve the etag + syncedAt so the next sync still benefits
378
+ // from a 304 short-circuit.
379
+ // 3. After the next successful sync, write a v2 file with the
380
+ // per-skill SHA map populated.
381
+ //
382
+ // Sync.test.mjs has unit-level coverage. This integration test
383
+ // exercises the path through runSync against the mock server, which
384
+ // is the only way to verify the v2 file that lands on disk has the
385
+ // expected shape AND the ETag carry-forward took effect.
386
+
387
+ describe("v1 → v2 .last-sync migration round-trip", () => {
388
+ beforeEach(setup);
389
+ afterEach(teardown);
390
+
391
+ it("reads v1 .last-sync, performs sync, writes v2 with SHA map populated", async () => {
392
+ // Seed a v1 state file at the documented path. Both fields are
393
+ // strings — that's the v1 shape `readLastSync` migrates from.
394
+ const v1Path = globalLastSyncPath();
395
+ mkdirSync(join(v1Path, ".."), { recursive: true });
396
+ writeFileSync(
397
+ v1Path,
398
+ JSON.stringify({
399
+ schemaVersion: 1,
400
+ etag: '"v1-etag"',
401
+ syncedAt: "2025-12-01T00:00:00Z",
402
+ }),
403
+ );
404
+
405
+ process.env.CLAUDECODE = "1";
406
+ server.setEtag('"v2-etag"');
407
+ server.setLibraryResponse({
408
+ skills: [makeSkill("alice", "migrated", "1.0.0")],
409
+ removals: [],
410
+ syncedAt: "2026-05-19T00:00:00Z",
411
+ });
412
+
413
+ // ── Run update — this triggers the v1→v2 in-memory migration
414
+ // in readLastSync and the v2 write at the end of runSync ──
415
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
416
+
417
+ // The on-disk file must now be v2 and carry the per-skill SHA
418
+ // map. The pre-existing v1 etag/syncedAt are NOT preserved
419
+ // because the sync actually completed and got a fresh ETag from
420
+ // the server. Carry-forward applies to the per-skill SHA map
421
+ // (which was empty in v1), not to the library ETag itself.
422
+ const afterRaw = readFileSync(v1Path, "utf-8");
423
+ const after = JSON.parse(afterRaw);
424
+ assert.equal(after.schemaVersion, 2, "must persist as v2");
425
+ assert.equal(after.etag, '"v2-etag"', "etag must reflect server's response");
426
+ assert.ok(
427
+ after.skills && typeof after.skills === "object",
428
+ "skills map must be populated",
429
+ );
430
+ assert.ok(after.skills["alice/migrated"], "synced skill must appear in the SHA map");
431
+ const entry = after.skills["alice/migrated"];
432
+ assert.equal(entry.version, "1.0.0");
433
+ assert.match(entry.skillMdSha256, /^[a-f0-9]{64}$/, "skillMdSha256 must be a hex SHA-256");
434
+ assert.match(entry.filesSha256, /^[a-f0-9]{64}$/, "filesSha256 must be a hex SHA-256");
435
+ });
436
+
437
+ it("v1 state with NO content changes (304) preserves the etag and lets list see the upgrade transparently", async () => {
438
+ // Edge case: user upgrades, runs update, server returns 304 (no
439
+ // changes). The v1 etag was carried into the in-memory v2 shape
440
+ // so the 304 fires. No writeLastSync happens on 304 — so the
441
+ // on-disk file STAYS v1 until something actually changes. This
442
+ // is the documented behavior; the test makes it explicit so a
443
+ // future "always rewrite on 304" change would surface here.
444
+ const v1Path = globalLastSyncPath();
445
+ mkdirSync(join(v1Path, ".."), { recursive: true });
446
+ writeFileSync(
447
+ v1Path,
448
+ JSON.stringify({
449
+ schemaVersion: 1,
450
+ etag: '"unchanged"',
451
+ syncedAt: "2025-12-01T00:00:00Z",
452
+ }),
453
+ );
454
+
455
+ process.env.CLAUDECODE = "1";
456
+ server.setEtag('"unchanged"');
457
+ server.setLibraryResponse({
458
+ skills: [makeSkill("alice", "unmigrated", "1.0.0")],
459
+ removals: [],
460
+ syncedAt: "2026-05-19T00:00:00Z",
461
+ });
462
+
463
+ // Force a 304 — the mock server's `setEtag('"unchanged"')`
464
+ // combined with the v1 etag triggers the conditional short-circuit.
465
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
466
+ assert.match(stdout.text(), /up to date/, "304 should produce the up-to-date message");
467
+
468
+ // The on-disk file stayed v1 because runSync's 304 branch
469
+ // doesn't write. readLastSync's in-memory migration is what kept
470
+ // the conditional request behaving correctly.
471
+ const afterRaw = readFileSync(v1Path, "utf-8");
472
+ const after = JSON.parse(afterRaw);
473
+ assert.equal(after.schemaVersion, 1, "304 should leave the v1 file untouched on disk");
474
+
475
+ // But — the migrated in-memory shape is what `list` consumes.
476
+ // Calling readLastSync directly should return the v2 shape
477
+ // (synthesized from v1 + empty skills map). The integration
478
+ // contract is: any reader on the new CLI sees a v2 shape, even
479
+ // if the file on disk is still v1.
480
+ const migrated = readLastSync();
481
+ assert.equal(migrated.schemaVersion, 2);
482
+ assert.equal(migrated.etag, '"unchanged"');
483
+ assert.deepEqual(migrated.skills, {});
484
+ });
485
+ });
486
+
487
+ // ───────────────────────────────────────────────────────────────────────
488
+ // End-to-end drift state through the real update path
489
+ // ───────────────────────────────────────────────────────────────────────
490
+ //
491
+ // list.test.mjs synthesizes `.last-sync` and disk state by hand to
492
+ // exercise each drift state. Those tests prove computeSkillState is
493
+ // correct given a state. They do NOT prove that the state runSync
494
+ // actually persists, when fed back into list, yields the expected
495
+ // classification.
496
+ //
497
+ // These tests run a real update first, then mutate something, then
498
+ // run list. They lock in the round-trip from update → on-disk state
499
+ // → list → drift verdict — which is the only layer where a mismatch
500
+ // between sync's persisted shape and list's expected shape would
501
+ // surface as a user-visible bug.
502
+
503
+ describe("update → mutate → list: drift verdicts on real persisted state", () => {
504
+ beforeEach(setup);
505
+ afterEach(teardown);
506
+
507
+ it("OK after update: a freshly synced skill is `current` in list with no mutation", async () => {
508
+ process.env.CLAUDECODE = "1";
509
+ server.setEtag('"v1"');
510
+ server.setLibraryResponse({
511
+ skills: [makeSkill("alice", "fresh", "1.0.0")],
512
+ removals: [],
513
+ syncedAt: "2026-05-19T00:00:00Z",
514
+ });
515
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
516
+
517
+ stdout = createCaptureStream();
518
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
519
+ const [item] = JSON.parse(stdout.text());
520
+ assert.equal(item.state, "current");
521
+ });
522
+
523
+ it("EDIT after update: hand-edit a SKILL.md and list shows `edited`", async () => {
524
+ process.env.CLAUDECODE = "1";
525
+ server.setEtag('"v1"');
526
+ server.setLibraryResponse({
527
+ skills: [makeSkill("alice", "tampered", "1.0.0")],
528
+ removals: [],
529
+ syncedAt: "2026-05-19T00:00:00Z",
530
+ });
531
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
532
+
533
+ // Append a line to SKILL.md — the persisted SHA in .last-sync
534
+ // no longer matches the on-disk SHA. drift.mjs's contract is
535
+ // "version match + SHA mismatch → edited."
536
+ const skillPath = join(
537
+ resolvePlacementDir("claudeProject", "tampered"),
538
+ "SKILL.md",
539
+ );
540
+ const original = readFileSync(skillPath, "utf-8");
541
+ writeFileSync(skillPath, original + "\n<!-- user edit -->\n");
542
+
543
+ stdout = createCaptureStream();
544
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
545
+ const [item] = JSON.parse(stdout.text());
546
+ assert.equal(item.state, "edited", "post-update hand-edit must surface as `edited`");
547
+ });
548
+
549
+ it("STALE after update: server bumps version and list shows `stale`", async () => {
550
+ process.env.CLAUDECODE = "1";
551
+ server.setEtag('"v1"');
552
+ server.setLibraryResponse({
553
+ skills: [makeSkill("alice", "aging", "1.0.0")],
554
+ removals: [],
555
+ syncedAt: "2026-05-19T00:00:00Z",
556
+ });
557
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
558
+
559
+ // Server's library moves forward: same skill, new version. No
560
+ // second `update` happens — `.last-sync` still shows 1.0.0 as
561
+ // synced.
562
+ server.setEtag('"v2"');
563
+ server.setLibraryResponse({
564
+ skills: [makeSkill("alice", "aging", "1.1.0")],
565
+ removals: [],
566
+ syncedAt: "2026-05-19T01:00:00Z",
567
+ });
568
+
569
+ stdout = createCaptureStream();
570
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
571
+ const [item] = JSON.parse(stdout.text());
572
+ assert.equal(item.state, "stale", "library moved ahead → `stale`");
573
+ });
574
+
575
+ it("MISSING after update + manual delete: removing a placement makes list show `missing`", async () => {
576
+ process.env.CLAUDECODE = "1";
577
+ server.setEtag('"v1"');
578
+ server.setLibraryResponse({
579
+ skills: [makeSkill("alice", "deletable", "1.0.0")],
580
+ removals: [],
581
+ syncedAt: "2026-05-19T00:00:00Z",
582
+ });
583
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
584
+
585
+ // Delete the placement directory the user just synced. .last-sync
586
+ // still records this skill as present (the SHA map remembers it)
587
+ // but the disk no longer has it — drift.mjs's contract is
588
+ // "baseline exists + on-disk gone → missing."
589
+ rmSync(resolvePlacementDir("claudeProject", "deletable"), {
590
+ recursive: true,
591
+ force: true,
592
+ });
593
+
594
+ stdout = createCaptureStream();
595
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
596
+ const [item] = JSON.parse(stdout.text());
597
+ assert.equal(item.state, "missing");
598
+ });
599
+
600
+ it("multi-vendor mixed drift: claudeCode edited + cursor current rolls up to `edited` (worst-state-wins)", async () => {
601
+ // The rollup contract is "worst state wins" — defined in
602
+ // drift.mjs's `rollupState`. End-to-end coverage proves the
603
+ // contract holds when both placements go through the real
604
+ // update + list path, not just the synthesized state path.
605
+ process.env.CLAUDECODE = "1";
606
+ process.env.CURSOR_AGENT = "1";
607
+
608
+ server.setEtag('"v1"');
609
+ server.setLibraryResponse({
610
+ skills: [makeSkill("alice", "split-drift", "1.0.0")],
611
+ removals: [],
612
+ syncedAt: "2026-05-19T00:00:00Z",
613
+ });
614
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
615
+
616
+ // Edit ONLY the claudeCode placement.
617
+ const claudeFile = join(
618
+ resolvePlacementDir("claudeProject", "split-drift"),
619
+ "SKILL.md",
620
+ );
621
+ writeFileSync(claudeFile, readFileSync(claudeFile, "utf-8") + "// drift\n");
622
+
623
+ stdout = createCaptureStream();
624
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
625
+ const [item] = JSON.parse(stdout.text());
626
+
627
+ // Rollup: edited vs current → edited (worst wins). The user-
628
+ // visible signal is "something needs attention" even though one
629
+ // vendor is fine.
630
+ assert.equal(item.state, "edited");
631
+ const byVendor = Object.fromEntries(item.placements.map((p) => [p.vendor, p]));
632
+ assert.equal(byVendor.claudeCode.state, "edited");
633
+ assert.equal(byVendor.cursor.state, "current");
634
+ });
635
+ });
636
+
637
+ // ───────────────────────────────────────────────────────────────────────
638
+ // `get` / `add` / `remove` write-back to `.last-sync`
639
+ // ───────────────────────────────────────────────────────────────────────
640
+ //
641
+ // The 4.5.1 effective-vendors fix was a write-side contract gap. PR
642
+ // #1574's production-readiness audit surfaced THREE more of the same
643
+ // shape: `get`, `add`, and `remove` write to disk via `writeSkillDir`
644
+ // (or `removeSkillDir`) but never updated `.last-sync` — so the very
645
+ // next `list` reported every freshly-fetched / freshly-added skill as
646
+ // `MISSING` even though it was sitting right there on disk. These
647
+ // tests lock in the closed contract.
648
+
649
+ describe("get → list cross-command contract", () => {
650
+ beforeEach(setup);
651
+ afterEach(teardown);
652
+
653
+ it("after `get`, list reports the skill as current (not MISSING)", async () => {
654
+ process.env.CLAUDECODE = "1";
655
+
656
+ // Prime `.last-sync` with one prior-synced skill so we can also
657
+ // verify that `get` doesn't clobber existing entries.
658
+ server.setEtag('"baseline"');
659
+ server.setLibraryResponse({
660
+ skills: [makeSkill("alice", "prior-sync", "1.0.0")],
661
+ removals: [],
662
+ syncedAt: "2026-01-01T00:00:00Z",
663
+ });
664
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
665
+
666
+ // Now `get` a different skill that's NOT in the library
667
+ // response yet. The mock server serves it from setSkillResponse.
668
+ server.setSkillResponse(
669
+ "bob",
670
+ "fetched",
671
+ makeSkill("bob", "fetched", "1.0.0"),
672
+ );
673
+ stdout = createCaptureStream();
674
+ await runGet(
675
+ ["@bob/fetched", "--key", VALID_KEY, "--url", serverUrl],
676
+ { stdout },
677
+ );
678
+
679
+ // Server's library now reports both skills (the maintainer
680
+ // published `bob/fetched` to the catalog). list must see both
681
+ // as `current` — the freshly-fetched one IS on disk and IS in
682
+ // the baseline.
683
+ server.setEtag('"baseline+1"');
684
+ server.setLibraryResponse({
685
+ skills: [
686
+ makeSkill("alice", "prior-sync", "1.0.0"),
687
+ makeSkill("bob", "fetched", "1.0.0"),
688
+ ],
689
+ removals: [],
690
+ syncedAt: "2026-01-02T00:00:00Z",
691
+ });
692
+
693
+ stdout = createCaptureStream();
694
+ await runList(
695
+ ["--key", VALID_KEY, "--url", serverUrl, "--json"],
696
+ { stdout },
697
+ );
698
+ const parsed = JSON.parse(stdout.text());
699
+ const prior = parsed.find((s) => s.name === "prior-sync");
700
+ const fetched = parsed.find((s) => s.name === "fetched");
701
+ assert.equal(prior.state, "current", "prior-synced skill must stay current");
702
+ assert.equal(
703
+ fetched.state,
704
+ "current",
705
+ "skill fetched via `get` must show as current — get must update .last-sync",
706
+ );
707
+ });
708
+
709
+ it("get preserves the library-level etag (next update can still 304)", async () => {
710
+ // Critical invariant: `get` updates the per-skill SHA entry but
711
+ // leaves the library-level `etag` and `syncedAt` alone. If `get`
712
+ // clobbered the etag, the next `update` would do a wasted full
713
+ // sync. This test guards that behavior.
714
+ process.env.CLAUDECODE = "1";
715
+
716
+ server.setEtag('"original-etag"');
717
+ server.setLibraryResponse({
718
+ skills: [makeSkill("alice", "prior", "1.0.0")],
719
+ removals: [],
720
+ syncedAt: "2026-01-01T00:00:00Z",
721
+ });
722
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
723
+
724
+ server.setSkillResponse("bob", "extra", makeSkill("bob", "extra", "1.0.0"));
725
+ await runGet(
726
+ ["@bob/extra", "--key", VALID_KEY, "--url", serverUrl],
727
+ { stdout },
728
+ );
729
+
730
+ // Read the persisted state directly — the etag/syncedAt must
731
+ // match what the prior update wrote, not what `get` happened to
732
+ // synthesize.
733
+ const after = readLastSync();
734
+ assert.equal(after.etag, '"original-etag"', "get must not clobber the library etag");
735
+ assert.equal(
736
+ after.syncedAt,
737
+ "2026-01-01T00:00:00Z",
738
+ "get must not clobber the library syncedAt",
739
+ );
740
+ // The new skill entry IS in the map alongside the prior entry.
741
+ assert.ok(after.skills["alice/prior"], "prior entry preserved");
742
+ assert.ok(after.skills["bob/extra"], "newly-fetched entry recorded");
743
+ });
744
+ });
745
+
746
+ describe("add → list cross-command contract", () => {
747
+ beforeEach(setup);
748
+ afterEach(teardown);
749
+
750
+ it("after `add`, list reports the skill as current (not MISSING)", async () => {
751
+ process.env.CLAUDECODE = "1";
752
+
753
+ // `add` does POST /library/refs THEN GET /skills/<owner>/<name>.
754
+ // Both need responses on the mock server.
755
+ server.setAddResponseForAny({
756
+ status: 201,
757
+ body: {
758
+ added: {
759
+ owner: "carol",
760
+ name: "added-skill",
761
+ version: "2.0.0",
762
+ addedAt: "2026-01-01T00:00:00Z",
763
+ },
764
+ },
765
+ });
766
+ server.setSkillResponse(
767
+ "carol",
768
+ "added-skill",
769
+ makeSkill("carol", "added-skill", "2.0.0"),
770
+ );
771
+
772
+ await runAdd(
773
+ ["@carol/added-skill", "--key", VALID_KEY, "--url", serverUrl],
774
+ { stdout },
775
+ );
776
+
777
+ // Subsequent list with the catalog now showing the same skill —
778
+ // it should report as `current`.
779
+ server.setEtag('"v1"');
780
+ server.setLibraryResponse({
781
+ skills: [makeSkill("carol", "added-skill", "2.0.0")],
782
+ removals: [],
783
+ syncedAt: "2026-01-01T00:00:00Z",
784
+ });
785
+
786
+ stdout = createCaptureStream();
787
+ await runList(
788
+ ["--key", VALID_KEY, "--url", serverUrl, "--json"],
789
+ { stdout },
790
+ );
791
+ const [item] = JSON.parse(stdout.text());
792
+ assert.equal(item.name, "added-skill");
793
+ assert.equal(
794
+ item.state,
795
+ "current",
796
+ "skill added via `add` must show as current — add must update .last-sync",
797
+ );
798
+ });
799
+ });
800
+
801
+ describe("remove → list cross-command contract", () => {
802
+ beforeEach(setup);
803
+ afterEach(teardown);
804
+
805
+ it("after `remove`, the skill's entry is purged from .last-sync", async () => {
806
+ process.env.CLAUDECODE = "1";
807
+
808
+ // Sync two skills, then remove one.
809
+ server.setEtag('"v1"');
810
+ server.setLibraryResponse({
811
+ skills: [
812
+ makeSkill("alice", "keep", "1.0.0"),
813
+ makeSkill("alice", "drop", "1.0.0"),
814
+ ],
815
+ removals: [],
816
+ syncedAt: "2026-01-01T00:00:00Z",
817
+ });
818
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
819
+
820
+ // Confirm both entries are present in .last-sync.
821
+ let state = readLastSync();
822
+ assert.ok(state.skills["alice/keep"]);
823
+ assert.ok(state.skills["alice/drop"]);
824
+
825
+ server.setRemoveResponseForAny({
826
+ status: 200,
827
+ body: { removed: { owner: "alice", name: "drop" } },
828
+ });
829
+ await runRemove(
830
+ ["@alice/drop", "--key", VALID_KEY, "--url", serverUrl],
831
+ { stdout },
832
+ );
833
+
834
+ // `alice/drop` is now absent from .last-sync; the kept skill is
835
+ // unaffected; the library-level etag is preserved (remove does
836
+ // NOT touch it).
837
+ state = readLastSync();
838
+ assert.ok(state.skills["alice/keep"], "kept skill's entry remains");
839
+ assert.equal(
840
+ state.skills["alice/drop"],
841
+ undefined,
842
+ "removed skill's entry must be purged",
843
+ );
844
+ assert.equal(state.etag, '"v1"', "remove must not clobber the library etag");
845
+ });
846
+
847
+ it("removing a skill that was never in .last-sync is idempotent (no throw, no spurious write)", async () => {
848
+ // Edge case: user runs `skillrepo remove @alice/never-synced`
849
+ // for a skill that was never on disk and was never tracked. The
850
+ // server returns 404 (not-in-library) but `remove` still tries
851
+ // to clean local files (which don't exist) and update
852
+ // `.last-sync` (which has no entry to remove). This must not
853
+ // throw and must not change the etag/syncedAt.
854
+ process.env.CLAUDECODE = "1";
855
+ server.setEtag('"unchanged"');
856
+ server.setLibraryResponse({
857
+ skills: [],
858
+ removals: [],
859
+ syncedAt: "2026-01-01T00:00:00Z",
860
+ });
861
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
862
+
863
+ server.setRemoveResponseForAny({
864
+ status: 404,
865
+ body: { error: "Skill not found", code: "not_found" },
866
+ });
867
+ await assert.doesNotReject(() =>
868
+ runRemove(
869
+ ["@alice/never-synced", "--key", VALID_KEY, "--url", serverUrl],
870
+ { stdout },
871
+ ),
872
+ );
873
+
874
+ const state = readLastSync();
875
+ assert.equal(state.etag, '"unchanged"');
876
+ });
877
+ });
878
+
879
+ // ───────────────────────────────────────────────────────────────────────
880
+ // QA-flagged coverage gaps (from PR #1574 production-readiness audit)
881
+ // ───────────────────────────────────────────────────────────────────────
882
+
883
+ describe("additional coverage from production-readiness audit", () => {
884
+ beforeEach(setup);
885
+ afterEach(teardown);
886
+
887
+ it("corrupt .last-sync survives — runUpdate writes a fresh v2 file and list works", async () => {
888
+ // Real-world failure mode: a power loss mid-write leaves a
889
+ // partially-written `.last-sync` that won't parse as JSON.
890
+ // `readLastSync` returns null on parse failure (documented
891
+ // forward-compat behavior) and runSync performs a full re-sync.
892
+ // This test holds that contract — a future refactor that removes
893
+ // the try/catch and throws on parse error would break every user
894
+ // with a corrupt cache.
895
+ const path = globalLastSyncPath();
896
+ mkdirSync(join(path, ".."), { recursive: true });
897
+ writeFileSync(path, "CORRUPTED-NOT-JSON{{{", "utf-8");
898
+
899
+ process.env.CLAUDECODE = "1";
900
+ server.setEtag('"fresh"');
901
+ server.setLibraryResponse({
902
+ skills: [makeSkill("alice", "recovered", "1.0.0")],
903
+ removals: [],
904
+ syncedAt: "2026-05-19T00:00:00Z",
905
+ });
906
+
907
+ await assert.doesNotReject(
908
+ () => runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
909
+ "runUpdate must survive a corrupt .last-sync and complete the sync",
910
+ );
911
+ const after = readLastSync();
912
+ assert.equal(after.schemaVersion, 2);
913
+ assert.equal(after.etag, '"fresh"');
914
+ assert.ok(after.skills["alice/recovered"]);
915
+ });
916
+
917
+ it("delta sync preserves entries the server didn't return in this round", async () => {
918
+ // The carry-forward contract. Server returns skill A on sync 1,
919
+ // then returns only skill B on sync 2 (delta — A didn't change).
920
+ // After sync 2, `.last-sync` must STILL contain A's SHA entry.
921
+ // Without this carry-forward, every delta sync shrinks the map
922
+ // until only the most-recently-touched skills remain — defeating
923
+ // list's drift detection for stable skills.
924
+ process.env.CLAUDECODE = "1";
925
+
926
+ // Sync 1: both A and B in the library.
927
+ server.setEtag('"sync1"');
928
+ server.setLibraryResponse({
929
+ skills: [
930
+ makeSkill("alice", "stable", "1.0.0"),
931
+ makeSkill("alice", "churning", "1.0.0"),
932
+ ],
933
+ removals: [],
934
+ syncedAt: "2026-01-01T00:00:00Z",
935
+ });
936
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
937
+ let state = readLastSync();
938
+ assert.ok(state.skills["alice/stable"]);
939
+ assert.ok(state.skills["alice/churning"]);
940
+
941
+ // Sync 2: server returns ONLY the changed skill (delta). The
942
+ // stable skill is untouched. Note: production runSync sends
943
+ // `since` based on the persisted syncedAt; the mock server
944
+ // doesn't filter by `since`, so the test mimics the delta-only
945
+ // server response shape directly.
946
+ server.setEtag('"sync2"');
947
+ server.setLibraryResponse({
948
+ skills: [makeSkill("alice", "churning", "1.1.0")],
949
+ removals: [],
950
+ syncedAt: "2026-01-02T00:00:00Z",
951
+ });
952
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
953
+
954
+ state = readLastSync();
955
+ assert.ok(
956
+ state.skills["alice/stable"],
957
+ "stable skill's entry must survive a delta sync that didn't return it",
958
+ );
959
+ assert.equal(state.skills["alice/churning"].version, "1.1.0");
960
+ });
961
+
962
+ it("three detected vendors all receive the skill (proves verbatim passthrough)", async () => {
963
+ // Earlier integration tests only cover two vendors. A hardcoded
964
+ // `["claudeCode", "cursor"]` impl would pass those — it would
965
+ // even produce both placements. This test forces three vendors
966
+ // (claudeCode + cursor + windsurf via CURSOR_AGENT + CLAUDECODE
967
+ // + the windsurf home signal). Detection returns three entries
968
+ // and `update` must write to all three.
969
+ process.env.CLAUDECODE = "1";
970
+ // Cursor needs its env signal AND its settings.json equivalent
971
+ // (cursor's home signal is `.cursor` which is a dir — cursor
972
+ // doesn't have the same `.claude` poisoning issue because we
973
+ // never write into `~/.cursor`).
974
+ process.env.CURSOR_AGENT = "1";
975
+ // Trigger windsurf detection via its home signal
976
+ // (`~/.codeium/windsurf/`). The dir-existing signal is the
977
+ // documented detection mechanism per the registry.
978
+ mkdirSync(join(process.env.HOME, ".codeium", "windsurf"), {
979
+ recursive: true,
980
+ });
981
+
982
+ server.setEtag('"v1"');
983
+ server.setLibraryResponse({
984
+ skills: [makeSkill("alice", "everywhere", "1.0.0")],
985
+ removals: [],
986
+ syncedAt: "2026-01-01T00:00:00Z",
987
+ });
988
+
989
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
990
+
991
+ // All three vendors' placements must exist on disk. claudeCode
992
+ // writes to `.claude/skills/`. cursor + windsurf both share the
993
+ // `.agents/skills/` cohort placement target — one physical
994
+ // write satisfies both (placementTargetsFor dedupes by target).
995
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", "everywhere")));
996
+ assert.ok(existsSync(resolvePlacementDir("agentsProject", "everywhere")));
997
+
998
+ // List walks every detected vendor independently. claudeCode,
999
+ // cursor, AND windsurf all have project targets (windsurf's
1000
+ // project target is `agentsProject` per the registry — windsurf
1001
+ // joins the cohort for project-scope skills, even though its
1002
+ // personal-scope placement is at `~/.codeium/windsurf/skills/`).
1003
+ // All three must appear in the JSON output, all `current`.
1004
+ stdout = createCaptureStream();
1005
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
1006
+ const [item] = JSON.parse(stdout.text());
1007
+ assert.equal(item.state, "current");
1008
+ const vendors = item.placements.map((p) => p.vendor).sort();
1009
+ assert.deepEqual(
1010
+ vendors,
1011
+ ["claudeCode", "cursor", "windsurf"],
1012
+ "list must walk every detected vendor (3 in this case — proves no hardcoded pair)",
1013
+ );
1014
+ for (const p of item.placements) {
1015
+ assert.equal(p.state, "current");
1016
+ }
1017
+ });
1018
+ });