skillrepo 3.0.0 → 3.1.1

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 (52) hide show
  1. package/README.md +74 -6
  2. package/bin/skillrepo.mjs +14 -0
  3. package/package.json +1 -1
  4. package/src/commands/init.mjs +184 -19
  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 +305 -0
  10. package/src/lib/cli-config.mjs +78 -0
  11. package/src/lib/config.mjs +6 -3
  12. package/src/lib/file-write.mjs +8 -3
  13. package/src/lib/fs-utils.mjs +90 -9
  14. package/src/lib/mergers/session-hook.mjs +378 -0
  15. package/src/lib/paths.mjs +21 -0
  16. package/src/lib/platform.mjs +124 -0
  17. package/src/lib/removers/claude-mcp.mjs +67 -0
  18. package/src/lib/removers/cursor-mcp.mjs +60 -0
  19. package/src/lib/removers/env-local.mjs +55 -0
  20. package/src/lib/removers/gitignore.mjs +108 -0
  21. package/src/lib/removers/settings.mjs +183 -0
  22. package/src/lib/removers/vscode-mcp.mjs +87 -0
  23. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  24. package/src/lib/sync.mjs +26 -0
  25. package/src/test/commands/add.test.mjs +10 -4
  26. package/src/test/commands/get.test.mjs +10 -4
  27. package/src/test/commands/init.test.mjs +428 -4
  28. package/src/test/commands/list.test.mjs +10 -4
  29. package/src/test/commands/remove.test.mjs +10 -4
  30. package/src/test/commands/search.test.mjs +10 -4
  31. package/src/test/commands/session-sync.test.mjs +352 -0
  32. package/src/test/commands/uninstall.test.mjs +774 -0
  33. package/src/test/commands/update.test.mjs +168 -4
  34. package/src/test/helpers/sandbox-home.mjs +161 -0
  35. package/src/test/helpers/skillrepo-shim.mjs +133 -0
  36. package/src/test/integration/file-write.integration.test.mjs +10 -4
  37. package/src/test/lib/artifact-registry.test.mjs +268 -0
  38. package/src/test/lib/cli-config.test.mjs +126 -5
  39. package/src/test/lib/config.test.mjs +10 -4
  40. package/src/test/lib/file-write.test.mjs +24 -10
  41. package/src/test/lib/mcp-merge.test.mjs +10 -4
  42. package/src/test/lib/paths.test.mjs +10 -4
  43. package/src/test/lib/platform.test.mjs +135 -0
  44. package/src/test/lib/sync.test.mjs +20 -4
  45. package/src/test/mergers/session-hook.test.mjs +1175 -0
  46. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  47. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  48. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  49. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  50. package/src/test/mergers/uninstall-settings.test.mjs +296 -0
  51. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  52. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +128 -0
@@ -0,0 +1,378 @@
1
+ /**
2
+ * SessionStart hook installer for Claude Code (#884).
3
+ *
4
+ * The inverse partner of `src/lib/removers/settings.mjs` (#885). Both
5
+ * modules identify SkillRepo-owned hooks via the shared
6
+ * `SESSION_HOOK_FINGERPRINT` constant from `artifact-registry.mjs` —
7
+ * the single source of truth that keeps the installer's output and
8
+ * the remover's predicate in lockstep. The round-trip is verified by
9
+ * `src/test/mergers/session-hook.test.mjs`.
10
+ *
11
+ * ## What this installs
12
+ *
13
+ * A Claude Code SessionStart hook shaped as:
14
+ *
15
+ * {
16
+ * "hooks": {
17
+ * "SessionStart": [
18
+ * {
19
+ * "hooks": [
20
+ * { "type": "command", "command": "/abs/path/skillrepo update --session-hook 2>&1 || true" }
21
+ * ]
22
+ * }
23
+ * ]
24
+ * }
25
+ * }
26
+ *
27
+ * Claude Code invokes every hook in `hooks.SessionStart[*].hooks[*]`
28
+ * on session start. The command shape is load-bearing in three ways:
29
+ *
30
+ * 1. **Absolute path**: resolved at install time via `which skillrepo`.
31
+ * Claude Code's hook runner does not load the user's interactive
32
+ * shell profile, so relying on PATH would break for any user
33
+ * whose global `skillrepo` isn't on the minimal shell PATH.
34
+ *
35
+ * 2. **`--session-hook` flag**: tells `update` to honor the
36
+ * exit-0-on-all-errors contract. A sync failure must NEVER block
37
+ * a session start — offline, server 500, revoked key, or any
38
+ * other error → exit 0 with a single failure-message line.
39
+ *
40
+ * 3. **`|| true` shell backstop**: non-negotiable. If a bug in
41
+ * `--session-hook` ever causes non-zero exit, the shell layer
42
+ * still returns 0. Two layers of defense. Per handoff learning
43
+ * #9 — cannot be removed in a future refactor.
44
+ *
45
+ * ## Why settings.local.json (not settings.json)
46
+ *
47
+ * Per-developer. Claude Code's `settings.local.json` is gitignored
48
+ * (v3.0.0 init adds it to the ignore section at step 3). A team-
49
+ * shared hook in `settings.json` would either silently no-op for
50
+ * developers without `skillrepo` installed, or (worse) error on
51
+ * every session for them. Local-scope avoids both.
52
+ *
53
+ * ## Idempotency
54
+ *
55
+ * Re-running `skillrepo init` or `skillrepo session-sync enable`
56
+ * should produce the same end state as the first run. The installer:
57
+ * - writes fresh if no SkillRepo hook is present
58
+ * - updates in place if the fingerprint matches but the absolute
59
+ * path changed (e.g. user moved the binary)
60
+ * - no-ops if the exact command is already present
61
+ * Non-SkillRepo hooks and unrelated groups are never touched.
62
+ *
63
+ * ## Atomic writes
64
+ *
65
+ * settings.local.json is parsed by Claude Code at session start. A
66
+ * half-written file would break every future session until manually
67
+ * fixed. `writeFileAtomic` (temp file + rename + unlink-on-failure)
68
+ * guarantees either the new content is fully in place or the old
69
+ * content is fully preserved.
70
+ */
71
+
72
+ import { existsSync, readFileSync } from "node:fs";
73
+ import { execFileSync } from "node:child_process";
74
+ import { isAbsolute } from "node:path";
75
+ import { writeFileAtomic } from "../fs-utils.mjs";
76
+ import { SESSION_HOOK_FINGERPRINT } from "../artifact-registry.mjs";
77
+ import {
78
+ claudeSettingsLocal,
79
+ claudeSettingsLocalGlobal,
80
+ } from "../paths.mjs";
81
+ import { diskError, validationError } from "../errors.mjs";
82
+ import { removeSettingsSessionHook } from "../removers/settings.mjs";
83
+ import { isNpxInvocation } from "../cli-config.mjs";
84
+ import { platformConventions } from "../platform.mjs";
85
+
86
+ /**
87
+ * Build the hook command string for a given absolute path. Exported
88
+ * so tests can assert the exact bytes the installer writes.
89
+ *
90
+ * Shell shape is platform-specific — see `platform.mjs` for the full
91
+ * rationale. Summary:
92
+ *
93
+ * - **POSIX** (macOS, Linux): `<path> update --session-hook 2>&1 || true`.
94
+ * `|| true` catches any non-zero exit at the shell level; primary
95
+ * defense is the `--session-hook` flag contract in the Node process.
96
+ * - **Windows** (cmd.exe / PowerShell): `<path> update --session-hook 2>&1`.
97
+ * `|| true` omitted because cmd.exe doesn't know the `true` builtin.
98
+ * `--session-hook` contract is the only defense; consequences of
99
+ * binary-vanished scenarios are slightly noisier in Claude Code's
100
+ * session log but still non-blocking.
101
+ *
102
+ * The suffix is supplied by `platformConventions().hookShellSuffix` —
103
+ * this function doesn't know which OS it's targeting, it just
104
+ * concatenates the convention's suffix.
105
+ *
106
+ * @param {string} binaryPath - Absolute path to the `skillrepo` binary.
107
+ * @param {object} [options]
108
+ * @param {NodeJS.Platform} [options.platform] - Override for testing.
109
+ * Default: `os.platform()`.
110
+ * @returns {string} The full shell command string.
111
+ */
112
+ export function buildHookCommand(binaryPath, { platform: platformOverride } = {}) {
113
+ if (typeof binaryPath !== "string" || binaryPath.length === 0) {
114
+ throw validationError(
115
+ "buildHookCommand: binaryPath must be a non-empty string.",
116
+ );
117
+ }
118
+ const conv = platformConventions({ platform: platformOverride });
119
+ return `${binaryPath} update --session-hook 2>&1${conv.hookShellSuffix}`;
120
+ }
121
+
122
+ /**
123
+ * Resolve the absolute path of the `skillrepo` binary via `which`.
124
+ * Returns null if resolution fails (e.g. user ran `npx skillrepo init`
125
+ * without a global install) — the caller should skip hook installation
126
+ * with a clear warning rather than fail init.
127
+ *
128
+ * @returns {string | null}
129
+ */
130
+ export function resolveSkillrepoBinary({ platform: platformOverride } = {}) {
131
+ // npx-invocation guard. Returns null early before any OS-specific
132
+ // logic runs — npx detection is platform-neutral (argv and env
133
+ // checks only) so it doesn't need the conventions object.
134
+ if (isNpxInvocation()) {
135
+ return null;
136
+ }
137
+
138
+ // Platform-specific binary locator name comes from the single
139
+ // source of truth in platform.mjs. Adding a new locator for a
140
+ // new platform is one edit in platform.mjs, not a scattered
141
+ // search for `platform() === "win32"` conditionals. See
142
+ // platform.mjs for the full rationale.
143
+ const conv = platformConventions({ platform: platformOverride });
144
+
145
+ try {
146
+ // 3-second timeout — `which`/`where` typically return in
147
+ // milliseconds, but a PATH that includes a network filesystem
148
+ // or a shell alias that does I/O could hang indefinitely.
149
+ // Bounding the call ensures `skillrepo init` never stalls on
150
+ // binary resolution.
151
+ const raw = execFileSync(conv.binaryLocator, ["skillrepo"], {
152
+ encoding: "utf-8",
153
+ stdio: ["ignore", "pipe", "ignore"],
154
+ timeout: 3000,
155
+ });
156
+ // Windows `where` can return multiple matching paths (one per
157
+ // PATH entry containing the binary) on separate lines. Take
158
+ // only the first. `which` always returns a single path but the
159
+ // split is harmless there. This is the one line where the
160
+ // platform difference actually leaks through — all platforms
161
+ // receive potentially-multi-line output that we canonicalize
162
+ // the same way.
163
+ const result = raw.split(/\r?\n/)[0].trim();
164
+ if (!result) return null;
165
+ // Sanity: the resolved path must be absolute. A relative
166
+ // result would be meaningless at session-start time because
167
+ // the Claude Code hook runner's cwd is undefined. `isAbsolute`
168
+ // handles both POSIX (`/foo/bar`) and Windows (`C:\foo\bar`)
169
+ // path styles — it's Node's built-in cross-platform check,
170
+ // not a platform-conditional we need to own.
171
+ if (!isAbsolute(result)) return null;
172
+ return result;
173
+ } catch {
174
+ // Locator exits non-zero if the binary isn't on PATH, or throws
175
+ // ENOENT if the locator itself isn't available (e.g. a minimal
176
+ // container image without `which`, or a Windows system with
177
+ // `where.exe` missing which is effectively never — but still
178
+ // safe-handled). Either way: null → caller routes to the
179
+ // architect-specified "requires stable install" skip message.
180
+ return null;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Install (or update) the SkillRepo SessionStart hook. Creates the
186
+ * settings.local.json file if it doesn't exist.
187
+ *
188
+ * @param {object} [options]
189
+ * @param {string} [options.binaryPath] - Absolute path to `skillrepo`.
190
+ * Default: resolved via `which skillrepo`. Passing explicitly
191
+ * is used by tests to inject a deterministic path.
192
+ * @param {boolean} [options.global=false] - When true, installs to the
193
+ * user-global `~/.claude/settings.local.json` instead of the
194
+ * project-local file.
195
+ * @param {NodeJS.Platform} [options.platform] - Override for testing.
196
+ * Propagated to both `resolveSkillrepoBinary` and
197
+ * `buildHookCommand` so a test can exercise the full
198
+ * installer path under simulated Windows semantics on a
199
+ * non-Windows host. Production callers leave this unset so
200
+ * both helpers see the real `os.platform()`. This option is
201
+ * the mechanism that closes the architect's round-3 HIGH
202
+ * finding — without it, a Windows-shaped `binaryPath` passed
203
+ * in the test still got a POSIX-shaped command back because
204
+ * `buildHookCommand` read `os.platform()` directly.
205
+ * @returns {{
206
+ * path: string;
207
+ * action: "installed" | "updated" | "unchanged" | "skipped";
208
+ * reason?: string;
209
+ * command?: string;
210
+ * }}
211
+ * `action`:
212
+ * - `"installed"` — no prior SkillRepo hook, we added one
213
+ * - `"updated"` — prior SkillRepo hook existed but the
214
+ * command differed (e.g. binary path changed); replaced
215
+ * in place
216
+ * - `"unchanged"` — exact command already present; no-op
217
+ * - `"skipped"` — prerequisite missing (no binary path, file
218
+ * corrupt, etc.). `reason` carries the human message.
219
+ */
220
+ export function mergeSessionHook({
221
+ binaryPath: binaryPathOpt,
222
+ global = false,
223
+ platform: platformOverride,
224
+ } = {}) {
225
+ const filePath = global ? claudeSettingsLocalGlobal() : claudeSettingsLocal();
226
+ const displayPath = global
227
+ ? "~/.claude/settings.local.json"
228
+ : ".claude/settings.local.json";
229
+
230
+ const binaryPath =
231
+ binaryPathOpt ?? resolveSkillrepoBinary({ platform: platformOverride });
232
+ if (!binaryPath) {
233
+ // Two reasons binaryPath can be null:
234
+ // 1. `isNpxInvocation()` returned true — the user ran
235
+ // `npx skillrepo ...`. The npx cache path is transient and
236
+ // unsuitable for baking into a long-lived hook command.
237
+ // 2. `which skillrepo` returned nothing — no global install
238
+ // exists at all.
239
+ // Both are the same problem from the hook's perspective: we
240
+ // can't produce a command that will still work later. The
241
+ // architect's #884 design specified the same warning text for
242
+ // both cases.
243
+ return {
244
+ path: displayPath,
245
+ action: "skipped",
246
+ reason:
247
+ "Session sync requires a stable `skillrepo` binary on PATH. " +
248
+ "Under `npx skillrepo ...` or without a global install, the " +
249
+ "hook would bind to a transient path that eventually breaks. " +
250
+ "Install globally with `npm install -g skillrepo` and re-run " +
251
+ "`skillrepo session-sync enable`.",
252
+ };
253
+ }
254
+
255
+ const desiredCommand = buildHookCommand(binaryPath, {
256
+ platform: platformOverride,
257
+ });
258
+
259
+ // Parse existing file (or start fresh). A corrupt-but-present file
260
+ // is a hard error: silently overwriting it would destroy any user-
261
+ // authored hooks we can't read.
262
+ let config = {};
263
+ if (existsSync(filePath)) {
264
+ const raw = readFileSync(filePath, "utf-8");
265
+ if (raw.trim().length > 0) {
266
+ try {
267
+ config = JSON.parse(raw);
268
+ } catch (err) {
269
+ throw diskError(
270
+ `Cannot parse ${displayPath}: ${err.message}. Fix or delete the file, then re-run.`,
271
+ { cause: err },
272
+ );
273
+ }
274
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
275
+ throw diskError(
276
+ `${displayPath} must be a JSON object at the top level.`,
277
+ );
278
+ }
279
+ }
280
+ }
281
+
282
+ // Walk `hooks.SessionStart[i].hooks[j].command` looking for an
283
+ // existing SkillRepo entry (fingerprint-matched). This MUST mirror
284
+ // the remover's walk in src/lib/removers/settings.mjs — the shared
285
+ // SESSION_HOOK_FINGERPRINT import is the mechanism that keeps
286
+ // them in lockstep. The round-trip test in session-hook.test.mjs
287
+ // locks the contract in.
288
+ if (!config.hooks || typeof config.hooks !== "object") {
289
+ config.hooks = {};
290
+ }
291
+ if (!Array.isArray(config.hooks.SessionStart)) {
292
+ config.hooks.SessionStart = [];
293
+ }
294
+
295
+ let foundAction = null; // null → install fresh
296
+ for (const group of config.hooks.SessionStart) {
297
+ if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) {
298
+ continue;
299
+ }
300
+ for (const inner of group.hooks) {
301
+ if (
302
+ inner &&
303
+ typeof inner === "object" &&
304
+ typeof inner.command === "string" &&
305
+ inner.command.includes(SESSION_HOOK_FINGERPRINT)
306
+ ) {
307
+ if (inner.command === desiredCommand) {
308
+ foundAction = "unchanged";
309
+ } else {
310
+ inner.command = desiredCommand;
311
+ // Also normalize the `type` field in case an older format
312
+ // was present (or the entry was hand-edited).
313
+ inner.type = "command";
314
+ foundAction = "updated";
315
+ }
316
+ break;
317
+ }
318
+ }
319
+ if (foundAction) break;
320
+ }
321
+
322
+ if (!foundAction) {
323
+ // Fresh install — append a new group with a single hook. We
324
+ // append (not prepend) so user-authored groups retain their
325
+ // relative order. Claude Code fires all groups in sequence.
326
+ config.hooks.SessionStart.push({
327
+ hooks: [{ type: "command", command: desiredCommand }],
328
+ });
329
+ foundAction = "installed";
330
+ }
331
+
332
+ if (foundAction === "unchanged") {
333
+ // No-op. Skip the write so we don't touch mtime for nothing.
334
+ return {
335
+ path: displayPath,
336
+ action: "unchanged",
337
+ command: desiredCommand,
338
+ };
339
+ }
340
+
341
+ writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
342
+
343
+ return {
344
+ path: displayPath,
345
+ action: foundAction,
346
+ command: desiredCommand,
347
+ };
348
+ }
349
+
350
+ /**
351
+ * Remove the SkillRepo SessionStart hook from settings.local.json.
352
+ *
353
+ * Thin adapter over `removeSettingsSessionHook` from
354
+ * `src/lib/removers/settings.mjs`. The consolidation to a single
355
+ * source of truth was the architect's round-1 priority tightening:
356
+ * before this PR, two separate implementations of the same walk
357
+ * existed (one here, one in settings.mjs) and they had already
358
+ * diverged in one observable behavior. Keeping both was a genuine
359
+ * maintenance hazard.
360
+ *
361
+ * This wrapper only forwards to the settings remover — it exists
362
+ * for the `session-sync disable` command to have a single import
363
+ * surface aligned with its installer counterpart (`mergeSessionHook`
364
+ * in this module). The actual logic lives in settings.mjs.
365
+ *
366
+ * @param {object} [options]
367
+ * @param {boolean} [options.global=false] - Forwarded to the
368
+ * settings remover; operates on `~/.claude/settings.local.json`
369
+ * when true.
370
+ * @returns {{
371
+ * path: string;
372
+ * action: "removed" | "would-remove" | "skipped" | "unchanged";
373
+ * error?: string;
374
+ * }}
375
+ */
376
+ export function removeSessionHook({ global = false } = {}) {
377
+ return removeSettingsSessionHook({ global });
378
+ }
package/src/lib/paths.mjs CHANGED
@@ -75,3 +75,24 @@ export const envLocal = () => join(cwd(), ".env.local");
75
75
  * project /skills/ fallback directory is gitignored on first write.
76
76
  */
77
77
  export const gitignorePath = () => join(cwd(), ".gitignore");
78
+
79
+ // ── Claude Code settings ──────────────────────────────────────────────
80
+ //
81
+ // settings.local.json is the per-developer, per-project Claude Code
82
+ // settings file. It's gitignored (the init flow adds it to .gitignore
83
+ // at step 3), so the SessionStart hook added by #884 lives there
84
+ // rather than in settings.json — that keeps each developer's sync
85
+ // behavior independent even when they share a repo. Claude Code
86
+ // applies both settings.json (checked in) and settings.local.json
87
+ // (gitignored) with local taking precedence.
88
+ //
89
+ // The global variant at ~/.claude/settings.local.json is the user-
90
+ // wide equivalent, used when init runs with --global.
91
+
92
+ /** Project-local Claude Code settings file (per-developer, gitignored). */
93
+ export const claudeSettingsLocal = () =>
94
+ join(cwd(), ".claude", "settings.local.json");
95
+
96
+ /** User-wide Claude Code settings file. Used by `init --global`. */
97
+ export const claudeSettingsLocalGlobal = () =>
98
+ join(homedir(), ".claude", "settings.local.json");
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Platform conventions — single source of truth for OS-specific
3
+ * differences the CLI has to honor.
4
+ *
5
+ * The CLI runs on POSIX systems (macOS, Linux) and Windows. Most
6
+ * code paths are platform-neutral via Node built-ins (path.join,
7
+ * os.homedir, fs.rmSync, etc.) — but a handful of surfaces have
8
+ * real platform differences that can't be abstracted away at the
9
+ * Node level:
10
+ *
11
+ * 1. **Binary-locator command**. POSIX provides `which`; Windows
12
+ * provides `where.exe`. `execFileSync` doesn't spawn a shell,
13
+ * so the literal name must exist on disk.
14
+ *
15
+ * 2. **Hook shell backstop suffix**. The SessionStart hook command
16
+ * relies on a shell-level fallback (`|| true`) to guarantee
17
+ * exit 0 even if the binary vanishes. POSIX shells support it;
18
+ * cmd.exe doesn't know the `true` builtin and would emit a
19
+ * confusing error. The `--session-hook` flag's exit-0 contract
20
+ * inside the Node process is the primary defense regardless of
21
+ * platform; the shell backstop is belt-and-suspenders that we
22
+ * lose on Windows.
23
+ *
24
+ * 3. **POSIX file permissions**. `chmodSync(0o600)` silently
25
+ * succeeds on Windows but doesn't produce the intended effect —
26
+ * Windows's ACL model doesn't map to the Unix mode bits. Any
27
+ * call meant to restrict permissions on credential files must
28
+ * be guarded so Windows users aren't misled into thinking their
29
+ * files are access-controlled when they aren't.
30
+ *
31
+ * 4. **Atomic directory replacement semantics**. POSIX's
32
+ * `renameSync` over an existing directory is atomic on the same
33
+ * filesystem — the swap is instantaneous from the perspective
34
+ * of any concurrent reader. Windows fails with EEXIST/EPERM if
35
+ * the target exists; the replacement must be done as a
36
+ * remove-then-rename sequence with a small window where the
37
+ * target is missing. Callers that write skills have to know
38
+ * which strategy applies so they can surface a meaningful
39
+ * recovery hint if the Windows path fails mid-sequence.
40
+ *
41
+ * This module exposes a single `platformConventions()` function that
42
+ * returns a frozen object with every platform-specific value the
43
+ * CLI needs. New platform-specific surfaces should be added here
44
+ * rather than spreading ad-hoc `platform() === "win32"` checks
45
+ * across the codebase. This is a convention, not an enforced rule —
46
+ * it's documentation plus a consumer pattern, not a linter.
47
+ *
48
+ * The `platform` parameter is an optional override for tests —
49
+ * production callers let it default to `os.platform()`.
50
+ */
51
+
52
+ import { platform as osPlatform } from "node:os";
53
+
54
+ /**
55
+ * @typedef {Object} PlatformConventions
56
+ * @property {"posix" | "windows"} family - High-level family name.
57
+ * @property {string} binaryLocator - Command used to resolve a
58
+ * binary's absolute path from PATH. `"which"` on POSIX,
59
+ * `"where"` on Windows.
60
+ * @property {string} hookShellSuffix - Suffix appended to hook
61
+ * commands to guarantee exit 0 at the shell level. `" || true"`
62
+ * on POSIX (appended to the base command), empty string on
63
+ * Windows (the `--session-hook` flag's exit-0 contract is
64
+ * the only defense).
65
+ * @property {boolean} supportsPosixPermissions - True when
66
+ * `chmodSync(mode)` produces the intended POSIX mode-bit
67
+ * effect. False on Windows, where the call nominally
68
+ * succeeds but doesn't restrict ACLs the way a 0600 bit
69
+ * would on Unix. Callers use this to skip chmod on
70
+ * Windows rather than leave misleading "perms applied"
71
+ * success paths that don't actually restrict access.
72
+ * @property {boolean} supportsAtomicDirectoryRename - True when
73
+ * `renameSync` over an existing directory is atomic.
74
+ * POSIX: true. Windows: false — callers must implement
75
+ * remove-then-rename, accepting the small non-atomic
76
+ * window where the target doesn't exist. The Windows
77
+ * code path MUST produce a recoverable failure state if
78
+ * the rename step fails (i.e. leave the `.tmp/` dir on
79
+ * disk so the user can rename it manually).
80
+ */
81
+
82
+ const POSIX = Object.freeze({
83
+ family: "posix",
84
+ binaryLocator: "which",
85
+ hookShellSuffix: " || true",
86
+ supportsPosixPermissions: true,
87
+ supportsAtomicDirectoryRename: true,
88
+ });
89
+
90
+ const WINDOWS = Object.freeze({
91
+ family: "windows",
92
+ binaryLocator: "where",
93
+ hookShellSuffix: "",
94
+ supportsPosixPermissions: false,
95
+ supportsAtomicDirectoryRename: false,
96
+ });
97
+
98
+ /**
99
+ * Return the platform-specific convention set for the current
100
+ * platform, or an override.
101
+ *
102
+ * @param {object} [options]
103
+ * @param {NodeJS.Platform} [options.platform] - Override for testing.
104
+ * Production callers should let this default to the real
105
+ * runtime platform.
106
+ * @returns {PlatformConventions}
107
+ */
108
+ export function platformConventions({ platform: platformOverride } = {}) {
109
+ const plat = platformOverride ?? osPlatform();
110
+ return plat === "win32" ? WINDOWS : POSIX;
111
+ }
112
+
113
+ /**
114
+ * True if the current (or overridden) platform is Windows.
115
+ * Convenience wrapper — prefer `platformConventions().family ===
116
+ * "windows"` in call sites that already hold a conventions object.
117
+ *
118
+ * @param {object} [options]
119
+ * @param {NodeJS.Platform} [options.platform]
120
+ * @returns {boolean}
121
+ */
122
+ export function isWindows({ platform: platformOverride } = {}) {
123
+ return platformConventions({ platform: platformOverride }).family === "windows";
124
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Claude Code `.mcp.json` remover (#885).
3
+ *
4
+ * Surgical inverse of `mergers/claude-mcp.mjs`: parse the JSON,
5
+ * delete `mcpServers.skillrepo` if present, write back. Any other
6
+ * `mcpServers.*` entries (vendor configs the user added manually or
7
+ * via another tool) are preserved verbatim.
8
+ *
9
+ * A file that doesn't exist is skipped. A file with unparseable
10
+ * JSON is reported as an error but does not throw from the remover
11
+ * — the uninstall command aggregates errors per artifact and
12
+ * surfaces them at the end. The user would need to fix or delete
13
+ * the file manually before re-running uninstall for this artifact.
14
+ */
15
+
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { writeFileAtomic } from "../fs-utils.mjs";
18
+ import { claudeMcpJson } from "../paths.mjs";
19
+
20
+ /**
21
+ * Delete `mcpServers.skillrepo` from `.mcp.json`.
22
+ *
23
+ * @param {object} [options]
24
+ * @param {boolean} [options.dryRun=false] - Preview-only mode; returns
25
+ * `"would-remove"` without touching the file.
26
+ *
27
+ * @returns {{ path: string; action: "removed" | "would-remove" | "skipped"; error?: string }}
28
+ */
29
+ export function removeClaudeMcp({ dryRun = false } = {}) {
30
+ const filePath = claudeMcpJson();
31
+
32
+ if (!existsSync(filePath)) {
33
+ return { path: ".mcp.json", action: "skipped" };
34
+ }
35
+
36
+ const raw = readFileSync(filePath, "utf-8");
37
+ let config;
38
+ try {
39
+ config = JSON.parse(raw);
40
+ } catch (err) {
41
+ return {
42
+ path: ".mcp.json",
43
+ action: "skipped",
44
+ error: `Cannot parse .mcp.json: ${err.message}. Fix or delete the file and re-run uninstall.`,
45
+ };
46
+ }
47
+
48
+ if (
49
+ !config ||
50
+ typeof config !== "object" ||
51
+ !config.mcpServers ||
52
+ typeof config.mcpServers !== "object" ||
53
+ !("skillrepo" in config.mcpServers)
54
+ ) {
55
+ return { path: ".mcp.json", action: "skipped" };
56
+ }
57
+
58
+ if (dryRun) {
59
+ return { path: ".mcp.json", action: "would-remove" };
60
+ }
61
+
62
+ delete config.mcpServers.skillrepo;
63
+
64
+ writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
65
+
66
+ return { path: ".mcp.json", action: "removed" };
67
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Cursor `.cursor/mcp.json` remover (#885).
3
+ *
4
+ * Identical shape to the Claude Code remover — Cursor uses the same
5
+ * `mcpServers.<key>` schema, only the path and env-var interpolation
6
+ * syntax differ. Kept as a separate module so each path has its own
7
+ * testable unit and so future divergence (Cursor adding a new
8
+ * config field, for instance) can land in this file without
9
+ * touching the Claude remover.
10
+ */
11
+
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { writeFileAtomic } from "../fs-utils.mjs";
14
+ import { cursorMcpJson } from "../paths.mjs";
15
+
16
+ /**
17
+ * @param {object} [options]
18
+ * @param {boolean} [options.dryRun=false]
19
+ *
20
+ * @returns {{ path: string; action: "removed" | "would-remove" | "skipped"; error?: string }}
21
+ */
22
+ export function removeCursorMcp({ dryRun = false } = {}) {
23
+ const filePath = cursorMcpJson();
24
+
25
+ if (!existsSync(filePath)) {
26
+ return { path: ".cursor/mcp.json", action: "skipped" };
27
+ }
28
+
29
+ const raw = readFileSync(filePath, "utf-8");
30
+ let config;
31
+ try {
32
+ config = JSON.parse(raw);
33
+ } catch (err) {
34
+ return {
35
+ path: ".cursor/mcp.json",
36
+ action: "skipped",
37
+ error: `Cannot parse .cursor/mcp.json: ${err.message}. Fix or delete the file and re-run uninstall.`,
38
+ };
39
+ }
40
+
41
+ if (
42
+ !config ||
43
+ typeof config !== "object" ||
44
+ !config.mcpServers ||
45
+ typeof config.mcpServers !== "object" ||
46
+ !("skillrepo" in config.mcpServers)
47
+ ) {
48
+ return { path: ".cursor/mcp.json", action: "skipped" };
49
+ }
50
+
51
+ if (dryRun) {
52
+ return { path: ".cursor/mcp.json", action: "would-remove" };
53
+ }
54
+
55
+ delete config.mcpServers.skillrepo;
56
+
57
+ writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
58
+
59
+ return { path: ".cursor/mcp.json", action: "removed" };
60
+ }