skillrepo 4.4.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -3
- package/bin/skillrepo.mjs +45 -0
- package/package.json +3 -2
- package/src/commands/list.mjs +328 -56
- package/src/lib/crypto-shas.mjs +131 -0
- package/src/lib/drift.mjs +175 -0
- package/src/lib/file-write.mjs +16 -1
- package/src/lib/npm-update-check.mjs +366 -0
- package/src/lib/paths.mjs +10 -0
- package/src/lib/placement-walk.mjs +285 -0
- package/src/lib/sync.mjs +163 -17
- package/src/test/commands/list.test.mjs +510 -2
- package/src/test/lib/crypto-shas.test.mjs +172 -0
- package/src/test/lib/drift.test.mjs +289 -0
- package/src/test/lib/npm-update-check.test.mjs +670 -0
- package/src/test/lib/placement-walk.test.mjs +453 -0
- package/src/test/lib/sync.test.mjs +409 -1
|
@@ -34,10 +34,17 @@ import {
|
|
|
34
34
|
import { join } from "node:path";
|
|
35
35
|
import { tmpdir } from "node:os";
|
|
36
36
|
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
runSync,
|
|
39
|
+
readLastSync,
|
|
40
|
+
writeLastSync,
|
|
41
|
+
LAST_SYNC_SCHEMA_VERSION,
|
|
42
|
+
} from "../../lib/sync.mjs";
|
|
38
43
|
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
39
44
|
import { globalLastSyncPath } from "../../lib/paths.mjs";
|
|
40
45
|
import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
46
|
+
import { computeSkillShas } from "../../lib/crypto-shas.mjs";
|
|
47
|
+
import { walkDetectedPlacements } from "../../lib/placement-walk.mjs";
|
|
41
48
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
42
49
|
import {
|
|
43
50
|
captureHome,
|
|
@@ -112,6 +119,128 @@ describe("readLastSync / writeLastSync", () => {
|
|
|
112
119
|
assert.equal(result.schemaVersion, LAST_SYNC_SCHEMA_VERSION);
|
|
113
120
|
});
|
|
114
121
|
|
|
122
|
+
it("writes v2 schema (LAST_SYNC_SCHEMA_VERSION === 2)", () => {
|
|
123
|
+
// Locks the constant so a future bump back to 1, or accidental
|
|
124
|
+
// change, fails loudly. The whole epic depends on v2 being the
|
|
125
|
+
// current schema and v1 files producing null (full-sync trigger).
|
|
126
|
+
assert.equal(LAST_SYNC_SCHEMA_VERSION, 2);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("persists an empty skills map by default", () => {
|
|
130
|
+
// Callers that omit `skills` (e.g. the round-1 304 short-circuit
|
|
131
|
+
// before any rewrite) get an empty map, not undefined. This keeps
|
|
132
|
+
// the on-disk shape stable.
|
|
133
|
+
writeLastSync({ etag: '"x"', syncedAt: "x" });
|
|
134
|
+
const result = readLastSync();
|
|
135
|
+
assert.deepEqual(result.skills, {});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("round-trips a populated skills map", () => {
|
|
139
|
+
const skills = {
|
|
140
|
+
"alice/pdf-helper": {
|
|
141
|
+
version: "1.4.0",
|
|
142
|
+
skillMdSha256: "a".repeat(64),
|
|
143
|
+
filesSha256: "b".repeat(64),
|
|
144
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
145
|
+
},
|
|
146
|
+
"bob/code-review": {
|
|
147
|
+
version: "2.0.1",
|
|
148
|
+
skillMdSha256: "c".repeat(64),
|
|
149
|
+
filesSha256: "d".repeat(64),
|
|
150
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
writeLastSync({
|
|
154
|
+
etag: '"abc"',
|
|
155
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
156
|
+
skills,
|
|
157
|
+
});
|
|
158
|
+
const result = readLastSync();
|
|
159
|
+
assert.deepEqual(result.skills, skills);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("an old v1 file migrates in-memory to a v2 shape with empty skills", () => {
|
|
163
|
+
// Backward compat: a user upgrading from a v1-era CLI to v2 still
|
|
164
|
+
// has a v1 `.last-sync` on disk. Returning null would force a
|
|
165
|
+
// full sync on every command (the etag is lost), which is
|
|
166
|
+
// wasteful AND breaks `init`'s "Library is up to date" branch
|
|
167
|
+
// for users on a fresh upgrade.
|
|
168
|
+
//
|
|
169
|
+
// The migration is in-memory only: the on-disk file stays v1
|
|
170
|
+
// until the next successful runSync writes a fresh v2. The
|
|
171
|
+
// skills map starts empty — we don't fabricate per-skill state
|
|
172
|
+
// from a sync that didn't track it.
|
|
173
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
174
|
+
const v1 = {
|
|
175
|
+
schemaVersion: 1,
|
|
176
|
+
etag: '"old"',
|
|
177
|
+
syncedAt: "2024-12-01T00:00:00Z",
|
|
178
|
+
};
|
|
179
|
+
writeFileSync(globalLastSyncPath(), JSON.stringify(v1));
|
|
180
|
+
const migrated = readLastSync();
|
|
181
|
+
assert.ok(migrated, "v1 file must migrate, not return null");
|
|
182
|
+
assert.equal(migrated.schemaVersion, LAST_SYNC_SCHEMA_VERSION);
|
|
183
|
+
assert.equal(migrated.etag, '"old"');
|
|
184
|
+
assert.equal(migrated.syncedAt, "2024-12-01T00:00:00Z");
|
|
185
|
+
assert.deepEqual(migrated.skills, {});
|
|
186
|
+
|
|
187
|
+
// The on-disk file is NOT modified by the read.
|
|
188
|
+
const onDisk = JSON.parse(readFileSync(globalLastSyncPath(), "utf-8"));
|
|
189
|
+
assert.equal(onDisk.schemaVersion, 1, "on-disk file must stay v1 until next write");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("coerces a malformed `skills` field (array) to {} in v2", () => {
|
|
193
|
+
// A truncated write or a hand-edit could produce a v2 file with
|
|
194
|
+
// `skills: []`. typeof [] === "object" so a naive guard at the
|
|
195
|
+
// caller would pass; direct dictionary access then returns
|
|
196
|
+
// undefined for every key and the user sees every skill as
|
|
197
|
+
// missing. readLastSync normalizes this to {} so callers can
|
|
198
|
+
// trust the contract.
|
|
199
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
200
|
+
writeFileSync(
|
|
201
|
+
globalLastSyncPath(),
|
|
202
|
+
JSON.stringify({ schemaVersion: 2, etag: "x", syncedAt: "x", skills: [] }),
|
|
203
|
+
);
|
|
204
|
+
const result = readLastSync();
|
|
205
|
+
assert.ok(result, "v2 file with malformed skills should still parse");
|
|
206
|
+
assert.equal(typeof result.skills, "object");
|
|
207
|
+
assert.equal(Array.isArray(result.skills), false);
|
|
208
|
+
assert.deepEqual(result.skills, {});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("coerces a null `skills` field to {} in v2", () => {
|
|
212
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
213
|
+
writeFileSync(
|
|
214
|
+
globalLastSyncPath(),
|
|
215
|
+
JSON.stringify({ schemaVersion: 2, etag: "x", syncedAt: "x", skills: null }),
|
|
216
|
+
);
|
|
217
|
+
const result = readLastSync();
|
|
218
|
+
assert.deepEqual(result.skills, {});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("coerces a missing `skills` field to {} in v2", () => {
|
|
222
|
+
// A v2 file from an older writer that forgot to emit `skills`.
|
|
223
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
224
|
+
writeFileSync(
|
|
225
|
+
globalLastSyncPath(),
|
|
226
|
+
JSON.stringify({ schemaVersion: 2, etag: "x", syncedAt: "x" }),
|
|
227
|
+
);
|
|
228
|
+
const result = readLastSync();
|
|
229
|
+
assert.deepEqual(result.skills, {});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("a file with an unknown schemaVersion still reads as null", () => {
|
|
233
|
+
// Forward compat: a v3 or v999 file (from a newer CLI we don't
|
|
234
|
+
// understand) reads as null, triggering a full sync. We only
|
|
235
|
+
// migrate KNOWN older shapes, not arbitrary unknown ones.
|
|
236
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
237
|
+
writeFileSync(
|
|
238
|
+
globalLastSyncPath(),
|
|
239
|
+
JSON.stringify({ schemaVersion: 999, etag: "x", syncedAt: "x", skills: {} }),
|
|
240
|
+
);
|
|
241
|
+
assert.equal(readLastSync(), null);
|
|
242
|
+
});
|
|
243
|
+
|
|
115
244
|
it("returns null on corrupt JSON", () => {
|
|
116
245
|
const path = globalLastSyncPath();
|
|
117
246
|
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
@@ -260,6 +389,285 @@ describe("runSync — write skills", () => {
|
|
|
260
389
|
});
|
|
261
390
|
});
|
|
262
391
|
|
|
392
|
+
// ── runSync — skills map population (v2) ───────────────────────────────
|
|
393
|
+
//
|
|
394
|
+
// #1553 — every successful sync populates `.last-sync.skills` with the
|
|
395
|
+
// per-skill version + content SHAs. This is what unblocks per-skill
|
|
396
|
+
// drift detection in #1555.
|
|
397
|
+
|
|
398
|
+
describe("runSync — skills map population (v2)", () => {
|
|
399
|
+
beforeEach(setupServer);
|
|
400
|
+
afterEach(teardownServer);
|
|
401
|
+
|
|
402
|
+
it("populates skills map for every written skill", async () => {
|
|
403
|
+
server.setEtag('"v1"');
|
|
404
|
+
server.setLibraryResponse({
|
|
405
|
+
skills: [makeSkill("first"), makeSkill("second")],
|
|
406
|
+
removals: [],
|
|
407
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
408
|
+
});
|
|
409
|
+
await runSync({
|
|
410
|
+
serverUrl,
|
|
411
|
+
apiKey: VALID_KEY,
|
|
412
|
+
vendors: ["claudeCode"],
|
|
413
|
+
});
|
|
414
|
+
const state = readLastSync();
|
|
415
|
+
assert.ok(state, "state file must exist after a successful sync");
|
|
416
|
+
assert.deepEqual(
|
|
417
|
+
Object.keys(state.skills).sort(),
|
|
418
|
+
["alice/first", "alice/second"],
|
|
419
|
+
);
|
|
420
|
+
for (const entry of Object.values(state.skills)) {
|
|
421
|
+
assert.equal(entry.version, "1.0.0");
|
|
422
|
+
assert.equal(typeof entry.skillMdSha256, "string");
|
|
423
|
+
assert.equal(entry.skillMdSha256.length, 64);
|
|
424
|
+
assert.equal(typeof entry.filesSha256, "string");
|
|
425
|
+
assert.equal(entry.filesSha256.length, 64);
|
|
426
|
+
assert.equal(entry.syncedAt, "2025-01-01T00:00:00Z");
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("persisted SHA matches the locally-computed SHA of the same content", async () => {
|
|
431
|
+
// The whole point of #1555's `edited` detection: a SHA persisted
|
|
432
|
+
// at sync time must equal the SHA of the same content recomputed
|
|
433
|
+
// later (e.g. from disk). This test pins that invariant by
|
|
434
|
+
// recomputing on the in-memory `skill.files` payload and asserting
|
|
435
|
+
// equality with the persisted entry.
|
|
436
|
+
const skill = makeSkill("integrity-check");
|
|
437
|
+
server.setEtag('"v1"');
|
|
438
|
+
server.setLibraryResponse({
|
|
439
|
+
skills: [skill],
|
|
440
|
+
removals: [],
|
|
441
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
442
|
+
});
|
|
443
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
444
|
+
|
|
445
|
+
const state = readLastSync();
|
|
446
|
+
const entry = state.skills["alice/integrity-check"];
|
|
447
|
+
const expected = computeSkillShas(skill.files);
|
|
448
|
+
assert.equal(entry.skillMdSha256, expected.skillMdSha256);
|
|
449
|
+
assert.equal(entry.filesSha256, expected.filesSha256);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("omits filesIncomplete skills from the skills map", async () => {
|
|
453
|
+
const incomplete = makeSkill("incomplete");
|
|
454
|
+
incomplete.filesIncomplete = true;
|
|
455
|
+
server.setEtag('"v1"');
|
|
456
|
+
server.setLibraryResponse({
|
|
457
|
+
skills: [incomplete, makeSkill("complete")],
|
|
458
|
+
removals: [],
|
|
459
|
+
syncedAt: "x",
|
|
460
|
+
});
|
|
461
|
+
await runSync({
|
|
462
|
+
serverUrl,
|
|
463
|
+
apiKey: VALID_KEY,
|
|
464
|
+
vendors: ["claudeCode"],
|
|
465
|
+
});
|
|
466
|
+
// The state file is NOT persisted at all when any skill is
|
|
467
|
+
// filesIncomplete (existing behavior), so the map is implicitly
|
|
468
|
+
// empty for this sync. The next sync will be a full fetch.
|
|
469
|
+
assert.equal(readLastSync(), null);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("carries forward prior entries on a delta sync that didn't touch a skill", async () => {
|
|
473
|
+
// First sync writes two skills, persists them in the map.
|
|
474
|
+
server.setEtag('"v1"');
|
|
475
|
+
server.setLibraryResponse({
|
|
476
|
+
skills: [makeSkill("untouched"), makeSkill("about-to-change", "v1")],
|
|
477
|
+
removals: [],
|
|
478
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
479
|
+
});
|
|
480
|
+
await runSync({
|
|
481
|
+
serverUrl,
|
|
482
|
+
apiKey: VALID_KEY,
|
|
483
|
+
vendors: ["claudeCode"],
|
|
484
|
+
});
|
|
485
|
+
const firstState = readLastSync();
|
|
486
|
+
const untouchedShaBefore = firstState.skills["alice/untouched"].skillMdSha256;
|
|
487
|
+
|
|
488
|
+
// Second sync (different ETag → not 304) returns ONLY the changed
|
|
489
|
+
// skill, simulating a delta sync. The untouched skill must remain
|
|
490
|
+
// in the persisted map — otherwise the local cache would shrink
|
|
491
|
+
// on every delta sync and drift detection would lose entries.
|
|
492
|
+
server.setEtag('"v2"');
|
|
493
|
+
server.setLibraryResponse({
|
|
494
|
+
skills: [makeSkill("about-to-change", "v2-content")],
|
|
495
|
+
removals: [],
|
|
496
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
497
|
+
});
|
|
498
|
+
await runSync({
|
|
499
|
+
serverUrl,
|
|
500
|
+
apiKey: VALID_KEY,
|
|
501
|
+
vendors: ["claudeCode"],
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const secondState = readLastSync();
|
|
505
|
+
// The untouched skill survives unchanged.
|
|
506
|
+
assert.ok(
|
|
507
|
+
secondState.skills["alice/untouched"],
|
|
508
|
+
"untouched skill must be carried forward",
|
|
509
|
+
);
|
|
510
|
+
assert.equal(
|
|
511
|
+
secondState.skills["alice/untouched"].skillMdSha256,
|
|
512
|
+
untouchedShaBefore,
|
|
513
|
+
"carried-forward SHA must equal the prior SHA, unchanged",
|
|
514
|
+
);
|
|
515
|
+
// The changed skill has fresh SHAs from the new content.
|
|
516
|
+
assert.notEqual(
|
|
517
|
+
secondState.skills["alice/about-to-change"].skillMdSha256,
|
|
518
|
+
firstState.skills["alice/about-to-change"].skillMdSha256,
|
|
519
|
+
"changed skill must have a different SHA",
|
|
520
|
+
);
|
|
521
|
+
assert.equal(
|
|
522
|
+
secondState.skills["alice/about-to-change"].syncedAt,
|
|
523
|
+
"2025-01-02T00:00:00Z",
|
|
524
|
+
);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("removes tombstoned entries from the skills map", async () => {
|
|
528
|
+
// Seed: two skills on disk + in the map.
|
|
529
|
+
server.setEtag('"v1"');
|
|
530
|
+
server.setLibraryResponse({
|
|
531
|
+
skills: [makeSkill("survivor"), makeSkill("doomed")],
|
|
532
|
+
removals: [],
|
|
533
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
534
|
+
});
|
|
535
|
+
await runSync({
|
|
536
|
+
serverUrl,
|
|
537
|
+
apiKey: VALID_KEY,
|
|
538
|
+
vendors: ["claudeCode"],
|
|
539
|
+
});
|
|
540
|
+
const before = readLastSync();
|
|
541
|
+
assert.ok(before.skills["alice/doomed"]);
|
|
542
|
+
|
|
543
|
+
// Second sync delivers a tombstone for `doomed`. The map entry
|
|
544
|
+
// must be purged — leaving a stale entry would let `list` think
|
|
545
|
+
// the skill is still tracked when in fact the library no longer
|
|
546
|
+
// references it.
|
|
547
|
+
server.setEtag('"v2"');
|
|
548
|
+
server.setLibraryResponse({
|
|
549
|
+
skills: [],
|
|
550
|
+
removals: [{ owner: "alice", name: "doomed", removedAt: "2025-01-02T00:00:00Z" }],
|
|
551
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
552
|
+
});
|
|
553
|
+
await runSync({
|
|
554
|
+
serverUrl,
|
|
555
|
+
apiKey: VALID_KEY,
|
|
556
|
+
vendors: ["claudeCode"],
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
const after = readLastSync();
|
|
560
|
+
assert.equal(after.skills["alice/doomed"], undefined, "tombstoned entry must be deleted");
|
|
561
|
+
assert.ok(after.skills["alice/survivor"], "untouched entry must remain");
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("the skills map is empty after 304 short-circuit IF prior state was empty", async () => {
|
|
565
|
+
// 304 short-circuit means runSync returns the existing `lastSync`
|
|
566
|
+
// unchanged. If there was no `.last-sync` file at all, a 304
|
|
567
|
+
// can't happen (no ETag to send) — so this test only validates
|
|
568
|
+
// the path where a prior empty-skills state exists.
|
|
569
|
+
writeLastSync({ etag: '"v1"', syncedAt: "x", skills: {} });
|
|
570
|
+
server.setEtag('"v1"');
|
|
571
|
+
server.setLibraryResponse({
|
|
572
|
+
skills: [],
|
|
573
|
+
removals: [],
|
|
574
|
+
syncedAt: "ignored",
|
|
575
|
+
});
|
|
576
|
+
await runSync({
|
|
577
|
+
serverUrl,
|
|
578
|
+
apiKey: VALID_KEY,
|
|
579
|
+
vendors: ["claudeCode"],
|
|
580
|
+
});
|
|
581
|
+
const state = readLastSync();
|
|
582
|
+
assert.deepEqual(state.skills, {});
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// ── SHA round-trip — runSync writes, placement-walk reads back ─────────
|
|
587
|
+
//
|
|
588
|
+
// THE load-bearing invariant of the staleness epic: the SHAs runSync
|
|
589
|
+
// persists to .last-sync must equal the SHAs placement-walk computes
|
|
590
|
+
// from the files on disk. If they don't match, `list`'s `edited`
|
|
591
|
+
// detection breaks for every skill on every invocation.
|
|
592
|
+
//
|
|
593
|
+
// Individual halves are tested in isolation (sync.test asserts SHAs
|
|
594
|
+
// against in-memory skill.files; placement-walk.test asserts disk
|
|
595
|
+
// reads round-trip with computeSkillShas). This block composes them.
|
|
596
|
+
|
|
597
|
+
describe("runSync + walkDetectedPlacements round-trip", () => {
|
|
598
|
+
beforeEach(setupServer);
|
|
599
|
+
afterEach(teardownServer);
|
|
600
|
+
|
|
601
|
+
it("disk SHAs after runSync match the persisted .last-sync SHAs", async () => {
|
|
602
|
+
server.setEtag('"v1"');
|
|
603
|
+
server.setLibraryResponse({
|
|
604
|
+
skills: [
|
|
605
|
+
makeSkill("single-file"),
|
|
606
|
+
// Multi-file skill to exercise the `filesSha256` path
|
|
607
|
+
// (sorted path|sha projection across several files).
|
|
608
|
+
{
|
|
609
|
+
...makeSkill("multi-file"),
|
|
610
|
+
files: [
|
|
611
|
+
{
|
|
612
|
+
path: "SKILL.md",
|
|
613
|
+
content: "---\nname: multi-file\ndescription: x\n---\n# body\n",
|
|
614
|
+
sha256: "x",
|
|
615
|
+
size: 0,
|
|
616
|
+
contentType: "text/markdown",
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
path: "references/notes.md",
|
|
620
|
+
content: "# notes\n",
|
|
621
|
+
sha256: "x",
|
|
622
|
+
size: 8,
|
|
623
|
+
contentType: "text/markdown",
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
path: "scripts/run.sh",
|
|
627
|
+
content: "#!/bin/sh\necho hi\n",
|
|
628
|
+
sha256: "x",
|
|
629
|
+
size: 18,
|
|
630
|
+
contentType: "text/x-shellscript",
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
},
|
|
634
|
+
],
|
|
635
|
+
removals: [],
|
|
636
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// runSync writes the files to .claude/skills/ AND persists SHAs.
|
|
640
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
641
|
+
|
|
642
|
+
// Walk the disk independently.
|
|
643
|
+
const placements = walkDetectedPlacements(["claudeCode"]);
|
|
644
|
+
|
|
645
|
+
// Read the persisted SHAs out of .last-sync.
|
|
646
|
+
const state = readLastSync();
|
|
647
|
+
assert.ok(state, "post-sync state must exist");
|
|
648
|
+
|
|
649
|
+
// For each skill: the SHAs on disk must equal the SHAs persisted.
|
|
650
|
+
// If this fails, EVERY user would see EVERY skill as `edited` on
|
|
651
|
+
// their first `list` after syncing — the epic's failure mode.
|
|
652
|
+
for (const skillName of ["single-file", "multi-file"]) {
|
|
653
|
+
const onDisk = placements.get(`claudeCode::project::${skillName}`);
|
|
654
|
+
const persisted = state.skills[`alice/${skillName}`];
|
|
655
|
+
assert.ok(onDisk, `disk placement for ${skillName} must exist`);
|
|
656
|
+
assert.ok(persisted, `persisted entry for ${skillName} must exist`);
|
|
657
|
+
assert.equal(
|
|
658
|
+
onDisk.skillMdSha256,
|
|
659
|
+
persisted.skillMdSha256,
|
|
660
|
+
`${skillName}: SKILL.md SHA must round-trip`,
|
|
661
|
+
);
|
|
662
|
+
assert.equal(
|
|
663
|
+
onDisk.filesSha256,
|
|
664
|
+
persisted.filesSha256,
|
|
665
|
+
`${skillName}: files SHA must round-trip`,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
263
671
|
// ── runSync — tombstones ───────────────────────────────────────────────
|
|
264
672
|
|
|
265
673
|
describe("runSync — tombstones", () => {
|