skillrepo 1.6.2 → 1.7.1

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.6.2",
3
+ "version": "1.7.1",
4
4
  "description": "Set up SkillRepo in any IDE — one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,14 +4,16 @@
4
4
  * Flow:
5
5
  * 1. Detect IDEs
6
6
  * 2. Prompt for access key
7
- * 3. Validate key + fetch skill mappings
8
- * 4. Write all configs
9
- * 5. Print summary
7
+ * 3. Validate key + fetch skill count
8
+ * 4. Write configs (global config, hooks, MCP, .gitignore)
9
+ * 5. Run first sync (writes rules files + local cache)
10
+ * 6. Print summary
10
11
  */
11
12
 
12
13
  import { detectIdes, formatDetectedIdes, getDetectedIdeKeys } from "../lib/detect-ides.mjs";
13
14
  import { fetchSetupPayload, AuthError, SuspendedError, NetworkError } from "../lib/http.mjs";
14
15
  import { writeAllConfigs } from "../lib/write-configs.mjs";
16
+ import { runFirstSync } from "../lib/first-sync.mjs";
15
17
  import {
16
18
  printHeader,
17
19
  printStep,
@@ -66,7 +68,7 @@ export async function runInit(argv) {
66
68
  printHeader("SkillRepo Setup");
67
69
 
68
70
  // ── Step 1: Detect IDEs ───────────────────────────────────────────────
69
- printStep(1, 4, "Detecting IDEs...");
71
+ printStep(1, 5, "Detecting IDEs...");
70
72
 
71
73
  const detected = detectIdes();
72
74
  const ideList = formatDetectedIdes(detected);
@@ -95,7 +97,7 @@ export async function runInit(argv) {
95
97
  printBlank();
96
98
 
97
99
  // ── Step 2: Access Key ────────────────────────────────────────────────
98
- printStep(2, 4, "Access Key");
100
+ printStep(2, 5, "Access Key");
99
101
 
100
102
  let apiKey = flags.key;
101
103
  if (!apiKey) {
@@ -111,7 +113,7 @@ export async function runInit(argv) {
111
113
  printBlank();
112
114
 
113
115
  // ── Step 3: Validate + Fetch ──────────────────────────────────────────
114
- printStep(3, 4, "Validating key...");
116
+ printStep(3, 5, "Validating key...");
115
117
 
116
118
  let payload;
117
119
  try {
@@ -135,13 +137,13 @@ export async function runInit(argv) {
135
137
  printSuccess(`Valid. ${payload.skillCount} skills in your library.`);
136
138
 
137
139
  if (payload.skillCount === 0) {
138
- printWarning("No skills in your library yet. MCP config will be written — skills will activate when you add them.");
140
+ printWarning("No skills in your library yet. Config will be written — skills will activate when you add them.");
139
141
  }
140
142
 
141
143
  printBlank();
142
144
 
143
145
  // ── Step 4: Write configs ─────────────────────────────────────────────
144
- printStep(4, 4, "Writing configuration...");
146
+ printStep(4, 5, "Writing configuration...");
145
147
  printBlank();
146
148
 
147
149
  const mcpUrl = `${flags.url}/api/mcp`;
@@ -152,7 +154,7 @@ export async function runInit(argv) {
152
154
  ides: detectedKeys,
153
155
  mcpUrl,
154
156
  apiKey,
155
- payload,
157
+ serverUrl: flags.url,
156
158
  });
157
159
  } catch (err) {
158
160
  printError(err.message);
@@ -163,6 +165,19 @@ export async function runInit(argv) {
163
165
  printResult(r.path, r.action);
164
166
  }
165
167
 
168
+ printBlank();
169
+
170
+ // ── Step 5: First sync ────────────────────────────────────────────────
171
+ printStep(5, 5, "Running first sync...");
172
+
173
+ try {
174
+ await runFirstSync();
175
+ printSuccess("Skills synced and rules files written.");
176
+ } catch (err) {
177
+ printWarning(`First sync failed: ${err.message}`);
178
+ printWarning("Skills will sync automatically on your next session start.");
179
+ }
180
+
166
181
  // ── Summary ───────────────────────────────────────────────────────────
167
182
  printBlank();
168
183
  printSuccess("SkillRepo is ready.");
@@ -172,14 +187,15 @@ export async function runInit(argv) {
172
187
  console.log(" • Each team member runs: npx skillrepo init");
173
188
  console.log(" • SKILLREPO_ACCESS_KEY is in .env.local (gitignored)");
174
189
 
175
- if (detectedKeys.includes("vscode")) {
190
+ if (detectedKeys.includes("claudeCode")) {
176
191
  printBlank();
177
- console.log(" Note: VS Code will prompt for your access key on first use.");
192
+ console.log(" Claude Code: Skills are delivered via .claude/rules/ files.");
193
+ console.log(" They refresh automatically on each session start.");
178
194
  }
179
195
 
180
- if (detectedKeys.includes("claudeCode")) {
196
+ if (detectedKeys.includes("vscode")) {
181
197
  printBlank();
182
- console.log(" Claude Code: Skills refresh automatically on each session start.");
198
+ console.log(" Note: VS Code will prompt for your access key on first use.");
183
199
  }
184
200
 
185
201
  printBlank();
@@ -0,0 +1,344 @@
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
+ import { execSync } from "node:child_process";
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 } 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 { apiKey: cfg.apiKey, serverUrl: cfg.serverUrl || DEFAULT_SERVER_URL };
40
+ }
41
+ } catch { /* not found */ }
42
+
43
+ const envKey = process.env.SKILLREPO_ACCESS_KEY;
44
+ if (envKey) {
45
+ return { apiKey: envKey, serverUrl: process.env.SKILLREPO_SERVER_URL || DEFAULT_SERVER_URL };
46
+ }
47
+
48
+ // .env.local fallback
49
+ for (const file of [".env.local", ".env"]) {
50
+ try {
51
+ const lines = readFileSync(join(process.cwd(), file), "utf-8").split("\n");
52
+ for (const line of lines) {
53
+ const trimmed = line.trim();
54
+ if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
55
+ const eqIdx = trimmed.indexOf("=");
56
+ if (trimmed.slice(0, eqIdx).trim() === "SKILLREPO_ACCESS_KEY") {
57
+ let val = trimmed.slice(eqIdx + 1).trim();
58
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
59
+ val = val.slice(1, -1);
60
+ }
61
+ val = val.replace(/\s+#.*$/, "");
62
+ if (val) return { apiKey: val, serverUrl: DEFAULT_SERVER_URL };
63
+ }
64
+ }
65
+ } catch { /* file doesn't exist */ }
66
+ }
67
+
68
+ return null;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Skill matching
73
+ // ---------------------------------------------------------------------------
74
+
75
+ const STOP_WORDS = new Set([
76
+ "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
77
+ "of", "with", "by", "is", "are", "was", "were", "be", "been",
78
+ "this", "that", "it", "its", "as", "if", "not", "no", "do", "does",
79
+ "can", "will", "use", "when", "how", "what", "which", "who", "any",
80
+ "all", "your", "you", "their", "they", "has", "have", "had",
81
+ "i", "me", "my", "we", "our",
82
+ ]);
83
+
84
+ /**
85
+ * Match a prompt against all skills in the index.
86
+ * Returns scored matches sorted by relevance (highest first).
87
+ */
88
+ export function matchSkills(prompt, skills) {
89
+ if (!prompt || !skills?.length) return [];
90
+
91
+ const lower = prompt.toLowerCase();
92
+ const tokens = new Set(
93
+ lower.replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter(w => w.length > 2 && !STOP_WORDS.has(w))
94
+ );
95
+
96
+ const matches = [];
97
+
98
+ for (const skill of skills) {
99
+ let score = 0;
100
+
101
+ // High-weight: contextSignals.tasks phrase matching (+3 per match)
102
+ const tasks = skill.contextSignals?.tasks ?? [];
103
+ for (const task of tasks) {
104
+ if (lower.includes(task.toLowerCase())) score += 3;
105
+ }
106
+
107
+ // Standard-weight: keyword token matching
108
+ for (const kw of (skill.keywords ?? [])) {
109
+ const kwLower = kw.toLowerCase();
110
+ if (tokens.has(kwLower)) {
111
+ score += 1;
112
+ } else if (kwLower.length > 3 && lower.includes(kwLower)) {
113
+ score += 0.5;
114
+ }
115
+ }
116
+
117
+ // Name-based matching: skill name parts
118
+ for (const part of skill.name.split("-")) {
119
+ if (part.length > 2 && tokens.has(part.toLowerCase())) score += 0.5;
120
+ }
121
+
122
+ // Description-based matching: key description words
123
+ if (skill.description) {
124
+ const descWords = skill.description.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/)
125
+ .filter(w => w.length > 3 && !STOP_WORDS.has(w));
126
+ for (const word of descWords) {
127
+ if (tokens.has(word)) score += 0.3;
128
+ }
129
+ }
130
+
131
+ if (score > 0) matches.push({ skill, score });
132
+ }
133
+
134
+ return matches.sort((a, b) => b.score - a.score);
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Session dedup
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /**
142
+ * Read session state for deduplication.
143
+ * State is stored in tmpdir keyed by session hash.
144
+ */
145
+ export function readSessionState(sessionId) {
146
+ const hash = createHash("sha256").update(sessionId || "default").digest("hex").slice(0, 16);
147
+ const statePath = join(tmpdir(), `skillrepo-match-${hash}.json`);
148
+
149
+ try {
150
+ return { path: statePath, state: JSON.parse(readFileSync(statePath, "utf-8")) };
151
+ } catch {
152
+ return { path: statePath, state: { reported: {} } };
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Filter matches to exclude skills already reported in this session.
158
+ */
159
+ export function deduplicateMatches(matches, sessionState) {
160
+ return matches.filter(m => {
161
+ const key = `${m.skill.owner}/${m.skill.name}`;
162
+ return !sessionState.reported[key];
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Mark skills as reported in session state.
168
+ */
169
+ export function updateSessionState(statePath, state, reportedMatches) {
170
+ for (const m of reportedMatches) {
171
+ const key = `${m.skill.owner}/${m.skill.name}`;
172
+ state.reported[key] = new Date().toISOString();
173
+ }
174
+ try { writeFileSync(statePath, JSON.stringify(state), "utf-8"); }
175
+ catch { /* non-critical */ }
176
+ }
177
+
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
+ // ---------------------------------------------------------------------------
218
+ // Telemetry payload
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /**
222
+ * Build telemetry payload for matched skills.
223
+ */
224
+ export function buildTelemetryPayload(matches, sessionInfo) {
225
+ const events = matches.slice(0, MAX_EVENTS_PER_BATCH).map(m => ({
226
+ skillOwner: m.skill.owner,
227
+ skillName: m.skill.name,
228
+ skillVersion: m.skill.version ?? "",
229
+ matchedAt: new Date().toISOString(),
230
+ ide: sessionInfo.ide || "claude-code",
231
+ sessionHash: sessionInfo.sessionHash,
232
+ githubUsername: sessionInfo.githubUsername || "",
233
+ wasRulesDelivered: m.skill.isRulesDelivered ?? false,
234
+ }));
235
+
236
+ return { events };
237
+ }
238
+
239
+ /**
240
+ * Fire-and-forget POST to telemetry endpoint.
241
+ *
242
+ * NOT awaited — the spec requires "do NOT block the agent" and Claude Code
243
+ * waits for hook process exit. The OS TCP stack flushes small payloads after
244
+ * process exit, so delivery reliability is ~99%+ for responsive servers.
245
+ * Occasional loss on slow/unreachable servers is acceptable for non-critical
246
+ * telemetry.
247
+ */
248
+ export function sendTelemetry(config, payload) {
249
+ const url = `${config.serverUrl}/api/v1/telemetry/match`;
250
+
251
+ fetch(url, {
252
+ method: "POST",
253
+ headers: {
254
+ Authorization: `Bearer ${config.apiKey}`,
255
+ "Content-Type": "application/json",
256
+ },
257
+ body: JSON.stringify(payload),
258
+ }).catch(() => { /* telemetry errors are non-critical */ });
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Main entry point
263
+ // ---------------------------------------------------------------------------
264
+
265
+ export async function main(input) {
266
+ const indexPath = join(homedir(), ".claude", "skillrepo", "index.json");
267
+
268
+ // ── Read index ──────────────────────────────────────────────
269
+ let index;
270
+ try {
271
+ index = JSON.parse(readFileSync(indexPath, "utf-8"));
272
+ } catch {
273
+ // No index → nothing to match. Exit cleanly.
274
+ process.stdout.write("{}");
275
+ return;
276
+ }
277
+
278
+ if (!index.skills?.length) {
279
+ process.stdout.write("{}");
280
+ return;
281
+ }
282
+
283
+ // ── Read config (needed for telemetry POST) ─────────────────
284
+ const config = readConfig();
285
+ if (!config) {
286
+ process.stdout.write("{}");
287
+ return;
288
+ }
289
+
290
+ // ── Match prompt against skills ─────────────────────────────
291
+ const prompt = input.prompt ?? "";
292
+ const matches = matchSkills(prompt, index.skills);
293
+
294
+ if (matches.length === 0) {
295
+ process.stdout.write("{}");
296
+ return;
297
+ }
298
+
299
+ // ── Session dedup ───────────────────────────────────────────
300
+ const sessionId = input.session_id ?? "default";
301
+ const { path: statePath, state } = readSessionState(sessionId);
302
+ const newMatches = deduplicateMatches(matches, state);
303
+
304
+ if (newMatches.length === 0) {
305
+ process.stdout.write("{}");
306
+ return;
307
+ }
308
+
309
+ // ── Build and send telemetry ────────────────────────────────
310
+ const sessionHash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
311
+ const githubUsername = resolveGithubUsername();
312
+
313
+ const payload = buildTelemetryPayload(newMatches, {
314
+ ide: "claude-code",
315
+ sessionHash,
316
+ githubUsername,
317
+ });
318
+
319
+ sendTelemetry(config, payload); // fire-and-forget — do NOT await
320
+
321
+ // ── Update session state ────────────────────────────────────
322
+ updateSessionState(statePath, state, newMatches);
323
+
324
+ // ── Output: NO additionalContext ────────────────────────────
325
+ process.stdout.write("{}");
326
+ }
327
+
328
+ // ── Run ───────────────────────────────────────────────────────
329
+ const __filename = fileURLToPath(import.meta.url);
330
+ const isMainModule = process.argv[1] === __filename;
331
+
332
+ if (isMainModule) {
333
+ let inputBuf = "";
334
+ for await (const chunk of process.stdin) inputBuf += chunk;
335
+
336
+ let input;
337
+ try { input = JSON.parse(inputBuf); }
338
+ catch { process.stdout.write("{}"); process.exit(0); }
339
+
340
+ main(input).catch(() => {
341
+ process.stdout.write("{}");
342
+ process.exit(0);
343
+ });
344
+ }