skillrepo 4.0.0 → 4.2.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 +49 -2
- package/bin/skillrepo.mjs +8 -0
- package/package.json +10 -4
- package/src/commands/init-cohort-hooks.mjs +127 -0
- package/src/commands/init.mjs +45 -6
- package/src/commands/push.mjs +187 -0
- package/src/commands/uninstall.mjs +12 -1
- package/src/commands/update.mjs +97 -16
- package/src/lib/agent-hook-merge.mjs +203 -0
- package/src/lib/agent-registry.mjs +186 -2
- package/src/lib/artifact-registry.mjs +111 -2
- package/src/lib/fs-utils.mjs +16 -1
- package/src/lib/http.mjs +169 -11
- package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
- package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
- package/src/lib/removers/agent-hooks.mjs +83 -0
- package/src/lib/skill-walk.mjs +97 -0
- package/src/test/commands/init.test.mjs +281 -0
- package/src/test/commands/push.test.mjs +289 -0
- package/src/test/commands/update.test.mjs +135 -0
- package/src/test/dispatcher.test.mjs +10 -2
- package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
- package/src/test/e2e/mock-server.mjs +92 -10
- package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
- package/src/test/lib/agent-hook-merge.test.mjs +172 -0
- package/src/test/lib/artifact-registry.test.mjs +39 -0
- package/src/test/lib/http.test.mjs +242 -1
- package/src/test/lib/skill-walk.test.mjs +127 -0
- package/src/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
- package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
- package/src/test/removers/agent-hooks.test.mjs +206 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionStart-hook installer/uninstaller for cursor-shape vendors
|
|
3
|
+
* (#1240). Currently only Cursor itself, but the shape is documented
|
|
4
|
+
* separately so a future vendor that ships the same flat-array
|
|
5
|
+
* sessionStart schema slots in without code changes.
|
|
6
|
+
*
|
|
7
|
+
* Cursor docs 2026-05 specify the file shape:
|
|
8
|
+
*
|
|
9
|
+
* {
|
|
10
|
+
* "version": 1,
|
|
11
|
+
* "hooks": {
|
|
12
|
+
* "sessionStart": [
|
|
13
|
+
* { "command": "..." }
|
|
14
|
+
* ]
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* Differences from the claude-shape merger:
|
|
19
|
+
*
|
|
20
|
+
* - `version: 1` at the top level. The merger preserves it on
|
|
21
|
+
* re-write and creates it for fresh files. A future Cursor
|
|
22
|
+
* `version: 2` is a real possibility — when that lands, the
|
|
23
|
+
* migration is a separate PR that bumps the writer here AND
|
|
24
|
+
* adds an upgrade-path test that proves both shapes round-trip
|
|
25
|
+
* through the remover.
|
|
26
|
+
* - Single-level `hooks.<event>` array (no nested `{ hooks: [...] }`
|
|
27
|
+
* groups). Walks are correspondingly shallower.
|
|
28
|
+
* - Event-name casing differs: lowercase `sessionStart` per Cursor
|
|
29
|
+
* docs. Comes from the agent registry's `agentHook.eventName`.
|
|
30
|
+
* - Multi-tool merge surface: 1Password, Snyk, and Apiiro all
|
|
31
|
+
* extend `~/.cursor/hooks.json`. The walk MUST preserve unknown
|
|
32
|
+
* entries — round-trip preservation is the load-bearing
|
|
33
|
+
* idempotency test.
|
|
34
|
+
*
|
|
35
|
+
* The shared concerns (atomic writes, fingerprint matching, idempotent
|
|
36
|
+
* actions, dispatcher integration) are intentionally implemented
|
|
37
|
+
* twice — once here, once in `agent-hook-claude-shape.mjs` — rather
|
|
38
|
+
* than abstracted. The architect review on #1240 specifically called
|
|
39
|
+
* out that a single-function `variant` switch becomes load-bearing
|
|
40
|
+
* config that no type system enforces; per-shape modules are the
|
|
41
|
+
* idiom that scales as new variants land.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
45
|
+
import { writeFileAtomic } from "../fs-utils.mjs";
|
|
46
|
+
import {
|
|
47
|
+
AGENT_HOOK_COMMAND,
|
|
48
|
+
AGENT_HOOK_FINGERPRINT,
|
|
49
|
+
} from "../artifact-registry.mjs";
|
|
50
|
+
import { getAgentByKey } from "../agent-registry.mjs";
|
|
51
|
+
import { diskError, validationError } from "../errors.mjs";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the per-hook entry the cursor-shape merger writes. The
|
|
55
|
+
* `entryFields` from the agent registry's `agentHook.entryFields` are
|
|
56
|
+
* spread BEFORE `command` so the registry can NEVER override the
|
|
57
|
+
* canonical command via a typo. Cursor itself has no extra entry
|
|
58
|
+
* fields today, but the spread is symmetrical with the claude-shape
|
|
59
|
+
* merger to keep both code paths consistent.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} entryFields
|
|
62
|
+
* @returns {Record<string, unknown>}
|
|
63
|
+
*/
|
|
64
|
+
export function buildCursorShapeEntry(entryFields = {}) {
|
|
65
|
+
return {
|
|
66
|
+
...entryFields,
|
|
67
|
+
command: AGENT_HOOK_COMMAND,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Install (or refresh) the cohort SessionStart hook in a cursor-shape
|
|
73
|
+
* vendor's hook config file. Creates the file with `version: 1` at
|
|
74
|
+
* the top level if it doesn't exist.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} options
|
|
77
|
+
* @param {string} options.vendorKey
|
|
78
|
+
* @returns {{
|
|
79
|
+
* path: string;
|
|
80
|
+
* action: "installed" | "updated" | "unchanged";
|
|
81
|
+
* command: string;
|
|
82
|
+
* }}
|
|
83
|
+
*/
|
|
84
|
+
export function mergeCursorShapeAgentHook({ vendorKey }) {
|
|
85
|
+
const { hookSpec } = resolveAgent(vendorKey);
|
|
86
|
+
const filePath = hookSpec.pathFn();
|
|
87
|
+
const eventName = hookSpec.eventName;
|
|
88
|
+
const desiredEntry = buildCursorShapeEntry(hookSpec.entryFields);
|
|
89
|
+
|
|
90
|
+
const config = readConfigOrEmpty(filePath, hookSpec.displayPath);
|
|
91
|
+
|
|
92
|
+
// Preserve any existing `version` value if the file already had one;
|
|
93
|
+
// otherwise default to 1 per Cursor's documented shape. A future
|
|
94
|
+
// user manually setting `version: 2` does NOT get silently demoted
|
|
95
|
+
// — we only set the default on a brand-new file.
|
|
96
|
+
if (config.version === undefined) {
|
|
97
|
+
config.version = 1;
|
|
98
|
+
}
|
|
99
|
+
if (!config.hooks || typeof config.hooks !== "object") {
|
|
100
|
+
config.hooks = {};
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(config.hooks[eventName])) {
|
|
103
|
+
config.hooks[eventName] = [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Exhaustive walk: drop any prior SkillRepo entries beyond the
|
|
107
|
+
// first match. The remover strips ALL fingerprint-matching entries,
|
|
108
|
+
// so the installer also has to handle ALL of them — otherwise a
|
|
109
|
+
// prior buggy run that left two ghosts would leave one behind on
|
|
110
|
+
// re-install. Cheap defense; aligns the installer-remover
|
|
111
|
+
// invariant ("exactly one SkillRepo entry per cohort hook config").
|
|
112
|
+
let primaryHandled = false;
|
|
113
|
+
let foundChanged = false;
|
|
114
|
+
let foundUnchangedExact = false;
|
|
115
|
+
|
|
116
|
+
const survivors = [];
|
|
117
|
+
let arrayMutated = false;
|
|
118
|
+
for (const inner of config.hooks[eventName]) {
|
|
119
|
+
if (
|
|
120
|
+
inner &&
|
|
121
|
+
typeof inner === "object" &&
|
|
122
|
+
typeof inner.command === "string" &&
|
|
123
|
+
inner.command.includes(AGENT_HOOK_FINGERPRINT)
|
|
124
|
+
) {
|
|
125
|
+
if (!primaryHandled) {
|
|
126
|
+
primaryHandled = true;
|
|
127
|
+
if (entriesEqual(inner, desiredEntry)) {
|
|
128
|
+
foundUnchangedExact = true;
|
|
129
|
+
survivors.push(inner);
|
|
130
|
+
} else {
|
|
131
|
+
foundChanged = true;
|
|
132
|
+
arrayMutated = true; // in-place replacement at same index
|
|
133
|
+
survivors.push(desiredEntry);
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
// Duplicate from a prior buggy run — drop it.
|
|
137
|
+
foundChanged = true;
|
|
138
|
+
arrayMutated = true; // length-changing drop
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
survivors.push(inner);
|
|
143
|
+
}
|
|
144
|
+
// Write back when we mutated the array — either by replacing the
|
|
145
|
+
// primary entry in place (same length) or by dropping duplicates
|
|
146
|
+
// (smaller length). A length-only check would miss the
|
|
147
|
+
// replacement case.
|
|
148
|
+
if (arrayMutated) {
|
|
149
|
+
config.hooks[eventName] = survivors;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let matchedAction;
|
|
153
|
+
if (!primaryHandled) {
|
|
154
|
+
config.hooks[eventName].push(desiredEntry);
|
|
155
|
+
matchedAction = "installed";
|
|
156
|
+
} else if (foundChanged) {
|
|
157
|
+
matchedAction = "updated";
|
|
158
|
+
} else if (foundUnchangedExact) {
|
|
159
|
+
matchedAction = "unchanged";
|
|
160
|
+
} else {
|
|
161
|
+
matchedAction = "updated";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (matchedAction === "unchanged") {
|
|
165
|
+
return {
|
|
166
|
+
path: hookSpec.displayPath,
|
|
167
|
+
action: "unchanged",
|
|
168
|
+
command: AGENT_HOOK_COMMAND,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
path: hookSpec.displayPath,
|
|
176
|
+
action: matchedAction,
|
|
177
|
+
command: AGENT_HOOK_COMMAND,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Strip the SkillRepo cohort hook from a cursor-shape vendor's hook
|
|
183
|
+
* config. Idempotent — non-SkillRepo entries and unknown event arrays
|
|
184
|
+
* are never touched.
|
|
185
|
+
*
|
|
186
|
+
* @param {object} options
|
|
187
|
+
* @param {string} options.vendorKey
|
|
188
|
+
* @param {boolean} [options.dryRun=false]
|
|
189
|
+
* @returns {{
|
|
190
|
+
* path: string;
|
|
191
|
+
* action: "removed" | "would-remove" | "skipped" | "unchanged";
|
|
192
|
+
* error?: string;
|
|
193
|
+
* }}
|
|
194
|
+
*/
|
|
195
|
+
export function unmergeCursorShapeAgentHook({ vendorKey, dryRun = false }) {
|
|
196
|
+
const { hookSpec } = resolveAgent(vendorKey);
|
|
197
|
+
const filePath = hookSpec.pathFn();
|
|
198
|
+
const eventName = hookSpec.eventName;
|
|
199
|
+
|
|
200
|
+
if (!existsSync(filePath)) {
|
|
201
|
+
return { path: hookSpec.displayPath, action: "skipped" };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
205
|
+
if (raw.trim().length === 0) {
|
|
206
|
+
return { path: hookSpec.displayPath, action: "unchanged" };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let config;
|
|
210
|
+
try {
|
|
211
|
+
config = JSON.parse(raw);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return {
|
|
214
|
+
path: hookSpec.displayPath,
|
|
215
|
+
action: "skipped",
|
|
216
|
+
error: `Cannot parse ${hookSpec.displayPath}: ${err.message}. Fix or delete the file and re-run.`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (
|
|
221
|
+
!config ||
|
|
222
|
+
typeof config !== "object" ||
|
|
223
|
+
!config.hooks ||
|
|
224
|
+
typeof config.hooks !== "object" ||
|
|
225
|
+
!Array.isArray(config.hooks[eventName])
|
|
226
|
+
) {
|
|
227
|
+
return { path: hookSpec.displayPath, action: "unchanged" };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const beforeCount = config.hooks[eventName].length;
|
|
231
|
+
const survivors = config.hooks[eventName].filter((h) => {
|
|
232
|
+
if (!h || typeof h !== "object" || typeof h.command !== "string") {
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
return !h.command.includes(AGENT_HOOK_FINGERPRINT);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (survivors.length === beforeCount) {
|
|
239
|
+
return { path: hookSpec.displayPath, action: "unchanged" };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (dryRun) {
|
|
243
|
+
return { path: hookSpec.displayPath, action: "would-remove" };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (survivors.length === 0) {
|
|
247
|
+
delete config.hooks[eventName];
|
|
248
|
+
} else {
|
|
249
|
+
config.hooks[eventName] = survivors;
|
|
250
|
+
}
|
|
251
|
+
if (Object.keys(config.hooks).length === 0) {
|
|
252
|
+
delete config.hooks;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
256
|
+
return { path: hookSpec.displayPath, action: "removed" };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Internal helpers ───────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
function resolveAgent(vendorKey) {
|
|
262
|
+
const entry = getAgentByKey(vendorKey);
|
|
263
|
+
if (!entry) {
|
|
264
|
+
throw validationError(
|
|
265
|
+
`Unknown agent key: ${vendorKey}. Add it to AGENT_REGISTRY.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (!entry.agentHook) {
|
|
269
|
+
throw validationError(
|
|
270
|
+
`Agent "${vendorKey}" has no agentHook spec — cannot install a SessionStart hook.`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (entry.agentHook.shape !== "cursor-shape") {
|
|
274
|
+
throw validationError(
|
|
275
|
+
`Agent "${vendorKey}" has shape "${entry.agentHook.shape}", expected "cursor-shape".`,
|
|
276
|
+
{
|
|
277
|
+
hint: "Use the matching shape merger (e.g. mergeClaudeShapeAgentHook).",
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return { entry, hookSpec: entry.agentHook };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function readConfigOrEmpty(filePath, displayPath) {
|
|
285
|
+
if (!existsSync(filePath)) return {};
|
|
286
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
287
|
+
if (raw.trim().length === 0) return {};
|
|
288
|
+
let parsed;
|
|
289
|
+
try {
|
|
290
|
+
parsed = JSON.parse(raw);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
throw diskError(
|
|
293
|
+
`Cannot parse ${displayPath}: ${err.message}. Fix or delete the file, then re-run.`,
|
|
294
|
+
{ cause: err },
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
298
|
+
throw diskError(
|
|
299
|
+
`${displayPath} must be a JSON object at the top level.`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return parsed;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function entriesEqual(a, b) {
|
|
306
|
+
return stableJson(a) === stableJson(b);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function stableJson(value) {
|
|
310
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
311
|
+
return JSON.stringify(value);
|
|
312
|
+
}
|
|
313
|
+
const sortedKeys = Object.keys(value).sort();
|
|
314
|
+
const parts = sortedKeys.map(
|
|
315
|
+
(k) => `${JSON.stringify(k)}:${stableJson(value[k])}`,
|
|
316
|
+
);
|
|
317
|
+
return `{${parts.join(",")}}`;
|
|
318
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cohort SessionStart-hook batch remover (#1240). Pairs with the two
|
|
3
|
+
* cohort installer mergers under `mergers/agent-hook-{claude,cursor}-shape.mjs`
|
|
4
|
+
* to satisfy the artifact-registry drift-protection test
|
|
5
|
+
* (`src/test/lib/artifact-registry.test.mjs`), which requires every
|
|
6
|
+
* descriptor to map to a remover.
|
|
7
|
+
*
|
|
8
|
+
* One file rather than per-vendor removers because:
|
|
9
|
+
*
|
|
10
|
+
* 1. The four cohort vendors share a single fingerprint
|
|
11
|
+
* (`AGENT_HOOK_FINGERPRINT`), so all per-vendor removers would
|
|
12
|
+
* delegate to identical logic.
|
|
13
|
+
* 2. The dispatch by `agentHook.shape` is already encoded in
|
|
14
|
+
* `agent-hook-merge.mjs`; reusing it here keeps the two-shape
|
|
15
|
+
* walk in exactly one place.
|
|
16
|
+
* 3. The uninstall command iterates the registry's
|
|
17
|
+
* `kind: "agent-hook"` descriptors and calls this module once per
|
|
18
|
+
* descriptor — the file count would otherwise grow with every
|
|
19
|
+
* cohort vendor added.
|
|
20
|
+
*
|
|
21
|
+
* The artifact-registry CI test maps `removers/agent-hooks.mjs` to the
|
|
22
|
+
* four `agent-hook-<vendorKey>` descriptor ids via `REMOVER_EXPECTED`;
|
|
23
|
+
* any drift in that mapping fails CI before the user can run.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { uninstallAgentHookFor } from "../agent-hook-merge.mjs";
|
|
27
|
+
import { ARTIFACT_REGISTRY } from "../artifact-registry.mjs";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Remove the cohort hook for a single artifact descriptor. Used by
|
|
31
|
+
* `commands/uninstall.mjs`'s dispatch loop, which routes any
|
|
32
|
+
* `kind: "agent-hook"` descriptor here.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} descriptor - One entry from `ARTIFACT_REGISTRY` with
|
|
35
|
+
* `kind: "agent-hook"`. Must carry `vendorKey`.
|
|
36
|
+
* @param {object} [options]
|
|
37
|
+
* @param {boolean} [options.dryRun=false]
|
|
38
|
+
* @returns {{ path: string; action: "removed" | "would-remove" | "skipped" | "unchanged"; error?: string }}
|
|
39
|
+
*/
|
|
40
|
+
export function removeAgentHookArtifact(descriptor, { dryRun = false } = {}) {
|
|
41
|
+
if (!descriptor || descriptor.kind !== "agent-hook") {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`removeAgentHookArtifact: expected kind="agent-hook", got "${descriptor?.kind}".`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (!descriptor.vendorKey) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`removeAgentHookArtifact: descriptor "${descriptor.id}" missing vendorKey.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return uninstallAgentHookFor(descriptor.vendorKey, { dryRun });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Remove every cohort hook in one call. Convenience wrapper for
|
|
56
|
+
* `skillrepo uninstall --global` and the standalone session-sync
|
|
57
|
+
* "disable all" workflow. Iterates all `kind: "agent-hook"` descriptors
|
|
58
|
+
* in the artifact registry — i.e. every cohort vendor that has an
|
|
59
|
+
* `agentHook` spec. Idempotent: vendors with no installed hook return
|
|
60
|
+
* `"skipped"` or `"unchanged"`, never failing.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} [options]
|
|
63
|
+
* @param {boolean} [options.dryRun=false]
|
|
64
|
+
* @returns {Array<{ id: string; path: string; action: string; error?: string }>}
|
|
65
|
+
*/
|
|
66
|
+
export function removeAllAgentHooks({ dryRun = false } = {}) {
|
|
67
|
+
const results = [];
|
|
68
|
+
for (const descriptor of ARTIFACT_REGISTRY) {
|
|
69
|
+
if (descriptor.kind !== "agent-hook") continue;
|
|
70
|
+
try {
|
|
71
|
+
const r = removeAgentHookArtifact(descriptor, { dryRun });
|
|
72
|
+
results.push({ id: descriptor.id, ...r });
|
|
73
|
+
} catch (err) {
|
|
74
|
+
results.push({
|
|
75
|
+
id: descriptor.id,
|
|
76
|
+
path: descriptor.displayPath,
|
|
77
|
+
action: "failed",
|
|
78
|
+
error: err?.message ?? String(err),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return results;
|
|
83
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory walker for `skillrepo push` (#1455).
|
|
3
|
+
*
|
|
4
|
+
* Walks a local skill directory and collects files for upload — the
|
|
5
|
+
* whole skill folder uniformly, including the root `SKILL.md`, per the
|
|
6
|
+
* agentskills.io specification (a skill is a directory containing
|
|
7
|
+
* `SKILL.md` at the root plus optional supporting files).
|
|
8
|
+
*
|
|
9
|
+
* Excludes:
|
|
10
|
+
* - Any path component starting with `.` (e.g. `.git`, `.DS_Store`)
|
|
11
|
+
* so `skillrepo push .` from a repo root doesn't accidentally
|
|
12
|
+
* upload git internals.
|
|
13
|
+
* - `node_modules` directories anywhere in the tree.
|
|
14
|
+
*
|
|
15
|
+
* Paths returned are relative to the skill directory and use forward
|
|
16
|
+
* slashes regardless of platform — that's what the server's
|
|
17
|
+
* `validateFilePath` expects, and what RFC 7578 `Content-Disposition:
|
|
18
|
+
* filename` carries on the wire.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { promises as fs } from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
const EXCLUDED_DIRS = new Set(["node_modules"]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} WalkedFile
|
|
28
|
+
* @property {string} relativePath - POSIX-style path within the skill directory.
|
|
29
|
+
* @property {string} absolutePath - Absolute filesystem path on local disk.
|
|
30
|
+
* @property {number} size - File size in bytes (from stat).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Walk a skill directory and yield every file that should be sent as
|
|
35
|
+
* a multipart `files` part.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} skillDir - Absolute path to the skill directory.
|
|
38
|
+
* @returns {Promise<WalkedFile[]>}
|
|
39
|
+
*/
|
|
40
|
+
export async function walkSkillFiles(skillDir) {
|
|
41
|
+
const absRoot = path.resolve(skillDir);
|
|
42
|
+
const out = [];
|
|
43
|
+
await walkRecursive(absRoot, absRoot, out);
|
|
44
|
+
// Deterministic order so SHA fingerprints (and tests that assert on
|
|
45
|
+
// ordered output) are reproducible.
|
|
46
|
+
out.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} absRoot
|
|
52
|
+
* @param {string} currentDir
|
|
53
|
+
* @param {WalkedFile[]} out
|
|
54
|
+
*/
|
|
55
|
+
async function walkRecursive(absRoot, currentDir, out) {
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err && /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT") {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const name = entry.name;
|
|
68
|
+
|
|
69
|
+
// Hidden files / directories (anything starting with `.`).
|
|
70
|
+
if (name.startsWith(".")) continue;
|
|
71
|
+
|
|
72
|
+
// Excluded directory names.
|
|
73
|
+
if (entry.isDirectory() && EXCLUDED_DIRS.has(name)) continue;
|
|
74
|
+
|
|
75
|
+
const absChild = path.join(currentDir, name);
|
|
76
|
+
|
|
77
|
+
if (entry.isDirectory()) {
|
|
78
|
+
await walkRecursive(absRoot, absChild, out);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!entry.isFile()) continue; // skip symlinks / sockets / etc.
|
|
83
|
+
|
|
84
|
+
// POSIX-style relative path from the skill root.
|
|
85
|
+
const relative = path
|
|
86
|
+
.relative(absRoot, absChild)
|
|
87
|
+
.split(path.sep)
|
|
88
|
+
.join("/");
|
|
89
|
+
|
|
90
|
+
const stat = await fs.stat(absChild);
|
|
91
|
+
out.push({
|
|
92
|
+
relativePath: relative,
|
|
93
|
+
absolutePath: absChild,
|
|
94
|
+
size: stat.size,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|