skillrepo 4.3.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 +160 -3
- package/bin/skillrepo.mjs +45 -0
- package/package.json +3 -2
- package/src/commands/init.mjs +60 -2
- package/src/commands/list.mjs +328 -56
- package/src/lib/config.mjs +6 -0
- 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/lib/telemetry.mjs +201 -0
- package/src/test/commands/init.test.mjs +85 -0
- package/src/test/commands/list.test.mjs +510 -2
- package/src/test/lib/config.test.mjs +33 -0
- 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/test/lib/telemetry.test.mjs +289 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk on-disk skill placements and compute SHAs (#1555).
|
|
3
|
+
*
|
|
4
|
+
* Read-side counterpart of `file-write.mjs`: given a set of detected
|
|
5
|
+
* vendor keys, enumerate the skill directories each vendor's
|
|
6
|
+
* placement target contains and compute the same SHA pair that
|
|
7
|
+
* `runSync` persists in `.last-sync` v2. The output feeds `drift.mjs`
|
|
8
|
+
* for per-skill state classification.
|
|
9
|
+
*
|
|
10
|
+
* Design notes
|
|
11
|
+
* ------------
|
|
12
|
+
*
|
|
13
|
+
* - **One walk per unique target.** Cohort vendors (cursor, windsurf,
|
|
14
|
+
* gemini, codex, cline, copilot) all share `agentsProject`. Walking
|
|
15
|
+
* the parent dir N times — once per detected cohort vendor — would
|
|
16
|
+
* read every file N times. Instead, we walk each unique target
|
|
17
|
+
* ONCE and expand the result to one entry per (vendor, scope,
|
|
18
|
+
* skill) combination.
|
|
19
|
+
*
|
|
20
|
+
* - **POSIX path normalization for hashing.** `filesSha256` is the
|
|
21
|
+
* SHA-256 of a sorted `<path>|<sha>` projection. The path component
|
|
22
|
+
* MUST be forward-slash-separated to match what the server-side
|
|
23
|
+
* hashing produces (and what `crypto-shas.mjs` documents). Disk
|
|
24
|
+
* paths on Windows have backslashes; we normalize at the
|
|
25
|
+
* computation boundary, not in the data shape.
|
|
26
|
+
*
|
|
27
|
+
* - **Project scope only in v1.** The spec defers `--global` to a
|
|
28
|
+
* future flag. This module walks each detected vendor's
|
|
29
|
+
* `projectTarget` exclusively. When the global flag lands, add a
|
|
30
|
+
* `scopes` parameter that defaults to `["project"]`.
|
|
31
|
+
*
|
|
32
|
+
* - **Errors degrade gracefully.** A skill directory we can't read
|
|
33
|
+
* (permission error, broken symlink, partial file) produces an
|
|
34
|
+
* entry with `present: true` and `skillMdSha256: null` /
|
|
35
|
+
* `filesSha256: null`. The drift classifier treats null SHAs as
|
|
36
|
+
* `edited` — conservative, surfaces the issue to the user without
|
|
37
|
+
* crashing the command.
|
|
38
|
+
*
|
|
39
|
+
* - **`.tmp/` and `.old/` are skipped.** These are crashed-write
|
|
40
|
+
* artifacts handled by `cleanupOrphans` in `file-write.mjs`. They
|
|
41
|
+
* are not skill directories.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { existsSync, readdirSync, readFileSync, lstatSync } from "node:fs";
|
|
45
|
+
import { join, relative, sep } from "node:path";
|
|
46
|
+
|
|
47
|
+
import { computeSkillShas } from "./crypto-shas.mjs";
|
|
48
|
+
import { resolvePlacementRoot } from "./file-write.mjs";
|
|
49
|
+
import { getAgentByKey } from "./agent-registry.mjs";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {Object} LocalPlacement
|
|
53
|
+
* @property {string} vendorKey - Canonical agent key from agent-registry.
|
|
54
|
+
* @property {"project"} scope - Always "project" in v1.
|
|
55
|
+
* @property {string} target - PlacementTarget enum value.
|
|
56
|
+
* @property {string} skillName - Directory name on disk.
|
|
57
|
+
* @property {boolean} present - Always true for entries returned here.
|
|
58
|
+
* @property {string | null} skillMdSha256
|
|
59
|
+
* @property {string | null} filesSha256
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Walk every detected vendor's project placement and return a Map
|
|
64
|
+
* keyed by `"<vendorKey>::project::<skillName>"` for O(1) lookup
|
|
65
|
+
* from `list.mjs`.
|
|
66
|
+
*
|
|
67
|
+
* Each unique placement TARGET is walked exactly once. Detected
|
|
68
|
+
* vendors that map to the same target share the walk's results.
|
|
69
|
+
*
|
|
70
|
+
* @param {string[]} detectedVendorKeys
|
|
71
|
+
* @returns {Map<string, LocalPlacement>}
|
|
72
|
+
*/
|
|
73
|
+
export function walkDetectedPlacements(detectedVendorKeys) {
|
|
74
|
+
if (!Array.isArray(detectedVendorKeys) || detectedVendorKeys.length === 0) {
|
|
75
|
+
return new Map();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Group detected vendors by their projectTarget. A vendor with a
|
|
79
|
+
// null projectTarget (no documented project-scope placement) is
|
|
80
|
+
// dropped from this map — there's nothing to walk.
|
|
81
|
+
/** @type {Map<string, string[]>} */
|
|
82
|
+
const targetToVendors = new Map();
|
|
83
|
+
for (const key of detectedVendorKeys) {
|
|
84
|
+
const entry = getAgentByKey(key);
|
|
85
|
+
if (!entry || !entry.projectTarget) continue;
|
|
86
|
+
const target = entry.projectTarget;
|
|
87
|
+
if (!targetToVendors.has(target)) targetToVendors.set(target, []);
|
|
88
|
+
targetToVendors.get(target).push(key);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @type {Map<string, LocalPlacement>} */
|
|
92
|
+
const result = new Map();
|
|
93
|
+
for (const [target, vendorKeys] of targetToVendors) {
|
|
94
|
+
const skillsAtTarget = walkPlacementTarget(target);
|
|
95
|
+
for (const skill of skillsAtTarget) {
|
|
96
|
+
for (const vendorKey of vendorKeys) {
|
|
97
|
+
const key = `${vendorKey}::project::${skill.skillName}`;
|
|
98
|
+
result.set(key, {
|
|
99
|
+
vendorKey,
|
|
100
|
+
scope: "project",
|
|
101
|
+
target,
|
|
102
|
+
skillName: skill.skillName,
|
|
103
|
+
present: true,
|
|
104
|
+
skillMdSha256: skill.skillMdSha256,
|
|
105
|
+
filesSha256: skill.filesSha256,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Walk a single placement target's PARENT directory and return one
|
|
115
|
+
* entry per skill subdirectory found. Exposed for tests and for
|
|
116
|
+
* callers that need a single-target view.
|
|
117
|
+
*
|
|
118
|
+
* Output shape: `{ skillName, skillMdSha256, filesSha256 }[]`. The
|
|
119
|
+
* vendor/scope expansion happens in `walkDetectedPlacements`.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} target - PlacementTarget enum value.
|
|
122
|
+
* @returns {{ skillName: string, skillMdSha256: string | null, filesSha256: string | null }[]}
|
|
123
|
+
*/
|
|
124
|
+
export function walkPlacementTarget(target) {
|
|
125
|
+
let parentDir;
|
|
126
|
+
try {
|
|
127
|
+
parentDir = resolvePlacementRoot(target);
|
|
128
|
+
} catch {
|
|
129
|
+
// Unknown target — surface as no placements rather than throwing.
|
|
130
|
+
// Defensive: detect-agents + agent-registry should never produce
|
|
131
|
+
// an unknown target, but if they ever do we don't want `list` to
|
|
132
|
+
// crash.
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!existsSync(parentDir)) return [];
|
|
137
|
+
|
|
138
|
+
/** @type {string[]} */
|
|
139
|
+
let entries;
|
|
140
|
+
try {
|
|
141
|
+
entries = readdirSync(parentDir);
|
|
142
|
+
} catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** @type {{ skillName: string, skillMdSha256: string | null, filesSha256: string | null }[]} */
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
// Skip crashed-write artifacts handled by cleanupOrphans.
|
|
150
|
+
if (entry.endsWith(".tmp") || entry.endsWith(".old")) continue;
|
|
151
|
+
|
|
152
|
+
const skillDir = join(parentDir, entry);
|
|
153
|
+
let stat;
|
|
154
|
+
try {
|
|
155
|
+
// lstatSync (not statSync) so a symlink-to-directory at the
|
|
156
|
+
// placements root is rejected here, not recursed into. A
|
|
157
|
+
// circular symlink would otherwise crash `list` with a stack
|
|
158
|
+
// overflow from infinite recursion in walkSkillDirRecursive.
|
|
159
|
+
stat = lstatSync(skillDir);
|
|
160
|
+
} catch {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (stat.isSymbolicLink()) continue;
|
|
164
|
+
if (!stat.isDirectory()) continue;
|
|
165
|
+
|
|
166
|
+
const shas = computeShasFromDir(skillDir);
|
|
167
|
+
out.push({
|
|
168
|
+
skillName: entry,
|
|
169
|
+
skillMdSha256: shas.skillMdSha256,
|
|
170
|
+
filesSha256: shas.filesSha256,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Read every file in `skillDir` and compute the same SHA pair that
|
|
178
|
+
* `runSync` persists. On ANY file-read failure (permission, broken
|
|
179
|
+
* symlink, truncated file), returns null SHAs — the caller treats
|
|
180
|
+
* this as `edited` (conservative). Returning null is intentional:
|
|
181
|
+
* we cannot honestly claim `current` on data we couldn't read.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} skillDir
|
|
184
|
+
* @returns {{ skillMdSha256: string | null, filesSha256: string | null }}
|
|
185
|
+
*/
|
|
186
|
+
function computeShasFromDir(skillDir) {
|
|
187
|
+
/** @type {{ path: string, content: string }[]} */
|
|
188
|
+
const files = [];
|
|
189
|
+
let readError = false;
|
|
190
|
+
walkSkillDirRecursive(skillDir, skillDir, files, (err) => {
|
|
191
|
+
if (err) readError = true;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (readError || files.length === 0) {
|
|
195
|
+
return { skillMdSha256: null, filesSha256: null };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
return computeSkillShas(files);
|
|
200
|
+
} catch {
|
|
201
|
+
// computeSkillShas only throws on bad input shape. We constructed
|
|
202
|
+
// the input ourselves, so a throw indicates a programming error
|
|
203
|
+
// we can't recover from — but treat as null SHAs rather than
|
|
204
|
+
// crashing the user's `list` command.
|
|
205
|
+
return { skillMdSha256: null, filesSha256: null };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Recursive directory walk that populates a flat `{path, content}[]`
|
|
211
|
+
* array, with paths POSIX-normalized relative to `skillDir`. Errors
|
|
212
|
+
* on individual entries are reported through the `onError` callback
|
|
213
|
+
* (the caller flips a `readError` boolean to discard the partial
|
|
214
|
+
* accum and return null SHAs) but the walk CONTINUES with the
|
|
215
|
+
* remaining siblings.
|
|
216
|
+
*
|
|
217
|
+
* The continue-not-return choice matters: a `readdirSync` listing of
|
|
218
|
+
* [unreadable, readable1, readable2] would previously bail on entry
|
|
219
|
+
* 0 and never see entries 1 and 2. Setting `readError` once and
|
|
220
|
+
* still walking the rest gives the caller correct behavior (null
|
|
221
|
+
* SHAs from the gate at `computeShasFromDir`) and produces a
|
|
222
|
+
* deterministic outcome that doesn't depend on `readdirSync`
|
|
223
|
+
* ordering. The accum is partially populated when `readError` is
|
|
224
|
+
* true but the gate in `computeShasFromDir` discards it.
|
|
225
|
+
*
|
|
226
|
+
* Depth is not capped — agentskills.io spec mandates files at most
|
|
227
|
+
* one level deep from SKILL.md (`scripts/foo.sh`, `references/bar.md`)
|
|
228
|
+
* but the CLI doesn't reject a deeper tree. If the user has hand-
|
|
229
|
+
* authored a deeper structure, we hash it faithfully.
|
|
230
|
+
*/
|
|
231
|
+
function walkSkillDirRecursive(rootDir, currentDir, accum, onError) {
|
|
232
|
+
let entries;
|
|
233
|
+
try {
|
|
234
|
+
entries = readdirSync(currentDir);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
// Can't list this directory at all — flag the error and bail
|
|
237
|
+
// out of this level (no entries to walk). The outer caller
|
|
238
|
+
// continues with whatever siblings the parent dir contains.
|
|
239
|
+
onError(err);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const entry of entries) {
|
|
244
|
+
const fullPath = join(currentDir, entry);
|
|
245
|
+
let stat;
|
|
246
|
+
try {
|
|
247
|
+
// lstatSync (not statSync) is load-bearing here. statSync
|
|
248
|
+
// follows symlinks, which means:
|
|
249
|
+
// 1. A symlink-to-directory passes isDirectory() and
|
|
250
|
+
// walkSkillDirRecursive recurses into it. A circular
|
|
251
|
+
// symlink (e.g. `references/loop -> .`) infinite-loops
|
|
252
|
+
// and crashes the process with a stack overflow.
|
|
253
|
+
// 2. A symlink-to-file passes isFile() and readFileSync
|
|
254
|
+
// reads the target — potentially OUTSIDE the skill dir.
|
|
255
|
+
// Not a privilege escalation (we only read what the user
|
|
256
|
+
// can read), but a principle-of-least-surprise violation.
|
|
257
|
+
// The agentskills.io spec does not define symlinks as valid
|
|
258
|
+
// skill content. Skip them.
|
|
259
|
+
stat = lstatSync(fullPath);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
onError(err);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (stat.isSymbolicLink()) continue;
|
|
265
|
+
if (stat.isDirectory()) {
|
|
266
|
+
walkSkillDirRecursive(rootDir, fullPath, accum, onError);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (!stat.isFile()) continue; // skip sockets / pipes / etc.
|
|
270
|
+
|
|
271
|
+
let content;
|
|
272
|
+
try {
|
|
273
|
+
content = readFileSync(fullPath, "utf8");
|
|
274
|
+
} catch (err) {
|
|
275
|
+
onError(err);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
// POSIX-normalize the path so SHA computation matches what the
|
|
279
|
+
// server produces. On POSIX, `sep` is "/" and this is a no-op;
|
|
280
|
+
// on Windows, `sep` is "\\" and we convert.
|
|
281
|
+
const posixRelPath =
|
|
282
|
+
sep === "/" ? relative(rootDir, fullPath) : relative(rootDir, fullPath).split(sep).join("/");
|
|
283
|
+
accum.push({ path: posixRelPath, content });
|
|
284
|
+
}
|
|
285
|
+
}
|
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
|