skillrepo 1.7.1 → 1.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "1.7.1",
3
+ "version": "1.9.0",
4
4
  "description": "Set up SkillRepo in any IDE — one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -155,6 +155,7 @@ export async function runInit(argv) {
155
155
  mcpUrl,
156
156
  apiKey,
157
157
  serverUrl: flags.url,
158
+ userId: payload.userId,
158
159
  });
159
160
  } catch (err) {
160
161
  printError(err.message);
@@ -0,0 +1,304 @@
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
+
19
+ // ---------------------------------------------------------------------------
20
+ // Constants
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const DEFAULT_SERVER_URL = "https://skillrepo.dev";
24
+ const MAX_EVENTS_PER_BATCH = 50;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Config resolution (lightweight — only needs API key and server URL)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Read config from ~/.claude/skillrepo/config.json with fallbacks.
32
+ * Returns { apiKey, serverUrl, userId } or null.
33
+ */
34
+ export function readConfig() {
35
+ const configPath = join(homedir(), ".claude", "skillrepo", "config.json");
36
+ try {
37
+ const cfg = JSON.parse(readFileSync(configPath, "utf-8"));
38
+ if (cfg.apiKey) {
39
+ return {
40
+ apiKey: cfg.apiKey,
41
+ serverUrl: cfg.serverUrl || DEFAULT_SERVER_URL,
42
+ userId: cfg.userId || null,
43
+ };
44
+ }
45
+ } catch { /* not found */ }
46
+
47
+ const envKey = process.env.SKILLREPO_ACCESS_KEY;
48
+ if (envKey) {
49
+ return { apiKey: envKey, serverUrl: process.env.SKILLREPO_SERVER_URL || DEFAULT_SERVER_URL, userId: null };
50
+ }
51
+
52
+ // .env.local fallback
53
+ for (const file of [".env.local", ".env"]) {
54
+ try {
55
+ const lines = readFileSync(join(process.cwd(), file), "utf-8").split("\n");
56
+ for (const line of lines) {
57
+ const trimmed = line.trim();
58
+ if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
59
+ const eqIdx = trimmed.indexOf("=");
60
+ if (trimmed.slice(0, eqIdx).trim() === "SKILLREPO_ACCESS_KEY") {
61
+ let val = trimmed.slice(eqIdx + 1).trim();
62
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
63
+ val = val.slice(1, -1);
64
+ }
65
+ val = val.replace(/\s+#.*$/, "");
66
+ if (val) return { apiKey: val, serverUrl: DEFAULT_SERVER_URL, userId: null };
67
+ }
68
+ }
69
+ } catch { /* file doesn't exist */ }
70
+ }
71
+
72
+ return null;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Tool pattern matching
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Match a tool invocation against all skills' contextSignals.toolPatterns.
81
+ *
82
+ * For Bash tools: prefix-match tool_input.command against each pattern.
83
+ * For non-Bash tools: match tool_name against patterns.
84
+ *
85
+ * Pattern matching rules:
86
+ * - Case-insensitive
87
+ * - Prefix match: pattern "gh issue" matches command "gh issue create --title ..."
88
+ * - Pattern must match at a word boundary (don't match "npm" against "npmx")
89
+ *
90
+ * Returns array of { skill, pattern } matches.
91
+ */
92
+ export function matchToolPatterns(toolName, toolInput, skills) {
93
+ if (!toolName || !skills?.length) return [];
94
+
95
+ const isBash = toolName.toLowerCase() === "bash";
96
+ const matches = [];
97
+
98
+ for (const skill of skills) {
99
+ const patterns = skill.contextSignals?.toolPatterns;
100
+ if (!patterns?.length) continue;
101
+
102
+ for (const pattern of patterns) {
103
+ if (!pattern) continue;
104
+ const patternLower = pattern.toLowerCase();
105
+
106
+ if (isBash) {
107
+ // Bash tool: prefix-match against command
108
+ const command = (toolInput?.command ?? "").trim();
109
+ if (!command) continue;
110
+ const commandLower = command.toLowerCase();
111
+
112
+ if (commandLower.startsWith(patternLower)) {
113
+ // Word boundary check: the character after the pattern must be
114
+ // end-of-string, whitespace, or a non-alphanumeric character
115
+ const afterIdx = patternLower.length;
116
+ if (afterIdx >= commandLower.length || /[^a-z0-9_]/i.test(commandLower[afterIdx])) {
117
+ matches.push({ skill, pattern });
118
+ break; // one match per skill is enough
119
+ }
120
+ }
121
+ } else {
122
+ // Non-Bash tool: match tool_name against patterns
123
+ const toolNameLower = toolName.toLowerCase();
124
+ if (toolNameLower === patternLower) {
125
+ matches.push({ skill, pattern });
126
+ break; // one match per skill is enough
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ return matches;
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Session dedup
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * Read activation state for deduplication.
141
+ * State is stored in tmpdir keyed by session hash.
142
+ */
143
+ export function readActivationState(sessionId) {
144
+ const hash = createHash("sha256").update(sessionId || "default").digest("hex").slice(0, 16);
145
+ const statePath = join(tmpdir(), `skillrepo-activation-${hash}.json`);
146
+
147
+ try {
148
+ return { path: statePath, state: JSON.parse(readFileSync(statePath, "utf-8")) };
149
+ } catch {
150
+ return { path: statePath, state: { reported: {} } };
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Filter matches to exclude skills already reported in this session.
156
+ */
157
+ export function deduplicateActivations(matches, sessionState) {
158
+ return matches.filter(m => {
159
+ const key = `${m.skill.owner}/${m.skill.name}`;
160
+ return !sessionState.reported[key];
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Mark skills as reported in session state.
166
+ */
167
+ export function updateActivationState(statePath, state, reportedMatches) {
168
+ for (const m of reportedMatches) {
169
+ const key = `${m.skill.owner}/${m.skill.name}`;
170
+ state.reported[key] = new Date().toISOString();
171
+ }
172
+ try { writeFileSync(statePath, JSON.stringify(state), "utf-8"); }
173
+ catch { /* non-critical */ }
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Telemetry payload
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * Build telemetry payload for tool-pattern-activated skills.
182
+ */
183
+ export function buildTelemetryPayload(matches, sessionInfo) {
184
+ const events = matches.slice(0, MAX_EVENTS_PER_BATCH).map(m => ({
185
+ skillOwner: m.skill.owner,
186
+ skillName: m.skill.name,
187
+ skillVersion: m.skill.version || undefined,
188
+ activatedAt: new Date().toISOString(),
189
+ ide: sessionInfo.ide || "claude-code",
190
+ sessionHash: sessionInfo.sessionHash,
191
+ userId: sessionInfo.userId || undefined,
192
+ source: "pretool_hook",
193
+ toolPattern: m.pattern,
194
+ }));
195
+
196
+ return { events };
197
+ }
198
+
199
+ /**
200
+ * Fire-and-forget POST to telemetry endpoint.
201
+ *
202
+ * NOT awaited — the spec requires "do NOT block the agent" and Claude Code
203
+ * waits for hook process exit. The OS TCP stack flushes small payloads after
204
+ * process exit, so delivery reliability is ~99%+ for responsive servers.
205
+ * Occasional loss on slow/unreachable servers is acceptable for non-critical
206
+ * telemetry.
207
+ */
208
+ export function sendTelemetry(config, payload) {
209
+ const url = `${config.serverUrl}/api/v1/telemetry/activation`;
210
+
211
+ fetch(url, {
212
+ method: "POST",
213
+ headers: {
214
+ Authorization: `Bearer ${config.apiKey}`,
215
+ "Content-Type": "application/json",
216
+ },
217
+ body: JSON.stringify(payload),
218
+ }).catch(() => { /* telemetry errors are non-critical */ });
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Main entry point
223
+ // ---------------------------------------------------------------------------
224
+
225
+ export async function main(input) {
226
+ const indexPath = join(homedir(), ".claude", "skillrepo", "index.json");
227
+
228
+ // -- Read index --
229
+ let index;
230
+ try {
231
+ index = JSON.parse(readFileSync(indexPath, "utf-8"));
232
+ } catch {
233
+ // No index -> nothing to match. Exit cleanly.
234
+ process.stdout.write("{}");
235
+ return;
236
+ }
237
+
238
+ if (!index.skills?.length) {
239
+ process.stdout.write("{}");
240
+ return;
241
+ }
242
+
243
+ // -- Read config (needed for telemetry POST) --
244
+ const config = readConfig();
245
+ if (!config) {
246
+ process.stdout.write("{}");
247
+ return;
248
+ }
249
+
250
+ // -- Match tool against skills --
251
+ const toolName = input.tool_name ?? "";
252
+ const toolInput = input.tool_input ?? {};
253
+ const matches = matchToolPatterns(toolName, toolInput, index.skills);
254
+
255
+ if (matches.length === 0) {
256
+ process.stdout.write("{}");
257
+ return;
258
+ }
259
+
260
+ // -- Session dedup --
261
+ const sessionId = input.session_id ?? "default";
262
+ const { path: statePath, state } = readActivationState(sessionId);
263
+ const newMatches = deduplicateActivations(matches, state);
264
+
265
+ if (newMatches.length === 0) {
266
+ process.stdout.write("{}");
267
+ return;
268
+ }
269
+
270
+ // -- Build and send telemetry --
271
+ const sessionHash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
272
+
273
+ const payload = buildTelemetryPayload(newMatches, {
274
+ ide: "claude-code",
275
+ sessionHash,
276
+ userId: config.userId,
277
+ });
278
+
279
+ sendTelemetry(config, payload); // fire-and-forget -- do NOT await
280
+
281
+ // -- Update session state --
282
+ updateActivationState(statePath, state, newMatches);
283
+
284
+ // -- Output: NO additionalContext --
285
+ process.stdout.write("{}");
286
+ }
287
+
288
+ // -- Run --
289
+ const __filename = fileURLToPath(import.meta.url);
290
+ const isMainModule = process.argv[1] === __filename;
291
+
292
+ if (isMainModule) {
293
+ let inputBuf = "";
294
+ for await (const chunk of process.stdin) inputBuf += chunk;
295
+
296
+ let input;
297
+ try { input = JSON.parse(inputBuf); }
298
+ catch { process.stdout.write("{}"); process.exit(0); }
299
+
300
+ main(input).catch(() => {
301
+ process.stdout.write("{}");
302
+ process.exit(0);
303
+ });
304
+ }
@@ -14,7 +14,6 @@ import { join } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { homedir, tmpdir } from "node:os";
16
16
  import { createHash } from "node:crypto";
17
- import { execSync } from "node:child_process";
18
17
 
19
18
  // ---------------------------------------------------------------------------
20
19
  // Constants
@@ -29,20 +28,24 @@ const MAX_EVENTS_PER_BATCH = 50;
29
28
 
30
29
  /**
31
30
  * Read config from ~/.claude/skillrepo/config.json with fallbacks.
32
- * Returns { apiKey, serverUrl } or null.
31
+ * Returns { apiKey, serverUrl, userId } or null.
33
32
  */
34
33
  export function readConfig() {
35
34
  const configPath = join(homedir(), ".claude", "skillrepo", "config.json");
36
35
  try {
37
36
  const cfg = JSON.parse(readFileSync(configPath, "utf-8"));
38
37
  if (cfg.apiKey) {
39
- return { apiKey: cfg.apiKey, serverUrl: cfg.serverUrl || DEFAULT_SERVER_URL };
38
+ return {
39
+ apiKey: cfg.apiKey,
40
+ serverUrl: cfg.serverUrl || DEFAULT_SERVER_URL,
41
+ userId: cfg.userId || null,
42
+ };
40
43
  }
41
44
  } catch { /* not found */ }
42
45
 
43
46
  const envKey = process.env.SKILLREPO_ACCESS_KEY;
44
47
  if (envKey) {
45
- return { apiKey: envKey, serverUrl: process.env.SKILLREPO_SERVER_URL || DEFAULT_SERVER_URL };
48
+ return { apiKey: envKey, serverUrl: process.env.SKILLREPO_SERVER_URL || DEFAULT_SERVER_URL, userId: null };
46
49
  }
47
50
 
48
51
  // .env.local fallback
@@ -59,7 +62,7 @@ export function readConfig() {
59
62
  val = val.slice(1, -1);
60
63
  }
61
64
  val = val.replace(/\s+#.*$/, "");
62
- if (val) return { apiKey: val, serverUrl: DEFAULT_SERVER_URL };
65
+ if (val) return { apiKey: val, serverUrl: DEFAULT_SERVER_URL, userId: null };
63
66
  }
64
67
  }
65
68
  } catch { /* file doesn't exist */ }
@@ -175,45 +178,6 @@ export function updateSessionState(statePath, state, reportedMatches) {
175
178
  catch { /* non-critical */ }
176
179
  }
177
180
 
178
- // ---------------------------------------------------------------------------
179
- // GitHub username resolution
180
- // ---------------------------------------------------------------------------
181
-
182
- const GITHUB_USERNAME_CACHE_PATH = join(tmpdir(), "skillrepo-gh-user.json");
183
-
184
- /**
185
- * Resolve GitHub username via `gh api user`. Cached per session.
186
- */
187
- export function resolveGithubUsername() {
188
- // Check cache first
189
- try {
190
- const cached = JSON.parse(readFileSync(GITHUB_USERNAME_CACHE_PATH, "utf-8"));
191
- if (cached.username && cached.expiresAt > Date.now()) return cached.username;
192
- } catch { /* no cache */ }
193
-
194
- // Resolve via gh CLI
195
- try {
196
- const result = execSync("gh api user --jq .login", {
197
- encoding: "utf-8",
198
- timeout: 3_000,
199
- stdio: ["pipe", "pipe", "pipe"],
200
- }).trim();
201
-
202
- if (result) {
203
- // Cache for 1 hour
204
- try {
205
- writeFileSync(GITHUB_USERNAME_CACHE_PATH, JSON.stringify({
206
- username: result,
207
- expiresAt: Date.now() + 3_600_000,
208
- }), "utf-8");
209
- } catch { /* non-critical */ }
210
- return result;
211
- }
212
- } catch { /* gh not installed or not authed */ }
213
-
214
- return null;
215
- }
216
-
217
181
  // ---------------------------------------------------------------------------
218
182
  // Telemetry payload
219
183
  // ---------------------------------------------------------------------------
@@ -229,7 +193,7 @@ export function buildTelemetryPayload(matches, sessionInfo) {
229
193
  matchedAt: new Date().toISOString(),
230
194
  ide: sessionInfo.ide || "claude-code",
231
195
  sessionHash: sessionInfo.sessionHash,
232
- githubUsername: sessionInfo.githubUsername || "",
196
+ userId: sessionInfo.userId || undefined,
233
197
  wasRulesDelivered: m.skill.isRulesDelivered ?? false,
234
198
  }));
235
199
 
@@ -308,12 +272,11 @@ export async function main(input) {
308
272
 
309
273
  // ── Build and send telemetry ────────────────────────────────
310
274
  const sessionHash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
311
- const githubUsername = resolveGithubUsername();
312
275
 
313
276
  const payload = buildTelemetryPayload(newMatches, {
314
277
  ide: "claude-code",
315
278
  sessionHash,
316
- githubUsername,
279
+ userId: config.userId,
317
280
  });
318
281
 
319
282
  sendTelemetry(config, payload); // fire-and-forget — do NOT await
@@ -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
- atomicWrite(join(claudeRulesDir, `${baseName}.md`), skillMd.content);
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/http.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  * Uses Node 18+ built-in fetch. Zero dependencies.
4
4
  */
5
5
 
6
- const VERSION = "1.0.0";
6
+ const VERSION = "1.9.0";
7
7
  const DEFAULT_URL = "https://skillrepo.dev";
8
8
 
9
9
  /**
@@ -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 UserPromptSubmit entries
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
- // The two hook script paths (relative to project root).
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 and UserPromptSubmit
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");
@@ -28,13 +28,14 @@ import { mergeGitignore } from "./mergers/gitignore.mjs";
28
28
  * @param {string} options.mcpUrl - The MCP endpoint URL
29
29
  * @param {string} options.apiKey - The access key
30
30
  * @param {string} options.serverUrl - The SkillRepo server URL (e.g. https://skillrepo.dev)
31
+ * @param {string} [options.userId] - The authenticated user's SkillRepo ID
31
32
  * @returns {{ path: string; action: string }[]}
32
33
  */
33
- export function writeAllConfigs({ ides, mcpUrl, apiKey, serverUrl }) {
34
+ export function writeAllConfigs({ ides, mcpUrl, apiKey, serverUrl, userId }) {
34
35
  const results = [];
35
36
 
36
37
  // ── Global config (shared across all projects) ────────────────────────
37
- const globalConfigAction = writeGlobalConfig(apiKey, serverUrl);
38
+ const globalConfigAction = writeGlobalConfig(apiKey, serverUrl, userId);
38
39
  results.push({ path: "~/.claude/skillrepo/config.json", action: globalConfigAction });
39
40
 
40
41
  // Claude Code
@@ -93,7 +94,7 @@ export function writeAllConfigs({ ides, mcpUrl, apiKey, serverUrl }) {
93
94
  * This is the primary config source for standalone hooks.
94
95
  * @returns {"created" | "updated"} The action taken.
95
96
  */
96
- function writeGlobalConfig(apiKey, serverUrl) {
97
+ function writeGlobalConfig(apiKey, serverUrl, userId) {
97
98
  const configDir = globalSkillrepoDir();
98
99
  if (!existsSync(configDir)) {
99
100
  mkdirSync(configDir, { recursive: true });
@@ -103,13 +104,17 @@ function writeGlobalConfig(apiKey, serverUrl) {
103
104
  const existingRaw = readFileSafe(configPath);
104
105
 
105
106
  // Preserve existing config fields (e.g. maxRulesFiles, maxRulesBudgetBytes)
106
- // while always overwriting apiKey and serverUrl with the new values.
107
+ // while always overwriting apiKey, serverUrl, and userId with the new values.
107
108
  let preserved = {};
108
109
  if (existingRaw !== null) {
109
110
  try { preserved = JSON.parse(existingRaw); }
110
111
  catch { /* corrupt config — start fresh */ }
111
112
  }
112
- const config = { ...preserved, apiKey, serverUrl };
113
+ // Always overwrite userId even if the new value is undefined/null — to
114
+ // prevent a stale userId from a previous account persisting across re-inits.
115
+ const { userId: _prevUserId, ...preservedWithoutUserId } = preserved;
116
+ const config = { ...preservedWithoutUserId, apiKey, serverUrl };
117
+ if (userId) config.userId = userId;
113
118
 
114
119
  writeFileSafe(configPath, JSON.stringify(config, null, 2) + "\n");
115
120
  return existingRaw !== null ? "updated" : "created";
@@ -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
- // PreToolUse should not exist at all
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. Two hooks registered in settings.local.json (no PreToolUse)
299
+ // 4. Three hooks registered in settings.local.json
300
300
  // =========================================================================
301
301
 
302
- it("two hooks registered in settings.local.json (no PreToolUse)", async () => {
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.equal(settings.hooks.PreToolUse, undefined, "PreToolUse should not exist");
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,376 @@
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 with userId", () => {
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
+ userId: "user-42",
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.userId, "user-42");
279
+ assert.equal(event.source, "pretool_hook");
280
+ assert.equal(event.toolPattern, "gh issue");
281
+ assert.ok(event.activatedAt); // ISO 8601 string
282
+ // githubUsername should NOT be in the payload
283
+ assert.equal(event.githubUsername, undefined);
284
+ });
285
+
286
+ it("caps events at 50 per batch", () => {
287
+ const matches = Array.from({ length: 60 }, (_, i) => ({
288
+ skill: makeSkill({ owner: "o", name: `s-${i}` }),
289
+ pattern: "gh",
290
+ }));
291
+ const payload = buildTelemetryPayload(matches, { ide: "claude-code", sessionHash: "x" });
292
+ assert.equal(payload.events.length, 50);
293
+ });
294
+
295
+ it("handles missing optional fields gracefully", () => {
296
+ const matches = [
297
+ { skill: makeSkill({ version: null }), pattern: "npm" },
298
+ ];
299
+ const payload = buildTelemetryPayload(matches, {
300
+ ide: "claude-code",
301
+ sessionHash: "hash",
302
+ userId: null,
303
+ });
304
+
305
+ const event = payload.events[0];
306
+ assert.equal(event.skillVersion, undefined);
307
+ assert.equal(event.userId, undefined); // null → undefined via || operator
308
+ assert.equal(event.source, "pretool_hook");
309
+ });
310
+ });
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // sendTelemetry — error resilience
314
+ // ---------------------------------------------------------------------------
315
+
316
+ describe("sendTelemetry", () => {
317
+ it("does not throw synchronously on network failure", () => {
318
+ // sendTelemetry is fire-and-forget — it calls fetch().catch() internally
319
+ // and returns undefined. We verify it doesn't throw synchronously.
320
+ assert.doesNotThrow(() => {
321
+ sendTelemetry(
322
+ { apiKey: "sk_live_test", serverUrl: "http://localhost:99999" },
323
+ { events: [] }
324
+ );
325
+ });
326
+ });
327
+ });
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // main() integration via subprocess
331
+ // ---------------------------------------------------------------------------
332
+
333
+ import { execFile } from "node:child_process";
334
+ import { fileURLToPath } from "node:url";
335
+ import { dirname } from "node:path";
336
+ const __testDir = dirname(fileURLToPath(import.meta.url));
337
+ const HOOK_SCRIPT = join(__testDir, "..", "..", "hooks", "skillrepo-pretool-activation.mjs");
338
+
339
+ function runHook(input) {
340
+ return new Promise((resolve, reject) => {
341
+ const child = execFile(process.execPath, [HOOK_SCRIPT], { timeout: 10_000 }, (err, stdout, stderr) => {
342
+ if (err && err.killed) return reject(new Error("Hook timed out"));
343
+ resolve({ stdout, stderr, code: err ? err.code : 0 });
344
+ });
345
+ child.stdin.write(JSON.stringify(input));
346
+ child.stdin.end();
347
+ });
348
+ }
349
+
350
+ describe("main() integration via subprocess", () => {
351
+ it("outputs {} for tool with no matching patterns", async () => {
352
+ const { stdout } = await runHook({ tool_name: "Bash", tool_input: { command: "echo hello" }, session_id: "sub-test-" + Date.now() });
353
+ assert.equal(stdout.trim(), "{}");
354
+ });
355
+
356
+ it("outputs {} for non-Bash tool with no matching patterns", async () => {
357
+ const { stdout } = await runHook({ tool_name: "Read", tool_input: { file_path: "/some/file" }, session_id: "sub-read-" + Date.now() });
358
+ assert.equal(stdout.trim(), "{}");
359
+ });
360
+
361
+ it("outputs {} for empty tool_name", async () => {
362
+ const { stdout } = await runHook({ tool_name: "", tool_input: {}, session_id: "sub-empty-" + Date.now() });
363
+ assert.equal(stdout.trim(), "{}");
364
+ });
365
+
366
+ it("outputs {} for invalid JSON input", async () => {
367
+ const result = await new Promise((resolve, _reject) => {
368
+ const child = execFile(process.execPath, [HOOK_SCRIPT], { timeout: 5_000 }, (err, stdout) => {
369
+ resolve({ stdout, code: err ? err.code : 0 });
370
+ });
371
+ child.stdin.write("not json");
372
+ child.stdin.end();
373
+ });
374
+ assert.equal(result.stdout.trim(), "{}");
375
+ });
376
+ });
@@ -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);
@@ -253,14 +253,14 @@ describe("session deduplication", () => {
253
253
  // ---------------------------------------------------------------------------
254
254
 
255
255
  describe("buildTelemetryPayload", () => {
256
- it("produces correct payload shape", () => {
256
+ it("produces correct payload shape with userId", () => {
257
257
  const matches = [
258
258
  { skill: makeSkill({ owner: "alice", name: "my-skill", version: "2.0.0", isRulesDelivered: true }), score: 5 },
259
259
  ];
260
260
  const sessionInfo = {
261
261
  ide: "claude-code",
262
262
  sessionHash: "abc123",
263
- githubUsername: "testuser",
263
+ userId: "user-42",
264
264
  };
265
265
 
266
266
  const payload = buildTelemetryPayload(matches, sessionInfo);
@@ -272,9 +272,11 @@ describe("buildTelemetryPayload", () => {
272
272
  assert.equal(event.skillVersion, "2.0.0");
273
273
  assert.equal(event.ide, "claude-code");
274
274
  assert.equal(event.sessionHash, "abc123");
275
- assert.equal(event.githubUsername, "testuser");
275
+ assert.equal(event.userId, "user-42");
276
276
  assert.equal(event.wasRulesDelivered, true);
277
277
  assert.ok(event.matchedAt); // ISO 8601 string
278
+ // githubUsername should NOT be in the payload
279
+ assert.equal(event.githubUsername, undefined);
278
280
  });
279
281
 
280
282
  it("caps events at 50 per batch", () => {
@@ -293,12 +295,12 @@ describe("buildTelemetryPayload", () => {
293
295
  const payload = buildTelemetryPayload(matches, {
294
296
  ide: "claude-code",
295
297
  sessionHash: "hash",
296
- githubUsername: null,
298
+ userId: null,
297
299
  });
298
300
 
299
301
  const event = payload.events[0];
300
302
  assert.equal(event.skillVersion, "");
301
- assert.equal(event.githubUsername, "");
303
+ assert.equal(event.userId, undefined); // null → undefined via || operator
302
304
  assert.equal(event.wasRulesDelivered, false);
303
305
  });
304
306
 
@@ -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.equal(content, "# My Skill\nBody");
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.equal(content, "# Cached Skill\nBody content");
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 settings.local.json created with only 2 hooks (no PreToolUse)
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, undefined, "PreToolUse should not exist");
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, 1);
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
- // PreToolUse should be removed entirely (was only skillrepo entry)
269
- assert.equal(config.hooks.PreToolUse, undefined, "PreToolUse should be removed when only skillrepo entry existed");
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 still exist because the user hook remains
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, 1, "Group should still exist");
298
- assert.equal(config.hooks.PreToolUse[0].hooks.length, 1, "Only user hook should remain");
299
- assert.ok(config.hooks.PreToolUse[0].hooks[0].command.includes("my-custom-pretool-hook"), "User hook preserved");
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 NOT exist
351
- assert.equal(config.hooks.PreToolUse, undefined, "PreToolUse should not be present after migration");
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 () => {