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,298 @@
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 { writeFileAtomic } from "../fs-utils.mjs";
75
+ import { SESSION_HOOK_FINGERPRINT } from "../artifact-registry.mjs";
76
+ import {
77
+ claudeSettingsLocal,
78
+ claudeSettingsLocalGlobal,
79
+ } from "../paths.mjs";
80
+ import { diskError, validationError } from "../errors.mjs";
81
+ import { removeSettingsSessionHook } from "../removers/settings.mjs";
82
+
83
+ /**
84
+ * Build the hook command string for a given absolute path. Exported
85
+ * so tests can assert the exact bytes the installer writes.
86
+ *
87
+ * @param {string} binaryPath - Absolute path to the `skillrepo` binary.
88
+ * @returns {string} The full shell command string.
89
+ */
90
+ export function buildHookCommand(binaryPath) {
91
+ if (typeof binaryPath !== "string" || binaryPath.length === 0) {
92
+ throw validationError(
93
+ "buildHookCommand: binaryPath must be a non-empty string.",
94
+ );
95
+ }
96
+ return `${binaryPath} update --session-hook 2>&1 || true`;
97
+ }
98
+
99
+ /**
100
+ * Resolve the absolute path of the `skillrepo` binary via `which`.
101
+ * Returns null if resolution fails (e.g. user ran `npx skillrepo init`
102
+ * without a global install) — the caller should skip hook installation
103
+ * with a clear warning rather than fail init.
104
+ *
105
+ * @returns {string | null}
106
+ */
107
+ export function resolveSkillrepoBinary() {
108
+ try {
109
+ // 3-second timeout — `which` typically returns in milliseconds,
110
+ // but a PATH that includes a network filesystem or a `which`
111
+ // alias that does I/O could hang indefinitely. Bounding the
112
+ // call ensures `skillrepo init` never stalls on binary
113
+ // resolution. Per code-reviewer round-1 LOW finding.
114
+ const result = execFileSync("which", ["skillrepo"], {
115
+ encoding: "utf-8",
116
+ stdio: ["ignore", "pipe", "ignore"],
117
+ timeout: 3000,
118
+ }).trim();
119
+ if (!result) return null;
120
+ // Sanity: the resolved path must be absolute. A relative result
121
+ // would be meaningless at session-start time because the Claude
122
+ // Code hook runner's cwd is undefined.
123
+ if (!result.startsWith("/")) return null;
124
+ return result;
125
+ } catch {
126
+ // `which` exits non-zero if the binary isn't found. Treat as
127
+ // "no global install, skip session-sync" — caller handles.
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Install (or update) the SkillRepo SessionStart hook. Creates the
134
+ * settings.local.json file if it doesn't exist.
135
+ *
136
+ * @param {object} [options]
137
+ * @param {string} [options.binaryPath] - Absolute path to `skillrepo`.
138
+ * Default: resolved via `which skillrepo`. Passing explicitly
139
+ * is used by tests to inject a deterministic path.
140
+ * @param {boolean} [options.global=false] - When true, installs to the
141
+ * user-global `~/.claude/settings.local.json` instead of the
142
+ * project-local file.
143
+ * @returns {{
144
+ * path: string;
145
+ * action: "installed" | "updated" | "unchanged" | "skipped";
146
+ * reason?: string;
147
+ * command?: string;
148
+ * }}
149
+ * `action`:
150
+ * - `"installed"` — no prior SkillRepo hook, we added one
151
+ * - `"updated"` — prior SkillRepo hook existed but the
152
+ * command differed (e.g. binary path changed); replaced
153
+ * in place
154
+ * - `"unchanged"` — exact command already present; no-op
155
+ * - `"skipped"` — prerequisite missing (no binary path, file
156
+ * corrupt, etc.). `reason` carries the human message.
157
+ */
158
+ export function mergeSessionHook({
159
+ binaryPath: binaryPathOpt,
160
+ global = false,
161
+ } = {}) {
162
+ const filePath = global ? claudeSettingsLocalGlobal() : claudeSettingsLocal();
163
+ const displayPath = global
164
+ ? "~/.claude/settings.local.json"
165
+ : ".claude/settings.local.json";
166
+
167
+ const binaryPath = binaryPathOpt ?? resolveSkillrepoBinary();
168
+ if (!binaryPath) {
169
+ return {
170
+ path: displayPath,
171
+ action: "skipped",
172
+ reason:
173
+ "Could not resolve a stable path for `skillrepo`. Session sync requires a global install. Run `npm install -g skillrepo` and re-run `skillrepo session-sync enable`.",
174
+ };
175
+ }
176
+
177
+ const desiredCommand = buildHookCommand(binaryPath);
178
+
179
+ // Parse existing file (or start fresh). A corrupt-but-present file
180
+ // is a hard error: silently overwriting it would destroy any user-
181
+ // authored hooks we can't read.
182
+ let config = {};
183
+ if (existsSync(filePath)) {
184
+ const raw = readFileSync(filePath, "utf-8");
185
+ if (raw.trim().length > 0) {
186
+ try {
187
+ config = JSON.parse(raw);
188
+ } catch (err) {
189
+ throw diskError(
190
+ `Cannot parse ${displayPath}: ${err.message}. Fix or delete the file, then re-run.`,
191
+ { cause: err },
192
+ );
193
+ }
194
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
195
+ throw diskError(
196
+ `${displayPath} must be a JSON object at the top level.`,
197
+ );
198
+ }
199
+ }
200
+ }
201
+
202
+ // Walk `hooks.SessionStart[i].hooks[j].command` looking for an
203
+ // existing SkillRepo entry (fingerprint-matched). This MUST mirror
204
+ // the remover's walk in src/lib/removers/settings.mjs — the shared
205
+ // SESSION_HOOK_FINGERPRINT import is the mechanism that keeps
206
+ // them in lockstep. The round-trip test in session-hook.test.mjs
207
+ // locks the contract in.
208
+ if (!config.hooks || typeof config.hooks !== "object") {
209
+ config.hooks = {};
210
+ }
211
+ if (!Array.isArray(config.hooks.SessionStart)) {
212
+ config.hooks.SessionStart = [];
213
+ }
214
+
215
+ let foundAction = null; // null → install fresh
216
+ for (const group of config.hooks.SessionStart) {
217
+ if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) {
218
+ continue;
219
+ }
220
+ for (const inner of group.hooks) {
221
+ if (
222
+ inner &&
223
+ typeof inner === "object" &&
224
+ typeof inner.command === "string" &&
225
+ inner.command.includes(SESSION_HOOK_FINGERPRINT)
226
+ ) {
227
+ if (inner.command === desiredCommand) {
228
+ foundAction = "unchanged";
229
+ } else {
230
+ inner.command = desiredCommand;
231
+ // Also normalize the `type` field in case an older format
232
+ // was present (or the entry was hand-edited).
233
+ inner.type = "command";
234
+ foundAction = "updated";
235
+ }
236
+ break;
237
+ }
238
+ }
239
+ if (foundAction) break;
240
+ }
241
+
242
+ if (!foundAction) {
243
+ // Fresh install — append a new group with a single hook. We
244
+ // append (not prepend) so user-authored groups retain their
245
+ // relative order. Claude Code fires all groups in sequence.
246
+ config.hooks.SessionStart.push({
247
+ hooks: [{ type: "command", command: desiredCommand }],
248
+ });
249
+ foundAction = "installed";
250
+ }
251
+
252
+ if (foundAction === "unchanged") {
253
+ // No-op. Skip the write so we don't touch mtime for nothing.
254
+ return {
255
+ path: displayPath,
256
+ action: "unchanged",
257
+ command: desiredCommand,
258
+ };
259
+ }
260
+
261
+ writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
262
+
263
+ return {
264
+ path: displayPath,
265
+ action: foundAction,
266
+ command: desiredCommand,
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Remove the SkillRepo SessionStart hook from settings.local.json.
272
+ *
273
+ * Thin adapter over `removeSettingsSessionHook` from
274
+ * `src/lib/removers/settings.mjs`. The consolidation to a single
275
+ * source of truth was the architect's round-1 priority tightening:
276
+ * before this PR, two separate implementations of the same walk
277
+ * existed (one here, one in settings.mjs) and they had already
278
+ * diverged in one observable behavior. Keeping both was a genuine
279
+ * maintenance hazard.
280
+ *
281
+ * This wrapper only forwards to the settings remover — it exists
282
+ * for the `session-sync disable` command to have a single import
283
+ * surface aligned with its installer counterpart (`mergeSessionHook`
284
+ * in this module). The actual logic lives in settings.mjs.
285
+ *
286
+ * @param {object} [options]
287
+ * @param {boolean} [options.global=false] - Forwarded to the
288
+ * settings remover; operates on `~/.claude/settings.local.json`
289
+ * when true.
290
+ * @returns {{
291
+ * path: string;
292
+ * action: "removed" | "would-remove" | "skipped" | "unchanged";
293
+ * error?: string;
294
+ * }}
295
+ */
296
+ export function removeSessionHook({ global = false } = {}) {
297
+ return removeSettingsSessionHook({ global });
298
+ }
package/src/lib/paths.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  * Uses Node built-ins only — no dependencies.
4
4
  */
5
5
 
6
- import { join, resolve } from "node:path";
6
+ import { join } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
 
9
9
  const cwd = () => process.cwd();
@@ -11,19 +11,10 @@ const cwd = () => process.cwd();
11
11
  // Claude Code
12
12
  export const claudeMcpJson = () => join(cwd(), ".mcp.json");
13
13
  export const claudeDir = () => join(cwd(), ".claude");
14
- export const claudeSettingsLocal = () => join(cwd(), ".claude", "settings.local.json");
15
- export const claudeSkillrepoMd = () => join(cwd(), ".claude", "skillrepo.md");
16
- export const claudeSkillrepoIndex = () => join(cwd(), ".claude", "skillrepo-index.json");
17
- export const claudeSkillrepoConfig = () => join(cwd(), ".claude", "skillrepo-config.json");
18
14
 
19
15
  // Cursor
20
16
  export const cursorDir = () => join(cwd(), ".cursor");
21
17
  export const cursorMcpJson = () => join(cwd(), ".cursor", "mcp.json");
22
- export const cursorRulesDir = () => join(cwd(), ".cursor", "rules");
23
- export const cursorHooksDir = () => join(cwd(), ".cursor", "hooks");
24
- export const cursorSkillrepoMdc = () => join(cwd(), ".cursor", "rules", "skillrepo.mdc");
25
- export const cursorSkillrepoIndex = () => join(cwd(), ".cursor", "skillrepo-index.json");
26
- export const cursorHooksJson = () => join(cwd(), ".cursor", "hooks.json");
27
18
 
28
19
  // Windsurf (always global — no project-level config)
29
20
  export const windsurfDir = () => join(homedir(), ".codeium", "windsurf");
@@ -34,15 +25,74 @@ export const vscodeDir = () => join(cwd(), ".vscode");
34
25
  export const vscodeMcpJson = () => join(cwd(), ".vscode", "mcp.json");
35
26
 
36
27
  // Global SkillRepo cache (shared across projects, lives in user home)
37
- export const globalSkillrepoDir = () => join(homedir(), ".claude", "skillrepo");
38
28
  export const globalConfigPath = () => join(homedir(), ".claude", "skillrepo", "config.json");
39
29
  export const globalLastSyncPath = () => join(homedir(), ".claude", "skillrepo", ".last-sync");
40
- export const globalIndexPath = () => join(homedir(), ".claude", "skillrepo", "index.json");
41
- export const globalSkillsDir = () => join(homedir(), ".claude", "skillrepo", "skills");
42
30
 
43
- // Project-level rules directories (deterministic skill delivery)
44
- export const claudeRulesDir = () => join(cwd(), ".claude", "rules");
31
+ // ── Skill placement targets (added in #646 / PR1) ─────────────────────
32
+ //
33
+ // Claude Code documents two skill discovery locations at
34
+ // https://code.claude.com/docs/en/skills:
35
+ //
36
+ // Personal: ~/.claude/skills/<name>/SKILL.md
37
+ // Project: <cwd>/.claude/skills/<name>/SKILL.md
38
+ //
39
+ // The `name` segment must match the `name` field in the SKILL.md
40
+ // frontmatter per the agentskills.io spec — the file-write pipeline
41
+ // enforces this at write time.
42
+ //
43
+ // Other detected vendors (Cursor, Windsurf, VS Code Copilot) do not
44
+ // currently document an on-disk skill discovery convention. For those
45
+ // vendors, the file-write pipeline writes to a project-level fallback
46
+ // at `<cwd>/skills/<name>/`, with an entry added to .gitignore on
47
+ // first write so the user-specific skill set never leaks into the repo
48
+ // history. See follow-up issue #876 for tracking when those IDEs
49
+ // publish their own conventions.
50
+
51
+ /** Claude Code project-local skill directory for a specific skill name. */
52
+ export const claudeSkillsProject = (name) => join(cwd(), ".claude", "skills", name);
53
+
54
+ /** Claude Code personal/global skill directory for a specific skill name. */
55
+ export const claudeSkillsGlobal = (name) => join(homedir(), ".claude", "skills", name);
56
+
57
+ /** Project-local fallback skills root (used when --ide includes a vendor without a documented convention). */
58
+ export const projectSkillsFallbackRoot = () => join(cwd(), "skills");
59
+
60
+ /** Project-local fallback for a specific skill name. */
61
+ export const projectSkillsFallback = (name) => join(cwd(), "skills", name);
62
+
63
+ /** Parent directory of the project-local Claude Code skills (used by orphan cleanup). */
64
+ export const claudeSkillsProjectRoot = () => join(cwd(), ".claude", "skills");
65
+
66
+ /** Parent directory of the personal/global Claude Code skills (used by orphan cleanup). */
67
+ export const claudeSkillsGlobalRoot = () => join(homedir(), ".claude", "skills");
68
+
69
+ // ── Shared ────────────────────────────────────────────────────────────
45
70
 
46
- // Shared
47
71
  export const envLocal = () => join(cwd(), ".env.local");
48
- export const projectRoot = () => resolve(cwd());
72
+
73
+ /**
74
+ * Project .gitignore — used by the file-write pipeline to ensure the
75
+ * project /skills/ fallback directory is gitignored on first write.
76
+ */
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");
@@ -1,56 +1,23 @@
1
1
  /**
2
2
  * Interactive prompts using Node's built-in readline.
3
3
  * Zero dependencies. Supports TTY detection and NO_COLOR.
4
+ *
5
+ * v3.0.0 cleanup (PR4 cross-review): the old v2.0.0 print helpers
6
+ * (printHeader, printStep, printSuccess, printWarning, printError,
7
+ * printResult, printBlank) that wrote to `console.log` were removed.
8
+ * They wrote directly to `process.stdout` via `console.log`, bypassing
9
+ * the stream-injection pattern every v3.0.0 command uses for
10
+ * testability. `init.mjs` defines its own `makePrinter` helper that
11
+ * ties into the injected io.stdout/io.stderr streams; every other
12
+ * command uses the same pattern. This module now only exports the
13
+ * three interactive primitives (`promptText`, `promptSecret`,
14
+ * `confirm`) that still need direct stdin/stdout access.
4
15
  */
5
16
 
6
17
  import { createInterface } from "node:readline";
7
18
 
8
19
  const isTTY = process.stdout.isTTY && !process.env.NO_COLOR;
9
-
10
- // ── Colors ──────────────────────────────────────────────────────────────
11
-
12
- const green = (s) => (isTTY ? `\x1b[32m${s}\x1b[0m` : s);
13
- const yellow = (s) => (isTTY ? `\x1b[33m${s}\x1b[0m` : s);
14
- const red = (s) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s);
15
20
  const dim = (s) => (isTTY ? `\x1b[2m${s}\x1b[0m` : s);
16
- const bold = (s) => (isTTY ? `\x1b[1m${s}\x1b[0m` : s);
17
-
18
- // ── Output helpers ──────────────────────────────────────────────────────
19
-
20
- export function printHeader(title) {
21
- console.log("");
22
- console.log(` ${bold(title)}`);
23
- console.log("");
24
- }
25
-
26
- export function printStep(n, total, message) {
27
- console.log(` ${dim(`Step ${n}/${total}:`)} ${message}`);
28
- }
29
-
30
- export function printSuccess(message) {
31
- console.log(` ${green("✓")} ${message}`);
32
- }
33
-
34
- export function printWarning(message) {
35
- console.log(` ${yellow("⚠")} ${message}`);
36
- }
37
-
38
- export function printError(message) {
39
- console.error(` ${red("✗")} ${message}`);
40
- }
41
-
42
- export function printResult(path, action) {
43
- const label =
44
- action === "created" ? green("created") :
45
- action === "merged" ? yellow("merged") :
46
- action === "updated" ? yellow("updated") :
47
- dim("skipped");
48
- console.log(` ${path.padEnd(45)} ${label}`);
49
- }
50
-
51
- export function printBlank() {
52
- console.log("");
53
- }
54
21
 
55
22
  // ── Prompts ─────────────────────────────────────────────────────────────
56
23
 
@@ -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
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `.env.local` credential remover (#885).
3
+ *
4
+ * Strips every line whose prefix matches `SKILLREPO_ACCESS_KEY=`. Uses
5
+ * atomic write so a rename-mid-write crash cannot leave a partial
6
+ * credential value on disk.
7
+ *
8
+ * Prefix match (not substring) so the remover cannot inadvertently
9
+ * strip a user-authored comment or template line. The `.env.local`
10
+ * installer writes only the `KEY=value` shape, so "starts with
11
+ * SKILLREPO_ACCESS_KEY=" is exactly the set the installer produces.
12
+ */
13
+
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { writeFileAtomic } from "../fs-utils.mjs";
16
+ import { ENV_LOCAL_KEY_NAME } from "../artifact-registry.mjs";
17
+ import { envLocal } from "../paths.mjs";
18
+
19
+ /**
20
+ * Remove every `SKILLREPO_ACCESS_KEY=` line from `.env.local`.
21
+ *
22
+ * @param {object} [options]
23
+ * @param {boolean} [options.dryRun=false] - Preview-only mode; see
24
+ * removeGitignore's JSDoc for rationale. Returns action
25
+ * `"would-remove"` without touching the file.
26
+ *
27
+ * @returns {{ path: string; action: "removed" | "would-remove" | "skipped"; removed: number }}
28
+ */
29
+ export function removeEnvLocal({ dryRun = false } = {}) {
30
+ const filePath = envLocal();
31
+
32
+ if (!existsSync(filePath)) {
33
+ return { path: ".env.local", action: "skipped", removed: 0 };
34
+ }
35
+
36
+ const original = readFileSync(filePath, "utf-8");
37
+ const lineEnding = original.includes("\r\n") ? "\r\n" : "\n";
38
+ const lines = original.split(/\r?\n/);
39
+
40
+ const prefix = `${ENV_LOCAL_KEY_NAME}=`;
41
+ const survivors = lines.filter((l) => !l.startsWith(prefix));
42
+ const removed = lines.length - survivors.length;
43
+
44
+ if (removed === 0) {
45
+ return { path: ".env.local", action: "skipped", removed: 0 };
46
+ }
47
+
48
+ if (dryRun) {
49
+ return { path: ".env.local", action: "would-remove", removed };
50
+ }
51
+
52
+ writeFileAtomic(filePath, survivors.join(lineEnding));
53
+
54
+ return { path: ".env.local", action: "removed", removed };
55
+ }