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.
- package/README.md +276 -145
- package/bin/skillrepo.mjs +224 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +589 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +162 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +184 -0
- package/src/lib/artifact-registry.mjs +265 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/fs-utils.mjs +83 -1
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/mergers/session-hook.mjs +298 -0
- package/src/lib/paths.mjs +67 -17
- package/src/lib/prompt.mjs +11 -44
- 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 +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +697 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/session-sync.test.mjs +350 -0
- package/src/test/commands/uninstall.test.mjs +768 -0
- package/src/test/commands/update.test.mjs +322 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/test/mergers/session-hook.test.mjs +745 -0
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
- package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
- package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
- package/src/test/mergers/uninstall-settings.test.mjs +285 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- 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
|
|
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
|
-
//
|
|
44
|
-
|
|
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
|
-
|
|
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");
|
package/src/lib/prompt.mjs
CHANGED
|
@@ -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
|
+
}
|