skillrepo 1.7.1 → 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/mergers/hooks-json.mjs +26 -6
- package/src/lib/paths.mjs +1 -0
- package/src/test/e2e/cli-init.test.mjs +6 -5
- package/src/test/hooks/skillrepo-pretool-activation.test.mjs +374 -0
- package/src/test/hooks/skillrepo-prompt-match.test.mjs +3 -3
- package/src/test/hooks/skillrepo-sync.test.mjs +17 -11
- 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);
|
|
@@ -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");
|
|
@@ -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,21 +296,22 @@ 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
|
// =========================================================================
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the PreToolUse activation telemetry hook (skillrepo-pretool-activation.mjs).
|
|
3
|
+
*
|
|
4
|
+
* Uses node:test + node:assert — zero external dependencies.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import {
|
|
10
|
+
mkdtempSync, rmSync, readFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
matchToolPatterns,
|
|
17
|
+
readActivationState,
|
|
18
|
+
deduplicateActivations,
|
|
19
|
+
updateActivationState,
|
|
20
|
+
buildTelemetryPayload,
|
|
21
|
+
sendTelemetry,
|
|
22
|
+
} from "../../hooks/skillrepo-pretool-activation.mjs";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Test helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function makeTmpDir() {
|
|
29
|
+
return mkdtempSync(join(tmpdir(), "skillrepo-activation-test-"));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeSkill(overrides = {}) {
|
|
33
|
+
return {
|
|
34
|
+
owner: "testowner",
|
|
35
|
+
name: "test-skill",
|
|
36
|
+
version: "1.0.0",
|
|
37
|
+
description: "A test skill for testing",
|
|
38
|
+
keywords: ["test"],
|
|
39
|
+
contextSignals: null,
|
|
40
|
+
localPath: "/tmp/test/SKILL.md",
|
|
41
|
+
isRulesDelivered: false,
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// matchToolPatterns
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
describe("matchToolPatterns", () => {
|
|
51
|
+
it("matches Bash command against tool pattern prefix", () => {
|
|
52
|
+
const skills = [makeSkill({
|
|
53
|
+
contextSignals: { files: [], project: [], tasks: [], toolPatterns: ["gh issue"] },
|
|
54
|
+
})];
|
|
55
|
+
const matches = matchToolPatterns("Bash", { command: "gh issue create --title 'Fix bug'" }, skills);
|
|
56
|
+
assert.equal(matches.length, 1);
|
|
57
|
+
assert.equal(matches[0].pattern, "gh issue");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("matches case-insensitively", () => {
|
|
61
|
+
const skills = [makeSkill({
|
|
62
|
+
contextSignals: { files: [], project: [], tasks: [], toolPatterns: ["NPX PLAYWRIGHT"] },
|
|
63
|
+
})];
|
|
64
|
+
const matches = matchToolPatterns("bash", { command: "npx playwright test" }, skills);
|
|
65
|
+
assert.equal(matches.length, 1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("enforces word boundary — does not match 'npm' against 'npmx'", () => {
|
|
69
|
+
const skills = [makeSkill({
|
|
70
|
+
contextSignals: { files: [], project: [], tasks: [], toolPatterns: ["npm"] },
|
|
71
|
+
})];
|
|
72
|
+
const matches = matchToolPatterns("Bash", { command: "npmx install something" }, skills);
|
|
73
|
+
assert.equal(matches.length, 0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("matches 'npm' against 'npm install'", () => {
|
|
77
|
+
const skills = [makeSkill({
|
|
78
|
+
contextSignals: { files: [], project: [], tasks: [], toolPatterns: ["npm"] },
|
|
79
|
+
})];
|
|
80
|
+
const matches = matchToolPatterns("Bash", { command: "npm install something" }, skills);
|
|
81
|
+
assert.equal(matches.length, 1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("matches exact command (pattern equals entire command)", () => {
|
|
85
|
+
const skills = [makeSkill({
|
|
86
|
+
contextSignals: { files: [], project: [], tasks: [], toolPatterns: ["npm test"] },
|
|
87
|
+
})];
|
|
88
|
+
const matches = matchToolPatterns("Bash", { command: "npm test" }, skills);
|
|
89
|
+
assert.equal(matches.length, 1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("matches non-Bash tool name against patterns", () => {
|
|
93
|
+
const skills = [makeSkill({
|
|
94
|
+
contextSignals: { files: [], project: [], tasks: [], toolPatterns: ["read"] },
|
|
95
|
+
})];
|
|
96
|
+
const matches = matchToolPatterns("Read", { file_path: "/some/file.ts" }, skills);
|
|
97
|
+
assert.equal(matches.length, 1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("matches non-Bash tool name case-insensitively", () => {
|
|
101
|
+
const skills = [makeSkill({
|
|
102
|
+
contextSignals: { files: [], project: [], tasks: [], toolPatterns: ["grep"] },
|
|
103
|
+
})];
|
|
104
|
+
const matches = matchToolPatterns("Grep", {}, skills);
|
|
105
|
+
assert.equal(matches.length, 1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("does not match non-Bash tool when name differs", () => {
|
|
109
|
+
const skills = [makeSkill({
|
|
110
|
+
contextSignals: { files: [], project: [], tasks: [], toolPatterns: ["read"] },
|
|
111
|
+
})];
|
|
112
|
+
const matches = matchToolPatterns("Write", { file_path: "/some/file.ts" }, skills);
|
|
113
|
+
assert.equal(matches.length, 0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns empty for skills with no toolPatterns", () => {
|
|
117
|
+
const skills = [
|
|
118
|
+
makeSkill({ contextSignals: { files: [], project: [], tasks: [], toolPatterns: [] } }),
|
|
119
|
+
makeSkill({ contextSignals: null }),
|
|
120
|
+
makeSkill({ contextSignals: { files: [], project: [], tasks: [] } }),
|
|
121
|
+
];
|
|
122
|
+
const matches = matchToolPatterns("Bash", { command: "gh issue list" }, skills);
|
|
123
|
+
assert.equal(matches.length, 0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns empty for empty skills list", () => {
|
|
127
|
+
assert.equal(matchToolPatterns("Bash", { command: "gh issue" }, []).length, 0);
|
|
128
|
+
assert.equal(matchToolPatterns("Bash", { command: "gh issue" }, null).length, 0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns empty for empty tool name", () => {
|
|
132
|
+
const skills = [makeSkill({
|
|
133
|
+
contextSignals: { files: [], project: [], tasks: [], toolPatterns: ["gh"] },
|
|
134
|
+
})];
|
|
135
|
+
assert.equal(matchToolPatterns("", { command: "gh issue" }, skills).length, 0);
|
|
136
|
+
assert.equal(matchToolPatterns(null, { command: "gh issue" }, skills).length, 0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("returns empty for Bash with empty command", () => {
|
|
140
|
+
const skills = [makeSkill({
|
|
141
|
+
contextSignals: { files: [], project: [], tasks: [], toolPatterns: ["gh"] },
|
|
142
|
+
})];
|
|
143
|
+
assert.equal(matchToolPatterns("Bash", { command: "" }, skills).length, 0);
|
|
144
|
+
assert.equal(matchToolPatterns("Bash", {}, skills).length, 0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("matches only one pattern per skill (breaks after first match)", () => {
|
|
148
|
+
const skills = [makeSkill({
|
|
149
|
+
contextSignals: { files: [], project: [], tasks: [], toolPatterns: ["gh issue", "gh"] },
|
|
150
|
+
})];
|
|
151
|
+
const matches = matchToolPatterns("Bash", { command: "gh issue create" }, skills);
|
|
152
|
+
assert.equal(matches.length, 1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("matches multiple skills independently", () => {
|
|
156
|
+
const skills = [
|
|
157
|
+
makeSkill({ owner: "a", name: "s1", contextSignals: { files: [], project: [], tasks: [], toolPatterns: ["gh issue"] } }),
|
|
158
|
+
makeSkill({ owner: "b", name: "s2", contextSignals: { files: [], project: [], tasks: [], toolPatterns: ["gh pr"] } }),
|
|
159
|
+
];
|
|
160
|
+
const matches = matchToolPatterns("Bash", { command: "gh issue create" }, skills);
|
|
161
|
+
assert.equal(matches.length, 1);
|
|
162
|
+
assert.equal(matches[0].skill.name, "s1");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("multi-word pattern does not match non-Bash tool", () => {
|
|
166
|
+
const skills = [makeSkill({
|
|
167
|
+
contextSignals: { files: [], project: [], tasks: [], toolPatterns: ["read file"] },
|
|
168
|
+
})];
|
|
169
|
+
const matches = matchToolPatterns("Read", {}, skills);
|
|
170
|
+
assert.equal(matches.length, 0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("word boundary allows hyphen after pattern", () => {
|
|
174
|
+
const skills = [makeSkill({
|
|
175
|
+
contextSignals: { files: [], project: [], tasks: [], toolPatterns: ["npm"] },
|
|
176
|
+
})];
|
|
177
|
+
// "npm-check" has a hyphen after "npm" — should match (hyphen is not alphanumeric)
|
|
178
|
+
const matches = matchToolPatterns("Bash", { command: "npm-check updates" }, skills);
|
|
179
|
+
assert.equal(matches.length, 1);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// readActivationState / deduplicateActivations / updateActivationState
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
describe("session deduplication", () => {
|
|
188
|
+
it("returns empty state for new session", () => {
|
|
189
|
+
const { state } = readActivationState("brand-new-activation-" + Date.now());
|
|
190
|
+
assert.deepEqual(state.reported, {});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("filters out already-reported skills", () => {
|
|
194
|
+
const matches = [
|
|
195
|
+
{ skill: makeSkill({ owner: "a", name: "reported" }), pattern: "gh" },
|
|
196
|
+
{ skill: makeSkill({ owner: "b", name: "new-match" }), pattern: "npm" },
|
|
197
|
+
];
|
|
198
|
+
const state = { reported: { "a/reported": "2026-04-07T00:00:00Z" } };
|
|
199
|
+
|
|
200
|
+
const deduped = deduplicateActivations(matches, state);
|
|
201
|
+
assert.equal(deduped.length, 1);
|
|
202
|
+
assert.equal(deduped[0].skill.name, "new-match");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("passes through all matches when nothing reported yet", () => {
|
|
206
|
+
const matches = [
|
|
207
|
+
{ skill: makeSkill({ owner: "a", name: "s1" }), pattern: "gh" },
|
|
208
|
+
{ skill: makeSkill({ owner: "b", name: "s2" }), pattern: "npm" },
|
|
209
|
+
];
|
|
210
|
+
const state = { reported: {} };
|
|
211
|
+
|
|
212
|
+
const deduped = deduplicateActivations(matches, state);
|
|
213
|
+
assert.equal(deduped.length, 2);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("updateActivationState marks skills as reported", () => {
|
|
217
|
+
let tmp;
|
|
218
|
+
try {
|
|
219
|
+
tmp = makeTmpDir();
|
|
220
|
+
const statePath = join(tmp, "state.json");
|
|
221
|
+
const state = { reported: {} };
|
|
222
|
+
const matches = [
|
|
223
|
+
{ skill: makeSkill({ owner: "a", name: "s1" }), pattern: "gh" },
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
updateActivationState(statePath, state, matches);
|
|
227
|
+
|
|
228
|
+
assert.ok(state.reported["a/s1"]);
|
|
229
|
+
const persisted = JSON.parse(readFileSync(statePath, "utf-8"));
|
|
230
|
+
assert.ok(persisted.reported["a/s1"]);
|
|
231
|
+
} finally {
|
|
232
|
+
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("persists across reads (roundtrip)", () => {
|
|
237
|
+
const sessionId = "activation-roundtrip-test-" + Date.now();
|
|
238
|
+
const { path: statePath, state } = readActivationState(sessionId);
|
|
239
|
+
|
|
240
|
+
const matches = [
|
|
241
|
+
{ skill: makeSkill({ owner: "x", name: "y" }), pattern: "gh" },
|
|
242
|
+
];
|
|
243
|
+
updateActivationState(statePath, state, matches);
|
|
244
|
+
|
|
245
|
+
// Re-read
|
|
246
|
+
const { state: reloaded } = readActivationState(sessionId);
|
|
247
|
+
assert.ok(reloaded.reported["x/y"]);
|
|
248
|
+
|
|
249
|
+
// Clean up
|
|
250
|
+
try { rmSync(statePath, { force: true }); } catch { /* ok */ }
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// buildTelemetryPayload
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
describe("buildTelemetryPayload", () => {
|
|
259
|
+
it("produces correct payload shape", () => {
|
|
260
|
+
const matches = [
|
|
261
|
+
{ skill: makeSkill({ owner: "alice", name: "my-skill", version: "2.0.0" }), pattern: "gh issue" },
|
|
262
|
+
];
|
|
263
|
+
const sessionInfo = {
|
|
264
|
+
ide: "claude-code",
|
|
265
|
+
sessionHash: "abc123",
|
|
266
|
+
githubUsername: "testuser",
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const payload = buildTelemetryPayload(matches, sessionInfo);
|
|
270
|
+
|
|
271
|
+
assert.equal(payload.events.length, 1);
|
|
272
|
+
const event = payload.events[0];
|
|
273
|
+
assert.equal(event.skillOwner, "alice");
|
|
274
|
+
assert.equal(event.skillName, "my-skill");
|
|
275
|
+
assert.equal(event.skillVersion, "2.0.0");
|
|
276
|
+
assert.equal(event.ide, "claude-code");
|
|
277
|
+
assert.equal(event.sessionHash, "abc123");
|
|
278
|
+
assert.equal(event.githubUsername, "testuser");
|
|
279
|
+
assert.equal(event.source, "pretool_hook");
|
|
280
|
+
assert.equal(event.toolPattern, "gh issue");
|
|
281
|
+
assert.ok(event.activatedAt); // ISO 8601 string
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("caps events at 50 per batch", () => {
|
|
285
|
+
const matches = Array.from({ length: 60 }, (_, i) => ({
|
|
286
|
+
skill: makeSkill({ owner: "o", name: `s-${i}` }),
|
|
287
|
+
pattern: "gh",
|
|
288
|
+
}));
|
|
289
|
+
const payload = buildTelemetryPayload(matches, { ide: "claude-code", sessionHash: "x" });
|
|
290
|
+
assert.equal(payload.events.length, 50);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("handles missing optional fields gracefully", () => {
|
|
294
|
+
const matches = [
|
|
295
|
+
{ skill: makeSkill({ version: null }), pattern: "npm" },
|
|
296
|
+
];
|
|
297
|
+
const payload = buildTelemetryPayload(matches, {
|
|
298
|
+
ide: "claude-code",
|
|
299
|
+
sessionHash: "hash",
|
|
300
|
+
githubUsername: null,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const event = payload.events[0];
|
|
304
|
+
assert.equal(event.skillVersion, undefined);
|
|
305
|
+
assert.equal(event.githubUsername, undefined);
|
|
306
|
+
assert.equal(event.source, "pretool_hook");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// sendTelemetry — error resilience
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
describe("sendTelemetry", () => {
|
|
315
|
+
it("does not throw synchronously on network failure", () => {
|
|
316
|
+
// sendTelemetry is fire-and-forget — it calls fetch().catch() internally
|
|
317
|
+
// and returns undefined. We verify it doesn't throw synchronously.
|
|
318
|
+
assert.doesNotThrow(() => {
|
|
319
|
+
sendTelemetry(
|
|
320
|
+
{ apiKey: "sk_live_test", serverUrl: "http://localhost:99999" },
|
|
321
|
+
{ events: [] }
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// main() integration via subprocess
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
import { execFile } from "node:child_process";
|
|
332
|
+
import { fileURLToPath } from "node:url";
|
|
333
|
+
import { dirname } from "node:path";
|
|
334
|
+
const __testDir = dirname(fileURLToPath(import.meta.url));
|
|
335
|
+
const HOOK_SCRIPT = join(__testDir, "..", "..", "hooks", "skillrepo-pretool-activation.mjs");
|
|
336
|
+
|
|
337
|
+
function runHook(input) {
|
|
338
|
+
return new Promise((resolve, reject) => {
|
|
339
|
+
const child = execFile(process.execPath, [HOOK_SCRIPT], { timeout: 10_000 }, (err, stdout, stderr) => {
|
|
340
|
+
if (err && err.killed) return reject(new Error("Hook timed out"));
|
|
341
|
+
resolve({ stdout, stderr, code: err ? err.code : 0 });
|
|
342
|
+
});
|
|
343
|
+
child.stdin.write(JSON.stringify(input));
|
|
344
|
+
child.stdin.end();
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
describe("main() integration via subprocess", () => {
|
|
349
|
+
it("outputs {} for tool with no matching patterns", async () => {
|
|
350
|
+
const { stdout } = await runHook({ tool_name: "Bash", tool_input: { command: "echo hello" }, session_id: "sub-test-" + Date.now() });
|
|
351
|
+
assert.equal(stdout.trim(), "{}");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("outputs {} for non-Bash tool with no matching patterns", async () => {
|
|
355
|
+
const { stdout } = await runHook({ tool_name: "Read", tool_input: { file_path: "/some/file" }, session_id: "sub-read-" + Date.now() });
|
|
356
|
+
assert.equal(stdout.trim(), "{}");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("outputs {} for empty tool_name", async () => {
|
|
360
|
+
const { stdout } = await runHook({ tool_name: "", tool_input: {}, session_id: "sub-empty-" + Date.now() });
|
|
361
|
+
assert.equal(stdout.trim(), "{}");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("outputs {} for invalid JSON input", async () => {
|
|
365
|
+
const result = await new Promise((resolve, _reject) => {
|
|
366
|
+
const child = execFile(process.execPath, [HOOK_SCRIPT], { timeout: 5_000 }, (err, stdout) => {
|
|
367
|
+
resolve({ stdout, code: err ? err.code : 0 });
|
|
368
|
+
});
|
|
369
|
+
child.stdin.write("not json");
|
|
370
|
+
child.stdin.end();
|
|
371
|
+
});
|
|
372
|
+
assert.equal(result.stdout.trim(), "{}");
|
|
373
|
+
});
|
|
374
|
+
});
|
|
@@ -61,7 +61,7 @@ describe("matchSkills", () => {
|
|
|
61
61
|
makeSkill({
|
|
62
62
|
name: "deploy-skill",
|
|
63
63
|
keywords: ["deploy"],
|
|
64
|
-
contextSignals: { files: [], project: [], tasks: ["deploy to production"] },
|
|
64
|
+
contextSignals: { files: [], project: [], tasks: ["deploy to production"], toolPatterns: [] },
|
|
65
65
|
}),
|
|
66
66
|
];
|
|
67
67
|
const matches = matchSkills("deploy to production", skills);
|
|
@@ -91,7 +91,7 @@ describe("matchSkills", () => {
|
|
|
91
91
|
makeSkill({ owner: "a", name: "low", keywords: ["testing"] }),
|
|
92
92
|
makeSkill({
|
|
93
93
|
owner: "b", name: "high", keywords: ["testing"],
|
|
94
|
-
contextSignals: { files: [], project: [], tasks: ["write tests for testing"] },
|
|
94
|
+
contextSignals: { files: [], project: [], tasks: ["write tests for testing"], toolPatterns: [] },
|
|
95
95
|
}),
|
|
96
96
|
];
|
|
97
97
|
const matches = matchSkills("write tests for testing", skills);
|
|
@@ -145,7 +145,7 @@ describe("matchSkills", () => {
|
|
|
145
145
|
it("matches case-insensitively for task phrases in contextSignals", () => {
|
|
146
146
|
const skills = [makeSkill({
|
|
147
147
|
keywords: [],
|
|
148
|
-
contextSignals: { files: [], project: [], tasks: ["Deploy To Production"] },
|
|
148
|
+
contextSignals: { files: [], project: [], tasks: ["Deploy To Production"], toolPatterns: [] },
|
|
149
149
|
})];
|
|
150
150
|
// Prompt in different case should still match the task phrase
|
|
151
151
|
const matches = matchSkills("deploy to production", skills);
|
|
@@ -338,19 +338,19 @@ describe("scoreSkillRelevance", () => {
|
|
|
338
338
|
const profile = { frameworks: ["next.js"], languages: ["typescript"], tools: ["vitest"] };
|
|
339
339
|
|
|
340
340
|
it("scores matching project tags higher", () => {
|
|
341
|
-
const skill = makeSkill({ contextSignals: { files: [], project: ["next.js"], tasks: [] } });
|
|
341
|
+
const skill = makeSkill({ contextSignals: { files: [], project: ["next.js"], tasks: [], toolPatterns: [] } });
|
|
342
342
|
const score = scoreSkillRelevance(skill, profile);
|
|
343
343
|
assert.ok(score >= 10, `Expected >= 10, got ${score}`);
|
|
344
344
|
});
|
|
345
345
|
|
|
346
346
|
it("scores multiple matching tags even higher", () => {
|
|
347
|
-
const skill = makeSkill({ contextSignals: { files: [], project: ["next.js", "typescript"], tasks: [] } });
|
|
347
|
+
const skill = makeSkill({ contextSignals: { files: [], project: ["next.js", "typescript"], tasks: [], toolPatterns: [] } });
|
|
348
348
|
const score = scoreSkillRelevance(skill, profile);
|
|
349
349
|
assert.ok(score > 12, `Expected > 12, got ${score}`);
|
|
350
350
|
});
|
|
351
351
|
|
|
352
352
|
it("gives neutral score to skills with no project tags", () => {
|
|
353
|
-
const skill = makeSkill({ contextSignals: { files: [], project: [], tasks: [] } });
|
|
353
|
+
const skill = makeSkill({ contextSignals: { files: [], project: [], tasks: [], toolPatterns: [] } });
|
|
354
354
|
const score = scoreSkillRelevance(skill, profile);
|
|
355
355
|
assert.equal(score, 5);
|
|
356
356
|
});
|
|
@@ -361,14 +361,14 @@ describe("scoreSkillRelevance", () => {
|
|
|
361
361
|
});
|
|
362
362
|
|
|
363
363
|
it("demotes skills with non-matching project tags", () => {
|
|
364
|
-
const skill = makeSkill({ contextSignals: { files: [], project: ["django", "python"], tasks: [] } });
|
|
364
|
+
const skill = makeSkill({ contextSignals: { files: [], project: ["django", "python"], tasks: [], toolPatterns: [] } });
|
|
365
365
|
const score = scoreSkillRelevance(skill, profile);
|
|
366
366
|
assert.equal(score, 1);
|
|
367
367
|
});
|
|
368
368
|
|
|
369
369
|
it("gives neutral score when no profile detected", () => {
|
|
370
370
|
const emptyProfile = { frameworks: [], languages: [], tools: [] };
|
|
371
|
-
const skill = makeSkill({ contextSignals: { files: [], project: ["next.js"], tasks: [] } });
|
|
371
|
+
const skill = makeSkill({ contextSignals: { files: [], project: ["next.js"], tasks: [], toolPatterns: [] } });
|
|
372
372
|
assert.equal(scoreSkillRelevance(skill, emptyProfile), 5);
|
|
373
373
|
});
|
|
374
374
|
});
|
|
@@ -382,9 +382,9 @@ describe("selectRulesSkills", () => {
|
|
|
382
382
|
|
|
383
383
|
it("selects top-N skills by relevance", () => {
|
|
384
384
|
const skills = [
|
|
385
|
-
makeSkill({ owner: "a", name: "nextjs-skill", contextSignals: { files: [], project: ["next.js"], tasks: [] } }),
|
|
385
|
+
makeSkill({ owner: "a", name: "nextjs-skill", contextSignals: { files: [], project: ["next.js"], tasks: [], toolPatterns: [] } }),
|
|
386
386
|
makeSkill({ owner: "b", name: "generic-skill", contextSignals: null }),
|
|
387
|
-
makeSkill({ owner: "c", name: "python-skill", contextSignals: { files: [], project: ["python"], tasks: [] } }),
|
|
387
|
+
makeSkill({ owner: "c", name: "python-skill", contextSignals: { files: [], project: ["python"], tasks: [], toolPatterns: [] } }),
|
|
388
388
|
];
|
|
389
389
|
const selected = selectRulesSkills(skills, profile, { ...defaultConfig, maxRulesFiles: 2 });
|
|
390
390
|
assert.equal(selected.length, 2);
|
|
@@ -477,12 +477,15 @@ describe("writeRulesFiles", () => {
|
|
|
477
477
|
assert.ok(written.has("skillrepo-alice-my-skill"));
|
|
478
478
|
});
|
|
479
479
|
|
|
480
|
-
it("writes SKILL.md content to Claude rules file", () => {
|
|
480
|
+
it("writes SKILL.md content to Claude rules file with self-report suffix", () => {
|
|
481
481
|
const skills = [makeSkill({ owner: "o", name: "s", files: [{ path: "SKILL.md", content: "# My Skill\nBody", encoding: "utf-8" }] })];
|
|
482
482
|
writeRulesFiles(skills, tmp);
|
|
483
483
|
|
|
484
484
|
const content = readFileSync(join(tmp, ".claude", "rules", "skillrepo-o-s.md"), "utf-8");
|
|
485
|
-
assert.
|
|
485
|
+
assert.ok(content.startsWith("# My Skill\nBody"));
|
|
486
|
+
assert.ok(content.includes('report_skill_activation'));
|
|
487
|
+
assert.ok(content.includes('owner "o"'));
|
|
488
|
+
assert.ok(content.includes('name "s"'));
|
|
486
489
|
});
|
|
487
490
|
|
|
488
491
|
it("writes Cursor .mdc with frontmatter (alwaysApply for no file signals)", () => {
|
|
@@ -496,7 +499,7 @@ describe("writeRulesFiles", () => {
|
|
|
496
499
|
it("writes Cursor .mdc with globs for file signals", () => {
|
|
497
500
|
const skills = [makeSkill({
|
|
498
501
|
owner: "o", name: "s",
|
|
499
|
-
contextSignals: { files: ["**/*.tsx", "**/*.ts"], project: [], tasks: [] },
|
|
502
|
+
contextSignals: { files: ["**/*.tsx", "**/*.ts"], project: [], tasks: [], toolPatterns: [] },
|
|
500
503
|
})];
|
|
501
504
|
writeRulesFiles(skills, tmp);
|
|
502
505
|
|
|
@@ -835,7 +838,10 @@ describe("writeRulesFromExistingIndex", () => {
|
|
|
835
838
|
// Rules file should exist
|
|
836
839
|
assert.ok(existsSync(join(projectDir, ".claude", "rules", "skillrepo-alice-cached-skill.md")));
|
|
837
840
|
const content = readFileSync(join(projectDir, ".claude", "rules", "skillrepo-alice-cached-skill.md"), "utf-8");
|
|
838
|
-
assert.
|
|
841
|
+
assert.ok(content.startsWith("# Cached Skill\nBody content"));
|
|
842
|
+
assert.ok(content.includes('report_skill_activation'));
|
|
843
|
+
assert.ok(content.includes('owner "alice"'));
|
|
844
|
+
assert.ok(content.includes('name "cached-skill"'));
|
|
839
845
|
|
|
840
846
|
// Index should be updated with isRulesDelivered: true
|
|
841
847
|
const updatedIndex = JSON.parse(readFileSync(indexPath, "utf-8"));
|
|
@@ -52,14 +52,23 @@ describe("Hooks settings.local.json merger", () => {
|
|
|
52
52
|
readBundledHook("skillrepo-prompt-match.mjs"),
|
|
53
53
|
);
|
|
54
54
|
|
|
55
|
-
// Check
|
|
55
|
+
// Check pretool activation hook written with bundled content
|
|
56
|
+
const pretoolResult = results.find((r) => r.path.includes("pretool-activation"));
|
|
57
|
+
assert.equal(pretoolResult.action, "created");
|
|
58
|
+
assert.equal(
|
|
59
|
+
readFileSync(join(tempDir, ".claude/hooks/skillrepo-pretool-activation.mjs"), "utf-8"),
|
|
60
|
+
readBundledHook("skillrepo-pretool-activation.mjs"),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Check settings.local.json created with all 3 hooks
|
|
56
64
|
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
57
65
|
assert.equal(settingsResult.action, "created");
|
|
58
66
|
|
|
59
67
|
const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
|
|
60
68
|
assert.equal(config.hooks.SessionStart.length, 1);
|
|
61
69
|
assert.equal(config.hooks.UserPromptSubmit.length, 1);
|
|
62
|
-
assert.equal(config.hooks.PreToolUse,
|
|
70
|
+
assert.equal(config.hooks.PreToolUse.length, 1);
|
|
71
|
+
assert.ok(config.hooks.PreToolUse[0].hooks[0].command.includes("skillrepo-pretool-activation.mjs"), "PreToolUse should reference activation hook");
|
|
63
72
|
});
|
|
64
73
|
|
|
65
74
|
it("merges into existing settings without destroying other keys", async () => {
|
|
@@ -83,8 +92,10 @@ describe("Hooks settings.local.json merger", () => {
|
|
|
83
92
|
const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
|
|
84
93
|
// Original permissions preserved
|
|
85
94
|
assert.deepEqual(config.permissions, { allow: ["Bash(*)", "Read(*)"] });
|
|
86
|
-
// Original non-skillrepo PreToolUse hooks preserved
|
|
87
|
-
assert.equal(config.hooks.PreToolUse.length,
|
|
95
|
+
// Original non-skillrepo PreToolUse hooks preserved + new activation hook added
|
|
96
|
+
assert.equal(config.hooks.PreToolUse.length, 2);
|
|
97
|
+
assert.ok(config.hooks.PreToolUse.some(g => g.hooks.some(h => h.command.includes("other-hook"))), "User hook preserved");
|
|
98
|
+
assert.ok(config.hooks.PreToolUse.some(g => g.hooks.some(h => h.command?.includes("skillrepo-pretool-activation.mjs"))), "Activation hook added");
|
|
88
99
|
// New hooks added
|
|
89
100
|
assert.equal(config.hooks.SessionStart.length, 1);
|
|
90
101
|
assert.equal(config.hooks.UserPromptSubmit.length, 1);
|
|
@@ -99,6 +110,7 @@ describe("Hooks settings.local.json merger", () => {
|
|
|
99
110
|
hooks: {
|
|
100
111
|
SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: nodeCmd(".claude/hooks/skillrepo-sync.mjs") }] }],
|
|
101
112
|
UserPromptSubmit: [{ hooks: [{ type: "command", command: nodeCmd(".claude/hooks/skillrepo-prompt-match.mjs") }] }],
|
|
113
|
+
PreToolUse: [{ hooks: [{ type: "command", command: nodeCmd(".claude/hooks/skillrepo-pretool-activation.mjs") }] }],
|
|
102
114
|
},
|
|
103
115
|
};
|
|
104
116
|
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
@@ -141,11 +153,13 @@ describe("Hooks settings.local.json merger", () => {
|
|
|
141
153
|
mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
|
|
142
154
|
writeFileSync(join(tempDir, ".claude/hooks/skillrepo-sync.mjs"), "// old sync");
|
|
143
155
|
writeFileSync(join(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "// old prompt");
|
|
156
|
+
writeFileSync(join(tempDir, ".claude/hooks/skillrepo-pretool-activation.mjs"), "// old pretool");
|
|
144
157
|
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
145
158
|
const existing = {
|
|
146
159
|
hooks: {
|
|
147
160
|
SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: nodeCmd(".claude/hooks/skillrepo-sync.mjs") }] }],
|
|
148
161
|
UserPromptSubmit: [{ hooks: [{ type: "command", command: nodeCmd(".claude/hooks/skillrepo-prompt-match.mjs") }] }],
|
|
162
|
+
PreToolUse: [{ hooks: [{ type: "command", command: nodeCmd(".claude/hooks/skillrepo-pretool-activation.mjs") }] }],
|
|
149
163
|
},
|
|
150
164
|
};
|
|
151
165
|
writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
|
|
@@ -167,6 +181,13 @@ describe("Hooks settings.local.json merger", () => {
|
|
|
167
181
|
readBundledHook("skillrepo-prompt-match.mjs"),
|
|
168
182
|
);
|
|
169
183
|
|
|
184
|
+
const pretoolResult = results.find((r) => r.path.includes("pretool-activation"));
|
|
185
|
+
assert.equal(pretoolResult.action, "updated");
|
|
186
|
+
assert.equal(
|
|
187
|
+
readFileSync(join(tempDir, ".claude/hooks/skillrepo-pretool-activation.mjs"), "utf-8"),
|
|
188
|
+
readBundledHook("skillrepo-pretool-activation.mjs"),
|
|
189
|
+
);
|
|
190
|
+
|
|
170
191
|
// Settings skipped (commands already use current node path)
|
|
171
192
|
const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
|
|
172
193
|
assert.equal(settingsResult.action, "skipped");
|
|
@@ -265,8 +286,10 @@ describe("Hooks settings.local.json merger", () => {
|
|
|
265
286
|
assert.equal(settingsResult.action, "merged");
|
|
266
287
|
|
|
267
288
|
const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
|
|
268
|
-
//
|
|
269
|
-
assert.equal(config.hooks.PreToolUse,
|
|
289
|
+
// Legacy pretool-match entry removed, but new pretool-activation added
|
|
290
|
+
assert.equal(config.hooks.PreToolUse.length, 1, "PreToolUse should have one entry (activation hook)");
|
|
291
|
+
assert.ok(config.hooks.PreToolUse[0].hooks[0].command.includes("skillrepo-pretool-activation.mjs"), "Should be activation hook, not legacy match hook");
|
|
292
|
+
assert.ok(!config.hooks.PreToolUse[0].hooks[0].command.includes("pretool-match"), "Legacy match hook should be gone");
|
|
270
293
|
// Other hooks should still be present
|
|
271
294
|
assert.equal(config.hooks.SessionStart.length, 1);
|
|
272
295
|
assert.equal(config.hooks.UserPromptSubmit.length, 1);
|
|
@@ -292,11 +315,11 @@ describe("Hooks settings.local.json merger", () => {
|
|
|
292
315
|
mergeHooksConfig();
|
|
293
316
|
|
|
294
317
|
const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
|
|
295
|
-
// PreToolUse should
|
|
318
|
+
// PreToolUse should have user hook (legacy match removed) + new activation hook
|
|
296
319
|
assert.ok(Array.isArray(config.hooks.PreToolUse), "PreToolUse should still exist");
|
|
297
|
-
assert.equal(config.hooks.PreToolUse.length,
|
|
298
|
-
assert.
|
|
299
|
-
assert.ok(config.hooks.PreToolUse
|
|
320
|
+
assert.equal(config.hooks.PreToolUse.length, 2, "User hook group + activation hook group");
|
|
321
|
+
assert.ok(config.hooks.PreToolUse.some(g => g.hooks.some(h => h.command.includes("my-custom-pretool-hook"))), "User hook preserved");
|
|
322
|
+
assert.ok(config.hooks.PreToolUse.some(g => g.hooks.some(h => h.command?.includes("skillrepo-pretool-activation.mjs"))), "Activation hook added");
|
|
300
323
|
});
|
|
301
324
|
|
|
302
325
|
it("deletes pretool hook script file during migration", async () => {
|
|
@@ -310,7 +333,7 @@ describe("Hooks settings.local.json merger", () => {
|
|
|
310
333
|
|
|
311
334
|
// Pretool script should be removed
|
|
312
335
|
assert.equal(existsSync(join(tempDir, ".claude/hooks/skillrepo-pretool-match.mjs")), false, "pretool script should be deleted");
|
|
313
|
-
const removedResult = results.find((r) => r.path.includes("pretool"));
|
|
336
|
+
const removedResult = results.find((r) => r.path.includes("pretool-match"));
|
|
314
337
|
assert.equal(removedResult.action, "removed");
|
|
315
338
|
});
|
|
316
339
|
|
|
@@ -347,8 +370,9 @@ describe("Hooks settings.local.json merger", () => {
|
|
|
347
370
|
assert.equal(config.hooks.UserPromptSubmit.length, 1);
|
|
348
371
|
assert.ok(config.hooks.UserPromptSubmit[0].hooks[0].command.includes(process.execPath), "Command should contain full node path");
|
|
349
372
|
assert.ok(config.hooks.UserPromptSubmit[0].hooks[0].command.endsWith(".claude/hooks/skillrepo-prompt-match.mjs"), "Command should end with script path");
|
|
350
|
-
// PreToolUse should
|
|
351
|
-
assert.equal(config.hooks.PreToolUse,
|
|
373
|
+
// PreToolUse should have the new activation hook (legacy match was not present)
|
|
374
|
+
assert.equal(config.hooks.PreToolUse.length, 1, "PreToolUse should have activation hook");
|
|
375
|
+
assert.ok(config.hooks.PreToolUse[0].hooks[0].command.includes("skillrepo-pretool-activation.mjs"), "Should be activation hook");
|
|
352
376
|
});
|
|
353
377
|
|
|
354
378
|
it("migrates bare shebang-style commands (v1.6.1 format)", async () => {
|