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.
@@ -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
+ }