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.
- package/README.md +74 -6
- package/bin/skillrepo.mjs +14 -0
- package/package.json +1 -1
- package/src/commands/init.mjs +184 -19
- 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 +305 -0
- package/src/lib/cli-config.mjs +78 -0
- package/src/lib/config.mjs +6 -3
- package/src/lib/file-write.mjs +8 -3
- package/src/lib/fs-utils.mjs +90 -9
- package/src/lib/mergers/session-hook.mjs +378 -0
- package/src/lib/paths.mjs +21 -0
- package/src/lib/platform.mjs +124 -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/lib/sync.mjs +26 -0
- package/src/test/commands/add.test.mjs +10 -4
- package/src/test/commands/get.test.mjs +10 -4
- package/src/test/commands/init.test.mjs +428 -4
- package/src/test/commands/list.test.mjs +10 -4
- package/src/test/commands/remove.test.mjs +10 -4
- package/src/test/commands/search.test.mjs +10 -4
- package/src/test/commands/session-sync.test.mjs +352 -0
- package/src/test/commands/uninstall.test.mjs +774 -0
- package/src/test/commands/update.test.mjs +168 -4
- package/src/test/helpers/sandbox-home.mjs +161 -0
- package/src/test/helpers/skillrepo-shim.mjs +133 -0
- package/src/test/integration/file-write.integration.test.mjs +10 -4
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/lib/cli-config.test.mjs +126 -5
- package/src/test/lib/config.test.mjs +10 -4
- package/src/test/lib/file-write.test.mjs +24 -10
- package/src/test/lib/mcp-merge.test.mjs +10 -4
- package/src/test/lib/paths.test.mjs +10 -4
- package/src/test/lib/platform.test.mjs +135 -0
- package/src/test/lib/sync.test.mjs +20 -4
- package/src/test/mergers/session-hook.test.mjs +1175 -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 +296 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- 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
|
+
}
|