skillrepo 1.8.0 → 1.10.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.8.0",
3
+ "version": "1.10.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);
@@ -15,7 +15,6 @@ import { join } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { homedir, tmpdir } from "node:os";
17
17
  import { createHash } from "node:crypto";
18
- import { execSync } from "node:child_process";
19
18
 
20
19
  // ---------------------------------------------------------------------------
21
20
  // Constants
@@ -30,20 +29,24 @@ const MAX_EVENTS_PER_BATCH = 50;
30
29
 
31
30
  /**
32
31
  * Read config from ~/.claude/skillrepo/config.json with fallbacks.
33
- * Returns { apiKey, serverUrl } or null.
32
+ * Returns { apiKey, serverUrl, userId } or null.
34
33
  */
35
34
  export function readConfig() {
36
35
  const configPath = join(homedir(), ".claude", "skillrepo", "config.json");
37
36
  try {
38
37
  const cfg = JSON.parse(readFileSync(configPath, "utf-8"));
39
38
  if (cfg.apiKey) {
40
- return { apiKey: cfg.apiKey, serverUrl: cfg.serverUrl || DEFAULT_SERVER_URL };
39
+ return {
40
+ apiKey: cfg.apiKey,
41
+ serverUrl: cfg.serverUrl || DEFAULT_SERVER_URL,
42
+ userId: cfg.userId || null,
43
+ };
41
44
  }
42
45
  } catch { /* not found */ }
43
46
 
44
47
  const envKey = process.env.SKILLREPO_ACCESS_KEY;
45
48
  if (envKey) {
46
- return { apiKey: envKey, serverUrl: process.env.SKILLREPO_SERVER_URL || DEFAULT_SERVER_URL };
49
+ return { apiKey: envKey, serverUrl: process.env.SKILLREPO_SERVER_URL || DEFAULT_SERVER_URL, userId: null };
47
50
  }
48
51
 
49
52
  // .env.local fallback
@@ -60,7 +63,7 @@ export function readConfig() {
60
63
  val = val.slice(1, -1);
61
64
  }
62
65
  val = val.replace(/\s+#.*$/, "");
63
- if (val) return { apiKey: val, serverUrl: DEFAULT_SERVER_URL };
66
+ if (val) return { apiKey: val, serverUrl: DEFAULT_SERVER_URL, userId: null };
64
67
  }
65
68
  }
66
69
  } catch { /* file doesn't exist */ }
@@ -170,45 +173,6 @@ export function updateActivationState(statePath, state, reportedMatches) {
170
173
  catch { /* non-critical */ }
171
174
  }
172
175
 
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
176
  // ---------------------------------------------------------------------------
213
177
  // Telemetry payload
214
178
  // ---------------------------------------------------------------------------
@@ -224,7 +188,7 @@ export function buildTelemetryPayload(matches, sessionInfo) {
224
188
  activatedAt: new Date().toISOString(),
225
189
  ide: sessionInfo.ide || "claude-code",
226
190
  sessionHash: sessionInfo.sessionHash,
227
- githubUsername: sessionInfo.githubUsername || undefined,
191
+ userId: sessionInfo.userId || undefined,
228
192
  source: "pretool_hook",
229
193
  toolPattern: m.pattern,
230
194
  }));
@@ -306,13 +270,10 @@ export async function main(input) {
306
270
  // -- Build and send telemetry --
307
271
  const sessionHash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
308
272
 
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
273
  const payload = buildTelemetryPayload(newMatches, {
313
274
  ide: "claude-code",
314
275
  sessionHash,
315
- githubUsername: "",
276
+ userId: config.userId,
316
277
  });
317
278
 
318
279
  sendTelemetry(config, payload); // fire-and-forget -- do NOT await
@@ -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
 
@@ -258,6 +222,83 @@ export function sendTelemetry(config, payload) {
258
222
  }).catch(() => { /* telemetry errors are non-critical */ });
259
223
  }
260
224
 
225
+ // ---------------------------------------------------------------------------
226
+ // Activation telemetry for rules-delivered matched skills
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /**
230
+ * Read the shared activation dedup state (same file as pretool-activation hook).
231
+ * This prevents double-counting: if rules_match fires first, pretool_hook
232
+ * won't re-report the same skill in the same session, and vice versa.
233
+ */
234
+ export function readActivationState(sessionId) {
235
+ const hash = createHash("sha256").update(sessionId || "default").digest("hex").slice(0, 16);
236
+ const statePath = join(tmpdir(), `skillrepo-activation-${hash}.json`);
237
+
238
+ try {
239
+ return { path: statePath, state: JSON.parse(readFileSync(statePath, "utf-8")) };
240
+ } catch {
241
+ return { path: statePath, state: { reported: {} } };
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Filter matches to exclude skills already reported as activated in this session.
247
+ */
248
+ export function deduplicateActivations(matches, sessionState) {
249
+ return matches.filter(m => {
250
+ const key = `${m.skill.owner}/${m.skill.name}`;
251
+ return !sessionState.reported[key];
252
+ });
253
+ }
254
+
255
+ /**
256
+ * Mark skills as reported in activation state.
257
+ */
258
+ export function updateActivationState(statePath, state, reportedMatches) {
259
+ for (const m of reportedMatches) {
260
+ const key = `${m.skill.owner}/${m.skill.name}`;
261
+ state.reported[key] = new Date().toISOString();
262
+ }
263
+ try { writeFileSync(statePath, JSON.stringify(state), "utf-8"); }
264
+ catch { /* non-critical */ }
265
+ }
266
+
267
+ /**
268
+ * Build activation telemetry payload for rules-delivered matched skills.
269
+ */
270
+ export function buildActivationPayload(matches, sessionInfo) {
271
+ const events = matches.slice(0, MAX_EVENTS_PER_BATCH).map(m => ({
272
+ skillOwner: m.skill.owner,
273
+ skillName: m.skill.name,
274
+ skillVersion: m.skill.version ?? "",
275
+ activatedAt: new Date().toISOString(),
276
+ ide: sessionInfo.ide || "claude-code",
277
+ sessionHash: sessionInfo.sessionHash,
278
+ userId: sessionInfo.userId || undefined,
279
+ source: "rules_match",
280
+ toolPattern: null,
281
+ }));
282
+
283
+ return { events };
284
+ }
285
+
286
+ /**
287
+ * Fire-and-forget POST to activation telemetry endpoint.
288
+ */
289
+ export function sendActivationTelemetry(config, payload) {
290
+ const url = `${config.serverUrl}/api/v1/telemetry/activation`;
291
+
292
+ fetch(url, {
293
+ method: "POST",
294
+ headers: {
295
+ Authorization: `Bearer ${config.apiKey}`,
296
+ "Content-Type": "application/json",
297
+ },
298
+ body: JSON.stringify(payload),
299
+ }).catch(() => { /* telemetry errors are non-critical */ });
300
+ }
301
+
261
302
  // ---------------------------------------------------------------------------
262
303
  // Main entry point
263
304
  // ---------------------------------------------------------------------------
@@ -308,16 +349,36 @@ export async function main(input) {
308
349
 
309
350
  // ── Build and send telemetry ────────────────────────────────
310
351
  const sessionHash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
311
- const githubUsername = resolveGithubUsername();
312
352
 
313
353
  const payload = buildTelemetryPayload(newMatches, {
314
354
  ide: "claude-code",
315
355
  sessionHash,
316
- githubUsername,
356
+ userId: config.userId,
317
357
  });
318
358
 
319
359
  sendTelemetry(config, payload); // fire-and-forget — do NOT await
320
360
 
361
+ // ── Send activation telemetry for rules-delivered matches ───
362
+ // For skills delivered via .claude/rules/, the match IS the activation
363
+ // signal — the skill was in context and the prompt was relevant.
364
+ // Uses the shared activation dedup state so pretool_hook won't
365
+ // double-report the same skill in this session.
366
+ const rulesMatches = newMatches.filter(m => m.skill.isRulesDelivered);
367
+ if (rulesMatches.length > 0) {
368
+ const { path: actStatePath, state: actState } = readActivationState(sessionId);
369
+ const newActivations = deduplicateActivations(rulesMatches, actState);
370
+
371
+ if (newActivations.length > 0) {
372
+ const actPayload = buildActivationPayload(newActivations, {
373
+ ide: "claude-code",
374
+ sessionHash,
375
+ userId: config.userId,
376
+ });
377
+ sendActivationTelemetry(config, actPayload); // fire-and-forget
378
+ updateActivationState(actStatePath, actState, newActivations);
379
+ }
380
+ }
381
+
321
382
  // ── Update session state ────────────────────────────────────
322
383
  updateSessionState(statePath, state, newMatches);
323
384
 
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
  /**
@@ -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";
@@ -256,14 +256,14 @@ describe("session deduplication", () => {
256
256
  // ---------------------------------------------------------------------------
257
257
 
258
258
  describe("buildTelemetryPayload", () => {
259
- it("produces correct payload shape", () => {
259
+ it("produces correct payload shape with userId", () => {
260
260
  const matches = [
261
261
  { skill: makeSkill({ owner: "alice", name: "my-skill", version: "2.0.0" }), pattern: "gh issue" },
262
262
  ];
263
263
  const sessionInfo = {
264
264
  ide: "claude-code",
265
265
  sessionHash: "abc123",
266
- githubUsername: "testuser",
266
+ userId: "user-42",
267
267
  };
268
268
 
269
269
  const payload = buildTelemetryPayload(matches, sessionInfo);
@@ -275,10 +275,12 @@ describe("buildTelemetryPayload", () => {
275
275
  assert.equal(event.skillVersion, "2.0.0");
276
276
  assert.equal(event.ide, "claude-code");
277
277
  assert.equal(event.sessionHash, "abc123");
278
- assert.equal(event.githubUsername, "testuser");
278
+ assert.equal(event.userId, "user-42");
279
279
  assert.equal(event.source, "pretool_hook");
280
280
  assert.equal(event.toolPattern, "gh issue");
281
281
  assert.ok(event.activatedAt); // ISO 8601 string
282
+ // githubUsername should NOT be in the payload
283
+ assert.equal(event.githubUsername, undefined);
282
284
  });
283
285
 
284
286
  it("caps events at 50 per batch", () => {
@@ -297,12 +299,12 @@ describe("buildTelemetryPayload", () => {
297
299
  const payload = buildTelemetryPayload(matches, {
298
300
  ide: "claude-code",
299
301
  sessionHash: "hash",
300
- githubUsername: null,
302
+ userId: null,
301
303
  });
302
304
 
303
305
  const event = payload.events[0];
304
306
  assert.equal(event.skillVersion, undefined);
305
- assert.equal(event.githubUsername, undefined);
307
+ assert.equal(event.userId, undefined); // null → undefined via || operator
306
308
  assert.equal(event.source, "pretool_hook");
307
309
  });
308
310
  });
@@ -7,11 +7,11 @@
7
7
  import { describe, it } from "node:test";
8
8
  import assert from "node:assert/strict";
9
9
  import {
10
- mkdtempSync, rmSync, readFileSync,
10
+ mkdtempSync, rmSync, readFileSync, writeFileSync,
11
11
  } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { tmpdir } from "node:os";
14
-
14
+ import { createHash } from "node:crypto";
15
15
  import {
16
16
  matchSkills,
17
17
  readSessionState,
@@ -19,6 +19,11 @@ import {
19
19
  updateSessionState,
20
20
  buildTelemetryPayload,
21
21
  sendTelemetry,
22
+ readActivationState,
23
+ deduplicateActivations,
24
+ updateActivationState,
25
+ buildActivationPayload,
26
+ sendActivationTelemetry,
22
27
  main,
23
28
  } from "../../hooks/skillrepo-prompt-match.mjs";
24
29
 
@@ -253,14 +258,14 @@ describe("session deduplication", () => {
253
258
  // ---------------------------------------------------------------------------
254
259
 
255
260
  describe("buildTelemetryPayload", () => {
256
- it("produces correct payload shape", () => {
261
+ it("produces correct payload shape with userId", () => {
257
262
  const matches = [
258
263
  { skill: makeSkill({ owner: "alice", name: "my-skill", version: "2.0.0", isRulesDelivered: true }), score: 5 },
259
264
  ];
260
265
  const sessionInfo = {
261
266
  ide: "claude-code",
262
267
  sessionHash: "abc123",
263
- githubUsername: "testuser",
268
+ userId: "user-42",
264
269
  };
265
270
 
266
271
  const payload = buildTelemetryPayload(matches, sessionInfo);
@@ -272,9 +277,11 @@ describe("buildTelemetryPayload", () => {
272
277
  assert.equal(event.skillVersion, "2.0.0");
273
278
  assert.equal(event.ide, "claude-code");
274
279
  assert.equal(event.sessionHash, "abc123");
275
- assert.equal(event.githubUsername, "testuser");
280
+ assert.equal(event.userId, "user-42");
276
281
  assert.equal(event.wasRulesDelivered, true);
277
282
  assert.ok(event.matchedAt); // ISO 8601 string
283
+ // githubUsername should NOT be in the payload
284
+ assert.equal(event.githubUsername, undefined);
278
285
  });
279
286
 
280
287
  it("caps events at 50 per batch", () => {
@@ -293,12 +300,12 @@ describe("buildTelemetryPayload", () => {
293
300
  const payload = buildTelemetryPayload(matches, {
294
301
  ide: "claude-code",
295
302
  sessionHash: "hash",
296
- githubUsername: null,
303
+ userId: null,
297
304
  });
298
305
 
299
306
  const event = payload.events[0];
300
307
  assert.equal(event.skillVersion, "");
301
- assert.equal(event.githubUsername, "");
308
+ assert.equal(event.userId, undefined); // null → undefined via || operator
302
309
  assert.equal(event.wasRulesDelivered, false);
303
310
  });
304
311
 
@@ -334,6 +341,145 @@ describe("sendTelemetry", () => {
334
341
  });
335
342
  });
336
343
 
344
+ // ---------------------------------------------------------------------------
345
+ // buildActivationPayload — rules-match activation telemetry
346
+ // ---------------------------------------------------------------------------
347
+
348
+ describe("buildActivationPayload", () => {
349
+ it("produces correct payload shape with source rules_match", () => {
350
+ const matches = [
351
+ { skill: makeSkill({ owner: "alice", name: "deploy-flow", version: "2.0.0", isRulesDelivered: true }), score: 5 },
352
+ ];
353
+ const sessionInfo = { ide: "claude-code", sessionHash: "abc123", userId: "user-42" };
354
+
355
+ const payload = buildActivationPayload(matches, sessionInfo);
356
+
357
+ assert.equal(payload.events.length, 1);
358
+ const event = payload.events[0];
359
+ assert.equal(event.skillOwner, "alice");
360
+ assert.equal(event.skillName, "deploy-flow");
361
+ assert.equal(event.skillVersion, "2.0.0");
362
+ assert.equal(event.ide, "claude-code");
363
+ assert.equal(event.sessionHash, "abc123");
364
+ assert.equal(event.userId, "user-42");
365
+ assert.equal(event.source, "rules_match");
366
+ assert.equal(event.toolPattern, null);
367
+ assert.ok(event.activatedAt); // ISO 8601 string
368
+ });
369
+
370
+ it("caps events at 50 per batch", () => {
371
+ const matches = Array.from({ length: 60 }, (_, i) => ({
372
+ skill: makeSkill({ owner: "o", name: `s-${i}` }),
373
+ score: 1,
374
+ }));
375
+ const payload = buildActivationPayload(matches, { ide: "claude-code", sessionHash: "x" });
376
+ assert.equal(payload.events.length, 50);
377
+ });
378
+
379
+ it("handles missing optional fields gracefully", () => {
380
+ const matches = [
381
+ { skill: makeSkill({ version: null, isRulesDelivered: undefined }), score: 1 },
382
+ ];
383
+ const payload = buildActivationPayload(matches, {
384
+ ide: "claude-code",
385
+ sessionHash: "hash",
386
+ userId: null,
387
+ });
388
+
389
+ const event = payload.events[0];
390
+ assert.equal(event.skillVersion, "");
391
+ assert.equal(event.userId, undefined); // null → undefined via || operator
392
+ assert.equal(event.source, "rules_match");
393
+ assert.equal(event.toolPattern, null);
394
+ });
395
+ });
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // sendActivationTelemetry — error resilience
399
+ // ---------------------------------------------------------------------------
400
+
401
+ describe("sendActivationTelemetry", () => {
402
+ it("does not throw on network failure", async () => {
403
+ const config = { apiKey: "sk_live_test", serverUrl: "http://127.0.0.1:1" };
404
+ const payload = { events: [{ skillOwner: "o", skillName: "s", activatedAt: new Date().toISOString(), ide: "test", sessionHash: "x", source: "rules_match", toolPattern: null }] };
405
+
406
+ // Should resolve without throwing
407
+ await sendActivationTelemetry(config, payload);
408
+ });
409
+ });
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // readActivationState / deduplicateActivations / updateActivationState
413
+ // — shared dedup state between rules_match and pretool_hook
414
+ // ---------------------------------------------------------------------------
415
+
416
+ describe("activation dedup state", () => {
417
+ it("returns empty state when file does not exist", () => {
418
+ const { state } = readActivationState("nonexistent-session-id-" + Date.now());
419
+ assert.deepEqual(state, { reported: {} });
420
+ });
421
+
422
+ it("reads activation state from shared tmpdir file", () => {
423
+ const sessionId = "dedup-read-test-" + Date.now();
424
+ const hash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
425
+ const statePath = join(tmpdir(), `skillrepo-activation-${hash}.json`);
426
+
427
+ // Pre-populate state file
428
+ writeFileSync(statePath, JSON.stringify({ reported: { "alice/deploy": "2025-01-01T00:00:00Z" } }));
429
+
430
+ try {
431
+ const { state } = readActivationState(sessionId);
432
+ assert.equal(state.reported["alice/deploy"], "2025-01-01T00:00:00Z");
433
+ } finally {
434
+ rmSync(statePath, { force: true });
435
+ }
436
+ });
437
+
438
+ it("filters out already-reported skills", () => {
439
+ const matches = [
440
+ { skill: makeSkill({ owner: "alice", name: "deploy" }), score: 5 },
441
+ { skill: makeSkill({ owner: "bob", name: "review" }), score: 3 },
442
+ ];
443
+ const sessionState = { reported: { "alice/deploy": "2025-01-01T00:00:00Z" } };
444
+
445
+ const filtered = deduplicateActivations(matches, sessionState);
446
+
447
+ assert.equal(filtered.length, 1);
448
+ assert.equal(filtered[0].skill.owner, "bob");
449
+ assert.equal(filtered[0].skill.name, "review");
450
+ });
451
+
452
+ it("persists reported skills to state file", () => {
453
+ const sessionId = "dedup-write-test-" + Date.now();
454
+ const hash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
455
+ const statePath = join(tmpdir(), `skillrepo-activation-${hash}.json`);
456
+ const state = { reported: {} };
457
+
458
+ const matches = [
459
+ { skill: makeSkill({ owner: "alice", name: "deploy" }), score: 5 },
460
+ ];
461
+
462
+ try {
463
+ updateActivationState(statePath, state, matches);
464
+
465
+ const persisted = JSON.parse(readFileSync(statePath, "utf-8"));
466
+ assert.ok(persisted.reported["alice/deploy"]);
467
+ } finally {
468
+ rmSync(statePath, { force: true });
469
+ }
470
+ });
471
+
472
+ it("uses same state file path format as pretool-activation hook", () => {
473
+ const sessionId = "cross-hook-dedup-test";
474
+ const hash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
475
+ const expectedPath = join(tmpdir(), `skillrepo-activation-${hash}.json`);
476
+
477
+ const { path } = readActivationState(sessionId);
478
+
479
+ assert.equal(path, expectedPath);
480
+ });
481
+ });
482
+
337
483
  // ---------------------------------------------------------------------------
338
484
  // main() integration via subprocess — avoids test runner interference
339
485
  // ---------------------------------------------------------------------------