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
package/src/lib/sync.mjs
CHANGED
|
@@ -22,6 +22,32 @@
|
|
|
22
22
|
* can evolve it without breaking older CLIs catastrophically. Future
|
|
23
23
|
* versions should bump the schema when fields are added/removed.
|
|
24
24
|
*
|
|
25
|
+
* Schema history
|
|
26
|
+
* --------------
|
|
27
|
+
* v1 — `{ schemaVersion, etag, syncedAt }`. Library-level state only;
|
|
28
|
+
* no per-skill information persisted. Sufficient for ETag-based
|
|
29
|
+
* short-circuit, insufficient for per-skill drift detection.
|
|
30
|
+
* v2 — Adds a `skills` map keyed by `"<owner>/<name>"` carrying the
|
|
31
|
+
* version + content SHAs of every skill written in the last
|
|
32
|
+
* successful sync. Unblocks per-skill drift detection in `list`
|
|
33
|
+
* (#1555) and any future `status`-style command.
|
|
34
|
+
*
|
|
35
|
+
* Forward/backward compatibility
|
|
36
|
+
* ------------------------------
|
|
37
|
+
* • Old CLI reading a v2 file → unknown schemaVersion → null → full
|
|
38
|
+
* sync → writes v1 file. User loses per-skill metadata until they
|
|
39
|
+
* upgrade again.
|
|
40
|
+
* • New CLI reading a v1 file → in-memory migration to a v2 shape
|
|
41
|
+
* with the existing `etag` + `syncedAt` preserved and an EMPTY
|
|
42
|
+
* `skills` map. The next successful sync writes a v2 file on
|
|
43
|
+
* disk. This is safe because an empty map means "we don't know
|
|
44
|
+
* per-skill state from this sync" — callers must treat absent
|
|
45
|
+
* entries as unknown, not as "missing from library." Preserving
|
|
46
|
+
* the etag also keeps the 304 short-circuit working, so an
|
|
47
|
+
* upgrade-then-no-changes path stays cheap.
|
|
48
|
+
* • Future unknown schemaVersion → null → full sync. Forward-compat
|
|
49
|
+
* without crashing.
|
|
50
|
+
*
|
|
25
51
|
* @typedef {Object} SyncSummary
|
|
26
52
|
* @property {number} added - Skills newly written that were not on disk
|
|
27
53
|
* @property {number} updated - Skills overwritten on disk
|
|
@@ -45,14 +71,20 @@
|
|
|
45
71
|
* @property {string} [syncedAt]
|
|
46
72
|
* @property {string} syncedAt - ISO timestamp from the server response
|
|
47
73
|
*
|
|
74
|
+
* @typedef {Object} SyncedSkillEntry
|
|
75
|
+
* @property {string} version - Version of the skill as synced (e.g. "1.4.0").
|
|
76
|
+
* @property {string} skillMdSha256 - Hex SHA-256 of SKILL.md content as written.
|
|
77
|
+
* @property {string} filesSha256 - Hex SHA-256 of the full file projection.
|
|
78
|
+
* @property {string} syncedAt - ISO timestamp when this skill was synced.
|
|
79
|
+
*
|
|
48
80
|
* @typedef {Object} SyncStateFile
|
|
49
81
|
* @property {number} schemaVersion
|
|
50
82
|
* @property {string|null} etag
|
|
51
83
|
* @property {string} syncedAt
|
|
84
|
+
* @property {Record<string, SyncedSkillEntry>} skills - v2+. Keyed by `"<owner>/<name>"`.
|
|
52
85
|
*/
|
|
53
86
|
|
|
54
|
-
import { existsSync,
|
|
55
|
-
import { dirname } from "node:path";
|
|
87
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
56
88
|
|
|
57
89
|
import { getLibrary } from "./http.mjs";
|
|
58
90
|
import {
|
|
@@ -62,16 +94,21 @@ import {
|
|
|
62
94
|
placementTargetsFor,
|
|
63
95
|
resolvePlacementDir,
|
|
64
96
|
} from "./file-write.mjs";
|
|
97
|
+
import { writeFileAtomic } from "./fs-utils.mjs";
|
|
65
98
|
import { globalLastSyncPath } from "./paths.mjs";
|
|
66
99
|
import { diskError, validationError } from "./errors.mjs";
|
|
100
|
+
import { computeSkillShas } from "./crypto-shas.mjs";
|
|
67
101
|
|
|
68
102
|
/**
|
|
69
103
|
* Current schema version of the .last-sync file. Bump on any
|
|
70
104
|
* structural change. The reader treats unknown future versions as
|
|
71
105
|
* "ignore the cache and do a full sync" — forward-compat without
|
|
72
106
|
* crashing.
|
|
107
|
+
*
|
|
108
|
+
* v2 (#1553): adds `skills` map keyed by `"<owner>/<name>"` →
|
|
109
|
+
* `{ version, skillMdSha256, filesSha256, syncedAt }`.
|
|
73
110
|
*/
|
|
74
|
-
export const LAST_SYNC_SCHEMA_VERSION =
|
|
111
|
+
export const LAST_SYNC_SCHEMA_VERSION = 2;
|
|
75
112
|
|
|
76
113
|
/**
|
|
77
114
|
* Read the persisted last-sync state from ~/.claude/skillrepo/.last-sync.
|
|
@@ -99,12 +136,42 @@ export function readLastSync() {
|
|
|
99
136
|
return null;
|
|
100
137
|
}
|
|
101
138
|
|
|
139
|
+
if (!parsed || typeof parsed !== "object") {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// v1 → v2 in-memory migration. Preserve the etag and syncedAt so
|
|
144
|
+
// delta sync still benefits from the prior state; start with an
|
|
145
|
+
// empty `skills` map. The next successful runSync writes v2 to
|
|
146
|
+
// disk. We do NOT write here — readers should be pure with
|
|
147
|
+
// respect to the filesystem.
|
|
148
|
+
if (parsed.schemaVersion === 1) {
|
|
149
|
+
return {
|
|
150
|
+
schemaVersion: LAST_SYNC_SCHEMA_VERSION,
|
|
151
|
+
etag: typeof parsed.etag === "string" ? parsed.etag : null,
|
|
152
|
+
syncedAt: typeof parsed.syncedAt === "string" ? parsed.syncedAt : null,
|
|
153
|
+
skills: {},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (parsed.schemaVersion !== LAST_SYNC_SCHEMA_VERSION) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Defense: coerce a malformed `skills` field to {}. Both an array
|
|
162
|
+
// (`typeof === "object"` but not a real map) and a missing/null
|
|
163
|
+
// field can otherwise sneak past callers that only check
|
|
164
|
+
// `typeof === "object"`. Specifically: `list.mjs` does direct
|
|
165
|
+
// dictionary access on `lastSync.skills`, and `lastSync.skills[key]`
|
|
166
|
+
// on an array returns `undefined`, which would render every skill
|
|
167
|
+
// as `missing` — wrong, not safe-fail. Normalizing here means every
|
|
168
|
+
// caller sees a real Record<string, ...>.
|
|
102
169
|
if (
|
|
103
|
-
!parsed ||
|
|
104
|
-
typeof parsed !== "object" ||
|
|
105
|
-
parsed.
|
|
170
|
+
!parsed.skills ||
|
|
171
|
+
typeof parsed.skills !== "object" ||
|
|
172
|
+
Array.isArray(parsed.skills)
|
|
106
173
|
) {
|
|
107
|
-
|
|
174
|
+
parsed.skills = {};
|
|
108
175
|
}
|
|
109
176
|
|
|
110
177
|
return parsed;
|
|
@@ -116,22 +183,36 @@ export function readLastSync() {
|
|
|
116
183
|
* error. Most callers should treat a state-file write failure as
|
|
117
184
|
* non-fatal because the actual skill files were already written
|
|
118
185
|
* successfully.
|
|
186
|
+
*
|
|
187
|
+
* v2 (#1553): `skills` is the per-skill version+SHA map. Omitting it
|
|
188
|
+
* persists an empty `{}` — the schema bump is structural even when
|
|
189
|
+
* the caller has no skills to record (e.g. a 304 short-circuit before
|
|
190
|
+
* runSync ran any writes). An empty map means "we know the library
|
|
191
|
+
* was up-to-date at syncedAt but have no per-skill details from this
|
|
192
|
+
* sync"; consumers should treat absent entries as "unknown" rather
|
|
193
|
+
* than as "missing from library."
|
|
194
|
+
*
|
|
195
|
+
* @param {object} state
|
|
196
|
+
* @param {string | null} [state.etag]
|
|
197
|
+
* @param {string} [state.syncedAt]
|
|
198
|
+
* @param {Record<string, SyncedSkillEntry>} [state.skills]
|
|
119
199
|
*/
|
|
120
|
-
export function writeLastSync({ etag, syncedAt }) {
|
|
200
|
+
export function writeLastSync({ etag, syncedAt, skills } = {}) {
|
|
121
201
|
const path = globalLastSyncPath();
|
|
122
|
-
const dir = dirname(path);
|
|
123
|
-
try {
|
|
124
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
125
|
-
} catch (err) {
|
|
126
|
-
throw diskError(`Cannot create ${dir}: ${err.message}`, { cause: err });
|
|
127
|
-
}
|
|
128
202
|
const body = {
|
|
129
203
|
schemaVersion: LAST_SYNC_SCHEMA_VERSION,
|
|
130
204
|
etag: etag ?? null,
|
|
131
205
|
syncedAt: syncedAt ?? new Date().toISOString(),
|
|
206
|
+
skills: skills && typeof skills === "object" && !Array.isArray(skills) ? skills : {},
|
|
132
207
|
};
|
|
133
208
|
try {
|
|
134
|
-
|
|
209
|
+
// writeFileAtomic handles parent-dir creation and uses a
|
|
210
|
+
// per-process unique temp suffix so two concurrent writers
|
|
211
|
+
// (e.g., a SessionStart hook running `skillrepo update` while
|
|
212
|
+
// the user runs another command) can't collide on the same
|
|
213
|
+
// .tmp path and produce a torn final file. The cohort hooks
|
|
214
|
+
// (#1240) made this race observable in production.
|
|
215
|
+
writeFileAtomic(path, JSON.stringify(body, null, 2) + "\n");
|
|
135
216
|
} catch (err) {
|
|
136
217
|
throw diskError(`Cannot write ${path}: ${err.message}`, { cause: err });
|
|
137
218
|
}
|
|
@@ -270,7 +351,50 @@ export async function runSync(options) {
|
|
|
270
351
|
// a partial skill would leave the user with broken resource
|
|
271
352
|
// references (the SKILL.md body would point to files that don't
|
|
272
353
|
// exist on disk).
|
|
354
|
+
//
|
|
355
|
+
// v2 of `.last-sync` carries a per-skill version + SHA map. We
|
|
356
|
+
// compute SHAs from the in-memory `skill.files` array — same bytes
|
|
357
|
+
// we're about to write to disk via writeSkillDir — so the persisted
|
|
358
|
+
// SHA exactly matches what landed on the user's filesystem. SHAs are
|
|
359
|
+
// recorded ONLY for skills we actually wrote (skipped on
|
|
360
|
+
// filesIncomplete, never persisted on a write failure since the
|
|
361
|
+
// throw propagates and the whole state file write is bypassed).
|
|
273
362
|
let anyIncomplete = false;
|
|
363
|
+
/** @type {Record<string, SyncedSkillEntry>} */
|
|
364
|
+
const skillsMap = {};
|
|
365
|
+
// Carry forward entries from the previous sync for skills we did
|
|
366
|
+
// NOT re-fetch this round. A delta sync that touched 2 of 50
|
|
367
|
+
// skills must keep the other 48's metadata — otherwise the
|
|
368
|
+
// persisted map shrinks every delta sync until only the most
|
|
369
|
+
// recently-touched skills remain, defeating `list`'s drift
|
|
370
|
+
// detection. The previous ETag is only persisted again when no
|
|
371
|
+
// filesIncomplete fired, so this carry-forward is safe even on
|
|
372
|
+
// partial syncs.
|
|
373
|
+
//
|
|
374
|
+
// We reuse `lastSync` (captured before the network call) rather
|
|
375
|
+
// than re-reading from disk. No write happens between line ~330
|
|
376
|
+
// and here, so the values are identical — but re-reading would
|
|
377
|
+
// be a footgun if a future change ever persisted partial state
|
|
378
|
+
// between those points (the carry-forward would silently pick up
|
|
379
|
+
// the freshly-written data instead of the truly-prior state).
|
|
380
|
+
if (lastSync && lastSync.skills && typeof lastSync.skills === "object") {
|
|
381
|
+
for (const [key, entry] of Object.entries(lastSync.skills)) {
|
|
382
|
+
if (entry && typeof entry === "object") {
|
|
383
|
+
skillsMap[key] = entry;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// Tombstones invalidate any prior map entry: a skill that was
|
|
388
|
+
// removed from the library should not linger in our local
|
|
389
|
+
// version/SHA cache. Apply this BEFORE re-adding entries from the
|
|
390
|
+
// current response so a re-add in the same sync window (handled
|
|
391
|
+
// earlier under "removals applied first") still ends up in the
|
|
392
|
+
// map with fresh data.
|
|
393
|
+
for (const tombstone of result.removals) {
|
|
394
|
+
const key = `${tombstone.owner}/${tombstone.name}`;
|
|
395
|
+
delete skillsMap[key];
|
|
396
|
+
}
|
|
397
|
+
|
|
274
398
|
for (const skill of result.skills) {
|
|
275
399
|
if (skill.filesIncomplete) {
|
|
276
400
|
anyIncomplete = true;
|
|
@@ -283,12 +407,34 @@ export async function runSync(options) {
|
|
|
283
407
|
} else {
|
|
284
408
|
summary.added++;
|
|
285
409
|
}
|
|
410
|
+
|
|
411
|
+
// Compute SHAs from the same content writeSkillDir just persisted
|
|
412
|
+
// to disk. If the skill has no SKILL.md (malformed payload that
|
|
413
|
+
// somehow passed validateSkill), `skillMdSha256` is null and we
|
|
414
|
+
// skip the map entry — recording a partial entry would poison
|
|
415
|
+
// future drift detection. validateSkill in file-write.mjs already
|
|
416
|
+
// rejects skills without SKILL.md, so this branch is defense in
|
|
417
|
+
// depth; if it ever fires, our cache is correct (no entry > bad
|
|
418
|
+
// entry).
|
|
419
|
+
const shas = computeSkillShas(skill.files);
|
|
420
|
+
if (shas.skillMdSha256 !== null) {
|
|
421
|
+
skillsMap[`${skill.owner}/${skill.name}`] = {
|
|
422
|
+
version: typeof skill.version === "string" ? skill.version : "",
|
|
423
|
+
skillMdSha256: shas.skillMdSha256,
|
|
424
|
+
filesSha256: shas.filesSha256,
|
|
425
|
+
syncedAt: result.syncedAt,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
286
428
|
}
|
|
287
429
|
|
|
288
|
-
// Step 7: persist new ETag (only if the response was complete)
|
|
430
|
+
// Step 7: persist new ETag + skills map (only if the response was complete)
|
|
289
431
|
if (!anyIncomplete && result.etag) {
|
|
290
432
|
try {
|
|
291
|
-
writeLastSync({
|
|
433
|
+
writeLastSync({
|
|
434
|
+
etag: result.etag,
|
|
435
|
+
syncedAt: result.syncedAt,
|
|
436
|
+
skills: skillsMap,
|
|
437
|
+
});
|
|
292
438
|
} catch (err) {
|
|
293
439
|
// Non-fatal — the skills are on disk, we just won't get the
|
|
294
440
|
// 304 short-circuit on the next run. Surface a warning via
|