skillrepo 3.0.0 → 3.1.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.
Files changed (32) hide show
  1. package/README.md +72 -6
  2. package/bin/skillrepo.mjs +14 -0
  3. package/package.json +1 -1
  4. package/src/commands/init.mjs +132 -14
  5. package/src/commands/remove.mjs +8 -13
  6. package/src/commands/session-sync.mjs +152 -0
  7. package/src/commands/uninstall.mjs +484 -0
  8. package/src/commands/update.mjs +125 -8
  9. package/src/lib/artifact-registry.mjs +265 -0
  10. package/src/lib/fs-utils.mjs +83 -1
  11. package/src/lib/mergers/session-hook.mjs +298 -0
  12. package/src/lib/paths.mjs +21 -0
  13. package/src/lib/removers/claude-mcp.mjs +67 -0
  14. package/src/lib/removers/cursor-mcp.mjs +60 -0
  15. package/src/lib/removers/env-local.mjs +55 -0
  16. package/src/lib/removers/gitignore.mjs +108 -0
  17. package/src/lib/removers/settings.mjs +183 -0
  18. package/src/lib/removers/vscode-mcp.mjs +87 -0
  19. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  20. package/src/test/commands/init.test.mjs +211 -0
  21. package/src/test/commands/session-sync.test.mjs +350 -0
  22. package/src/test/commands/uninstall.test.mjs +768 -0
  23. package/src/test/commands/update.test.mjs +158 -0
  24. package/src/test/lib/artifact-registry.test.mjs +268 -0
  25. package/src/test/mergers/session-hook.test.mjs +745 -0
  26. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  27. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  28. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  29. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  30. package/src/test/mergers/uninstall-settings.test.mjs +285 -0
  31. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  32. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
@@ -162,3 +162,161 @@ describe("runUpdate — flag handling", () => {
162
162
  assert.ok(existsSync(dir));
163
163
  });
164
164
  });
165
+
166
+ // ── --session-hook mode (#884) ────────────────────────────────────────
167
+ //
168
+ // Session-hook mode is what the Claude Code SessionStart hook #884's
169
+ // installer writes invokes: `skillrepo update --session-hook`. The
170
+ // contract: exit 0 on ALL errors, silent on 304, one-line summary on
171
+ // changes, one-line failure message on error. A sync failure must
172
+ // NEVER block a session start.
173
+ //
174
+ // Architect pre-flight review flagged the flag-acceptance bug as
175
+ // "most likely silent-failure bug" — if `resolveFlags` rejects the
176
+ // flag before the exit-0 contract activates, every session-start
177
+ // hook silently fails with a symptom indistinguishable from 304.
178
+ // Tests in this suite specifically guard against that.
179
+
180
+ describe("runUpdate — --session-hook contract", () => {
181
+ beforeEach(setup);
182
+ afterEach(teardown);
183
+
184
+ it("accepts the --session-hook flag without throwing a validation error", async () => {
185
+ // THE architect-flagged regression guard. If a future refactor
186
+ // removes the acceptPositional callback from update.mjs,
187
+ // resolveFlags throws `Unknown argument: --session-hook` before
188
+ // the exit-0 contract has a chance to fire. The `|| true` shell
189
+ // backstop would catch it, but the user would lose the failure
190
+ // message AND every sync would silently no-op. This test makes
191
+ // that class of regression fail loudly at unit-test time.
192
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
193
+ await assert.doesNotReject(
194
+ () =>
195
+ runUpdate(
196
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
197
+ { stdout },
198
+ ),
199
+ "update --session-hook must NOT throw Unknown argument",
200
+ );
201
+ });
202
+
203
+ it("is silent on 304 / up-to-date (no 'Syncing' output every session)", async () => {
204
+ // INTENT: 304 means nothing changed. Printing "Syncing..." on
205
+ // every session start with no value to show would clutter the
206
+ // system-message surface. The contract is SILENCE on 304.
207
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
208
+ await runUpdate(
209
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
210
+ { stdout },
211
+ );
212
+ assert.equal(
213
+ stdout.text(),
214
+ "",
215
+ "session-hook mode must emit zero output when there's nothing to sync",
216
+ );
217
+ });
218
+
219
+ it("prints one line on successful sync with changes", async () => {
220
+ // INTENT: when content CHANGES, surface exactly one actionable
221
+ // line so the user knows their library updated. No banners, no
222
+ // multi-line output — a single line the hook runner can render
223
+ // as a system message.
224
+ server.setLibraryResponse({
225
+ skills: [makeSkill("sync-hook-test")],
226
+ removals: [],
227
+ syncedAt: "x",
228
+ });
229
+ await runUpdate(
230
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
231
+ { stdout },
232
+ );
233
+ const out = stdout.text();
234
+ assert.match(out, /^\[SkillRepo\] Library synced: \d+ added, \d+ updated, \d+ removed\.\n$/);
235
+ });
236
+
237
+ it("exits 0 with a failure message on a server error instead of throwing", async () => {
238
+ // THE load-bearing contract. A sync failure must NEVER block a
239
+ // session start. runUpdate called with --session-hook against an
240
+ // unreachable server must return normally (no throw) and emit a
241
+ // one-line failure message.
242
+ //
243
+ // We simulate an unreachable server by pointing at an invalid
244
+ // port. Without the exit-0 contract, this would throw
245
+ // networkError from runSync; WITH the contract, the catch block
246
+ // swallows it and prints a failure message.
247
+ await assert.doesNotReject(
248
+ () =>
249
+ runUpdate(
250
+ ["--key", VALID_KEY, "--url", "http://127.0.0.1:1", "--session-hook"],
251
+ { stdout },
252
+ ),
253
+ "session-hook mode must NEVER throw — session start must proceed",
254
+ );
255
+ const out = stdout.text();
256
+ assert.match(out, /^\[SkillRepo\] Sync failed: .+\n$/);
257
+ });
258
+
259
+ it("exits 0 with a failure message on auth error (invalid key)", async () => {
260
+ // Auth error is non-retryable AND non-blocking for session start.
261
+ // If the user's key was rotated, we want them to see "access key
262
+ // invalid" in their session system message, but the session must
263
+ // still open normally.
264
+ server.setForcedStatus(401, { error: "Invalid access key" });
265
+ await assert.doesNotReject(
266
+ () =>
267
+ runUpdate(
268
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
269
+ { stdout },
270
+ ),
271
+ );
272
+ const out = stdout.text();
273
+ assert.match(out, /Sync failed/);
274
+ });
275
+
276
+ it("exits 0 with a failure message when no access key is configured", async () => {
277
+ // INTENT: the VERY first session after installing skillrepo, if
278
+ // init hasn't run yet, has no key. The hook must not block the
279
+ // session. This is a genuine first-run scenario, not a synthetic
280
+ // edge case — users who set up Claude Code before running
281
+ // `skillrepo init` will hit exactly this.
282
+ //
283
+ // No --key flag, no cached config, no env var. resolveFlags
284
+ // normally throws authError in this case. Session-hook mode
285
+ // must catch that and exit 0 anyway.
286
+ await assert.doesNotReject(
287
+ () =>
288
+ runUpdate(["--url", serverUrl, "--session-hook"], { stdout }),
289
+ "session-hook mode with no key must NEVER throw",
290
+ );
291
+ const out = stdout.text();
292
+ assert.match(out, /Sync failed/, "failure message must still surface");
293
+ });
294
+
295
+ it("exits 0 when the --session-hook flag appears after other flags", async () => {
296
+ // INTENT: flag ordering is not part of the contract. The installer
297
+ // writes a specific order today, but a user hand-editing their
298
+ // settings (or a future refactor moving flags around) must not
299
+ // break the exit-0 behavior.
300
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
301
+ await assert.doesNotReject(
302
+ () =>
303
+ runUpdate(
304
+ ["--session-hook", "--key", VALID_KEY, "--url", serverUrl],
305
+ { stdout },
306
+ ),
307
+ );
308
+ assert.equal(stdout.text(), "", "still silent on 304 regardless of flag order");
309
+ });
310
+
311
+ it("exits 0 silently when the server returns zero deltas (empty success, not 304)", async () => {
312
+ // INTENT: 304 isn't the only silent-success case. A 200 response
313
+ // with zero added/updated/removed (a fresh sync that happened to
314
+ // produce no work) is also silent — same UX reasoning as 304.
315
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
316
+ await runUpdate(
317
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
318
+ { stdout },
319
+ );
320
+ assert.equal(stdout.text(), "", "zero-delta 200 is silent");
321
+ });
322
+ });
@@ -0,0 +1,268 @@
1
+ /**
2
+ * CI enforcement test for the artifact registry (#885).
3
+ *
4
+ * Architect tightening #1: the artifact-registry test must be
5
+ * BIDIRECTIONAL. It's not enough to iterate registry entries and
6
+ * assert each has a matching remover — that catches "descriptor
7
+ * without implementation" but misses the more dangerous direction,
8
+ * "new merger writes a file we never catalogued."
9
+ *
10
+ * The checks below:
11
+ *
12
+ * 1. Filesystem → registry:
13
+ * - Every file in `src/lib/mergers/*.mjs` (installer modules)
14
+ * must be declared in MERGER_EXPECTED, which maps installer
15
+ * names to the registry ids they produce. A new merger file
16
+ * without an expected-set entry FAILS this test.
17
+ * - Every file in `src/lib/removers/*.mjs` (uninstaller
18
+ * modules) must be declared in REMOVER_EXPECTED, which
19
+ * maps remover names to the registry ids they delete. A new
20
+ * remover file without an expected-set entry FAILS.
21
+ *
22
+ * 2. Registry → filesystem:
23
+ * - Every descriptor id in ARTIFACT_REGISTRY must appear in
24
+ * REMOVER_EXPECTED or DIRECTORY_ARTIFACT_IDS (the inline-
25
+ * handled directory removals in uninstall.mjs). A new
26
+ * descriptor without a remover mapping FAILS.
27
+ *
28
+ * 3. Mutual consistency:
29
+ * - Every registry id in MERGER_EXPECTED also appears in
30
+ * REMOVER_EXPECTED or DIRECTORY_ARTIFACT_IDS. A merger that
31
+ * writes an artifact with no remover path FAILS.
32
+ *
33
+ * When this test fails, the fix is always either (a) update the
34
+ * expected-set in this test because the change was intentional
35
+ * (adding a new merger/remover pair), or (b) update the registry
36
+ * because the implementation drifted. NEVER silence the test — that
37
+ * defeats the entire drift-protection mechanism.
38
+ */
39
+
40
+ import { describe, it } from "node:test";
41
+ import assert from "node:assert/strict";
42
+ import { readdirSync } from "node:fs";
43
+ import { join, dirname } from "node:path";
44
+ import { fileURLToPath } from "node:url";
45
+
46
+ import { ARTIFACT_REGISTRY } from "../../lib/artifact-registry.mjs";
47
+
48
+ const __dirname = dirname(fileURLToPath(import.meta.url));
49
+ const LIB_DIR = join(__dirname, "..", "..", "lib");
50
+ const MERGERS_DIR = join(LIB_DIR, "mergers");
51
+ const REMOVERS_DIR = join(LIB_DIR, "removers");
52
+
53
+ /**
54
+ * Maps each installer merger file (by basename without `.mjs`) to
55
+ * the registry descriptor ids it produces. The init flow writes
56
+ * through these mergers; every installed artifact must correspond
57
+ * to a catalogued descriptor so the uninstaller can find it.
58
+ *
59
+ * UPDATE this table in the same PR that adds a new merger. The
60
+ * "filesystem → registry" assertion below reads the directory and
61
+ * requires every file to appear here. A missing entry fails with:
62
+ *
63
+ * "merger X.mjs has no entry in MERGER_EXPECTED"
64
+ *
65
+ * which points directly at this file.
66
+ */
67
+ const MERGER_EXPECTED = Object.freeze({
68
+ "claude-mcp": ["claude-mcp-entry"],
69
+ "cursor-mcp": ["cursor-mcp-entry"],
70
+ "vscode-mcp": ["vscode-mcp-entry", "vscode-mcp-input"],
71
+ "windsurf-mcp": ["windsurf-mcp-entry"],
72
+ "env-local": ["env-local-key"],
73
+ gitignore: ["gitignore-entries"],
74
+ // Session-sync installer added by #884. Writes the hook entry that
75
+ // the `settings` remover (src/lib/removers/settings.mjs) identifies
76
+ // via SESSION_HOOK_FINGERPRINT. One installer module, two target
77
+ // paths: project-local (`.claude/settings.local.json`) when called
78
+ // without --global, user-wide (`~/.claude/settings.local.json`)
79
+ // with --global. Each path has its own registry descriptor so
80
+ // `skillrepo uninstall` and `skillrepo uninstall --global` both
81
+ // know to look in the right place. Round-trip contracts are
82
+ // verified in src/test/mergers/session-hook.test.mjs.
83
+ "session-hook": ["settings-session-hook", "settings-session-hook-global"],
84
+ });
85
+
86
+ /**
87
+ * Maps each remover file (by basename without `.mjs`) to the
88
+ * registry descriptor ids it tears down. Symmetric to
89
+ * MERGER_EXPECTED — every file in src/lib/removers/ must appear
90
+ * here.
91
+ */
92
+ const REMOVER_EXPECTED = Object.freeze({
93
+ "claude-mcp": ["claude-mcp-entry"],
94
+ "cursor-mcp": ["cursor-mcp-entry"],
95
+ "vscode-mcp": ["vscode-mcp-entry", "vscode-mcp-input"],
96
+ "windsurf-mcp": ["windsurf-mcp-entry"],
97
+ "env-local": ["env-local-key"],
98
+ gitignore: ["gitignore-entries"],
99
+ // One remover file (settings.mjs), two descriptor ids — project-
100
+ // local and global variants go through the same walk with the
101
+ // settings remover's `{ global }` option.
102
+ settings: ["settings-session-hook", "settings-session-hook-global"],
103
+ });
104
+
105
+ /**
106
+ * Registry ids handled inline by uninstall.mjs's
107
+ * `removeDirectoryArtifact` (not by a dedicated remover module).
108
+ * These are the `kind: "directory"` descriptors in the registry.
109
+ */
110
+ const DIRECTORY_ARTIFACT_IDS = Object.freeze([
111
+ "skills-dir-project",
112
+ "skills-dir-global",
113
+ "global-config-dir",
114
+ ]);
115
+
116
+ /**
117
+ * Read an mjs directory and return an array of basenames without
118
+ * extension, excluding any hidden files or subdirectories.
119
+ */
120
+ function listMjsBasenames(dir) {
121
+ return readdirSync(dir, { withFileTypes: true })
122
+ .filter((d) => d.isFile() && d.name.endsWith(".mjs"))
123
+ .map((d) => d.name.replace(/\.mjs$/, ""));
124
+ }
125
+
126
+ describe("artifact-registry: drift enforcement", () => {
127
+ it("every file in src/lib/mergers/ is declared in MERGER_EXPECTED", () => {
128
+ // Filesystem-first direction: a new merger with no expected-set
129
+ // entry fails here. This is the load-bearing check — it catches
130
+ // "engineer added a new write path without updating the catalog."
131
+ const found = listMjsBasenames(MERGERS_DIR);
132
+ for (const name of found) {
133
+ assert.ok(
134
+ MERGER_EXPECTED[name],
135
+ `merger ${name}.mjs has no entry in MERGER_EXPECTED. ` +
136
+ `Add it to src/test/lib/artifact-registry.test.mjs AND add ` +
137
+ `a matching remover + registry descriptor.`,
138
+ );
139
+ }
140
+ });
141
+
142
+ it("every entry in MERGER_EXPECTED has a corresponding mjs file", () => {
143
+ // Inverse: catches a typo or stale entry in MERGER_EXPECTED
144
+ // (e.g., someone removed a merger file but forgot to trim this
145
+ // table). Low-severity drift but still worth catching.
146
+ const found = new Set(listMjsBasenames(MERGERS_DIR));
147
+ for (const name of Object.keys(MERGER_EXPECTED)) {
148
+ assert.ok(
149
+ found.has(name),
150
+ `MERGER_EXPECTED names ${name}.mjs but the file does not exist at ${MERGERS_DIR}.`,
151
+ );
152
+ }
153
+ });
154
+
155
+ it("every file in src/lib/removers/ is declared in REMOVER_EXPECTED", () => {
156
+ const found = listMjsBasenames(REMOVERS_DIR);
157
+ for (const name of found) {
158
+ assert.ok(
159
+ REMOVER_EXPECTED[name],
160
+ `remover ${name}.mjs has no entry in REMOVER_EXPECTED. ` +
161
+ `Add it to src/test/lib/artifact-registry.test.mjs AND confirm ` +
162
+ `its target ids are in ARTIFACT_REGISTRY.`,
163
+ );
164
+ }
165
+ });
166
+
167
+ it("every entry in REMOVER_EXPECTED has a corresponding mjs file", () => {
168
+ const found = new Set(listMjsBasenames(REMOVERS_DIR));
169
+ for (const name of Object.keys(REMOVER_EXPECTED)) {
170
+ assert.ok(
171
+ found.has(name),
172
+ `REMOVER_EXPECTED names ${name}.mjs but the file does not exist at ${REMOVERS_DIR}.`,
173
+ );
174
+ }
175
+ });
176
+
177
+ it("every registry descriptor id has a remover or is an inline directory removal", () => {
178
+ // Registry-first direction: catches "descriptor added but no
179
+ // one implemented the removal."
180
+ const allImplementedIds = new Set([
181
+ ...Object.values(REMOVER_EXPECTED).flat(),
182
+ ...DIRECTORY_ARTIFACT_IDS,
183
+ ]);
184
+ for (const d of ARTIFACT_REGISTRY) {
185
+ assert.ok(
186
+ allImplementedIds.has(d.id),
187
+ `ARTIFACT_REGISTRY id "${d.id}" has no remover. Either add ` +
188
+ `it to REMOVER_EXPECTED (with a matching src/lib/removers/ ` +
189
+ `file) or to DIRECTORY_ARTIFACT_IDS if it's a whole-directory ` +
190
+ `removal handled inline by uninstall.mjs.`,
191
+ );
192
+ }
193
+ });
194
+
195
+ it("every REMOVER_EXPECTED id exists in ARTIFACT_REGISTRY", () => {
196
+ // Catches a typo in the expected-set that doesn't match a real
197
+ // descriptor.
198
+ const registryIds = new Set(ARTIFACT_REGISTRY.map((d) => d.id));
199
+ for (const [removerName, ids] of Object.entries(REMOVER_EXPECTED)) {
200
+ for (const id of ids) {
201
+ assert.ok(
202
+ registryIds.has(id),
203
+ `REMOVER_EXPECTED["${removerName}"] references id "${id}" ` +
204
+ `but ARTIFACT_REGISTRY has no descriptor with that id.`,
205
+ );
206
+ }
207
+ }
208
+ });
209
+
210
+ it("every MERGER_EXPECTED id has a matching descriptor AND a remover path", () => {
211
+ // Mutual consistency: a merger that installs an artifact must
212
+ // have both a registry descriptor AND a teardown path (direct
213
+ // remover or inline directory removal). Otherwise we're
214
+ // writing state the user can never clean up.
215
+ const registryIds = new Set(ARTIFACT_REGISTRY.map((d) => d.id));
216
+ const allImplementedIds = new Set([
217
+ ...Object.values(REMOVER_EXPECTED).flat(),
218
+ ...DIRECTORY_ARTIFACT_IDS,
219
+ ]);
220
+ for (const [mergerName, ids] of Object.entries(MERGER_EXPECTED)) {
221
+ for (const id of ids) {
222
+ assert.ok(
223
+ registryIds.has(id),
224
+ `MERGER_EXPECTED["${mergerName}"] references id "${id}" ` +
225
+ `but ARTIFACT_REGISTRY has no descriptor with that id.`,
226
+ );
227
+ assert.ok(
228
+ allImplementedIds.has(id),
229
+ `MERGER_EXPECTED["${mergerName}"] installs id "${id}" but ` +
230
+ `no remover is bound to it. A write with no teardown is ` +
231
+ `exactly the drift #885 exists to prevent.`,
232
+ );
233
+ }
234
+ }
235
+ });
236
+
237
+ it("every DIRECTORY_ARTIFACT_ID is a directory-kind descriptor in the registry", () => {
238
+ for (const id of DIRECTORY_ARTIFACT_IDS) {
239
+ const d = ARTIFACT_REGISTRY.find((x) => x.id === id);
240
+ assert.ok(d, `DIRECTORY_ARTIFACT_IDS references unknown id ${id}`);
241
+ assert.equal(
242
+ d.kind,
243
+ "directory",
244
+ `DIRECTORY_ARTIFACT_IDS contains non-directory descriptor ${id} (kind=${d.kind})`,
245
+ );
246
+ }
247
+ });
248
+
249
+ it("descriptor kind enum is limited to the known set", () => {
250
+ // Catches a descriptor that uses a `kind` the dispatch table
251
+ // doesn't understand — would produce a runtime "no remover
252
+ // bound" error at uninstall time rather than a clean test failure.
253
+ const VALID_KINDS = new Set([
254
+ "json-key",
255
+ "json-input",
256
+ "line",
257
+ "section",
258
+ "directory",
259
+ ]);
260
+ for (const d of ARTIFACT_REGISTRY) {
261
+ assert.ok(
262
+ VALID_KINDS.has(d.kind),
263
+ `descriptor ${d.id} has unknown kind "${d.kind}". Allowed: ` +
264
+ `${[...VALID_KINDS].join(", ")}`,
265
+ );
266
+ }
267
+ });
268
+ });