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.
- package/README.md +72 -6
- package/bin/skillrepo.mjs +14 -0
- package/package.json +1 -1
- package/src/commands/init.mjs +132 -14
- package/src/commands/remove.mjs +8 -13
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +125 -8
- package/src/lib/artifact-registry.mjs +265 -0
- package/src/lib/fs-utils.mjs +83 -1
- package/src/lib/mergers/session-hook.mjs +298 -0
- package/src/lib/paths.mjs +21 -0
- package/src/lib/removers/claude-mcp.mjs +67 -0
- package/src/lib/removers/cursor-mcp.mjs +60 -0
- package/src/lib/removers/env-local.mjs +55 -0
- package/src/lib/removers/gitignore.mjs +108 -0
- package/src/lib/removers/settings.mjs +183 -0
- package/src/lib/removers/vscode-mcp.mjs +87 -0
- package/src/lib/removers/windsurf-mcp.mjs +65 -0
- package/src/test/commands/init.test.mjs +211 -0
- package/src/test/commands/session-sync.test.mjs +350 -0
- package/src/test/commands/uninstall.test.mjs +768 -0
- package/src/test/commands/update.test.mjs +158 -0
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/mergers/session-hook.test.mjs +745 -0
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
- package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
- package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
- package/src/test/mergers/uninstall-settings.test.mjs +285 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
|
@@ -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
|
+
}
|
package/src/commands/update.mjs
CHANGED
|
@@ -12,32 +12,138 @@
|
|
|
12
12
|
* --key <key> Override config-file access key
|
|
13
13
|
* --url <url> Override config-file server URL
|
|
14
14
|
*
|
|
15
|
-
*
|
|
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.
|
|
16
26
|
*/
|
|
17
27
|
|
|
18
28
|
import { runSync } from "../lib/sync.mjs";
|
|
19
29
|
import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
|
|
20
30
|
|
|
21
31
|
/**
|
|
22
|
-
* Run `update`.
|
|
23
|
-
*
|
|
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.
|
|
24
53
|
*
|
|
25
54
|
* @param {string[]} argv
|
|
26
55
|
* @param {object} [io] - Optional injected streams for testability.
|
|
27
|
-
* Defaults to process.stdout/stderr. Tests pass a Writable
|
|
28
|
-
* sink so they can capture output without monkey-patching the
|
|
29
|
-
* global stdout (which collides with node:test's TAP IPC).
|
|
30
56
|
* @param {NodeJS.WritableStream} [io.stdout=process.stdout]
|
|
31
57
|
* @param {NodeJS.WritableStream} [io.stderr=process.stderr]
|
|
32
58
|
*/
|
|
33
59
|
export async function runUpdate(argv, io = {}) {
|
|
34
60
|
const stdout = io.stdout ?? process.stdout;
|
|
35
|
-
|
|
36
|
-
|
|
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);
|
|
37
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 ───────────────────────────────
|
|
38
141
|
// Forward `io` to runSync so the non-fatal "failed to persist
|
|
39
142
|
// last-sync state" warning lands on the injected stderr stream
|
|
40
143
|
// when tests inject one.
|
|
144
|
+
const flags = resolveFlags(argv);
|
|
145
|
+
const vendors = effectiveVendors(flags);
|
|
146
|
+
|
|
41
147
|
const summary = await runSync({
|
|
42
148
|
serverUrl: flags.serverUrl,
|
|
43
149
|
apiKey: flags.apiKey,
|
|
@@ -65,3 +171,14 @@ function printSummary(s, out) {
|
|
|
65
171
|
if (s.removed > 0) out.write(` − ${s.removed} removed\n`);
|
|
66
172
|
out.write("\n");
|
|
67
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
|
+
};
|