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 +1 -1
- package/src/lib/first-sync.mjs +14 -0
- package/src/test/e2e/HANDOFF.md +4 -22
- package/src/test/e2e/cli-init.test.mjs +4 -19
- package/src/test/e2e/payload-factory.mjs +8 -669
- package/src/test/hooks/skillrepo-prompt-match.test.mjs +27 -0
- package/src/test/hooks/skillrepo-sync.test.mjs +3 -2
package/package.json
CHANGED
package/src/lib/first-sync.mjs
CHANGED
|
@@ -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,
|
package/src/test/e2e/HANDOFF.md
CHANGED
|
@@ -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
|
|
96
|
+
Must generate `SetupPayload` objects matching what `generateSetupPayload()` produces.
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
2
|
+
* Factory for SetupPayload objects used in CLI E2E tests.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
-
* @
|
|
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:
|
|
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
|
-
|
|
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
|
|