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 +1 -1
- package/src/commands/init.mjs +1 -0
- package/src/hooks/skillrepo-pretool-activation.mjs +10 -49
- package/src/hooks/skillrepo-prompt-match.mjs +108 -47
- package/src/lib/http.mjs +1 -1
- package/src/lib/write-configs.mjs +10 -5
- package/src/test/hooks/skillrepo-pretool-activation.test.mjs +7 -5
- package/src/test/hooks/skillrepo-prompt-match.test.mjs +153 -7
package/package.json
CHANGED
package/src/commands/init.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
303
|
+
userId: null,
|
|
297
304
|
});
|
|
298
305
|
|
|
299
306
|
const event = payload.events[0];
|
|
300
307
|
assert.equal(event.skillVersion, "");
|
|
301
|
-
assert.equal(event.
|
|
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
|
// ---------------------------------------------------------------------------
|