skillrepo 1.10.0 → 2.0.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.
@@ -1,405 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * SkillRepo UserPromptSubmit hook — matches prompts against the local skill
4
- * index and reports telemetry. No content injection.
5
- *
6
- * Standalone script: no npm dependencies, Node.js built-ins only.
7
- * Installed by `npx skillrepo init` to `.claude/hooks/skillrepo-prompt-match.mjs`.
8
- *
9
- * Part of #532 (Re-architect skill delivery, Phase B).
10
- */
11
-
12
- import { readFileSync, writeFileSync } from "node:fs";
13
- import { join } from "node:path";
14
- import { fileURLToPath } from "node:url";
15
- import { homedir, tmpdir } from "node:os";
16
- import { createHash } from "node:crypto";
17
-
18
- // ---------------------------------------------------------------------------
19
- // Constants
20
- // ---------------------------------------------------------------------------
21
-
22
- const DEFAULT_SERVER_URL = "https://skillrepo.dev";
23
- const MAX_EVENTS_PER_BATCH = 50;
24
-
25
- // ---------------------------------------------------------------------------
26
- // Config resolution (lightweight — only needs API key and server URL)
27
- // ---------------------------------------------------------------------------
28
-
29
- /**
30
- * Read config from ~/.claude/skillrepo/config.json with fallbacks.
31
- * Returns { apiKey, serverUrl, userId } or null.
32
- */
33
- export function readConfig() {
34
- const configPath = join(homedir(), ".claude", "skillrepo", "config.json");
35
- try {
36
- const cfg = JSON.parse(readFileSync(configPath, "utf-8"));
37
- if (cfg.apiKey) {
38
- return {
39
- apiKey: cfg.apiKey,
40
- serverUrl: cfg.serverUrl || DEFAULT_SERVER_URL,
41
- userId: cfg.userId || null,
42
- };
43
- }
44
- } catch { /* not found */ }
45
-
46
- const envKey = process.env.SKILLREPO_ACCESS_KEY;
47
- if (envKey) {
48
- return { apiKey: envKey, serverUrl: process.env.SKILLREPO_SERVER_URL || DEFAULT_SERVER_URL, userId: null };
49
- }
50
-
51
- // .env.local fallback
52
- for (const file of [".env.local", ".env"]) {
53
- try {
54
- const lines = readFileSync(join(process.cwd(), file), "utf-8").split("\n");
55
- for (const line of lines) {
56
- const trimmed = line.trim();
57
- if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
58
- const eqIdx = trimmed.indexOf("=");
59
- if (trimmed.slice(0, eqIdx).trim() === "SKILLREPO_ACCESS_KEY") {
60
- let val = trimmed.slice(eqIdx + 1).trim();
61
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
62
- val = val.slice(1, -1);
63
- }
64
- val = val.replace(/\s+#.*$/, "");
65
- if (val) return { apiKey: val, serverUrl: DEFAULT_SERVER_URL, userId: null };
66
- }
67
- }
68
- } catch { /* file doesn't exist */ }
69
- }
70
-
71
- return null;
72
- }
73
-
74
- // ---------------------------------------------------------------------------
75
- // Skill matching
76
- // ---------------------------------------------------------------------------
77
-
78
- const STOP_WORDS = new Set([
79
- "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
80
- "of", "with", "by", "is", "are", "was", "were", "be", "been",
81
- "this", "that", "it", "its", "as", "if", "not", "no", "do", "does",
82
- "can", "will", "use", "when", "how", "what", "which", "who", "any",
83
- "all", "your", "you", "their", "they", "has", "have", "had",
84
- "i", "me", "my", "we", "our",
85
- ]);
86
-
87
- /**
88
- * Match a prompt against all skills in the index.
89
- * Returns scored matches sorted by relevance (highest first).
90
- */
91
- export function matchSkills(prompt, skills) {
92
- if (!prompt || !skills?.length) return [];
93
-
94
- const lower = prompt.toLowerCase();
95
- const tokens = new Set(
96
- lower.replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter(w => w.length > 2 && !STOP_WORDS.has(w))
97
- );
98
-
99
- const matches = [];
100
-
101
- for (const skill of skills) {
102
- let score = 0;
103
-
104
- // High-weight: contextSignals.tasks phrase matching (+3 per match)
105
- const tasks = skill.contextSignals?.tasks ?? [];
106
- for (const task of tasks) {
107
- if (lower.includes(task.toLowerCase())) score += 3;
108
- }
109
-
110
- // Standard-weight: keyword token matching
111
- for (const kw of (skill.keywords ?? [])) {
112
- const kwLower = kw.toLowerCase();
113
- if (tokens.has(kwLower)) {
114
- score += 1;
115
- } else if (kwLower.length > 3 && lower.includes(kwLower)) {
116
- score += 0.5;
117
- }
118
- }
119
-
120
- // Name-based matching: skill name parts
121
- for (const part of skill.name.split("-")) {
122
- if (part.length > 2 && tokens.has(part.toLowerCase())) score += 0.5;
123
- }
124
-
125
- // Description-based matching: key description words
126
- if (skill.description) {
127
- const descWords = skill.description.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/)
128
- .filter(w => w.length > 3 && !STOP_WORDS.has(w));
129
- for (const word of descWords) {
130
- if (tokens.has(word)) score += 0.3;
131
- }
132
- }
133
-
134
- if (score > 0) matches.push({ skill, score });
135
- }
136
-
137
- return matches.sort((a, b) => b.score - a.score);
138
- }
139
-
140
- // ---------------------------------------------------------------------------
141
- // Session dedup
142
- // ---------------------------------------------------------------------------
143
-
144
- /**
145
- * Read session state for deduplication.
146
- * State is stored in tmpdir keyed by session hash.
147
- */
148
- export function readSessionState(sessionId) {
149
- const hash = createHash("sha256").update(sessionId || "default").digest("hex").slice(0, 16);
150
- const statePath = join(tmpdir(), `skillrepo-match-${hash}.json`);
151
-
152
- try {
153
- return { path: statePath, state: JSON.parse(readFileSync(statePath, "utf-8")) };
154
- } catch {
155
- return { path: statePath, state: { reported: {} } };
156
- }
157
- }
158
-
159
- /**
160
- * Filter matches to exclude skills already reported in this session.
161
- */
162
- export function deduplicateMatches(matches, sessionState) {
163
- return matches.filter(m => {
164
- const key = `${m.skill.owner}/${m.skill.name}`;
165
- return !sessionState.reported[key];
166
- });
167
- }
168
-
169
- /**
170
- * Mark skills as reported in session state.
171
- */
172
- export function updateSessionState(statePath, state, reportedMatches) {
173
- for (const m of reportedMatches) {
174
- const key = `${m.skill.owner}/${m.skill.name}`;
175
- state.reported[key] = new Date().toISOString();
176
- }
177
- try { writeFileSync(statePath, JSON.stringify(state), "utf-8"); }
178
- catch { /* non-critical */ }
179
- }
180
-
181
- // ---------------------------------------------------------------------------
182
- // Telemetry payload
183
- // ---------------------------------------------------------------------------
184
-
185
- /**
186
- * Build telemetry payload for matched skills.
187
- */
188
- export function buildTelemetryPayload(matches, sessionInfo) {
189
- const events = matches.slice(0, MAX_EVENTS_PER_BATCH).map(m => ({
190
- skillOwner: m.skill.owner,
191
- skillName: m.skill.name,
192
- skillVersion: m.skill.version ?? "",
193
- matchedAt: new Date().toISOString(),
194
- ide: sessionInfo.ide || "claude-code",
195
- sessionHash: sessionInfo.sessionHash,
196
- userId: sessionInfo.userId || undefined,
197
- wasRulesDelivered: m.skill.isRulesDelivered ?? false,
198
- }));
199
-
200
- return { events };
201
- }
202
-
203
- /**
204
- * Fire-and-forget POST to telemetry endpoint.
205
- *
206
- * NOT awaited — the spec requires "do NOT block the agent" and Claude Code
207
- * waits for hook process exit. The OS TCP stack flushes small payloads after
208
- * process exit, so delivery reliability is ~99%+ for responsive servers.
209
- * Occasional loss on slow/unreachable servers is acceptable for non-critical
210
- * telemetry.
211
- */
212
- export function sendTelemetry(config, payload) {
213
- const url = `${config.serverUrl}/api/v1/telemetry/match`;
214
-
215
- fetch(url, {
216
- method: "POST",
217
- headers: {
218
- Authorization: `Bearer ${config.apiKey}`,
219
- "Content-Type": "application/json",
220
- },
221
- body: JSON.stringify(payload),
222
- }).catch(() => { /* telemetry errors are non-critical */ });
223
- }
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
-
302
- // ---------------------------------------------------------------------------
303
- // Main entry point
304
- // ---------------------------------------------------------------------------
305
-
306
- export async function main(input) {
307
- const indexPath = join(homedir(), ".claude", "skillrepo", "index.json");
308
-
309
- // ── Read index ──────────────────────────────────────────────
310
- let index;
311
- try {
312
- index = JSON.parse(readFileSync(indexPath, "utf-8"));
313
- } catch {
314
- // No index → nothing to match. Exit cleanly.
315
- process.stdout.write("{}");
316
- return;
317
- }
318
-
319
- if (!index.skills?.length) {
320
- process.stdout.write("{}");
321
- return;
322
- }
323
-
324
- // ── Read config (needed for telemetry POST) ─────────────────
325
- const config = readConfig();
326
- if (!config) {
327
- process.stdout.write("{}");
328
- return;
329
- }
330
-
331
- // ── Match prompt against skills ─────────────────────────────
332
- const prompt = input.prompt ?? "";
333
- const matches = matchSkills(prompt, index.skills);
334
-
335
- if (matches.length === 0) {
336
- process.stdout.write("{}");
337
- return;
338
- }
339
-
340
- // ── Session dedup ───────────────────────────────────────────
341
- const sessionId = input.session_id ?? "default";
342
- const { path: statePath, state } = readSessionState(sessionId);
343
- const newMatches = deduplicateMatches(matches, state);
344
-
345
- if (newMatches.length === 0) {
346
- process.stdout.write("{}");
347
- return;
348
- }
349
-
350
- // ── Build and send telemetry ────────────────────────────────
351
- const sessionHash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
352
-
353
- const payload = buildTelemetryPayload(newMatches, {
354
- ide: "claude-code",
355
- sessionHash,
356
- userId: config.userId,
357
- });
358
-
359
- sendTelemetry(config, payload); // fire-and-forget — do NOT await
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
-
382
- // ── Update session state ────────────────────────────────────
383
- updateSessionState(statePath, state, newMatches);
384
-
385
- // ── Output: NO additionalContext ────────────────────────────
386
- process.stdout.write("{}");
387
- }
388
-
389
- // ── Run ───────────────────────────────────────────────────────
390
- const __filename = fileURLToPath(import.meta.url);
391
- const isMainModule = process.argv[1] === __filename;
392
-
393
- if (isMainModule) {
394
- let inputBuf = "";
395
- for await (const chunk of process.stdin) inputBuf += chunk;
396
-
397
- let input;
398
- try { input = JSON.parse(inputBuf); }
399
- catch { process.stdout.write("{}"); process.exit(0); }
400
-
401
- main(input).catch(() => {
402
- process.stdout.write("{}");
403
- process.exit(0);
404
- });
405
- }