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.
- package/README.md +0 -2
- package/package.json +1 -1
- package/src/commands/init.mjs +6 -19
- package/src/lib/paths.mjs +0 -5
- package/src/lib/write-configs.mjs +0 -5
- package/src/test/e2e/HANDOFF.md +0 -3
- package/src/test/e2e/cli-init.test.mjs +13 -125
- package/src/hooks/skillrepo-pretool-activation.mjs +0 -304
- package/src/hooks/skillrepo-prompt-match.mjs +0 -405
- package/src/hooks/skillrepo-sync.mjs +0 -772
- package/src/lib/first-sync.mjs +0 -65
- package/src/lib/mergers/hooks-json.mjs +0 -261
- package/src/test/hooks/detect-migration.test.mjs +0 -93
- package/src/test/hooks/skillrepo-pretool-activation.test.mjs +0 -376
- package/src/test/hooks/skillrepo-prompt-match.test.mjs +0 -530
- package/src/test/hooks/skillrepo-sync.test.mjs +0 -1043
- package/src/test/mergers/hooks-json.test.mjs +0 -419
|
@@ -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
|
-
}
|