skillrepo 1.7.0 → 1.8.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/package.json +1 -1
- package/src/hooks/skillrepo-pretool-activation.mjs +343 -0
- package/src/hooks/skillrepo-sync.mjs +4 -1
- package/src/lib/first-sync.mjs +14 -0
- package/src/lib/mergers/hooks-json.mjs +26 -6
- package/src/lib/paths.mjs +1 -0
- package/src/test/e2e/HANDOFF.md +4 -22
- package/src/test/e2e/cli-init.test.mjs +10 -24
- package/src/test/e2e/payload-factory.mjs +8 -669
- package/src/test/hooks/skillrepo-pretool-activation.test.mjs +374 -0
- package/src/test/hooks/skillrepo-prompt-match.test.mjs +29 -2
- package/src/test/hooks/skillrepo-sync.test.mjs +20 -13
- package/src/test/mergers/hooks-json.test.mjs +37 -13
package/package.json
CHANGED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SkillRepo PreToolUse hook — detects when skills are actually USED during a
|
|
4
|
+
* coding session by matching tool/command patterns against skill-declared
|
|
5
|
+
* `contextSignals.toolPatterns`. Telemetry-only: no content injection, no blocking.
|
|
6
|
+
*
|
|
7
|
+
* Standalone script: no npm dependencies, Node.js built-ins only.
|
|
8
|
+
* Installed by `npx skillrepo init` to `.claude/hooks/skillrepo-pretool-activation.mjs`.
|
|
9
|
+
*
|
|
10
|
+
* Part of #569 (PreToolUse hook for tool fingerprinting activation telemetry).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { homedir, tmpdir } from "node:os";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { execSync } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Constants
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const DEFAULT_SERVER_URL = "https://skillrepo.dev";
|
|
25
|
+
const MAX_EVENTS_PER_BATCH = 50;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Config resolution (lightweight — only needs API key and server URL)
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read config from ~/.claude/skillrepo/config.json with fallbacks.
|
|
33
|
+
* Returns { apiKey, serverUrl } or null.
|
|
34
|
+
*/
|
|
35
|
+
export function readConfig() {
|
|
36
|
+
const configPath = join(homedir(), ".claude", "skillrepo", "config.json");
|
|
37
|
+
try {
|
|
38
|
+
const cfg = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
39
|
+
if (cfg.apiKey) {
|
|
40
|
+
return { apiKey: cfg.apiKey, serverUrl: cfg.serverUrl || DEFAULT_SERVER_URL };
|
|
41
|
+
}
|
|
42
|
+
} catch { /* not found */ }
|
|
43
|
+
|
|
44
|
+
const envKey = process.env.SKILLREPO_ACCESS_KEY;
|
|
45
|
+
if (envKey) {
|
|
46
|
+
return { apiKey: envKey, serverUrl: process.env.SKILLREPO_SERVER_URL || DEFAULT_SERVER_URL };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// .env.local fallback
|
|
50
|
+
for (const file of [".env.local", ".env"]) {
|
|
51
|
+
try {
|
|
52
|
+
const lines = readFileSync(join(process.cwd(), file), "utf-8").split("\n");
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const trimmed = line.trim();
|
|
55
|
+
if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
|
|
56
|
+
const eqIdx = trimmed.indexOf("=");
|
|
57
|
+
if (trimmed.slice(0, eqIdx).trim() === "SKILLREPO_ACCESS_KEY") {
|
|
58
|
+
let val = trimmed.slice(eqIdx + 1).trim();
|
|
59
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
60
|
+
val = val.slice(1, -1);
|
|
61
|
+
}
|
|
62
|
+
val = val.replace(/\s+#.*$/, "");
|
|
63
|
+
if (val) return { apiKey: val, serverUrl: DEFAULT_SERVER_URL };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch { /* file doesn't exist */ }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Tool pattern matching
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Match a tool invocation against all skills' contextSignals.toolPatterns.
|
|
78
|
+
*
|
|
79
|
+
* For Bash tools: prefix-match tool_input.command against each pattern.
|
|
80
|
+
* For non-Bash tools: match tool_name against patterns.
|
|
81
|
+
*
|
|
82
|
+
* Pattern matching rules:
|
|
83
|
+
* - Case-insensitive
|
|
84
|
+
* - Prefix match: pattern "gh issue" matches command "gh issue create --title ..."
|
|
85
|
+
* - Pattern must match at a word boundary (don't match "npm" against "npmx")
|
|
86
|
+
*
|
|
87
|
+
* Returns array of { skill, pattern } matches.
|
|
88
|
+
*/
|
|
89
|
+
export function matchToolPatterns(toolName, toolInput, skills) {
|
|
90
|
+
if (!toolName || !skills?.length) return [];
|
|
91
|
+
|
|
92
|
+
const isBash = toolName.toLowerCase() === "bash";
|
|
93
|
+
const matches = [];
|
|
94
|
+
|
|
95
|
+
for (const skill of skills) {
|
|
96
|
+
const patterns = skill.contextSignals?.toolPatterns;
|
|
97
|
+
if (!patterns?.length) continue;
|
|
98
|
+
|
|
99
|
+
for (const pattern of patterns) {
|
|
100
|
+
if (!pattern) continue;
|
|
101
|
+
const patternLower = pattern.toLowerCase();
|
|
102
|
+
|
|
103
|
+
if (isBash) {
|
|
104
|
+
// Bash tool: prefix-match against command
|
|
105
|
+
const command = (toolInput?.command ?? "").trim();
|
|
106
|
+
if (!command) continue;
|
|
107
|
+
const commandLower = command.toLowerCase();
|
|
108
|
+
|
|
109
|
+
if (commandLower.startsWith(patternLower)) {
|
|
110
|
+
// Word boundary check: the character after the pattern must be
|
|
111
|
+
// end-of-string, whitespace, or a non-alphanumeric character
|
|
112
|
+
const afterIdx = patternLower.length;
|
|
113
|
+
if (afterIdx >= commandLower.length || /[^a-z0-9_]/i.test(commandLower[afterIdx])) {
|
|
114
|
+
matches.push({ skill, pattern });
|
|
115
|
+
break; // one match per skill is enough
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
// Non-Bash tool: match tool_name against patterns
|
|
120
|
+
const toolNameLower = toolName.toLowerCase();
|
|
121
|
+
if (toolNameLower === patternLower) {
|
|
122
|
+
matches.push({ skill, pattern });
|
|
123
|
+
break; // one match per skill is enough
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return matches;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Session dedup
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Read activation state for deduplication.
|
|
138
|
+
* State is stored in tmpdir keyed by session hash.
|
|
139
|
+
*/
|
|
140
|
+
export function readActivationState(sessionId) {
|
|
141
|
+
const hash = createHash("sha256").update(sessionId || "default").digest("hex").slice(0, 16);
|
|
142
|
+
const statePath = join(tmpdir(), `skillrepo-activation-${hash}.json`);
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
return { path: statePath, state: JSON.parse(readFileSync(statePath, "utf-8")) };
|
|
146
|
+
} catch {
|
|
147
|
+
return { path: statePath, state: { reported: {} } };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Filter matches to exclude skills already reported in this session.
|
|
153
|
+
*/
|
|
154
|
+
export function deduplicateActivations(matches, sessionState) {
|
|
155
|
+
return matches.filter(m => {
|
|
156
|
+
const key = `${m.skill.owner}/${m.skill.name}`;
|
|
157
|
+
return !sessionState.reported[key];
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Mark skills as reported in session state.
|
|
163
|
+
*/
|
|
164
|
+
export function updateActivationState(statePath, state, reportedMatches) {
|
|
165
|
+
for (const m of reportedMatches) {
|
|
166
|
+
const key = `${m.skill.owner}/${m.skill.name}`;
|
|
167
|
+
state.reported[key] = new Date().toISOString();
|
|
168
|
+
}
|
|
169
|
+
try { writeFileSync(statePath, JSON.stringify(state), "utf-8"); }
|
|
170
|
+
catch { /* non-critical */ }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// GitHub username resolution
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
const GITHUB_USERNAME_CACHE_PATH = join(tmpdir(), "skillrepo-gh-user.json");
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolve GitHub username via `gh api user`. Cached per session.
|
|
181
|
+
*/
|
|
182
|
+
export function resolveGithubUsername() {
|
|
183
|
+
// Check cache first
|
|
184
|
+
try {
|
|
185
|
+
const cached = JSON.parse(readFileSync(GITHUB_USERNAME_CACHE_PATH, "utf-8"));
|
|
186
|
+
if (cached.username && cached.expiresAt > Date.now()) return cached.username;
|
|
187
|
+
} catch { /* no cache */ }
|
|
188
|
+
|
|
189
|
+
// Resolve via gh CLI
|
|
190
|
+
try {
|
|
191
|
+
const result = execSync("gh api user --jq .login", {
|
|
192
|
+
encoding: "utf-8",
|
|
193
|
+
timeout: 3_000,
|
|
194
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
195
|
+
}).trim();
|
|
196
|
+
|
|
197
|
+
if (result) {
|
|
198
|
+
// Cache for 1 hour
|
|
199
|
+
try {
|
|
200
|
+
writeFileSync(GITHUB_USERNAME_CACHE_PATH, JSON.stringify({
|
|
201
|
+
username: result,
|
|
202
|
+
expiresAt: Date.now() + 3_600_000,
|
|
203
|
+
}), "utf-8");
|
|
204
|
+
} catch { /* non-critical */ }
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
} catch { /* gh not installed or not authed */ }
|
|
208
|
+
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Telemetry payload
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build telemetry payload for tool-pattern-activated skills.
|
|
218
|
+
*/
|
|
219
|
+
export function buildTelemetryPayload(matches, sessionInfo) {
|
|
220
|
+
const events = matches.slice(0, MAX_EVENTS_PER_BATCH).map(m => ({
|
|
221
|
+
skillOwner: m.skill.owner,
|
|
222
|
+
skillName: m.skill.name,
|
|
223
|
+
skillVersion: m.skill.version || undefined,
|
|
224
|
+
activatedAt: new Date().toISOString(),
|
|
225
|
+
ide: sessionInfo.ide || "claude-code",
|
|
226
|
+
sessionHash: sessionInfo.sessionHash,
|
|
227
|
+
githubUsername: sessionInfo.githubUsername || undefined,
|
|
228
|
+
source: "pretool_hook",
|
|
229
|
+
toolPattern: m.pattern,
|
|
230
|
+
}));
|
|
231
|
+
|
|
232
|
+
return { events };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Fire-and-forget POST to telemetry endpoint.
|
|
237
|
+
*
|
|
238
|
+
* NOT awaited — the spec requires "do NOT block the agent" and Claude Code
|
|
239
|
+
* waits for hook process exit. The OS TCP stack flushes small payloads after
|
|
240
|
+
* process exit, so delivery reliability is ~99%+ for responsive servers.
|
|
241
|
+
* Occasional loss on slow/unreachable servers is acceptable for non-critical
|
|
242
|
+
* telemetry.
|
|
243
|
+
*/
|
|
244
|
+
export function sendTelemetry(config, payload) {
|
|
245
|
+
const url = `${config.serverUrl}/api/v1/telemetry/activation`;
|
|
246
|
+
|
|
247
|
+
fetch(url, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: {
|
|
250
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
251
|
+
"Content-Type": "application/json",
|
|
252
|
+
},
|
|
253
|
+
body: JSON.stringify(payload),
|
|
254
|
+
}).catch(() => { /* telemetry errors are non-critical */ });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Main entry point
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
export async function main(input) {
|
|
262
|
+
const indexPath = join(homedir(), ".claude", "skillrepo", "index.json");
|
|
263
|
+
|
|
264
|
+
// -- Read index --
|
|
265
|
+
let index;
|
|
266
|
+
try {
|
|
267
|
+
index = JSON.parse(readFileSync(indexPath, "utf-8"));
|
|
268
|
+
} catch {
|
|
269
|
+
// No index -> nothing to match. Exit cleanly.
|
|
270
|
+
process.stdout.write("{}");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!index.skills?.length) {
|
|
275
|
+
process.stdout.write("{}");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// -- Read config (needed for telemetry POST) --
|
|
280
|
+
const config = readConfig();
|
|
281
|
+
if (!config) {
|
|
282
|
+
process.stdout.write("{}");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// -- Match tool against skills --
|
|
287
|
+
const toolName = input.tool_name ?? "";
|
|
288
|
+
const toolInput = input.tool_input ?? {};
|
|
289
|
+
const matches = matchToolPatterns(toolName, toolInput, index.skills);
|
|
290
|
+
|
|
291
|
+
if (matches.length === 0) {
|
|
292
|
+
process.stdout.write("{}");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// -- Session dedup --
|
|
297
|
+
const sessionId = input.session_id ?? "default";
|
|
298
|
+
const { path: statePath, state } = readActivationState(sessionId);
|
|
299
|
+
const newMatches = deduplicateActivations(matches, state);
|
|
300
|
+
|
|
301
|
+
if (newMatches.length === 0) {
|
|
302
|
+
process.stdout.write("{}");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// -- Build and send telemetry --
|
|
307
|
+
const sessionHash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
|
|
308
|
+
|
|
309
|
+
// Skip GitHub username resolution — it can block up to 3s on cold cache via
|
|
310
|
+
// `gh api user`. The prompt-match hook already identifies the session, and
|
|
311
|
+
// the server can correlate by API key if needed.
|
|
312
|
+
const payload = buildTelemetryPayload(newMatches, {
|
|
313
|
+
ide: "claude-code",
|
|
314
|
+
sessionHash,
|
|
315
|
+
githubUsername: "",
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
sendTelemetry(config, payload); // fire-and-forget -- do NOT await
|
|
319
|
+
|
|
320
|
+
// -- Update session state --
|
|
321
|
+
updateActivationState(statePath, state, newMatches);
|
|
322
|
+
|
|
323
|
+
// -- Output: NO additionalContext --
|
|
324
|
+
process.stdout.write("{}");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// -- Run --
|
|
328
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
329
|
+
const isMainModule = process.argv[1] === __filename;
|
|
330
|
+
|
|
331
|
+
if (isMainModule) {
|
|
332
|
+
let inputBuf = "";
|
|
333
|
+
for await (const chunk of process.stdin) inputBuf += chunk;
|
|
334
|
+
|
|
335
|
+
let input;
|
|
336
|
+
try { input = JSON.parse(inputBuf); }
|
|
337
|
+
catch { process.stdout.write("{}"); process.exit(0); }
|
|
338
|
+
|
|
339
|
+
main(input).catch(() => {
|
|
340
|
+
process.stdout.write("{}");
|
|
341
|
+
process.exit(0);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
@@ -441,7 +441,10 @@ export function writeRulesFiles(selectedSkills, projectDir) {
|
|
|
441
441
|
written.add(baseName);
|
|
442
442
|
|
|
443
443
|
// Claude Code: .claude/rules/skillrepo-{owner}-{name}.md
|
|
444
|
-
|
|
444
|
+
// Append self-report instruction so the agent calls report_skill_activation
|
|
445
|
+
// when it follows this skill's instructions. Probabilistic but measurable.
|
|
446
|
+
const selfReportSuffix = `\n\n---\n_When you follow instructions from this skill, call \`report_skill_activation\` with owner "${skill.owner}" and name "${skill.name}" to report usage._`;
|
|
447
|
+
atomicWrite(join(claudeRulesDir, `${baseName}.md`), skillMd.content + selfReportSuffix);
|
|
445
448
|
|
|
446
449
|
// Cursor: .cursor/rules/skillrepo-{owner}-{name}.mdc
|
|
447
450
|
const mdcContent = buildCursorMdc(skill, skillMd.content);
|
package/src/lib/first-sync.mjs
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import { execFile } from "node:child_process";
|
|
10
10
|
import { join, dirname } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { unlinkSync } from "node:fs";
|
|
12
14
|
|
|
13
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
16
|
const __dirname = dirname(__filename);
|
|
@@ -23,9 +25,21 @@ function syncHookPath() {
|
|
|
23
25
|
/**
|
|
24
26
|
* Run the sync hook as a subprocess.
|
|
25
27
|
* Pipes '{}' as stdin (SessionStart hook input format).
|
|
28
|
+
*
|
|
29
|
+
* Deletes .last-sync before running so the hook performs a FULL sync
|
|
30
|
+
* instead of a delta. This is critical: if init is re-run, the previous
|
|
31
|
+
* .last-sync timestamp would cause a delta sync that returns 0 skills
|
|
32
|
+
* (nothing changed since that timestamp), leaving the index empty.
|
|
33
|
+
*
|
|
26
34
|
* Returns a promise that resolves on success, rejects on failure.
|
|
27
35
|
*/
|
|
28
36
|
export function runFirstSync() {
|
|
37
|
+
// Force full sync — delete the delta-sync marker so the hook fetches
|
|
38
|
+
// the complete library instead of only changes since the last sync.
|
|
39
|
+
try {
|
|
40
|
+
unlinkSync(join(homedir(), ".claude", "skillrepo", ".last-sync"));
|
|
41
|
+
} catch { /* doesn't exist yet — fine */ }
|
|
42
|
+
|
|
29
43
|
return new Promise((resolve, reject) => {
|
|
30
44
|
const child = execFile(
|
|
31
45
|
process.execPath,
|
|
@@ -5,25 +5,26 @@
|
|
|
5
5
|
* Phase C (#535): Hook scripts are bundled with the CLI package and copied
|
|
6
6
|
* to .claude/hooks/ — no longer generated from server template strings.
|
|
7
7
|
*
|
|
8
|
-
* Merge strategy: add SessionStart and
|
|
8
|
+
* Merge strategy: add SessionStart, UserPromptSubmit, and PreToolUse entries
|
|
9
9
|
* without destroying existing settings. Idempotent — skips if already installed.
|
|
10
|
-
* Removes stale PreToolUse entries and hook scripts from pre-Phase C installs.
|
|
10
|
+
* Removes stale legacy PreToolUse entries and hook scripts from pre-Phase C installs.
|
|
11
11
|
* Cleans up stale .claude/hooks/hooks.json if it exists.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { unlinkSync, readFileSync } from "node:fs";
|
|
15
15
|
import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
|
|
16
|
-
import { claudeSettingsLocal, claudeSyncHook, claudePromptHook, claudePreToolHook, claudeHooksDir } from "../paths.mjs";
|
|
16
|
+
import { claudeSettingsLocal, claudeSyncHook, claudePromptHook, claudePreToolHook, claudePreToolActivationHook, claudeHooksDir } from "../paths.mjs";
|
|
17
17
|
import { join, dirname } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
|
|
20
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
21
21
|
const __dirname = dirname(__filename);
|
|
22
22
|
|
|
23
|
-
//
|
|
23
|
+
// Hook script paths (relative to project root).
|
|
24
24
|
const HOOK_SCRIPTS = {
|
|
25
25
|
sync: ".claude/hooks/skillrepo-sync.mjs",
|
|
26
26
|
prompt: ".claude/hooks/skillrepo-prompt-match.mjs",
|
|
27
|
+
pretoolActivation: ".claude/hooks/skillrepo-pretool-activation.mjs",
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
// Legacy hook script path (removed in Phase C).
|
|
@@ -119,8 +120,8 @@ function removeSkillRepoHook(groups, scriptPath) {
|
|
|
119
120
|
* Merge SkillRepo hooks into .claude/settings.local.json and write hook scripts.
|
|
120
121
|
*
|
|
121
122
|
* Reads standalone hook scripts from the CLI package (bundled files) and
|
|
122
|
-
* copies them to .claude/hooks/. Registers SessionStart
|
|
123
|
-
* hooks. Removes legacy PreToolUse hook entries and scripts.
|
|
123
|
+
* copies them to .claude/hooks/. Registers SessionStart, UserPromptSubmit,
|
|
124
|
+
* and PreToolUse hooks. Removes legacy PreToolUse hook entries and scripts.
|
|
124
125
|
*
|
|
125
126
|
* @returns {{ results: { path: string; action: string }[] }}
|
|
126
127
|
*/
|
|
@@ -147,6 +148,7 @@ export function mergeHooksConfig() {
|
|
|
147
148
|
// Read bundled standalone hook scripts from the CLI package.
|
|
148
149
|
const syncContent = readBundledHook("skillrepo-sync.mjs");
|
|
149
150
|
const promptContent = readBundledHook("skillrepo-prompt-match.mjs");
|
|
151
|
+
const pretoolActivationContent = readBundledHook("skillrepo-pretool-activation.mjs");
|
|
150
152
|
|
|
151
153
|
// Always write hook scripts (latest version from CLI package).
|
|
152
154
|
const syncExisted = readFileSafe(claudeSyncHook()) !== null;
|
|
@@ -163,6 +165,13 @@ export function mergeHooksConfig() {
|
|
|
163
165
|
action: promptExisted ? "updated" : "created",
|
|
164
166
|
});
|
|
165
167
|
|
|
168
|
+
const pretoolActivationExisted = readFileSafe(claudePreToolActivationHook()) !== null;
|
|
169
|
+
writeFileSafe(claudePreToolActivationHook(), pretoolActivationContent);
|
|
170
|
+
results.push({
|
|
171
|
+
path: HOOK_SCRIPTS.pretoolActivation,
|
|
172
|
+
action: pretoolActivationExisted ? "updated" : "created",
|
|
173
|
+
});
|
|
174
|
+
|
|
166
175
|
// Remove legacy PreToolUse hook script if it exists
|
|
167
176
|
try {
|
|
168
177
|
unlinkSync(claudePreToolHook());
|
|
@@ -198,6 +207,7 @@ export function mergeHooksConfig() {
|
|
|
198
207
|
for (const [event, scriptPath] of [
|
|
199
208
|
["SessionStart", HOOK_SCRIPTS.sync],
|
|
200
209
|
["UserPromptSubmit", HOOK_SCRIPTS.prompt],
|
|
210
|
+
["PreToolUse", HOOK_SCRIPTS.pretoolActivation],
|
|
201
211
|
]) {
|
|
202
212
|
if (Array.isArray(config.hooks[event]) && updateExistingCommands(config.hooks[event], scriptPath)) {
|
|
203
213
|
changed = true;
|
|
@@ -207,6 +217,7 @@ export function mergeHooksConfig() {
|
|
|
207
217
|
// Build commands using the absolute node path from this machine.
|
|
208
218
|
const syncCommand = buildCommand(HOOK_SCRIPTS.sync);
|
|
209
219
|
const promptCommand = buildCommand(HOOK_SCRIPTS.prompt);
|
|
220
|
+
const pretoolActivationCommand = buildCommand(HOOK_SCRIPTS.pretoolActivation);
|
|
210
221
|
|
|
211
222
|
// Merge SessionStart
|
|
212
223
|
if (!Array.isArray(config.hooks.SessionStart)) config.hooks.SessionStart = [];
|
|
@@ -227,6 +238,15 @@ export function mergeHooksConfig() {
|
|
|
227
238
|
changed = true;
|
|
228
239
|
}
|
|
229
240
|
|
|
241
|
+
// Merge PreToolUse (activation telemetry)
|
|
242
|
+
if (!Array.isArray(config.hooks.PreToolUse)) config.hooks.PreToolUse = [];
|
|
243
|
+
if (!hasSkillRepoHook(config.hooks.PreToolUse, HOOK_SCRIPTS.pretoolActivation)) {
|
|
244
|
+
config.hooks.PreToolUse.push({
|
|
245
|
+
hooks: [{ type: "command", command: pretoolActivationCommand }],
|
|
246
|
+
});
|
|
247
|
+
changed = true;
|
|
248
|
+
}
|
|
249
|
+
|
|
230
250
|
if (existing === null) {
|
|
231
251
|
writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
232
252
|
results.push({ path: ".claude/settings.local.json", action: "created" });
|
package/src/lib/paths.mjs
CHANGED
|
@@ -16,6 +16,7 @@ export const claudeSettingsLocal = () => join(cwd(), ".claude", "settings.local.
|
|
|
16
16
|
export const claudeSyncHook = () => join(cwd(), ".claude", "hooks", "skillrepo-sync.mjs");
|
|
17
17
|
export const claudePromptHook = () => join(cwd(), ".claude", "hooks", "skillrepo-prompt-match.mjs");
|
|
18
18
|
export const claudePreToolHook = () => join(cwd(), ".claude", "hooks", "skillrepo-pretool-match.mjs");
|
|
19
|
+
export const claudePreToolActivationHook = () => join(cwd(), ".claude", "hooks", "skillrepo-pretool-activation.mjs");
|
|
19
20
|
export const claudeSkillrepoMd = () => join(cwd(), ".claude", "skillrepo.md");
|
|
20
21
|
export const claudeSkillrepoIndex = () => join(cwd(), ".claude", "skillrepo-index.json");
|
|
21
22
|
export const claudeSkillrepoConfig = () => join(cwd(), ".claude", "skillrepo-config.json");
|
package/src/test/e2e/HANDOFF.md
CHANGED
|
@@ -93,13 +93,11 @@ Use `node:test` (Node.js built-in) — consistent with existing CLI tests in `pa
|
|
|
93
93
|
|
|
94
94
|
### Payload Factory Requirements
|
|
95
95
|
|
|
96
|
-
Must generate
|
|
96
|
+
Must generate `SetupPayload` objects matching what `generateSetupPayload()` produces.
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
4. Configurable skill count
|
|
102
|
-
5. Both Claude Code and Cursor output structures
|
|
98
|
+
Phase D (#534) stripped the payload to a minimal shape — the CLI only uses `skillCount`.
|
|
99
|
+
All other setup work (hooks, rules, sync) is handled by standalone scripts bundled
|
|
100
|
+
with the CLI package.
|
|
103
101
|
|
|
104
102
|
Reference `src/lib/setup/generate.ts` for the exact `SetupPayload` type:
|
|
105
103
|
|
|
@@ -107,22 +105,6 @@ Reference `src/lib/setup/generate.ts` for the exact `SetupPayload` type:
|
|
|
107
105
|
interface SetupPayload {
|
|
108
106
|
skillCount: number;
|
|
109
107
|
mcpUrl: string;
|
|
110
|
-
skillEntries: string[];
|
|
111
|
-
claudeCode: {
|
|
112
|
-
skillrepoMd: { path: string; content: string };
|
|
113
|
-
syncHook: { path: string; content: string };
|
|
114
|
-
settingsHooks: { hooks: Record<string, unknown> };
|
|
115
|
-
skillIndex: { path: string; content: string };
|
|
116
|
-
skillrepoConfig: { path: string; content: string };
|
|
117
|
-
promptHook: { path: string; content: string };
|
|
118
|
-
preToolHook: { path: string; content: string };
|
|
119
|
-
};
|
|
120
|
-
cursor: {
|
|
121
|
-
rules: Array<{ path: string; content: string }>;
|
|
122
|
-
hooksJson: { path: string; content: string };
|
|
123
|
-
sessionHook: { path: string; content: string };
|
|
124
|
-
skillIndex: { path: string; content: string };
|
|
125
|
-
};
|
|
126
108
|
}
|
|
127
109
|
```
|
|
128
110
|
|
|
@@ -204,8 +204,7 @@ describe("CLI E2E: skillrepo init", () => {
|
|
|
204
204
|
|
|
205
205
|
assert.equal(countHook(settings.hooks.SessionStart, "skillrepo-sync"), 1);
|
|
206
206
|
assert.equal(countHook(settings.hooks.UserPromptSubmit, "skillrepo-prompt-match"), 1);
|
|
207
|
-
|
|
208
|
-
assert.equal(settings.hooks.PreToolUse, undefined, "PreToolUse should not exist");
|
|
207
|
+
assert.equal(countHook(settings.hooks.PreToolUse, "skillrepo-pretool-activation"), 1, "PreToolUse activation hook should exist once");
|
|
209
208
|
});
|
|
210
209
|
|
|
211
210
|
it("invalid API key (401) exits with error", async () => {
|
|
@@ -256,6 +255,7 @@ describe("CLI E2E: skillrepo init", () => {
|
|
|
256
255
|
for (const f of [
|
|
257
256
|
".claude/hooks/skillrepo-sync.mjs",
|
|
258
257
|
".claude/hooks/skillrepo-prompt-match.mjs",
|
|
258
|
+
".claude/hooks/skillrepo-pretool-activation.mjs",
|
|
259
259
|
]) {
|
|
260
260
|
assert.doesNotThrow(
|
|
261
261
|
() => execFileSync(process.execPath, ["--check", join(tempDir, f)], { timeout: 5000 }),
|
|
@@ -296,44 +296,30 @@ describe("CLI E2E: skillrepo init", () => {
|
|
|
296
296
|
});
|
|
297
297
|
|
|
298
298
|
// =========================================================================
|
|
299
|
-
// 4.
|
|
299
|
+
// 4. Three hooks registered in settings.local.json
|
|
300
300
|
// =========================================================================
|
|
301
301
|
|
|
302
|
-
it("
|
|
302
|
+
it("three hooks registered in settings.local.json", async () => {
|
|
303
303
|
await runInit(tempDir, port);
|
|
304
304
|
|
|
305
305
|
const settings = readJSON(tempDir, ".claude/settings.local.json");
|
|
306
306
|
assert.ok(settings.hooks);
|
|
307
307
|
assert.ok(Array.isArray(settings.hooks.SessionStart));
|
|
308
308
|
assert.ok(Array.isArray(settings.hooks.UserPromptSubmit));
|
|
309
|
-
assert.
|
|
309
|
+
assert.ok(Array.isArray(settings.hooks.PreToolUse), "PreToolUse should exist");
|
|
310
310
|
|
|
311
311
|
const getCmds = (groups) => groups.flatMap((g) => g.hooks.map((h) => h.command));
|
|
312
312
|
assert.ok(getCmds(settings.hooks.SessionStart).some((c) => c.includes("skillrepo-sync")));
|
|
313
313
|
assert.ok(getCmds(settings.hooks.UserPromptSubmit).some((c) => c.includes("skillrepo-prompt-match")));
|
|
314
|
+
assert.ok(getCmds(settings.hooks.PreToolUse).some((c) => c.includes("skillrepo-pretool-activation")));
|
|
314
315
|
});
|
|
315
316
|
|
|
316
317
|
// =========================================================================
|
|
317
318
|
// 5. Security
|
|
318
319
|
// =========================================================================
|
|
319
320
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
maliciousPayload.cursor.rules.push({
|
|
325
|
-
path: "../outside-cursor.txt",
|
|
326
|
-
content: "should not be written",
|
|
327
|
-
});
|
|
328
|
-
srv.setPayload(maliciousPayload);
|
|
329
|
-
|
|
330
|
-
try {
|
|
331
|
-
// Init may succeed or fail depending on whether cursor rules are written
|
|
332
|
-
// — the critical check is that the file is NOT written outside .cursor/
|
|
333
|
-
await runInit(tempDir, p).catch(() => {});
|
|
334
|
-
assert.ok(!existsSync(join(tempDir, "outside-cursor.txt")), "Should not write outside .cursor/");
|
|
335
|
-
} finally {
|
|
336
|
-
await srv.stop();
|
|
337
|
-
}
|
|
338
|
-
});
|
|
321
|
+
// Path traversal test removed in Phase D (#534): the setup payload no longer
|
|
322
|
+
// contains cursor.rules — Cursor rules are written by the standalone sync hook,
|
|
323
|
+
// not from server-generated payload content. The attack surface tested here
|
|
324
|
+
// no longer exists in the init flow.
|
|
339
325
|
});
|