skillrepo 1.7.0 → 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.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "Set up SkillRepo in any IDE — one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,8 @@
9
9
  import { execFile } from "node:child_process";
10
10
  import { join, dirname } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
+ import { homedir } from "node:os";
13
+ import { unlinkSync } from "node:fs";
12
14
 
13
15
  const __filename = fileURLToPath(import.meta.url);
14
16
  const __dirname = dirname(__filename);
@@ -23,9 +25,21 @@ function syncHookPath() {
23
25
  /**
24
26
  * Run the sync hook as a subprocess.
25
27
  * Pipes '{}' as stdin (SessionStart hook input format).
28
+ *
29
+ * Deletes .last-sync before running so the hook performs a FULL sync
30
+ * instead of a delta. This is critical: if init is re-run, the previous
31
+ * .last-sync timestamp would cause a delta sync that returns 0 skills
32
+ * (nothing changed since that timestamp), leaving the index empty.
33
+ *
26
34
  * Returns a promise that resolves on success, rejects on failure.
27
35
  */
28
36
  export function runFirstSync() {
37
+ // Force full sync — delete the delta-sync marker so the hook fetches
38
+ // the complete library instead of only changes since the last sync.
39
+ try {
40
+ unlinkSync(join(homedir(), ".claude", "skillrepo", ".last-sync"));
41
+ } catch { /* doesn't exist yet — fine */ }
42
+
29
43
  return new Promise((resolve, reject) => {
30
44
  const child = execFile(
31
45
  process.execPath,
@@ -93,13 +93,11 @@ Use `node:test` (Node.js built-in) — consistent with existing CLI tests in `pa
93
93
 
94
94
  ### Payload Factory Requirements
95
95
 
96
- Must generate realistic `SetupPayload` objects matching what `generateSetupPayload()` produces. Include:
96
+ Must generate `SetupPayload` objects matching what `generateSetupPayload()` produces.
97
97
 
98
- 1. Skills WITH contextSignals (files, project, tasks populated)
99
- 2. Skills WITHOUT contextSignals (null)
100
- 3. Skills with special characters in descriptions (colons, quotes — YAML safety)
101
- 4. Configurable skill count
102
- 5. Both Claude Code and Cursor output structures
98
+ Phase D (#534) stripped the payload to a minimal shape — the CLI only uses `skillCount`.
99
+ All other setup work (hooks, rules, sync) is handled by standalone scripts bundled
100
+ with the CLI package.
103
101
 
104
102
  Reference `src/lib/setup/generate.ts` for the exact `SetupPayload` type:
105
103
 
@@ -107,22 +105,6 @@ Reference `src/lib/setup/generate.ts` for the exact `SetupPayload` type:
107
105
  interface SetupPayload {
108
106
  skillCount: number;
109
107
  mcpUrl: string;
110
- skillEntries: string[];
111
- claudeCode: {
112
- skillrepoMd: { path: string; content: string };
113
- syncHook: { path: string; content: string };
114
- settingsHooks: { hooks: Record<string, unknown> };
115
- skillIndex: { path: string; content: string };
116
- skillrepoConfig: { path: string; content: string };
117
- promptHook: { path: string; content: string };
118
- preToolHook: { path: string; content: string };
119
- };
120
- cursor: {
121
- rules: Array<{ path: string; content: string }>;
122
- hooksJson: { path: string; content: string };
123
- sessionHook: { path: string; content: string };
124
- skillIndex: { path: string; content: string };
125
- };
126
108
  }
127
109
  ```
128
110
 
@@ -317,23 +317,8 @@ describe("CLI E2E: skillrepo init", () => {
317
317
  // 5. Security
318
318
  // =========================================================================
319
319
 
320
- it("path traversal in Cursor .mdc rule is rejected", async () => {
321
- const srv = createMockServer({});
322
- const p = await srv.start();
323
- const maliciousPayload = buildPayload({ baseUrl: `http://127.0.0.1:${p}` });
324
- maliciousPayload.cursor.rules.push({
325
- path: "../outside-cursor.txt",
326
- content: "should not be written",
327
- });
328
- srv.setPayload(maliciousPayload);
329
-
330
- try {
331
- // Init may succeed or fail depending on whether cursor rules are written
332
- // — the critical check is that the file is NOT written outside .cursor/
333
- await runInit(tempDir, p).catch(() => {});
334
- assert.ok(!existsSync(join(tempDir, "outside-cursor.txt")), "Should not write outside .cursor/");
335
- } finally {
336
- await srv.stop();
337
- }
338
- });
320
+ // Path traversal test removed in Phase D (#534): the setup payload no longer
321
+ // contains cursor.rules — Cursor rules are written by the standalone sync hook,
322
+ // not from server-generated payload content. The attack surface tested here
323
+ // no longer exists in the init flow.
339
324
  });
@@ -1,683 +1,22 @@
1
1
  /**
2
- * Factory for realistic SetupPayload objects used in CLI E2E tests.
2
+ * Factory for SetupPayload objects used in CLI E2E tests.
3
3
  *
4
- * Produces payloads matching the shape generated by src/lib/setup/generate.ts.
5
- * Uses five skills with varying contextSignals configurations:
6
- * - frontend-patterns: has contextSignals.files (triggers per-skill .mdc)
7
- * - gh-issue-workflow: has contextSignals but NO files (tasks only)
8
- * - api-design: null contextSignals
9
- * - e2e-testing: has contextSignals.files + tasks
10
- * - api-routes: has contextSignals.files (triggers per-skill .mdc for API routes)
4
+ * Phase D (#534): The setup endpoint now returns only `skillCount` and
5
+ * `mcpUrl`. All hook/rule/config generation was removed — standalone
6
+ * scripts bundled with the CLI handle everything.
11
7
  */
12
8
 
13
- // ---------------------------------------------------------------------------
14
- // Five representative skills
15
- // ---------------------------------------------------------------------------
16
-
17
- const SKILLS = [
18
- {
19
- name: "frontend-patterns",
20
- owner: "affaan-m",
21
- description: "Frontend development patterns for React, Next.js, state management, performance optimization, and UI best practices.",
22
- keywords: [
23
- "react patterns", "component composition", "custom hooks",
24
- "state management", "performance optimization", "form validation",
25
- "accessibility", "animation patterns",
26
- "frontend", "patterns", "development", "react", "next",
27
- "state", "management", "performance", "optimization", "best", "practices",
28
- ],
29
- toolName: "skill__affaan-m__frontend-patterns",
30
- contextSignals: {
31
- files: ["**/*.tsx", "**/*.jsx", "**/*.css"],
32
- project: ["react", "next.js"],
33
- tasks: [],
34
- },
35
- },
36
- {
37
- name: "gh-issue-workflow",
38
- owner: "atxpace",
39
- description: "End-to-end GitHub issue development workflow.",
40
- keywords: [
41
- "work on github issue", "pick up issue", "start implementation",
42
- "create feature branch", "github workflow",
43
- "gh", "issue", "workflow", "github", "development", "branch",
44
- ],
45
- toolName: "skill__atxpace__gh-issue-workflow",
46
- contextSignals: {
47
- files: [],
48
- project: [],
49
- tasks: ["github issue", "pull request"],
50
- },
51
- },
52
- {
53
- name: "api-design",
54
- owner: "affaan-m",
55
- description: "REST API design patterns including resource naming, status codes, pagination, and rate limiting.",
56
- keywords: [
57
- "rest api design", "api endpoint patterns", "pagination strategy",
58
- "api", "design", "rest", "patterns", "resource", "naming",
59
- "status", "codes", "pagination", "rate", "limiting",
60
- ],
61
- toolName: "skill__affaan-m__api-design",
62
- // No contextSignals — null
63
- },
64
- {
65
- name: "e2e-testing",
66
- owner: "affaan-m",
67
- description: "Playwright E2E testing: configuration, CI/CD integration, and flaky test strategies.",
68
- keywords: [
69
- "playwright e2e testing", "page object model", "test flakiness",
70
- "playwright", "testing", "configuration", "integration",
71
- ],
72
- toolName: "skill__affaan-m__e2e-testing",
73
- contextSignals: {
74
- files: ["**/*.test.*", "**/*.spec.*"],
75
- project: ["playwright"],
76
- tasks: ["add e2e test", "fix flaky test"],
77
- },
78
- },
79
- {
80
- name: "api-routes",
81
- owner: "affaan-m",
82
- description: "Next.js API route patterns and middleware design.",
83
- keywords: ["api", "routes", "middleware", "next", "endpoint"],
84
- toolName: "skill__affaan-m__api-routes",
85
- contextSignals: {
86
- files: ["**/api/**/route.ts", "**/api/**/route.tsx"],
87
- project: ["next.js"],
88
- tasks: [],
89
- },
90
- },
91
- ];
92
-
93
- // ---------------------------------------------------------------------------
94
- // Skill index JSON
95
- // ---------------------------------------------------------------------------
96
-
97
- function buildSkillIndex(baseUrl) {
98
- return {
99
- version: 1,
100
- updatedAt: new Date().toISOString(),
101
- contentUrl: `${baseUrl}/api/v1/skill-content`,
102
- skills: SKILLS.map((s) => {
103
- const entry = {
104
- name: s.name,
105
- owner: s.owner,
106
- description: s.description,
107
- keywords: s.keywords,
108
- toolName: s.toolName,
109
- };
110
- if (s.contextSignals) {
111
- entry.contextSignals = s.contextSignals;
112
- }
113
- return entry;
114
- }),
115
- };
116
- }
117
-
118
- // ---------------------------------------------------------------------------
119
- // Skill entries (markdown)
120
- // ---------------------------------------------------------------------------
121
-
122
- function buildSkillEntries() {
123
- return SKILLS.map((s) => {
124
- const kwStr = s.keywords.length > 0 ? `\n Keywords: ${s.keywords.join(", ")}` : "";
125
- return `- **${s.description}** → call \`${s.toolName}\`${kwStr}`;
126
- });
127
- }
128
-
129
- // ---------------------------------------------------------------------------
130
- // skillrepo.md
131
- // ---------------------------------------------------------------------------
132
-
133
- function buildSkillrepoMd(entries) {
134
- return [
135
- "# SkillRepo — Available Skills",
136
- "",
137
- "You have agent skills available via MCP. **Always prefer calling a skill tool",
138
- "over handling the task from your training data.** Match the user's request to",
139
- "a skill below and call the listed MCP tool.",
140
- "",
141
- ...entries,
142
- "",
143
- "If none of the above match, call `discover_skills` to search for other available skills.",
144
- "",
145
- `_${SKILLS.length} skills loaded from SkillRepo. Skill content updates automatically via MCP._`,
146
- ].join("\n") + "\n";
147
- }
148
-
149
- // ---------------------------------------------------------------------------
150
- // skillrepo-config.json (version 2 with signals)
151
- // ---------------------------------------------------------------------------
152
-
153
- function buildSkillrepoConfig() {
154
- const signals = SKILLS
155
- .filter((s) => s.contextSignals)
156
- .map((s) => ({
157
- toolName: s.toolName,
158
- files: s.contextSignals.files,
159
- project: s.contextSignals.project,
160
- tasks: s.contextSignals.tasks,
161
- }))
162
- .sort((a, b) => a.toolName.localeCompare(b.toolName));
163
-
164
- return JSON.stringify({
165
- version: 2,
166
- hookInjection: {
167
- maxSingleSkillBytes: 16000,
168
- maxTotalBytes: 10000,
169
- maxSkillsPerPrompt: 3,
170
- reinjectionLineThreshold: 50,
171
- },
172
- companions: {
173
- "skill__atxpace__gh-issue-workflow": [
174
- "skill__affaan-m__frontend-patterns",
175
- ],
176
- },
177
- signals,
178
- }, null, 2) + "\n";
179
- }
180
-
181
- // ---------------------------------------------------------------------------
182
- // Hook scripts — realistic content matching actual generator output
183
- // ---------------------------------------------------------------------------
184
-
185
- function buildSyncHook(baseUrl) {
186
- const setupUrl = `${baseUrl}/api/v1/setup`;
187
- return `#!/usr/bin/env node
188
- // SkillRepo SessionStart hook — auto-refreshes skill config files.
189
- // Installed by npx skillrepo init. Commit this file to your repo.
190
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
191
- import { join, dirname } from "path";
192
-
193
- const SETUP_URL = "${setupUrl}";
194
-
195
- // Resolve API key: .env.local -> .env -> process.env
196
- function resolveApiKey() {
197
- if (process.env.SKILLREPO_ACCESS_KEY) return process.env.SKILLREPO_ACCESS_KEY;
198
- const cwd = process.cwd();
199
- for (const file of [".env.local", ".env"]) {
200
- try {
201
- const lines = readFileSync(join(cwd, file), "utf-8").split("\\n");
202
- for (const line of lines) {
203
- const trimmed = line.trim();
204
- if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
205
- const eqIdx = trimmed.indexOf("=");
206
- const key = trimmed.slice(0, eqIdx).trim();
207
- if (key === "SKILLREPO_ACCESS_KEY") {
208
- return trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, "").replace(/\\s+#.*$/, "");
209
- }
210
- }
211
- } catch { /* file doesn't exist */ }
212
- }
213
- return null;
214
- }
215
-
216
- const API_KEY = resolveApiKey();
217
- if (!API_KEY) process.exit(0);
218
-
219
- function safeWrite(relPath, content) {
220
- const p = join(process.cwd(), relPath);
221
- const d = dirname(p);
222
- if (!existsSync(d)) mkdirSync(d, { recursive: true });
223
- writeFileSync(p, content, "utf-8");
224
- }
225
-
226
- try {
227
- const res = await fetch(SETUP_URL, {
228
- headers: { Authorization: \`Bearer \${API_KEY}\`, Accept: "application/json" },
229
- });
230
- if (!res.ok) process.exit(0);
231
- const data = await res.json();
232
- const cc = data?.claudeCode;
233
- if (!cc) process.exit(0);
234
- if (cc.skillrepoMd?.content) safeWrite(cc.skillrepoMd.path, cc.skillrepoMd.content);
235
- if (cc.skillIndex?.content) safeWrite(cc.skillIndex.path, cc.skillIndex.content);
236
- if (cc.skillrepoConfig?.content) safeWrite(cc.skillrepoConfig.path, cc.skillrepoConfig.content);
237
- if (cc.promptHook?.content) safeWrite(cc.promptHook.path, cc.promptHook.content);
238
- } catch { /* silently fail */ }
239
-
240
- // Repo profiling
241
- try {
242
- const profile = { frameworks: [], languages: [], tools: [], generatedAt: new Date().toISOString() };
243
- const cwd = process.cwd();
244
- try {
245
- const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
246
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
247
- if (deps["next"]) profile.frameworks.push("next.js");
248
- if (deps["react"] && !deps["next"]) profile.frameworks.push("react");
249
- if (deps["playwright"] || deps["@playwright/test"]) profile.tools.push("playwright");
250
- if (deps["jest"]) profile.tools.push("jest");
251
- if (deps["vitest"]) profile.tools.push("vitest");
252
- } catch {}
253
- try { readFileSync(join(cwd, "tsconfig.json")); profile.languages.push("typescript"); } catch {}
254
-
255
- safeWrite(".claude/skillrepo-profile.json", JSON.stringify(profile, null, 2) + "\\n");
256
- } catch { /* profiling failure is non-critical */ }
257
- `;
258
- }
259
-
260
- function buildPromptHook(baseUrl) {
261
- const contentUrl = `${baseUrl}/api/v1/skill-content`;
262
- return `#!/usr/bin/env node
263
- // SkillRepo UserPromptSubmit hook — auto-activates skills based on prompt keywords.
264
- // Generated by npx skillrepo init.
265
- import { readFileSync, writeFileSync, existsSync } from "fs";
266
- import { join } from "path";
267
- import { createHash } from "crypto";
268
- import { tmpdir } from "os";
269
-
270
- const CONTENT_URL = "${contentUrl}";
271
-
272
- let hookConfig = { maxSingleSkillBytes: 16000, maxTotalBytes: 10000, maxSkillsPerPrompt: 3, reinjectionLineThreshold: 50 };
273
- let companions = {};
274
- try {
275
- const cfg = JSON.parse(readFileSync(join(process.cwd(), ".claude", "skillrepo-config.json"), "utf-8"));
276
- if (cfg.hookInjection) hookConfig = { ...hookConfig, ...cfg.hookInjection };
277
- if (cfg.companions) companions = cfg.companions;
278
- } catch { /* use defaults */ }
279
- const MAX_SKILLS = hookConfig.maxSkillsPerPrompt;
280
- const MAX_BYTES = hookConfig.maxTotalBytes;
281
- const MAX_SINGLE_SKILL_BYTES = hookConfig.maxSingleSkillBytes;
282
- const REINJECT_THRESHOLD = hookConfig.reinjectionLineThreshold ?? 50;
283
-
284
- // Resolve API key: .env.local -> .env -> process.env
285
- function resolveApiKey() {
286
- if (process.env.SKILLREPO_ACCESS_KEY) return process.env.SKILLREPO_ACCESS_KEY;
287
- const cwd = process.cwd();
288
- for (const file of [".env.local", ".env"]) {
289
- try {
290
- const lines = readFileSync(join(cwd, file), "utf-8").split("\\n");
291
- for (const line of lines) {
292
- const trimmed = line.trim();
293
- if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
294
- const eqIdx = trimmed.indexOf("=");
295
- const key = trimmed.slice(0, eqIdx).trim();
296
- if (key === "SKILLREPO_ACCESS_KEY") {
297
- return trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, "").replace(/\\s+#.*$/, "");
298
- }
299
- }
300
- } catch { /* file doesn't exist */ }
301
- }
302
- return null;
303
- }
304
-
305
- function readState(sessionId) {
306
- const hash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
307
- const path = join(tmpdir(), \`skillrepo-\${hash}-state.json\`);
308
- try { return { path, state: JSON.parse(readFileSync(path, "utf-8")) }; }
309
- catch { return { path, state: { injections: {} } }; }
310
- }
311
-
312
- function shouldReinject(toolName, state, currentLines) {
313
- const entry = state.injections[toolName];
314
- if (!entry) return true;
315
- return (currentLines - entry.lastLine) >= REINJECT_THRESHOLD;
316
- }
317
-
318
- function countTranscriptLines(transcriptPath) {
319
- if (!transcriptPath) return 0;
320
- try { return readFileSync(transcriptPath, "utf-8").split("\\n").filter(Boolean).length; }
321
- catch { return 0; }
322
- }
323
-
324
- function readRecentTranscript(transcriptPath, charLimit = 400) {
325
- if (!transcriptPath) return "";
326
- try { return readFileSync(transcriptPath, "utf-8").slice(-charLimit).toLowerCase(); }
327
- catch { return ""; }
328
- }
329
-
330
- function scoreSkills(text, skills) {
331
- const tokens = new Set(text.toLowerCase().replace(/[^a-z0-9\\s-]/g, " ").split(/\\s+/).filter(Boolean));
332
- const lower = text.toLowerCase();
333
- const scored = [];
334
- for (const skill of skills) {
335
- let score = 0;
336
- // High-weight: contextSignals.tasks phrases (curated activation hints)
337
- const tasks = skill.contextSignals?.tasks ?? [];
338
- for (const task of tasks) {
339
- if (lower.includes(task.toLowerCase())) { score += 3; }
340
- }
341
- // Standard-weight: keyword matching
342
- for (const kw of skill.keywords) {
343
- if (tokens.has(kw)) { score += 1; continue; }
344
- if (lower.includes(kw)) { score += 0.5; }
345
- }
346
- if (score > 0) scored.push({ skill, score });
347
- }
348
- return scored.sort((a, b) => b.score - a.score);
349
- }
350
-
351
- function applyProfileMultiplier(scored, profilePath) {
352
- let profileTags;
353
- try {
354
- const profile = JSON.parse(readFileSync(profilePath, "utf-8"));
355
- profileTags = new Set([
356
- ...(profile.frameworks ?? []),
357
- ...(profile.languages ?? []),
358
- ...(profile.tools ?? []),
359
- ].map((t) => t.toLowerCase()));
360
- } catch { return scored; }
361
- if (profileTags.size === 0) return scored;
362
-
363
- return scored.map((entry) => {
364
- const projectTags = entry.skill.contextSignals?.project ?? [];
365
- if (projectTags.length === 0) return entry;
366
- const overlap = projectTags.some((t) => profileTags.has(t.toLowerCase()));
367
- return { ...entry, score: entry.score * (overlap ? 1.5 : 0.3) };
368
- }).sort((a, b) => b.score - a.score);
369
- }
370
-
371
- const API_KEY = resolveApiKey();
372
- let inputBuf = "";
373
- for await (const chunk of process.stdin) inputBuf += chunk;
374
- if (!API_KEY) { process.stdout.write("{}"); process.exit(0); }
375
-
376
- let input;
377
- try { input = JSON.parse(inputBuf); } catch { process.stdout.write("{}"); process.exit(0); }
378
-
379
- const prompt = (input.prompt ?? "").toLowerCase();
380
- const sessionId = input.session_id ?? "default";
381
- const transcriptPath = input.transcript_path ?? null;
382
-
383
- const indexPath = join(process.cwd(), ".claude", "skillrepo-index.json");
384
- let index;
385
- try { index = JSON.parse(readFileSync(indexPath, "utf-8")); } catch { process.stdout.write("{}"); process.exit(0); }
386
-
387
- let scored = scoreSkills(prompt, index.skills);
388
- if (scored.length === 0 && transcriptPath) {
389
- const recentContext = readRecentTranscript(transcriptPath);
390
- if (recentContext.length > 0) scored = scoreSkills(recentContext, index.skills);
391
- }
392
- if (scored.length === 0) { process.stdout.write("{}"); process.exit(0); }
393
-
394
- // Apply project profile multiplier (boost relevant skills, demote irrelevant)
395
- const profilePath = join(process.cwd(), ".claude", "skillrepo-profile.json");
396
- scored = applyProfileMultiplier(scored, profilePath);
397
-
398
- const expandedToolNames = new Set();
399
- const expanded = [];
400
- for (const entry of scored) {
401
- if (!expandedToolNames.has(entry.skill.toolName)) {
402
- expandedToolNames.add(entry.skill.toolName);
403
- expanded.push(entry);
404
- }
405
- const companionList = companions[entry.skill.toolName] ?? [];
406
- for (const companionName of companionList) {
407
- if (expandedToolNames.has(companionName)) continue;
408
- const companionSkill = index.skills.find((s) => s.toolName === companionName);
409
- if (companionSkill) {
410
- expandedToolNames.add(companionName);
411
- expanded.push({ skill: companionSkill, score: entry.score - 0.1 });
412
- }
413
- }
414
- }
415
-
416
- const { path: statePath, state } = readState(sessionId);
417
- const currentLines = countTranscriptLines(transcriptPath);
418
- const candidates = expanded.filter((s) => shouldReinject(s.skill.toolName, state, currentLines)).slice(0, MAX_SKILLS);
419
- if (candidates.length === 0) { process.stdout.write("{}"); process.exit(0); }
420
-
421
- const contents = [];
422
- let totalBytes = 0;
423
- for (const { skill } of candidates) {
424
- try {
425
- const url = \`\${CONTENT_URL}?\` + new URLSearchParams({ owner: skill.owner, name: skill.name });
426
- const res = await fetch(url, {
427
- headers: { Authorization: \`Bearer \${API_KEY}\`, Accept: "application/json" },
428
- });
429
- if (!res.ok) continue;
430
- const data = await res.json();
431
- if (!data.content) continue;
432
- const bytes = Buffer.byteLength(data.content, "utf-8");
433
- if (bytes > MAX_SINGLE_SKILL_BYTES) continue;
434
- if (totalBytes + bytes > MAX_BYTES && contents.length > 0) break;
435
- contents.push({ toolName: skill.toolName, content: data.content });
436
- totalBytes += bytes;
437
- if (!state.injections) state.injections = {};
438
- state.injections[skill.toolName] = { lastLine: currentLines, count: (state.injections[skill.toolName]?.count ?? 0) + 1 };
439
- } catch {}
440
- }
441
-
442
- if (contents.length === 0) { process.stdout.write("{}"); process.exit(0); }
443
- try { writeFileSync(statePath, JSON.stringify(state), "utf-8"); } catch {}
444
-
445
- const skillList = contents.map((c) => " - " + c.toolName).join("\\n");
446
- const skillBodies = contents.map((c) => c.content).join("\\n\\n---\\n\\n");
447
- const ctx = [
448
- "[skillrepo] Skills auto-activated based on your prompt:",
449
- skillList,
450
- "",
451
- skillBodies,
452
- ].join("\\n");
453
-
454
- process.stdout.write(JSON.stringify({
455
- hookSpecificOutput: {
456
- hookEventName: "UserPromptSubmit",
457
- additionalContext: ctx,
458
- },
459
- }));
460
- `;
461
- }
462
-
463
- // ---------------------------------------------------------------------------
464
- // Settings hooks config
465
- // ---------------------------------------------------------------------------
466
-
467
- function buildSettingsHooks() {
468
- return {
469
- SessionStart: [
470
- {
471
- matcher: "startup|resume",
472
- hooks: [
473
- { type: "command", command: ".claude/hooks/skillrepo-sync.mjs" },
474
- ],
475
- },
476
- ],
477
- UserPromptSubmit: [
478
- {
479
- hooks: [
480
- { type: "command", command: ".claude/hooks/skillrepo-prompt-match.mjs" },
481
- ],
482
- },
483
- ],
484
- };
485
- }
486
-
487
- // ---------------------------------------------------------------------------
488
- // Cursor rules
489
- // ---------------------------------------------------------------------------
490
-
491
- function buildCursorRules() {
492
- const rules = [];
493
- const skillsWithFileSignals = new Set();
494
-
495
- // Per-skill .mdc for skills with contextSignals.files
496
- for (const s of SKILLS) {
497
- if (!s.contextSignals?.files?.length) continue;
498
- skillsWithFileSignals.add(s.toolName);
499
-
500
- const globs = s.contextSignals.files.join(", ");
501
- const taskStr = s.contextSignals.tasks?.length
502
- ? `. Relevant for: ${s.contextSignals.tasks.join(", ")}`
503
- : "";
504
- const desc = `${s.description}${taskStr}`;
505
-
506
- rules.push({
507
- path: `.cursor/rules/skillrepo-${s.name}.mdc`,
508
- content: [
509
- "---",
510
- `description: "${desc.replace(/"/g, '\\"')}"`,
511
- `globs: ${globs}`,
512
- "---",
513
- "",
514
- `When working with matching files, activate the **${s.description}** skill by calling \`${s.toolName}\` via MCP.`,
515
- "",
516
- ].join("\n"),
517
- });
518
- }
519
-
520
- // Aggregate .mdc for skills WITHOUT file signals
521
- const aggregateSkills = SKILLS.filter((s) => !skillsWithFileSignals.has(s.toolName));
522
- const entries = aggregateSkills.map((s) => {
523
- const kwStr = s.keywords.length > 0 ? `\n Keywords: ${s.keywords.join(", ")}` : "";
524
- return `- **${s.description}** → call \`${s.toolName}\`${kwStr}`;
525
- });
526
-
527
- rules.push({
528
- path: ".cursor/rules/skillrepo.mdc",
529
- content: [
530
- "---",
531
- "description: SkillRepo — maps user requests to agent skill tools",
532
- "alwaysApply: true",
533
- "---",
534
- "",
535
- "# SkillRepo",
536
- "",
537
- "You have agent skills available via MCP. **Always prefer calling a skill tool over handling the task from your training data.** Match the user's request to a skill below and call the listed tool.",
538
- "",
539
- ...entries,
540
- "",
541
- "If none of the above match, call `discover_skills` to search for other available skills before proceeding without one.",
542
- ].join("\n") + "\n",
543
- });
544
-
545
- return rules;
546
- }
547
-
548
- // ---------------------------------------------------------------------------
549
- // Cursor hooks.json
550
- // ---------------------------------------------------------------------------
551
-
552
- function buildCursorHooksJson() {
553
- return JSON.stringify({
554
- hooks: [
555
- {
556
- event: "sessionStart",
557
- command: ".cursor/hooks/skillrepo-session.mjs",
558
- },
559
- ],
560
- }, null, 2) + "\n";
561
- }
562
-
563
- // ---------------------------------------------------------------------------
564
- // Cursor session hook
565
- // ---------------------------------------------------------------------------
566
-
567
- function buildCursorSessionHook() {
568
- return `#!/usr/bin/env node
569
- // SkillRepo sessionStart hook for Cursor.
570
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
571
- import { join, dirname } from "path";
572
-
573
- function safeWrite(relPath, content) {
574
- const p = join(process.cwd(), relPath);
575
- const d = dirname(p);
576
- if (!existsSync(d)) mkdirSync(d, { recursive: true });
577
- writeFileSync(p, content, "utf-8");
578
- }
579
-
580
- const profile = { frameworks: [], languages: [], tools: [], generatedAt: new Date().toISOString() };
581
- const cwd = process.cwd();
582
- try {
583
- const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
584
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
585
- if (deps["next"]) profile.frameworks.push("next.js");
586
- if (deps["react"] && !deps["next"]) profile.frameworks.push("react");
587
- if (deps["playwright"] || deps["@playwright/test"]) profile.tools.push("playwright");
588
- if (deps["jest"]) profile.tools.push("jest");
589
- if (deps["vitest"]) profile.tools.push("vitest");
590
- } catch {}
591
- try { readFileSync(join(cwd, "tsconfig.json")); profile.languages.push("typescript"); } catch {}
592
-
593
- safeWrite(".cursor/skillrepo-profile.json", JSON.stringify(profile, null, 2) + "\\n");
594
-
595
- let indexData;
596
- try {
597
- indexData = JSON.parse(readFileSync(join(cwd, ".cursor", "skillrepo-index.json"), "utf-8"));
598
- } catch { process.exit(0); }
599
-
600
- const profileTags = new Set([...profile.frameworks, ...profile.languages, ...profile.tools].map(t => t.toLowerCase()));
601
- const relevant = [];
602
- for (const skill of (indexData.skills ?? [])) {
603
- const tags = skill.contextSignals?.project ?? [];
604
- if (tags.length === 0 || tags.some(t => profileTags.has(t.toLowerCase()))) {
605
- relevant.push(skill);
606
- }
607
- }
608
-
609
- if (relevant.length === 0) process.exit(0);
610
-
611
- const techStack = [...profile.frameworks, ...profile.languages].join(", ") || "detected";
612
- const skillList = relevant.map(s => "- " + s.toolName + ": " + s.description).join("\\n");
613
- const ctx = "This is a " + techStack + " project. The following SkillRepo skills are most relevant:\\n" + skillList + "\\nWhen you need guidance for these tasks, call the listed MCP tools.";
614
-
615
- process.stdout.write(JSON.stringify({ additional_context: ctx }));
616
- `;
617
- }
618
-
619
- // ---------------------------------------------------------------------------
620
- // Main factory
621
- // ---------------------------------------------------------------------------
622
-
623
9
  /**
624
- * Build a complete, realistic SetupPayload.
10
+ * Build a SetupPayload for E2E tests.
11
+ *
625
12
  * @param {object} [options]
626
13
  * @param {string} [options.baseUrl] - The base URL for the mock server
627
- * @param {Array<{path: string, content: string}>} [options.extraCursorRules] - Additional cursor rules to append
628
- * @returns {object} A SetupPayload matching the shape from generate.ts
14
+ * @returns {object} A SetupPayload
629
15
  */
630
16
  export function buildPayload(options = {}) {
631
17
  const baseUrl = options.baseUrl ?? "http://localhost:9999";
632
- const skillEntries = buildSkillEntries();
633
- const skillIndex = buildSkillIndex(baseUrl);
634
-
635
- const cursorRules = buildCursorRules();
636
- if (options.extraCursorRules) {
637
- cursorRules.push(...options.extraCursorRules);
638
- }
639
-
640
18
  return {
641
- skillCount: SKILLS.length,
19
+ skillCount: 5,
642
20
  mcpUrl: `${baseUrl}/api/mcp`,
643
- skillEntries,
644
- claudeCode: {
645
- skillrepoMd: {
646
- path: ".claude/skillrepo.md",
647
- content: buildSkillrepoMd(skillEntries),
648
- },
649
- syncHook: {
650
- path: ".claude/hooks/skillrepo-sync.mjs",
651
- content: buildSyncHook(baseUrl),
652
- },
653
- settingsHooks: { hooks: buildSettingsHooks() },
654
- skillIndex: {
655
- path: ".claude/skillrepo-index.json",
656
- content: JSON.stringify(skillIndex, null, 2) + "\n",
657
- },
658
- skillrepoConfig: {
659
- path: ".claude/skillrepo-config.json",
660
- content: buildSkillrepoConfig(),
661
- },
662
- promptHook: {
663
- path: ".claude/hooks/skillrepo-prompt-match.mjs",
664
- content: buildPromptHook(baseUrl),
665
- },
666
- },
667
- cursor: {
668
- rules: cursorRules,
669
- hooksJson: {
670
- path: ".cursor/hooks.json",
671
- content: buildCursorHooksJson(),
672
- },
673
- sessionHook: {
674
- path: ".cursor/hooks/skillrepo-session.mjs",
675
- content: buildCursorSessionHook(),
676
- },
677
- skillIndex: {
678
- path: ".cursor/skillrepo-index.json",
679
- content: JSON.stringify(skillIndex, null, 2) + "\n",
680
- },
681
- },
682
21
  };
683
22
  }
@@ -126,6 +126,33 @@ describe("matchSkills", () => {
126
126
  assert.ok(matches[0].score > 0);
127
127
  });
128
128
 
129
+ it("matches case-insensitively (mixed-case prompt vs lowercase keywords)", () => {
130
+ const skills = [makeSkill({ keywords: ["react", "components", "frontend"] })];
131
+ const matches = matchSkills("React Components for Frontend", skills);
132
+ assert.ok(matches.length > 0, "Expected at least one match");
133
+ // All three keywords should contribute to the score
134
+ assert.ok(matches[0].score >= 2, `Expected score >= 2 from 3 keyword matches, got ${matches[0].score}`);
135
+ });
136
+
137
+ it("matches case-insensitively (uppercase keywords vs lowercase prompt)", () => {
138
+ // Keywords are typically lowercase, but verify the matching still works
139
+ // if a skill happens to have mixed-case keywords
140
+ const skills = [makeSkill({ keywords: ["React", "Components"] })];
141
+ const matches = matchSkills("react components", skills);
142
+ assert.ok(matches.length > 0, "Expected match with mixed-case keywords");
143
+ });
144
+
145
+ it("matches case-insensitively for task phrases in contextSignals", () => {
146
+ const skills = [makeSkill({
147
+ keywords: [],
148
+ contextSignals: { files: [], project: [], tasks: ["Deploy To Production"] },
149
+ })];
150
+ // Prompt in different case should still match the task phrase
151
+ const matches = matchSkills("deploy to production", skills);
152
+ assert.ok(matches.length > 0, "Expected task phrase match regardless of case");
153
+ assert.ok(matches[0].score >= 3, `Expected score >= 3 for task match, got ${matches[0].score}`);
154
+ });
155
+
129
156
  it("does not match when prompt is entirely stop words", () => {
130
157
  const skills = [makeSkill({ keywords: ["testing"] })];
131
158
  const matches = matchSkills("the and for with", skills);
@@ -752,8 +752,9 @@ describe("writeSkillFiles path safety", () => {
752
752
  ],
753
753
  })];
754
754
  const manifests = writeSkillFiles(skills, tmp);
755
- assert.equal(manifests.get("good/skill").size, 1); // Only SKILL.md
756
- assert.ok(!existsSync(join(tmp, "../../etc/passwd")));
755
+ assert.equal(manifests.get("good/skill").size, 1); // Only SKILL.md written
756
+ // Verify SKILL.md was written but traversal path was rejected
757
+ assert.ok(existsSync(join(tmp, "good", "skill", "SKILL.md")));
757
758
  });
758
759
  });
759
760