skillrepo 2.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 (72) hide show
  1. package/README.md +276 -145
  2. package/bin/skillrepo.mjs +224 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +589 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +162 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/session-sync.mjs +152 -0
  11. package/src/commands/uninstall.mjs +484 -0
  12. package/src/commands/update.mjs +184 -0
  13. package/src/lib/artifact-registry.mjs +265 -0
  14. package/src/lib/cli-config.mjs +230 -0
  15. package/src/lib/config.mjs +238 -0
  16. package/src/lib/detect-ides.mjs +0 -19
  17. package/src/lib/errors.mjs +264 -0
  18. package/src/lib/file-write.mjs +705 -0
  19. package/src/lib/fs-utils.mjs +83 -1
  20. package/src/lib/http.mjs +817 -37
  21. package/src/lib/identifier.mjs +153 -0
  22. package/src/lib/mcp-merge.mjs +275 -0
  23. package/src/lib/mergers/gitignore.mjs +73 -18
  24. package/src/lib/mergers/session-hook.mjs +298 -0
  25. package/src/lib/paths.mjs +67 -17
  26. package/src/lib/prompt.mjs +11 -44
  27. package/src/lib/removers/claude-mcp.mjs +67 -0
  28. package/src/lib/removers/cursor-mcp.mjs +60 -0
  29. package/src/lib/removers/env-local.mjs +55 -0
  30. package/src/lib/removers/gitignore.mjs +108 -0
  31. package/src/lib/removers/settings.mjs +183 -0
  32. package/src/lib/removers/vscode-mcp.mjs +87 -0
  33. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  34. package/src/lib/sync.mjs +305 -0
  35. package/src/test/commands/add.test.mjs +285 -0
  36. package/src/test/commands/get.test.mjs +176 -0
  37. package/src/test/commands/init.test.mjs +697 -0
  38. package/src/test/commands/list.test.mjs +172 -0
  39. package/src/test/commands/remove.test.mjs +234 -0
  40. package/src/test/commands/search.test.mjs +204 -0
  41. package/src/test/commands/session-sync.test.mjs +350 -0
  42. package/src/test/commands/uninstall.test.mjs +768 -0
  43. package/src/test/commands/update.test.mjs +322 -0
  44. package/src/test/detect-ides.test.mjs +9 -14
  45. package/src/test/dispatcher.test.mjs +224 -0
  46. package/src/test/e2e/cli-commands.test.mjs +576 -0
  47. package/src/test/e2e/mock-server.mjs +364 -22
  48. package/src/test/helpers/capture-stream.mjs +48 -0
  49. package/src/test/integration/file-write.integration.test.mjs +279 -0
  50. package/src/test/lib/artifact-registry.test.mjs +268 -0
  51. package/src/test/lib/cli-config.test.mjs +407 -0
  52. package/src/test/lib/config.test.mjs +257 -0
  53. package/src/test/lib/errors.test.mjs +359 -0
  54. package/src/test/lib/file-write.test.mjs +784 -0
  55. package/src/test/lib/http.test.mjs +1198 -0
  56. package/src/test/lib/identifier.test.mjs +157 -0
  57. package/src/test/lib/mcp-merge.test.mjs +345 -0
  58. package/src/test/lib/paths.test.mjs +83 -0
  59. package/src/test/lib/sync.test.mjs +514 -0
  60. package/src/test/mergers/gitignore.test.mjs +145 -20
  61. package/src/test/mergers/session-hook.test.mjs +745 -0
  62. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  63. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  64. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  65. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  66. package/src/test/mergers/uninstall-settings.test.mjs +285 -0
  67. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  68. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
  69. package/src/lib/write-configs.mjs +0 -202
  70. package/src/test/e2e/HANDOFF.md +0 -223
  71. package/src/test/e2e/cli-init.test.mjs +0 -213
  72. package/src/test/e2e/payload-factory.mjs +0 -22
@@ -0,0 +1,484 @@
1
+ /**
2
+ * `skillrepo uninstall` (#885).
3
+ *
4
+ * Removes every SkillRepo artifact from the project (default) and
5
+ * optionally from user-global state (`--global`). Uses the shared
6
+ * `artifact-registry` catalog to drive per-descriptor removers; the
7
+ * registry is the single source of truth between init (which writes
8
+ * these files) and uninstall (which tears them down).
9
+ *
10
+ * Design guarantees (from issue #885):
11
+ *
12
+ * - Surgical. Shared files (`.mcp.json`, `.gitignore`, `.env.local`,
13
+ * `.claude/settings.local.json`) are parsed, SkillRepo-owned
14
+ * entries are removed, and non-SkillRepo content is written back
15
+ * unchanged. The file itself is never deleted.
16
+ * - Offline. No server call is made. Uninstall works with a
17
+ * revoked key, no network, or after the user deleted their
18
+ * account — the user's local state is fully CLI-local.
19
+ * - Idempotent. Running twice produces the same end state as
20
+ * running once, with the second run reporting "Nothing to
21
+ * remove" and exiting 0.
22
+ * - Continue-with-errors. A single failing artifact does not
23
+ * abort the operation; other artifacts are still processed.
24
+ * The final exit code is 3 (EXIT_DISK) if any artifact failed,
25
+ * 0 otherwise.
26
+ * - Interactive confirmation. The command prints a full list of
27
+ * what will be removed BEFORE touching any file, then prompts.
28
+ * `--yes` skips the prompt; `--dry-run` skips execution
29
+ * entirely and just prints the preview.
30
+ * - Stream injection. Every write goes through the injected
31
+ * `io.stdout` / `io.stderr` streams — no `console.log`. Matches
32
+ * the pattern every other command uses so tests can capture
33
+ * output without monkey-patching process streams.
34
+ *
35
+ * Not in scope (explicit per the architect design):
36
+ * - v2.0.0 artifacts (.claude/rules/skillrepo-*, hooks, etc.).
37
+ * v3.0.0 is the minimum supported version; users of earlier
38
+ * versions clean up manually.
39
+ * - The `<cwd>/skills/` fallback directory (tracked in #876).
40
+ */
41
+
42
+ import {
43
+ existsSync,
44
+ readdirSync,
45
+ realpathSync,
46
+ rmSync,
47
+ statSync,
48
+ } from "node:fs";
49
+
50
+ import {
51
+ ARTIFACT_REGISTRY,
52
+ artifactsByScope,
53
+ } from "../lib/artifact-registry.mjs";
54
+ import { removeGitignore } from "../lib/removers/gitignore.mjs";
55
+ import { removeEnvLocal } from "../lib/removers/env-local.mjs";
56
+ import { removeClaudeMcp } from "../lib/removers/claude-mcp.mjs";
57
+ import { removeCursorMcp } from "../lib/removers/cursor-mcp.mjs";
58
+ import { removeVscodeMcp } from "../lib/removers/vscode-mcp.mjs";
59
+ import { removeWindsurfMcp } from "../lib/removers/windsurf-mcp.mjs";
60
+ import { removeSettingsSessionHook } from "../lib/removers/settings.mjs";
61
+ import { confirm } from "../lib/prompt.mjs";
62
+ import { resolveFlags } from "../lib/cli-config.mjs";
63
+ import { diskError } from "../lib/errors.mjs";
64
+
65
+ // ── Descriptor → remover binding ──────────────────────────────────
66
+
67
+ /**
68
+ * Per-descriptor dispatch table. The registry is a pure data module;
69
+ * the actual remover implementations live in `src/lib/removers/*.mjs`.
70
+ * This map is the single place where "descriptor id" meets "function
71
+ * that removes it." If a new descriptor is added to the registry
72
+ * without a matching entry here, the CI enforcement test at
73
+ * `src/test/lib/artifact-registry.test.mjs` fails.
74
+ *
75
+ * Directory artifacts (`skills-dir-project`, `skills-dir-global`,
76
+ * `global-config-dir`) are handled inline by `removeDirectoryArtifact`
77
+ * below rather than via separate modules — the removal is a single
78
+ * `rmSync({ recursive: true })` call with a basename assertion.
79
+ */
80
+ const FILE_REMOVERS = Object.freeze({
81
+ "claude-mcp-entry": removeClaudeMcp,
82
+ "cursor-mcp-entry": removeCursorMcp,
83
+ "vscode-mcp-entry": removeVscodeMcp,
84
+ // vscode-mcp-input is handled by the same remover as vscode-mcp-entry
85
+ // (both live in one file; one call handles both in a single parse-
86
+ // mutate-write cycle). The registry still lists them as two
87
+ // descriptors for catalog completeness, but only one remover call
88
+ // is made.
89
+ "vscode-mcp-input": null,
90
+ "env-local-key": removeEnvLocal,
91
+ "gitignore-entries": removeGitignore,
92
+ "settings-session-hook": removeSettingsSessionHook,
93
+ // Global-scope variant added in #884: routes to the SAME remover
94
+ // but with `{ global: true }` so the pathFn resolves to
95
+ // ~/.claude/settings.local.json. The dryRun option is forwarded
96
+ // from the orchestrator's call site so preview + execute passes
97
+ // both work unchanged.
98
+ "settings-session-hook-global": (opts = {}) =>
99
+ removeSettingsSessionHook({ ...opts, global: true }),
100
+ "windsurf-mcp-entry": removeWindsurfMcp,
101
+ // Directory artifacts — handled inline.
102
+ "skills-dir-project": null,
103
+ "skills-dir-global": null,
104
+ "global-config-dir": null,
105
+ });
106
+
107
+ /**
108
+ * Inline remover for directory artifacts. Both the preview (dryRun)
109
+ * path and the execute path share this function. Preserves a strict
110
+ * basename assertion before any `rmSync` — if the path resolves to
111
+ * something other than `skills` / `skillrepo`, the removal is
112
+ * refused with a structured error rather than executing. This is
113
+ * defense in depth against a path-resolution bug or a future
114
+ * refactor that changes `claudeSkillsProjectRoot()` etc.
115
+ */
116
+ function removeDirectoryArtifact(descriptor, { dryRun }) {
117
+ const path = descriptor.pathFn();
118
+ const displayPath = descriptor.displayPath;
119
+
120
+ if (!existsSync(path)) {
121
+ return { path: displayPath, action: "skipped" };
122
+ }
123
+
124
+ // Basename safety net on the RESOLVED path — following symlinks
125
+ // first. The earlier version only checked the path-string's last
126
+ // segment, which a `.claude/skills` symlink pointing at
127
+ // `/home/user/important-data/` would have bypassed: basename of
128
+ // the symlink was "skills" but `rmSync` would have walked the
129
+ // target. architect review round 1 flagged this as a defense-
130
+ // in-depth gap. `realpathSync` throws on dangling symlinks, so
131
+ // wrap it — if the target doesn't resolve, refuse rather than
132
+ // guessing.
133
+ let realPath;
134
+ try {
135
+ realPath = realpathSync(path);
136
+ } catch (err) {
137
+ return {
138
+ path: displayPath,
139
+ action: "skipped",
140
+ error: `Cannot resolve ${path}: ${err.message}. Refusing to rmSync a path that doesn't resolve.`,
141
+ };
142
+ }
143
+ const basename = realPath.split(/[\\/]/).filter(Boolean).pop();
144
+ if (basename !== "skills" && basename !== "skillrepo") {
145
+ return {
146
+ path: displayPath,
147
+ action: "skipped",
148
+ error: `Refusing to recursively remove ${path} (resolved to ${realPath}): basename "${basename}" is neither "skills" nor "skillrepo".`,
149
+ };
150
+ }
151
+
152
+ let childCount = 0;
153
+ try {
154
+ if (statSync(path).isDirectory()) {
155
+ childCount = readdirSync(path).length;
156
+ }
157
+ } catch {
158
+ // Stat failure is tolerable — we just lose the child-count detail.
159
+ }
160
+
161
+ if (dryRun) {
162
+ return {
163
+ path: displayPath,
164
+ action: "would-remove",
165
+ detail: childCount > 0 ? `${childCount} entries` : undefined,
166
+ };
167
+ }
168
+
169
+ rmSync(path, { recursive: true, force: true });
170
+
171
+ return {
172
+ path: displayPath,
173
+ action: "removed",
174
+ detail: childCount > 0 ? `${childCount} entries` : undefined,
175
+ };
176
+ }
177
+
178
+ // ── Command entry point ──────────────────────────────────────────
179
+
180
+ /**
181
+ * Run uninstall.
182
+ *
183
+ * @param {string[]} argv
184
+ * @param {object} [io]
185
+ * @param {NodeJS.WritableStream} [io.stdout=process.stdout]
186
+ * @param {NodeJS.WritableStream} [io.stderr=process.stderr]
187
+ * @returns {Promise<void>}
188
+ */
189
+ export async function runUninstall(argv, io = {}) {
190
+ const stdout = io.stdout ?? process.stdout;
191
+ const stderr = io.stderr ?? process.stderr;
192
+ const { green, yellow, bold } = makeColors(stdout);
193
+
194
+ const { dryRun, yes, global, json } = parseUninstallFlags(argv);
195
+
196
+ // ── Scope filter ────────────────────────────────────────────────
197
+ // Project artifacts always included. Global artifacts only when
198
+ // `--global` is passed — a fresh-install-on-one-project uninstall
199
+ // should not nuke a multi-project user's shared credentials.
200
+ const descriptors = global
201
+ ? ARTIFACT_REGISTRY
202
+ : artifactsByScope("project");
203
+
204
+ // ── Scan phase (dry run every remover, collect preview) ────────
205
+ //
206
+ // Every descriptor runs with `dryRun: true` first so we can show
207
+ // the user a complete list before they confirm. The vscode-mcp-
208
+ // input descriptor is a catalog sibling of vscode-mcp-entry and
209
+ // shares a remover — running the remover twice would double-count,
210
+ // so we skip the second-descriptor entry.
211
+ const scanned = [];
212
+ for (const d of descriptors) {
213
+ if (d.id === "vscode-mcp-input") continue;
214
+ const result = runForDescriptor(d, { dryRun: true });
215
+ if (result.action === "would-remove" || result.error) {
216
+ scanned.push({ descriptor: d, result });
217
+ }
218
+ }
219
+
220
+ // ── Nothing to do ──────────────────────────────────────────────
221
+ if (scanned.length === 0) {
222
+ if (json) {
223
+ stdout.write(
224
+ JSON.stringify(
225
+ {
226
+ action: dryRun ? "dry-run" : "nothing-to-remove",
227
+ scope: global ? "global" : "project",
228
+ removed: [],
229
+ "would-remove": [],
230
+ errors: [],
231
+ },
232
+ null,
233
+ 2,
234
+ ) + "\n",
235
+ );
236
+ } else {
237
+ stdout.write(
238
+ `\n Nothing to remove. SkillRepo is not installed in this ${global ? "account" : "project"}.\n\n`,
239
+ );
240
+ }
241
+ return;
242
+ }
243
+
244
+ // ── Render preview ─────────────────────────────────────────────
245
+ if (!json) {
246
+ stdout.write(`\n ${bold("SkillRepo Uninstall")}\n\n`);
247
+ stdout.write(
248
+ ` ${dryRun ? "Would remove" : "The following will be removed from " + (global ? "global state and " : "") + "this project"}:\n\n`,
249
+ );
250
+ for (const { descriptor, result } of scanned) {
251
+ stdout.write(renderPreviewLine(descriptor, result));
252
+ }
253
+ if (!global) {
254
+ stdout.write(
255
+ `\n Nothing will be removed from global state (~/.claude/skillrepo/, Windsurf config).\n`,
256
+ );
257
+ stdout.write(` Run with --global to also remove global state.\n`);
258
+ }
259
+ stdout.write("\n");
260
+ }
261
+
262
+ // ── Dry-run short-circuit ──────────────────────────────────────
263
+ if (dryRun) {
264
+ if (json) {
265
+ stdout.write(
266
+ JSON.stringify(
267
+ {
268
+ action: "dry-run",
269
+ scope: global ? "global" : "project",
270
+ "would-remove": scanned.map(({ descriptor, result }) => ({
271
+ id: descriptor.id,
272
+ path: result.path,
273
+ detail: result.detail,
274
+ })),
275
+ errors: scanned
276
+ .filter(({ result }) => result.error)
277
+ .map(({ descriptor, result }) => ({
278
+ id: descriptor.id,
279
+ path: result.path,
280
+ error: result.error,
281
+ })),
282
+ },
283
+ null,
284
+ 2,
285
+ ) + "\n",
286
+ );
287
+ }
288
+ return;
289
+ }
290
+
291
+ // ── Prompt for confirmation (unless --yes or --json) ───────────
292
+ if (!yes && !json) {
293
+ const ok = await confirm("Proceed?", false);
294
+ if (!ok) {
295
+ stdout.write(" Cancelled. Nothing was removed.\n\n");
296
+ return;
297
+ }
298
+ }
299
+
300
+ // ── Execute phase ──────────────────────────────────────────────
301
+ const executed = [];
302
+ const errors = [];
303
+ for (const { descriptor } of scanned) {
304
+ try {
305
+ const result = runForDescriptor(descriptor, { dryRun: false });
306
+ executed.push({ descriptor, result });
307
+ if (result.error) {
308
+ errors.push({ descriptor, result });
309
+ }
310
+ } catch (err) {
311
+ // Re-thrown error (e.g., atomic-write rename failure). Continue
312
+ // with the rest; aggregate and report at the end.
313
+ const result = {
314
+ path: descriptor.displayPath,
315
+ action: "skipped",
316
+ error: err?.message ?? String(err),
317
+ };
318
+ executed.push({ descriptor, result });
319
+ errors.push({ descriptor, result });
320
+ }
321
+ }
322
+
323
+ // ── Render summary ─────────────────────────────────────────────
324
+ if (json) {
325
+ stdout.write(
326
+ JSON.stringify(
327
+ {
328
+ action: errors.length === 0 ? "uninstalled" : "partially-uninstalled",
329
+ scope: global ? "global" : "project",
330
+ removed: executed
331
+ .filter(({ result }) => result.action === "removed")
332
+ .map(({ descriptor, result }) => ({
333
+ id: descriptor.id,
334
+ path: result.path,
335
+ detail: result.detail,
336
+ })),
337
+ errors: errors.map(({ descriptor, result }) => ({
338
+ id: descriptor.id,
339
+ path: result.path,
340
+ error: result.error,
341
+ })),
342
+ },
343
+ null,
344
+ 2,
345
+ ) + "\n",
346
+ );
347
+ } else {
348
+ for (const { result } of executed) {
349
+ if (result.action === "removed") {
350
+ stdout.write(` ${green("✓")} ${result.path}`);
351
+ if (result.detail) stdout.write(` (${result.detail})`);
352
+ stdout.write("\n");
353
+ }
354
+ }
355
+ if (errors.length > 0) {
356
+ stderr.write(`\n ${yellow("⚠")} Some artifacts could not be removed:\n`);
357
+ for (const { result } of errors) {
358
+ stderr.write(` • ${result.path}: ${result.error}\n`);
359
+ }
360
+ stderr.write(
361
+ ` Fix the issues above and re-run \`skillrepo uninstall\`.\n\n`,
362
+ );
363
+ } else {
364
+ stdout.write(
365
+ `\n SkillRepo has been removed from this ${global ? "account" : "project"}.\n`,
366
+ );
367
+ if (!global && !json) {
368
+ stdout.write(
369
+ ` Your access key at ~/.claude/skillrepo/config.json is still valid.\n`,
370
+ );
371
+ }
372
+ stdout.write(` Run \`skillrepo init\` to re-initialize.\n\n`);
373
+ }
374
+ }
375
+
376
+ // Propagate the partial-failure exit code through the normal
377
+ // CliError mechanism. The dispatcher at bin/skillrepo.mjs catches
378
+ // any CliError and exits with its carried exit code, so throwing
379
+ // here (rather than calling process.exit directly) matches the
380
+ // pattern every other command uses AND keeps the test runner
381
+ // alive when uninstall is exercised from node:test.
382
+ if (errors.length > 0) {
383
+ throw diskError(
384
+ `Uninstall completed with ${errors.length} error${errors.length === 1 ? "" : "s"}. See output above for details.`,
385
+ );
386
+ }
387
+ }
388
+
389
+ // ── Helpers ──────────────────────────────────────────────────────
390
+
391
+ /**
392
+ * Dispatch a descriptor to its remover. Directory kinds go through
393
+ * the inline `removeDirectoryArtifact`; everything else goes through
394
+ * the `FILE_REMOVERS` table. Returns a `{ path, action, ... }`
395
+ * shape that the caller can consume uniformly.
396
+ */
397
+ function runForDescriptor(descriptor, { dryRun }) {
398
+ if (descriptor.kind === "directory") {
399
+ return removeDirectoryArtifact(descriptor, { dryRun });
400
+ }
401
+ const fn = FILE_REMOVERS[descriptor.id];
402
+ if (!fn) {
403
+ // Only reachable for descriptors that share a remover with a
404
+ // sibling (e.g. vscode-mcp-input → handled via vscode-mcp-entry).
405
+ // Callers already skip those, so reaching here is a programming
406
+ // error — surface it loudly.
407
+ throw new Error(
408
+ `No remover bound to artifact descriptor "${descriptor.id}".`,
409
+ );
410
+ }
411
+ return fn({ dryRun });
412
+ }
413
+
414
+ function renderPreviewLine(descriptor, result) {
415
+ const prefix = result.error
416
+ ? " [error] "
417
+ : descriptor.kind === "directory"
418
+ ? " [dir] "
419
+ : descriptor.kind === "line" || descriptor.kind === "section"
420
+ ? " [lines] "
421
+ : " [entry] ";
422
+ const detail = result.error
423
+ ? `→ ${result.error}`
424
+ : result.detail
425
+ ? `(${result.detail})`
426
+ : "";
427
+ return `${prefix}${result.path.padEnd(40)} ${detail}\n`;
428
+ }
429
+
430
+ function parseUninstallFlags(argv) {
431
+ let dryRun = false;
432
+ let yes = false;
433
+ // Reuse resolveFlags for the standard --global / --json / --ide /
434
+ // --key / --url shape. resolveFlags ignores unknown flags when an
435
+ // acceptPositional callback is provided that can consume them —
436
+ // the callback pattern matches init's own parsing.
437
+ const flags = resolveFlags(argv, {
438
+ requireAuth: false,
439
+ skipConfig: true,
440
+ acceptPositional(arg) {
441
+ if (arg === "--dry-run" || arg === "-n") {
442
+ dryRun = true;
443
+ return 1;
444
+ }
445
+ if (arg === "--yes" || arg === "-y") {
446
+ yes = true;
447
+ return 1;
448
+ }
449
+ return false;
450
+ },
451
+ });
452
+
453
+ // `global` and `json` live on flags via resolveFlags.
454
+ return {
455
+ dryRun,
456
+ yes,
457
+ global: Boolean(flags.global),
458
+ json: Boolean(flags.json),
459
+ };
460
+ }
461
+
462
+ // ── Color helpers (local — same pattern as init.mjs) ────────────
463
+ //
464
+ // The three color wrappers are constructed per-invocation inside
465
+ // `runUninstall` via `makeColors(stdout)` below so they honor the
466
+ // INJECTED stdout stream (not `process.stdout`). Under test the
467
+ // capture stream is not a TTY, so ANSI codes never leak into
468
+ // asserted output; under real use the user's TTY stream drives
469
+ // color as expected. Matches `init.mjs`'s `makePrinter` pattern
470
+ // exactly — the inconsistency was flagged by the code-reviewer.
471
+
472
+ const GREEN = (s) => `\x1b[32m${s}\x1b[0m`;
473
+ const YELLOW = (s) => `\x1b[33m${s}\x1b[0m`;
474
+ const BOLD = (s) => `\x1b[1m${s}\x1b[0m`;
475
+
476
+ function makeColors(stdout) {
477
+ const useColor = Boolean(stdout?.isTTY) && !process.env.NO_COLOR;
478
+ const paint = (color) => (s) => (useColor ? color(s) : s);
479
+ return {
480
+ green: paint(GREEN),
481
+ yellow: paint(YELLOW),
482
+ bold: paint(BOLD),
483
+ };
484
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `skillrepo update` (#675).
3
+ *
4
+ * Re-syncs the user's library against the registry by calling the
5
+ * shared `sync.mjs` engine. This is a thin command wrapper around
6
+ * `runSync` plus argument parsing and a printer.
7
+ *
8
+ * Flags (parsed by the shared `resolveFlags` helper):
9
+ * --global Write to ~/.claude/skills/ instead of project-local
10
+ * --ide <list> Comma-separated vendor list
11
+ * --json Print summary as JSON
12
+ * --key <key> Override config-file access key
13
+ * --url <url> Override config-file server URL
14
+ *
15
+ * v3.1.0 session-hook mode (#884):
16
+ * --session-hook Enforces the Claude Code SessionStart hook
17
+ * contract: EXIT 0 ON ALL ERRORS, silent on 304,
18
+ * one-line summary on changes, one-line failure
19
+ * message on error. See `runUpdate` JSDoc for the
20
+ * full contract. When this flag is absent, the
21
+ * command behaves as before (exit non-zero on
22
+ * network / auth / disk failures).
23
+ *
24
+ * Exit codes are inherited from sync.mjs / http.mjs error types,
25
+ * EXCEPT under `--session-hook` — see the flag's contract below.
26
+ */
27
+
28
+ import { runSync } from "../lib/sync.mjs";
29
+ import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
30
+
31
+ /**
32
+ * Run `update`.
33
+ *
34
+ * NORMAL MODE (no `--session-hook`): throws CliError on any failure;
35
+ * the dispatcher formats and exits. This is the pre-v3.1.0 behavior.
36
+ *
37
+ * SESSION-HOOK MODE (`--session-hook`): invoked by the Claude Code
38
+ * SessionStart hook #884 installs. Enforces this contract:
39
+ *
40
+ * - 304 Not Modified → exit 0, NO output.
41
+ * - 200 with changes → exit 0, ONE line: `[SkillRepo] Library synced: N added, N updated, N removed.`
42
+ * - Any failure → exit 0, ONE line: `[SkillRepo] Sync failed: <reason>.`
43
+ *
44
+ * The "exit 0 on all errors" contract is non-negotiable: a sync
45
+ * failure must NEVER block a Claude Code session start. Users on a
46
+ * plane, behind a corporate firewall, or with a rotated key must
47
+ * still be able to open Claude Code. The installer adds a `|| true`
48
+ * shell backstop for defense in depth, but this flag is the first
49
+ * line of defense — a buggy implementation that throws would be
50
+ * caught by `|| true`, but the visible failure-message line would
51
+ * be lost. Honoring the contract from inside this function ensures
52
+ * the user sees the reason.
53
+ *
54
+ * @param {string[]} argv
55
+ * @param {object} [io] - Optional injected streams for testability.
56
+ * @param {NodeJS.WritableStream} [io.stdout=process.stdout]
57
+ * @param {NodeJS.WritableStream} [io.stderr=process.stderr]
58
+ */
59
+ export async function runUpdate(argv, io = {}) {
60
+ const stdout = io.stdout ?? process.stdout;
61
+ // `stderr` is not used directly — the session-hook path pipes to
62
+ // BLACK_HOLE_STREAM and the normal path forwards `io` to runSync
63
+ // which reads `io.stderr` itself.
64
+
65
+ // ── Pre-pass: detect --session-hook BEFORE resolveFlags runs ─────
66
+ //
67
+ // resolveFlags can throw in three categories we must catch under
68
+ // session-hook mode:
69
+ // - `authError` when no access key is configured (e.g., session
70
+ // fires before `skillrepo init` has run — a real first-run
71
+ // scenario, not synthetic)
72
+ // - `validationError` on unknown flags
73
+ // - `validationError` from parseVendorList on a malformed --ide
74
+ //
75
+ // All three happen INSIDE resolveFlags, before our try/catch block
76
+ // could see them if we called it after. The only robust answer is
77
+ // to detect --session-hook without invoking resolveFlags, then
78
+ // wrap resolveFlags + runSync together in the error handler.
79
+ //
80
+ // A simple argv scan is safe here: `--session-hook` has no value
81
+ // argument, so a plain `.includes` match can't misinterpret
82
+ // positional args. This DOES NOT accept variations like
83
+ // `--session-hook=true` or `-SH` — single canonical form only,
84
+ // matching what the installer writes.
85
+ const sessionHook = argv.includes("--session-hook");
86
+
87
+ if (sessionHook) {
88
+ // Session-hook mode: wrap EVERY error path in try/catch so a
89
+ // sync failure cannot block a Claude Code session start.
90
+ try {
91
+ const flags = resolveFlags(argv, {
92
+ acceptPositional(arg) {
93
+ // resolveFlags sees --session-hook as unknown unless we
94
+ // consume it here. Kept for the non-error path — the
95
+ // real "catch errors" logic is the outer try.
96
+ if (arg === "--session-hook") return 1;
97
+ return false;
98
+ },
99
+ });
100
+ const vendors = effectiveVendors(flags);
101
+
102
+ const summary = await runSync({
103
+ serverUrl: flags.serverUrl,
104
+ apiKey: flags.apiKey,
105
+ vendors,
106
+ global: flags.global,
107
+ io: { stdout: BLACK_HOLE_STREAM, stderr: BLACK_HOLE_STREAM },
108
+ });
109
+ const total = summary.added + summary.updated + summary.removed;
110
+ if (summary.notModified || total === 0) {
111
+ // 304 Not Modified OR 200 with zero deltas — silent by
112
+ // contract. Users should not see "Syncing..." on every
113
+ // session for no visible value.
114
+ return;
115
+ }
116
+ stdout.write(
117
+ `[SkillRepo] Library synced: ${summary.added} added, ${summary.updated} updated, ${summary.removed} removed.\n`,
118
+ );
119
+ } catch (err) {
120
+ // The one-line failure message is the user's primary signal
121
+ // that something's wrong. Do not surface a stack trace — the
122
+ // hook-runner's UI treats hook output as a system message and
123
+ // multi-line tracebacks clutter it.
124
+ //
125
+ // Defensive: `err?.message ?? String(err)` handles a non-Error
126
+ // throw (plain string, number, symbol) without crashing the
127
+ // interpolation. The `|| true` shell backstop remains as the
128
+ // absolute last line of defense.
129
+ const reason = err?.message ?? String(err);
130
+ try {
131
+ stdout.write(`[SkillRepo] Sync failed: ${reason}\n`);
132
+ } catch {
133
+ // Writing to a closed pipe etc. — the `|| true` wrapper
134
+ // will save the session. Nothing more to do here.
135
+ }
136
+ }
137
+ return;
138
+ }
139
+
140
+ // ── Normal mode: original behavior ───────────────────────────────
141
+ // Forward `io` to runSync so the non-fatal "failed to persist
142
+ // last-sync state" warning lands on the injected stderr stream
143
+ // when tests inject one.
144
+ const flags = resolveFlags(argv);
145
+ const vendors = effectiveVendors(flags);
146
+
147
+ const summary = await runSync({
148
+ serverUrl: flags.serverUrl,
149
+ apiKey: flags.apiKey,
150
+ vendors,
151
+ global: flags.global,
152
+ io,
153
+ });
154
+
155
+ if (flags.json) {
156
+ stdout.write(JSON.stringify(summary, null, 2) + "\n");
157
+ return;
158
+ }
159
+ printSummary(summary, stdout);
160
+ }
161
+
162
+ function printSummary(s, out) {
163
+ const total = s.added + s.updated + s.removed;
164
+ if (s.notModified || total === 0) {
165
+ out.write(" ✓ Library is up to date.\n");
166
+ return;
167
+ }
168
+ out.write("\n Library sync complete:\n");
169
+ if (s.added > 0) out.write(` + ${s.added} added\n`);
170
+ if (s.updated > 0) out.write(` ↻ ${s.updated} updated\n`);
171
+ if (s.removed > 0) out.write(` − ${s.removed} removed\n`);
172
+ out.write("\n");
173
+ }
174
+
175
+ /**
176
+ * Black-hole writable stream used in session-hook mode to silence
177
+ * `runSync`'s internal warning prints. The command's own output
178
+ * (the success/failure one-liner) goes to the real stdout so the
179
+ * user sees it in the Claude Code session system message.
180
+ */
181
+ const BLACK_HOLE_STREAM = {
182
+ write: () => true,
183
+ isTTY: false,
184
+ };