skillrepo 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -6
- package/bin/skillrepo.mjs +14 -0
- package/package.json +1 -1
- package/src/commands/init.mjs +132 -14
- package/src/commands/remove.mjs +8 -13
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +125 -8
- package/src/lib/artifact-registry.mjs +265 -0
- package/src/lib/fs-utils.mjs +83 -1
- package/src/lib/mergers/session-hook.mjs +298 -0
- package/src/lib/paths.mjs +21 -0
- package/src/lib/removers/claude-mcp.mjs +67 -0
- package/src/lib/removers/cursor-mcp.mjs +60 -0
- package/src/lib/removers/env-local.mjs +55 -0
- package/src/lib/removers/gitignore.mjs +108 -0
- package/src/lib/removers/settings.mjs +183 -0
- package/src/lib/removers/vscode-mcp.mjs +87 -0
- package/src/lib/removers/windsurf-mcp.mjs +65 -0
- package/src/test/commands/init.test.mjs +211 -0
- package/src/test/commands/session-sync.test.mjs +350 -0
- package/src/test/commands/uninstall.test.mjs +768 -0
- package/src/test/commands/update.test.mjs +158 -0
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/mergers/session-hook.test.mjs +745 -0
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
- package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
- package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
- package/src/test/mergers/uninstall-settings.test.mjs +285 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for every file and in-file entry `skillrepo init`
|
|
3
|
+
* writes to the user's filesystem (#885).
|
|
4
|
+
*
|
|
5
|
+
* The goal is drift prevention: any future command that writes a new
|
|
6
|
+
* artifact (a new MCP vendor, a new project-local config file, etc.)
|
|
7
|
+
* MUST add its descriptor to this registry in the same PR. The CI
|
|
8
|
+
* enforcement test at `src/test/lib/artifact-registry.test.mjs` walks
|
|
9
|
+
* the mergers/ and removers/ directories on the filesystem and fails
|
|
10
|
+
* loudly if a merger has no matching remover, or if the registry is
|
|
11
|
+
* missing a descriptor for an id that the test's expected-set covers.
|
|
12
|
+
*
|
|
13
|
+
* This file is DATA ONLY. No filesystem access, no dynamic behavior,
|
|
14
|
+
* no side effects — if you find yourself importing `fs` here you are
|
|
15
|
+
* writing in the wrong module. Actual removal logic lives in
|
|
16
|
+
* `src/lib/removers/*.mjs`; this module only catalogs WHAT gets
|
|
17
|
+
* removed, not HOW.
|
|
18
|
+
*
|
|
19
|
+
* Out of scope for v3.1.0 (see #885 section 3 Non-Goals):
|
|
20
|
+
* - v2.0.0 artifacts (.claude/rules/skillrepo-*.md,
|
|
21
|
+
* .claude/hooks/skillrepo-*). v3.0.0 is the minimum supported
|
|
22
|
+
* version; users of earlier versions clean up manually.
|
|
23
|
+
* - The <cwd>/skills/ fallback directory written for non-Claude-Code
|
|
24
|
+
* vendors. Tracked in #876 until those IDEs publish their own
|
|
25
|
+
* on-disk skill discovery conventions.
|
|
26
|
+
*
|
|
27
|
+
* The `settings-session-hook` descriptor is a forward-declaration: it
|
|
28
|
+
* names the SessionStart hook entry that #884 installs. The fingerprint
|
|
29
|
+
* string is exported below so #884's installer can import it and both
|
|
30
|
+
* modules stay in lockstep without a circular dependency.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
claudeMcpJson,
|
|
35
|
+
cursorMcpJson,
|
|
36
|
+
vscodeMcpJson,
|
|
37
|
+
windsurfMcpJson,
|
|
38
|
+
envLocal,
|
|
39
|
+
gitignorePath,
|
|
40
|
+
claudeSkillsProjectRoot,
|
|
41
|
+
claudeSkillsGlobalRoot,
|
|
42
|
+
} from "./paths.mjs";
|
|
43
|
+
import { join } from "node:path";
|
|
44
|
+
import { homedir } from "node:os";
|
|
45
|
+
|
|
46
|
+
// ── Fingerprint constants exported for cross-module consumption ───────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `.gitignore` section header. Lines directly under this header, up to
|
|
50
|
+
* the next blank line or the end of file, are considered SkillRepo-owned
|
|
51
|
+
* and are removed by the gitignore remover. User-authored lines outside
|
|
52
|
+
* the section are never touched — this is the attribution mechanism.
|
|
53
|
+
*
|
|
54
|
+
* MUST stay in lockstep with `src/lib/mergers/gitignore.mjs`
|
|
55
|
+
* SECTION_HEADER. The artifact-registry CI test asserts both modules
|
|
56
|
+
* resolve the same header text.
|
|
57
|
+
*/
|
|
58
|
+
export const GITIGNORE_SECTION_HEADER =
|
|
59
|
+
"# SkillRepo CLI (added by `skillrepo init`)";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Entries written under the gitignore section by init. The remover
|
|
63
|
+
* does not match individual entries — it removes every line between the
|
|
64
|
+
* header and the next blank line — but the expected set is exported so
|
|
65
|
+
* tests can verify the two lists stay in sync.
|
|
66
|
+
*/
|
|
67
|
+
export const GITIGNORE_REQUIRED_ENTRIES = Object.freeze([
|
|
68
|
+
".env.local",
|
|
69
|
+
".claude/skills/",
|
|
70
|
+
".claude/settings.local.json",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Environment variable name owned by the CLI in `.env.local`. Any line
|
|
75
|
+
* starting with `${ENV_LOCAL_KEY_NAME}=` is considered SkillRepo-owned.
|
|
76
|
+
* Exact prefix match (not substring) so a user-authored
|
|
77
|
+
* `# SKILLREPO_ACCESS_KEY is set via ...` comment line is never
|
|
78
|
+
* stripped.
|
|
79
|
+
*/
|
|
80
|
+
export const ENV_LOCAL_KEY_NAME = "SKILLREPO_ACCESS_KEY";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* VS Code input `id` owned by the CLI. Lives in the `inputs` array of
|
|
84
|
+
* `.vscode/mcp.json` alongside the server entry.
|
|
85
|
+
*/
|
|
86
|
+
export const VSCODE_INPUT_ID = "skillrepo-api-key";
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Substring that identifies a SessionStart hook command entry as
|
|
90
|
+
* SkillRepo-owned. The #884 installer writes a hook whose `command`
|
|
91
|
+
* field contains `skillrepo update --session-hook`; any entry whose
|
|
92
|
+
* command contains this substring is removed by the uninstall path.
|
|
93
|
+
*
|
|
94
|
+
* Exported so #884's installer can import and use the same constant —
|
|
95
|
+
* this is the module boundary that makes #884 depend on #885 rather
|
|
96
|
+
* than the other way around. The architect design (issue #884 section
|
|
97
|
+
* 5.3) notes the bidirectional-fingerprint requirement; centralizing
|
|
98
|
+
* it here enforces it at the language level.
|
|
99
|
+
*/
|
|
100
|
+
export const SESSION_HOOK_FINGERPRINT = "skillrepo update --session-hook";
|
|
101
|
+
|
|
102
|
+
// ── Artifact descriptors ────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @typedef {Object} ArtifactDescriptor
|
|
106
|
+
* @property {string} id - Stable identifier. Shows up in --json output.
|
|
107
|
+
* @property {"project" | "global"} scope - Which teardown pass owns this.
|
|
108
|
+
* @property {"json-key" | "json-input" | "line" | "section" | "directory"} kind
|
|
109
|
+
* How the remover deletes this artifact.
|
|
110
|
+
* @property {() => string} pathFn - Resolves the on-disk path (absolute).
|
|
111
|
+
* @property {string} displayPath - Human-readable path for output (e.g.
|
|
112
|
+
* `~/.codeium/windsurf/mcp_config.json`). Never an absolute
|
|
113
|
+
* path — those leak the user's home directory.
|
|
114
|
+
* @property {string[]} [jsonPath] - For json-key: property path to delete.
|
|
115
|
+
* @property {string} [inputId] - For json-input: the id field to match.
|
|
116
|
+
* @property {string} [linePrefix] - For line: lines starting with this
|
|
117
|
+
* string are owned and removed.
|
|
118
|
+
* @property {string} [sectionHeader] - For section: the header line;
|
|
119
|
+
* every line under it up to a blank-line sentinel is owned.
|
|
120
|
+
* @property {string} [commandFingerprint] - For the SessionStart hook
|
|
121
|
+
* entry: `command` field contains this substring.
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The complete v3.1.0 artifact list. Frozen so callers can't mutate
|
|
126
|
+
* the catalog at runtime — if a future feature needs to conditionally
|
|
127
|
+
* include/exclude entries, do the filter at consumption time, not by
|
|
128
|
+
* editing the module.
|
|
129
|
+
*
|
|
130
|
+
* Order matters for display: the uninstall command's pre-removal
|
|
131
|
+
* summary renders in this order, so keep "scope grouped and cheapest
|
|
132
|
+
* operation first" within each scope for a predictable UX.
|
|
133
|
+
*/
|
|
134
|
+
export const ARTIFACT_REGISTRY = Object.freeze([
|
|
135
|
+
// ── Project scope ────────────────────────────────────────────────
|
|
136
|
+
Object.freeze({
|
|
137
|
+
id: "claude-mcp-entry",
|
|
138
|
+
scope: "project",
|
|
139
|
+
kind: "json-key",
|
|
140
|
+
pathFn: claudeMcpJson,
|
|
141
|
+
displayPath: ".mcp.json",
|
|
142
|
+
jsonPath: ["mcpServers", "skillrepo"],
|
|
143
|
+
}),
|
|
144
|
+
Object.freeze({
|
|
145
|
+
id: "cursor-mcp-entry",
|
|
146
|
+
scope: "project",
|
|
147
|
+
kind: "json-key",
|
|
148
|
+
pathFn: cursorMcpJson,
|
|
149
|
+
displayPath: ".cursor/mcp.json",
|
|
150
|
+
jsonPath: ["mcpServers", "skillrepo"],
|
|
151
|
+
}),
|
|
152
|
+
Object.freeze({
|
|
153
|
+
id: "vscode-mcp-entry",
|
|
154
|
+
scope: "project",
|
|
155
|
+
kind: "json-key",
|
|
156
|
+
pathFn: vscodeMcpJson,
|
|
157
|
+
displayPath: ".vscode/mcp.json",
|
|
158
|
+
jsonPath: ["servers", "skillrepo"],
|
|
159
|
+
}),
|
|
160
|
+
Object.freeze({
|
|
161
|
+
id: "vscode-mcp-input",
|
|
162
|
+
scope: "project",
|
|
163
|
+
kind: "json-input",
|
|
164
|
+
pathFn: vscodeMcpJson,
|
|
165
|
+
displayPath: ".vscode/mcp.json",
|
|
166
|
+
inputId: VSCODE_INPUT_ID,
|
|
167
|
+
}),
|
|
168
|
+
Object.freeze({
|
|
169
|
+
id: "env-local-key",
|
|
170
|
+
scope: "project",
|
|
171
|
+
kind: "line",
|
|
172
|
+
pathFn: envLocal,
|
|
173
|
+
displayPath: ".env.local",
|
|
174
|
+
linePrefix: `${ENV_LOCAL_KEY_NAME}=`,
|
|
175
|
+
}),
|
|
176
|
+
Object.freeze({
|
|
177
|
+
id: "gitignore-entries",
|
|
178
|
+
scope: "project",
|
|
179
|
+
kind: "section",
|
|
180
|
+
pathFn: gitignorePath,
|
|
181
|
+
displayPath: ".gitignore",
|
|
182
|
+
sectionHeader: GITIGNORE_SECTION_HEADER,
|
|
183
|
+
}),
|
|
184
|
+
Object.freeze({
|
|
185
|
+
id: "settings-session-hook",
|
|
186
|
+
scope: "project",
|
|
187
|
+
kind: "json-key",
|
|
188
|
+
pathFn: () => join(process.cwd(), ".claude", "settings.local.json"),
|
|
189
|
+
displayPath: ".claude/settings.local.json",
|
|
190
|
+
// Hook entries live in `hooks.SessionStart` as an array of
|
|
191
|
+
// objects. The remover filters the array by a predicate on the
|
|
192
|
+
// `command` field rather than by index, because array order is
|
|
193
|
+
// not load-bearing and user-authored hooks may precede or follow
|
|
194
|
+
// SkillRepo's entry.
|
|
195
|
+
commandFingerprint: SESSION_HOOK_FINGERPRINT,
|
|
196
|
+
}),
|
|
197
|
+
Object.freeze({
|
|
198
|
+
id: "skills-dir-project",
|
|
199
|
+
scope: "project",
|
|
200
|
+
kind: "directory",
|
|
201
|
+
pathFn: claudeSkillsProjectRoot,
|
|
202
|
+
displayPath: ".claude/skills/",
|
|
203
|
+
}),
|
|
204
|
+
|
|
205
|
+
// ── Global scope ─────────────────────────────────────────────────
|
|
206
|
+
Object.freeze({
|
|
207
|
+
id: "windsurf-mcp-entry",
|
|
208
|
+
scope: "global",
|
|
209
|
+
kind: "json-key",
|
|
210
|
+
pathFn: windsurfMcpJson,
|
|
211
|
+
displayPath: "~/.codeium/windsurf/mcp_config.json",
|
|
212
|
+
jsonPath: ["mcpServers", "skillrepo"],
|
|
213
|
+
}),
|
|
214
|
+
Object.freeze({
|
|
215
|
+
id: "skills-dir-global",
|
|
216
|
+
scope: "global",
|
|
217
|
+
kind: "directory",
|
|
218
|
+
pathFn: claudeSkillsGlobalRoot,
|
|
219
|
+
displayPath: "~/.claude/skills/",
|
|
220
|
+
}),
|
|
221
|
+
Object.freeze({
|
|
222
|
+
id: "global-config-dir",
|
|
223
|
+
scope: "global",
|
|
224
|
+
kind: "directory",
|
|
225
|
+
// `~/.claude/skillrepo/` — the parent dir of both config.json and
|
|
226
|
+
// .last-sync. Whole-directory removal is correct because both
|
|
227
|
+
// children are CLI-owned and there's no room for user content.
|
|
228
|
+
pathFn: () => join(homedir(), ".claude", "skillrepo"),
|
|
229
|
+
displayPath: "~/.claude/skillrepo/",
|
|
230
|
+
}),
|
|
231
|
+
Object.freeze({
|
|
232
|
+
// Global-scope counterpart to `settings-session-hook` above. Added
|
|
233
|
+
// in #884 alongside the session-sync feature: `skillrepo init
|
|
234
|
+
// --global` and `skillrepo session-sync enable --global` write the
|
|
235
|
+
// hook to the user-wide settings file, not the project-local one.
|
|
236
|
+
// Without this descriptor, `skillrepo uninstall --global` would
|
|
237
|
+
// miss the global hook — a real gap that `session-sync disable
|
|
238
|
+
// --global` could clean up but the project-scope uninstall could
|
|
239
|
+
// not. Mirrors the skills-dir-project / skills-dir-global pair.
|
|
240
|
+
id: "settings-session-hook-global",
|
|
241
|
+
scope: "global",
|
|
242
|
+
kind: "json-key",
|
|
243
|
+
pathFn: () => join(homedir(), ".claude", "settings.local.json"),
|
|
244
|
+
displayPath: "~/.claude/settings.local.json",
|
|
245
|
+
commandFingerprint: SESSION_HOOK_FINGERPRINT,
|
|
246
|
+
}),
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Convenience filter by scope. Used by the uninstall command to
|
|
251
|
+
* partition the registry for the "project only" vs "project + global"
|
|
252
|
+
* passes.
|
|
253
|
+
*/
|
|
254
|
+
export function artifactsByScope(scope) {
|
|
255
|
+
return ARTIFACT_REGISTRY.filter((a) => a.scope === scope);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Lookup by id. Returns `undefined` for unknown ids — callers should
|
|
260
|
+
* treat that as a programming error (registry lookup for an id that
|
|
261
|
+
* was never declared), not a user-visible failure.
|
|
262
|
+
*/
|
|
263
|
+
export function artifactById(id) {
|
|
264
|
+
return ARTIFACT_REGISTRY.find((a) => a.id === id);
|
|
265
|
+
}
|
package/src/lib/fs-utils.mjs
CHANGED
|
@@ -3,7 +3,16 @@
|
|
|
3
3
|
* Creates directories as needed, handles errors cleanly.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
readFileSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
statSync,
|
|
12
|
+
chmodSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
} from "node:fs";
|
|
7
16
|
import { dirname } from "node:path";
|
|
8
17
|
|
|
9
18
|
/**
|
|
@@ -50,3 +59,76 @@ export function writeExecutable(filePath, content) {
|
|
|
50
59
|
export function pathExists(p) {
|
|
51
60
|
return existsSync(p);
|
|
52
61
|
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Atomic write via temp-file + rename. Matches the pattern in
|
|
65
|
+
* `config.mjs`: write to `<path>.tmp`, then `renameSync` into place
|
|
66
|
+
* so the destination is never observed in a half-written state, and
|
|
67
|
+
* unlink the temp file on rename failure so a partial credential
|
|
68
|
+
* value is never left behind on disk.
|
|
69
|
+
*
|
|
70
|
+
* Use this (not `writeFileSafe`) for any module that modifies a user
|
|
71
|
+
* config file containing credentials or shared state. The uninstall
|
|
72
|
+
* removers (#885) touch `.env.local`, `.mcp.json`, and
|
|
73
|
+
* `settings.local.json` — all three benefit from atomic writes, the
|
|
74
|
+
* first two because of the credential-leak risk, the third because
|
|
75
|
+
* Claude Code parses it on startup and a half-written JSON would
|
|
76
|
+
* break the user's session.
|
|
77
|
+
*
|
|
78
|
+
* Parent-directory semantics match `writeFileSafe`: created
|
|
79
|
+
* recursively if missing. Directory-as-file collision is rejected
|
|
80
|
+
* before the temp-file is written.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} filePath - Absolute path to the final destination.
|
|
83
|
+
* @param {string} content - UTF-8 content to persist.
|
|
84
|
+
* @param {object} [options]
|
|
85
|
+
* @param {number} [options.mode] - chmod applied to the temp file
|
|
86
|
+
* BEFORE rename so the destination never exists with looser
|
|
87
|
+
* permissions than intended. Skipped on Windows.
|
|
88
|
+
*/
|
|
89
|
+
export function writeFileAtomic(filePath, content, { mode } = {}) {
|
|
90
|
+
const dir = dirname(filePath);
|
|
91
|
+
if (!existsSync(dir)) {
|
|
92
|
+
mkdirSync(dir, { recursive: true });
|
|
93
|
+
}
|
|
94
|
+
if (existsSync(filePath) && statSync(filePath).isDirectory()) {
|
|
95
|
+
throw new Error(`${filePath} is a directory, expected a file`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const tmpPath = `${filePath}.tmp`;
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(tmpPath, content, "utf-8");
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// Re-throw with a clearer message but preserve the cause for
|
|
103
|
+
// --verbose. A temp-file write failure is almost always a
|
|
104
|
+
// permissions or disk-full issue; the original error surfaces it.
|
|
105
|
+
throw new Error(`Cannot write ${tmpPath}: ${err.message}`, { cause: err });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (mode !== undefined && process.platform !== "win32") {
|
|
109
|
+
try {
|
|
110
|
+
chmodSync(tmpPath, mode);
|
|
111
|
+
} catch {
|
|
112
|
+
// Non-fatal — same rationale as config.mjs: chmod failure
|
|
113
|
+
// doesn't corrupt the file, the destination just has looser
|
|
114
|
+
// permissions than intended. Callers that care can stat the
|
|
115
|
+
// file after the write.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
renameSync(tmpPath, filePath);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// Clean up the stale temp file so a partial credential value
|
|
123
|
+
// isn't left behind. Best-effort — if the unlink also fails,
|
|
124
|
+
// the original rename error is still what we surface.
|
|
125
|
+
try {
|
|
126
|
+
unlinkSync(tmpPath);
|
|
127
|
+
} catch {
|
|
128
|
+
/* best-effort */
|
|
129
|
+
}
|
|
130
|
+
throw new Error(`Cannot install ${filePath}: ${err.message}`, {
|
|
131
|
+
cause: err,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -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
|
@@ -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");
|