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 +1 -1
- package/src/commands/init.mjs +29 -13
- package/src/hooks/skillrepo-prompt-match.mjs +344 -0
- package/src/hooks/skillrepo-sync.mjs +769 -0
- package/src/lib/first-sync.mjs +65 -0
- package/src/lib/mergers/gitignore.mjs +39 -0
- package/src/lib/mergers/hooks-json.mjs +107 -57
- package/src/lib/paths.mjs +10 -0
- package/src/lib/write-configs.mjs +151 -71
- package/src/test/detect-ides.test.mjs +1 -1
- package/src/test/e2e/HANDOFF.md +4 -22
- package/src/test/e2e/cli-init.test.mjs +37 -943
- package/src/test/e2e/mock-server.mjs +13 -0
- package/src/test/e2e/payload-factory.mjs +8 -881
- package/src/test/hooks/detect-migration.test.mjs +93 -0
- package/src/test/hooks/skillrepo-prompt-match.test.mjs +384 -0
- package/src/test/hooks/skillrepo-sync.test.mjs +1037 -0
- package/src/test/mergers/gitignore.test.mjs +63 -0
- package/src/test/mergers/hooks-json.test.mjs +106 -71
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SkillRepo SessionStart hook — syncs skill library to local files and writes
|
|
4
|
+
* deterministic .claude/rules/ files for skill delivery.
|
|
5
|
+
*
|
|
6
|
+
* Standalone script: no npm dependencies, Node.js built-ins only.
|
|
7
|
+
* Installed by `npx skillrepo init` to `.claude/hooks/skillrepo-sync.mjs`.
|
|
8
|
+
*
|
|
9
|
+
* Part of #531 (Re-architect skill delivery, Phase B).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, renameSync } from "node:fs";
|
|
13
|
+
import { join, dirname, resolve } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Constants
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const SYNC_TIMEOUT_MS = 10_000;
|
|
23
|
+
const DEFAULT_SERVER_URL = "https://skillrepo.dev";
|
|
24
|
+
// Default: no cap — governance requires parity with manually created rules files.
|
|
25
|
+
// Users can override via config.json: { "maxRulesFiles": 10, "maxRulesBudgetBytes": 512000 }
|
|
26
|
+
const DEFAULT_MAX_RULES_FILES = Infinity;
|
|
27
|
+
const DEFAULT_MAX_RULES_BUDGET_BYTES = Infinity;
|
|
28
|
+
const RULES_FILE_PREFIX = "skillrepo-";
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Path safety — prevent traversal from server-controlled strings
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** Validate a path segment (owner, name) is safe for use in file paths. */
|
|
35
|
+
export function isSafeSegment(segment) {
|
|
36
|
+
if (!segment || typeof segment !== "string") return false;
|
|
37
|
+
if (segment.includes("..")) return false;
|
|
38
|
+
if (segment.includes("/") || segment.includes("\\")) return false;
|
|
39
|
+
if (segment.startsWith(".")) return false;
|
|
40
|
+
// agentskills.io spec: lowercase alphanumeric + hyphens,
|
|
41
|
+
// no leading/trailing/consecutive hyphens
|
|
42
|
+
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(segment);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Validate a file path from the server is contained within a base directory. */
|
|
46
|
+
export function isSafeFilePath(baseDir, filePath) {
|
|
47
|
+
const resolved = resolve(baseDir, filePath);
|
|
48
|
+
return resolved.startsWith(resolve(baseDir) + "/") || resolved === resolve(baseDir);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Config resolution
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read config from ~/.claude/skillrepo/config.json with fallbacks.
|
|
57
|
+
* Returns null if no config/key can be found.
|
|
58
|
+
*/
|
|
59
|
+
export function readConfig() {
|
|
60
|
+
const configPath = join(homedir(), ".claude", "skillrepo", "config.json");
|
|
61
|
+
|
|
62
|
+
// Primary: global config file (written by `npx skillrepo init`, #535)
|
|
63
|
+
try {
|
|
64
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
65
|
+
const cfg = JSON.parse(raw);
|
|
66
|
+
if (cfg.apiKey) {
|
|
67
|
+
return {
|
|
68
|
+
apiKey: cfg.apiKey,
|
|
69
|
+
serverUrl: cfg.serverUrl || DEFAULT_SERVER_URL,
|
|
70
|
+
maxRulesFiles: cfg.maxRulesFiles ?? DEFAULT_MAX_RULES_FILES,
|
|
71
|
+
maxRulesBudgetBytes: cfg.maxRulesBudgetBytes ?? DEFAULT_MAX_RULES_BUDGET_BYTES,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
} catch { /* config not found or invalid — try fallbacks */ }
|
|
75
|
+
|
|
76
|
+
// Fallback: environment variable
|
|
77
|
+
const envKey = process.env.SKILLREPO_ACCESS_KEY;
|
|
78
|
+
if (envKey) {
|
|
79
|
+
return {
|
|
80
|
+
apiKey: envKey,
|
|
81
|
+
serverUrl: process.env.SKILLREPO_SERVER_URL || DEFAULT_SERVER_URL,
|
|
82
|
+
maxRulesFiles: DEFAULT_MAX_RULES_FILES,
|
|
83
|
+
maxRulesBudgetBytes: DEFAULT_MAX_RULES_BUDGET_BYTES,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Fallback: .env.local in project root
|
|
88
|
+
const projectKey = readEnvFileKey(process.cwd());
|
|
89
|
+
if (projectKey) {
|
|
90
|
+
return {
|
|
91
|
+
apiKey: projectKey,
|
|
92
|
+
serverUrl: DEFAULT_SERVER_URL,
|
|
93
|
+
maxRulesFiles: DEFAULT_MAX_RULES_FILES,
|
|
94
|
+
maxRulesBudgetBytes: DEFAULT_MAX_RULES_BUDGET_BYTES,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Read SKILLREPO_ACCESS_KEY from .env.local or .env in a directory.
|
|
103
|
+
*/
|
|
104
|
+
export function readEnvFileKey(dir) {
|
|
105
|
+
for (const file of [".env.local", ".env"]) {
|
|
106
|
+
try {
|
|
107
|
+
const lines = readFileSync(join(dir, file), "utf-8").split("\n");
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
const trimmed = line.trim();
|
|
110
|
+
if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
|
|
111
|
+
const eqIdx = trimmed.indexOf("=");
|
|
112
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
113
|
+
if (key === "SKILLREPO_ACCESS_KEY") {
|
|
114
|
+
let val = trimmed.slice(eqIdx + 1).trim();
|
|
115
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
116
|
+
val = val.slice(1, -1);
|
|
117
|
+
}
|
|
118
|
+
val = val.replace(/\s+#.*$/, "");
|
|
119
|
+
if (val) return val;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch { /* file doesn't exist */ }
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Sync API
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Fetch library data from the sync endpoint.
|
|
133
|
+
* Returns the parsed response or null on failure.
|
|
134
|
+
*/
|
|
135
|
+
export async function fetchSync(config, lastSync) {
|
|
136
|
+
const url = new URL("/api/v1/sync/library", config.serverUrl);
|
|
137
|
+
if (lastSync) url.searchParams.set("since", lastSync);
|
|
138
|
+
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
const timeout = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS);
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const res = await fetch(url.toString(), {
|
|
144
|
+
headers: {
|
|
145
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
146
|
+
Accept: "application/json",
|
|
147
|
+
},
|
|
148
|
+
signal: controller.signal,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (res.status === 304) return { notModified: true };
|
|
152
|
+
if (!res.ok) {
|
|
153
|
+
process.stderr.write(`[skillrepo] Sync API returned ${res.status}\n`);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
return await res.json();
|
|
159
|
+
} catch {
|
|
160
|
+
process.stderr.write("[skillrepo] Sync API returned non-JSON response\n");
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
const msg = err?.name === "AbortError" ? "timed out" : err?.message ?? "unknown error";
|
|
165
|
+
process.stderr.write(`[skillrepo] Sync failed: ${msg}\n`);
|
|
166
|
+
return null;
|
|
167
|
+
} finally {
|
|
168
|
+
clearTimeout(timeout);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// File operations (atomic writes)
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Atomically write a file: write to a temp file then rename.
|
|
178
|
+
* Prevents race conditions between concurrent sessions.
|
|
179
|
+
*/
|
|
180
|
+
export function atomicWrite(filePath, content, encoding = "utf-8") {
|
|
181
|
+
const dir = dirname(filePath);
|
|
182
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
183
|
+
|
|
184
|
+
const tmpName = `.tmp-${randomBytes(8).toString("hex")}`;
|
|
185
|
+
const tmpPath = join(dir, tmpName);
|
|
186
|
+
|
|
187
|
+
if (encoding === "base64") {
|
|
188
|
+
writeFileSync(tmpPath, Buffer.from(content, "base64"));
|
|
189
|
+
} else {
|
|
190
|
+
writeFileSync(tmpPath, content, "utf-8");
|
|
191
|
+
}
|
|
192
|
+
renameSync(tmpPath, filePath);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Recursively remove a directory if it exists.
|
|
197
|
+
*/
|
|
198
|
+
function removeDir(dirPath) {
|
|
199
|
+
try {
|
|
200
|
+
rmSync(dirPath, { recursive: true, force: true });
|
|
201
|
+
} catch { /* ignore */ }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Remove a single file if it exists.
|
|
206
|
+
*/
|
|
207
|
+
function removeFile(filePath) {
|
|
208
|
+
try {
|
|
209
|
+
rmSync(filePath, { force: true });
|
|
210
|
+
} catch { /* ignore */ }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Skill file writing
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Write synced skill files to the local cache.
|
|
219
|
+
* Returns the set of file paths that should exist for each skill (manifest).
|
|
220
|
+
*/
|
|
221
|
+
export function writeSkillFiles(skills, skillsDir) {
|
|
222
|
+
const manifests = new Map(); // owner/name → Set of relative paths
|
|
223
|
+
|
|
224
|
+
for (const skill of skills) {
|
|
225
|
+
if (!isSafeSegment(skill.owner) || !isSafeSegment(skill.name)) continue;
|
|
226
|
+
|
|
227
|
+
const skillDir = join(skillsDir, skill.owner, skill.name);
|
|
228
|
+
const currentPaths = new Set();
|
|
229
|
+
|
|
230
|
+
for (const file of skill.files ?? []) {
|
|
231
|
+
if (!isSafeFilePath(skillDir, file.path)) continue; // skip traversal attempts
|
|
232
|
+
const filePath = join(skillDir, file.path);
|
|
233
|
+
const encoding = file.encoding === "base64" ? "base64" : "utf-8";
|
|
234
|
+
atomicWrite(filePath, file.content, encoding);
|
|
235
|
+
currentPaths.add(file.path);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
manifests.set(`${skill.owner}/${skill.name}`, currentPaths);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return manifests;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Manifest-diff cleanup: remove files that existed in a previous version
|
|
246
|
+
* but are absent in the current version.
|
|
247
|
+
*/
|
|
248
|
+
export function cleanupStaleSkillFiles(manifests, skillsDir) {
|
|
249
|
+
for (const [key, expectedPaths] of manifests) {
|
|
250
|
+
const [owner, name] = key.split("/");
|
|
251
|
+
const skillDir = join(skillsDir, owner, name);
|
|
252
|
+
if (!existsSync(skillDir)) continue;
|
|
253
|
+
|
|
254
|
+
// Walk the skill directory and remove files not in the manifest
|
|
255
|
+
walkDir(skillDir, (filePath) => {
|
|
256
|
+
const relativePath = filePath.slice(skillDir.length + 1).replace(/\\/g, "/");
|
|
257
|
+
if (!expectedPaths.has(relativePath)) {
|
|
258
|
+
removeFile(filePath);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Walk directory recursively, calling fn for each file (not directory).
|
|
266
|
+
*/
|
|
267
|
+
function walkDir(dir, fn) {
|
|
268
|
+
let entries;
|
|
269
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); }
|
|
270
|
+
catch { return; }
|
|
271
|
+
|
|
272
|
+
for (const entry of entries) {
|
|
273
|
+
const full = join(dir, entry.name);
|
|
274
|
+
if (entry.isDirectory()) {
|
|
275
|
+
walkDir(full, fn);
|
|
276
|
+
} else {
|
|
277
|
+
fn(full);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Process removals: delete cache directories and corresponding rules files.
|
|
284
|
+
*/
|
|
285
|
+
export function processRemovals(removals, skillsDir, projectDir) {
|
|
286
|
+
for (const removal of removals) {
|
|
287
|
+
if (!isSafeSegment(removal.owner) || !isSafeSegment(removal.name)) continue;
|
|
288
|
+
const cacheDir = join(skillsDir, removal.owner, removal.name);
|
|
289
|
+
removeDir(cacheDir);
|
|
290
|
+
|
|
291
|
+
// Also remove the owner dir if now empty
|
|
292
|
+
const ownerDir = join(skillsDir, removal.owner);
|
|
293
|
+
try {
|
|
294
|
+
const remaining = readdirSync(ownerDir);
|
|
295
|
+
if (remaining.length === 0) removeDir(ownerDir);
|
|
296
|
+
} catch { /* ignore */ }
|
|
297
|
+
|
|
298
|
+
// Remove corresponding rules files
|
|
299
|
+
const rulesName = `${RULES_FILE_PREFIX}${removal.owner}-${removal.name}`;
|
|
300
|
+
removeFile(join(projectDir, ".claude", "rules", `${rulesName}.md`));
|
|
301
|
+
removeFile(join(projectDir, ".cursor", "rules", `${rulesName}.mdc`));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// Repo profiling
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Detect project tech stack by inspecting config files.
|
|
311
|
+
* Returns { frameworks, languages, tools }.
|
|
312
|
+
*/
|
|
313
|
+
export function buildRepoProfile(projectDir) {
|
|
314
|
+
const profile = { frameworks: [], languages: [], tools: [] };
|
|
315
|
+
|
|
316
|
+
// package.json dependencies
|
|
317
|
+
try {
|
|
318
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf-8"));
|
|
319
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
320
|
+
if (deps["next"]) profile.frameworks.push("next.js");
|
|
321
|
+
if (deps["react"] && !deps["next"]) profile.frameworks.push("react");
|
|
322
|
+
if (deps["vue"]) profile.frameworks.push("vue");
|
|
323
|
+
if (deps["angular"] || deps["@angular/core"]) profile.frameworks.push("angular");
|
|
324
|
+
if (deps["svelte"]) profile.frameworks.push("svelte");
|
|
325
|
+
if (deps["express"]) profile.frameworks.push("express");
|
|
326
|
+
if (deps["fastify"]) profile.frameworks.push("fastify");
|
|
327
|
+
if (deps["hono"]) profile.frameworks.push("hono");
|
|
328
|
+
if (deps["playwright"] || deps["@playwright/test"]) profile.tools.push("playwright");
|
|
329
|
+
if (deps["jest"]) profile.tools.push("jest");
|
|
330
|
+
if (deps["vitest"]) profile.tools.push("vitest");
|
|
331
|
+
if (deps["drizzle-orm"]) profile.tools.push("drizzle");
|
|
332
|
+
if (deps["prisma"] || deps["@prisma/client"]) profile.tools.push("prisma");
|
|
333
|
+
if (deps["tailwindcss"]) profile.tools.push("tailwindcss");
|
|
334
|
+
} catch { /* no package.json */ }
|
|
335
|
+
|
|
336
|
+
// Language detection
|
|
337
|
+
const langFiles = [
|
|
338
|
+
["tsconfig.json", "typescript"],
|
|
339
|
+
["pyproject.toml", "python"],
|
|
340
|
+
["go.mod", "go"],
|
|
341
|
+
["Cargo.toml", "rust"],
|
|
342
|
+
["Gemfile", "ruby"],
|
|
343
|
+
];
|
|
344
|
+
for (const [file, lang] of langFiles) {
|
|
345
|
+
try { readFileSync(join(projectDir, file)); profile.languages.push(lang); }
|
|
346
|
+
catch { /* not found */ }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return profile;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// Profile-based scoring
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Score a single skill's relevance to the repo profile.
|
|
358
|
+
* Returns a numeric score (higher = more relevant).
|
|
359
|
+
*
|
|
360
|
+
* Scoring:
|
|
361
|
+
* - Skills with matching contextSignals.project tags: base score 10 + 2 per match
|
|
362
|
+
* - Skills with no project tags: base score 5 (neutral, not penalized)
|
|
363
|
+
* - Skills with project tags that don't overlap: base score 1 (demoted)
|
|
364
|
+
*/
|
|
365
|
+
export function scoreSkillRelevance(skill, profile) {
|
|
366
|
+
const projectTags = skill.contextSignals?.project ?? [];
|
|
367
|
+
if (projectTags.length === 0) return 5; // Neutral — no tags means general-purpose
|
|
368
|
+
|
|
369
|
+
const profileTags = new Set([
|
|
370
|
+
...profile.frameworks,
|
|
371
|
+
...profile.languages,
|
|
372
|
+
...profile.tools,
|
|
373
|
+
].map(t => t.toLowerCase()));
|
|
374
|
+
|
|
375
|
+
if (profileTags.size === 0) return 5; // No profile detected — treat all as neutral
|
|
376
|
+
|
|
377
|
+
const matchCount = projectTags.filter(t => profileTags.has(t.toLowerCase())).length;
|
|
378
|
+
if (matchCount > 0) return 10 + (matchCount * 2);
|
|
379
|
+
return 1; // Tags exist but none match — demoted
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Select top-N skills for rules delivery, enforcing budget constraints.
|
|
384
|
+
*/
|
|
385
|
+
export function selectRulesSkills(skills, profile, config) {
|
|
386
|
+
const maxFiles = config.maxRulesFiles ?? DEFAULT_MAX_RULES_FILES;
|
|
387
|
+
const maxBudget = config.maxRulesBudgetBytes ?? DEFAULT_MAX_RULES_BUDGET_BYTES;
|
|
388
|
+
|
|
389
|
+
// Score and sort
|
|
390
|
+
const scored = skills
|
|
391
|
+
.map(skill => ({ skill, score: scoreSkillRelevance(skill, profile) }))
|
|
392
|
+
.sort((a, b) => b.score - a.score);
|
|
393
|
+
|
|
394
|
+
// Select within budget
|
|
395
|
+
const selected = [];
|
|
396
|
+
let totalBytes = 0;
|
|
397
|
+
|
|
398
|
+
for (const { skill } of scored) {
|
|
399
|
+
if (selected.length >= maxFiles) break;
|
|
400
|
+
|
|
401
|
+
// Find SKILL.md content size
|
|
402
|
+
const skillMd = skill.files?.find(f => f.path === "SKILL.md");
|
|
403
|
+
if (!skillMd) continue;
|
|
404
|
+
|
|
405
|
+
const contentBytes = Buffer.byteLength(skillMd.content, "utf-8");
|
|
406
|
+
if (totalBytes + contentBytes > maxBudget) continue; // Skip if over budget
|
|
407
|
+
|
|
408
|
+
selected.push(skill);
|
|
409
|
+
totalBytes += contentBytes;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return selected;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
// Rules file writing
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Build the rules file naming key: skillrepo-{owner}-{name}
|
|
421
|
+
*/
|
|
422
|
+
export function rulesFileName(owner, name) {
|
|
423
|
+
return `${RULES_FILE_PREFIX}${owner}-${name}`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Write selected skills as .claude/rules/ and .cursor/rules/ files.
|
|
428
|
+
* Returns the set of rules file base names that were written.
|
|
429
|
+
*/
|
|
430
|
+
export function writeRulesFiles(selectedSkills, projectDir) {
|
|
431
|
+
const written = new Set();
|
|
432
|
+
|
|
433
|
+
const claudeRulesDir = join(projectDir, ".claude", "rules");
|
|
434
|
+
const cursorRulesDir = join(projectDir, ".cursor", "rules");
|
|
435
|
+
|
|
436
|
+
for (const skill of selectedSkills) {
|
|
437
|
+
const skillMd = skill.files?.find(f => f.path === "SKILL.md");
|
|
438
|
+
if (!skillMd) continue;
|
|
439
|
+
|
|
440
|
+
const baseName = rulesFileName(skill.owner, skill.name);
|
|
441
|
+
written.add(baseName);
|
|
442
|
+
|
|
443
|
+
// Claude Code: .claude/rules/skillrepo-{owner}-{name}.md
|
|
444
|
+
atomicWrite(join(claudeRulesDir, `${baseName}.md`), skillMd.content);
|
|
445
|
+
|
|
446
|
+
// Cursor: .cursor/rules/skillrepo-{owner}-{name}.mdc
|
|
447
|
+
const mdcContent = buildCursorMdc(skill, skillMd.content);
|
|
448
|
+
atomicWrite(join(cursorRulesDir, `${baseName}.mdc`), mdcContent);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return written;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Build Cursor .mdc file content with frontmatter.
|
|
456
|
+
*/
|
|
457
|
+
function buildCursorMdc(skill, content) {
|
|
458
|
+
const cs = skill.contextSignals;
|
|
459
|
+
const desc = (skill.description || skill.name).replace(/"/g, '\\"').replace(/[\r\n]+/g, " ");
|
|
460
|
+
|
|
461
|
+
// If skill has file signals, use them as globs; otherwise alwaysApply
|
|
462
|
+
if (cs?.files?.length > 0) {
|
|
463
|
+
const globs = cs.files.join(", ");
|
|
464
|
+
return `---\ndescription: "${desc}"\nglobs: ${globs}\n---\n\n${content}`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return `---\ndescription: "${desc}"\nalwaysApply: true\n---\n\n${content}`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Remove stale .claude/rules/skillrepo-*.md and .cursor/rules/skillrepo-*.mdc
|
|
472
|
+
* files that are not in the current selection. Only touches skillrepo- prefixed files.
|
|
473
|
+
*/
|
|
474
|
+
export function cleanupStaleRules(currentSet, projectDir) {
|
|
475
|
+
const claudeRulesDir = join(projectDir, ".claude", "rules");
|
|
476
|
+
const cursorRulesDir = join(projectDir, ".cursor", "rules");
|
|
477
|
+
|
|
478
|
+
cleanupRulesDir(claudeRulesDir, ".md", currentSet);
|
|
479
|
+
cleanupRulesDir(cursorRulesDir, ".mdc", currentSet);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function cleanupRulesDir(dir, extension, currentSet) {
|
|
483
|
+
let entries;
|
|
484
|
+
try { entries = readdirSync(dir); }
|
|
485
|
+
catch { return; } // Directory doesn't exist — nothing to clean
|
|
486
|
+
|
|
487
|
+
for (const entry of entries) {
|
|
488
|
+
if (!entry.startsWith(RULES_FILE_PREFIX)) continue;
|
|
489
|
+
if (!entry.endsWith(extension)) continue;
|
|
490
|
+
|
|
491
|
+
const baseName = entry.slice(0, -extension.length);
|
|
492
|
+
if (!currentSet.has(baseName)) {
|
|
493
|
+
removeFile(join(dir, entry));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
// Local index builder
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Build the local skill index consumed by the UserPromptSubmit hook (#532).
|
|
504
|
+
*/
|
|
505
|
+
export function buildLocalIndex(skills, selectedRulesNames, syncedAt, skillsDir = null) {
|
|
506
|
+
if (!skillsDir) skillsDir = join(homedir(), ".claude", "skillrepo", "skills");
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
version: 1,
|
|
510
|
+
syncedAt,
|
|
511
|
+
skills: skills.map(skill => ({
|
|
512
|
+
owner: skill.owner,
|
|
513
|
+
name: skill.name,
|
|
514
|
+
version: skill.version ?? null,
|
|
515
|
+
description: skill.description ?? "",
|
|
516
|
+
keywords: skill.keywords ?? [],
|
|
517
|
+
contextSignals: skill.contextSignals ?? null,
|
|
518
|
+
localPath: join(skillsDir, skill.owner, skill.name, "SKILL.md"),
|
|
519
|
+
isRulesDelivered: selectedRulesNames.has(rulesFileName(skill.owner, skill.name)),
|
|
520
|
+
})),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
// Delta merge — combine incremental sync with existing index
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Merge delta sync results with the existing local index.
|
|
530
|
+
* Keeps existing skills that weren't updated or removed, loading their
|
|
531
|
+
* SKILL.md content from the local cache.
|
|
532
|
+
*/
|
|
533
|
+
export function mergeDeltaWithIndex(newSkills, removedKeys, indexPath) {
|
|
534
|
+
let existingIndex;
|
|
535
|
+
try {
|
|
536
|
+
existingIndex = JSON.parse(readFileSync(indexPath, "utf-8"));
|
|
537
|
+
} catch { return newSkills; }
|
|
538
|
+
|
|
539
|
+
if (!existingIndex?.skills?.length) return newSkills;
|
|
540
|
+
|
|
541
|
+
const updatedKeys = new Set(newSkills.map(s => `${s.owner}/${s.name}`));
|
|
542
|
+
|
|
543
|
+
// Keep existing skills that weren't updated or removed
|
|
544
|
+
const kept = existingIndex.skills.filter(s => {
|
|
545
|
+
const key = `${s.owner}/${s.name}`;
|
|
546
|
+
return !updatedKeys.has(key) && !removedKeys.has(key);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// Load SKILL.md content from cache for kept skills
|
|
550
|
+
const keptWithContent = kept.map(s => {
|
|
551
|
+
try {
|
|
552
|
+
const content = readFileSync(s.localPath, "utf-8");
|
|
553
|
+
return { ...s, files: [{ path: "SKILL.md", content, encoding: "utf-8" }] };
|
|
554
|
+
} catch { return null; }
|
|
555
|
+
}).filter(Boolean);
|
|
556
|
+
|
|
557
|
+
return [...newSkills, ...keptWithContent];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// Migration detection
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Detect if this is the first run after upgrading from template-string hooks
|
|
566
|
+
* to rules-based delivery (Phase C migration).
|
|
567
|
+
*
|
|
568
|
+
* Detection signals (checked in order):
|
|
569
|
+
* 1. Migration marker file (.claude/skillrepo-migrated) — written by init
|
|
570
|
+
* before it cleans up old files. This is the primary signal for users
|
|
571
|
+
* who upgrade via `npx skillrepo init`.
|
|
572
|
+
* 2. Old format files (.claude/skillrepo-config.json or .claude/skillrepo.md)
|
|
573
|
+
* still present AND no rules files yet. This covers users who upgrade
|
|
574
|
+
* the CLI without re-running init.
|
|
575
|
+
*
|
|
576
|
+
* Returns true if a migration is detected. The caller should show the
|
|
577
|
+
* one-time migration message and delete the marker file.
|
|
578
|
+
*/
|
|
579
|
+
export function detectUpgradeMigration(projectDir) {
|
|
580
|
+
// Signal 1: migration marker from init
|
|
581
|
+
const markerPath = join(projectDir, ".claude", "skillrepo-migrated");
|
|
582
|
+
if (existsSync(markerPath)) return true;
|
|
583
|
+
|
|
584
|
+
// Signal 2: old files present, no rules files yet
|
|
585
|
+
const oldFiles = [
|
|
586
|
+
join(projectDir, ".claude", "skillrepo-config.json"),
|
|
587
|
+
join(projectDir, ".claude", "skillrepo.md"),
|
|
588
|
+
];
|
|
589
|
+
|
|
590
|
+
const hasOldFiles = oldFiles.some(f => existsSync(f));
|
|
591
|
+
if (!hasOldFiles) return false;
|
|
592
|
+
|
|
593
|
+
// Check if rules files already exist
|
|
594
|
+
const rulesDir = join(projectDir, ".claude", "rules");
|
|
595
|
+
try {
|
|
596
|
+
const entries = readdirSync(rulesDir);
|
|
597
|
+
const hasRulesFiles = entries.some(e => e.startsWith(RULES_FILE_PREFIX) && e.endsWith(".md"));
|
|
598
|
+
return !hasRulesFiles;
|
|
599
|
+
} catch {
|
|
600
|
+
// .claude/rules/ doesn't exist yet — definitely a migration
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
// Main entry point
|
|
607
|
+
// ---------------------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
export async function main() {
|
|
610
|
+
const globalDir = join(homedir(), ".claude", "skillrepo");
|
|
611
|
+
const skillsDir = join(globalDir, "skills");
|
|
612
|
+
const lastSyncPath = join(globalDir, ".last-sync");
|
|
613
|
+
const indexPath = join(globalDir, "index.json");
|
|
614
|
+
const projectDir = process.cwd();
|
|
615
|
+
|
|
616
|
+
// ── Config ──────────────────────────────────────────────────
|
|
617
|
+
const config = readConfig();
|
|
618
|
+
if (!config) {
|
|
619
|
+
// No config → can't sync. Check if we have a cache to work from.
|
|
620
|
+
if (!existsSync(indexPath)) {
|
|
621
|
+
// No config AND no cache — user needs to run init
|
|
622
|
+
process.stderr.write("[skillrepo] No config found. Run `npx skillrepo init` to set up.\n");
|
|
623
|
+
}
|
|
624
|
+
process.exit(0);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ── Read last sync timestamp ────────────────────────────────
|
|
628
|
+
let lastSync = null;
|
|
629
|
+
try {
|
|
630
|
+
lastSync = readFileSync(lastSyncPath, "utf-8").trim();
|
|
631
|
+
if (!lastSync || isNaN(new Date(lastSync).getTime())) lastSync = null;
|
|
632
|
+
} catch { /* first sync */ }
|
|
633
|
+
|
|
634
|
+
// ── Fetch from sync endpoint ────────────────────────────────
|
|
635
|
+
const syncResult = await fetchSync(config, lastSync);
|
|
636
|
+
|
|
637
|
+
if (!syncResult || syncResult.notModified) {
|
|
638
|
+
// Sync failed or not modified — but we may still need to write rules
|
|
639
|
+
// from the existing cache
|
|
640
|
+
if (syncResult?.notModified) {
|
|
641
|
+
// Cache is current — re-run rules selection from existing index
|
|
642
|
+
await writeRulesFromExistingIndex(config, projectDir, indexPath);
|
|
643
|
+
process.exit(0);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Sync failed
|
|
647
|
+
if (!existsSync(indexPath)) {
|
|
648
|
+
// First sync failed with no cache — surface warning
|
|
649
|
+
const warning = JSON.stringify({
|
|
650
|
+
hookSpecificOutput: {
|
|
651
|
+
hookEventName: "SessionStart",
|
|
652
|
+
additionalContext: "[skillrepo] Sync failed. No skills available. Check your API key and network connection, or run `npx skillrepo init`.",
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
process.stdout.write(warning);
|
|
656
|
+
}
|
|
657
|
+
// Sync failure with existing cache: preserve everything, exit
|
|
658
|
+
process.exit(0);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ── Write new/updated skill files to cache ───────────────────
|
|
662
|
+
if (syncResult.skills.length > 0) {
|
|
663
|
+
const manifests = writeSkillFiles(syncResult.skills, skillsDir);
|
|
664
|
+
cleanupStaleSkillFiles(manifests, skillsDir);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── Process removals ────────────────────────────────────────
|
|
668
|
+
const removedKeys = new Set();
|
|
669
|
+
if (syncResult.removals?.length > 0) {
|
|
670
|
+
processRemovals(syncResult.removals, skillsDir, projectDir);
|
|
671
|
+
for (const r of syncResult.removals) removedKeys.add(`${r.owner}/${r.name}`);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ── Update .last-sync ───────────────────────────────────────
|
|
675
|
+
atomicWrite(lastSyncPath, syncResult.syncedAt);
|
|
676
|
+
|
|
677
|
+
// ── Merge delta with existing index ─────────────────────────
|
|
678
|
+
const allSkills = lastSync
|
|
679
|
+
? mergeDeltaWithIndex(syncResult.skills, removedKeys, indexPath)
|
|
680
|
+
: syncResult.skills;
|
|
681
|
+
|
|
682
|
+
// ── Profile scoring + rules selection ───────────────────────
|
|
683
|
+
const profile = buildRepoProfile(projectDir);
|
|
684
|
+
const selected = selectRulesSkills(allSkills, profile, config);
|
|
685
|
+
|
|
686
|
+
// ── Detect pre-upgrade state (before writing rules) ─────────
|
|
687
|
+
const isUpgradeMigration = detectUpgradeMigration(projectDir);
|
|
688
|
+
|
|
689
|
+
// ── Write rules files ───────────────────────────────────────
|
|
690
|
+
const writtenNames = writeRulesFiles(selected, projectDir);
|
|
691
|
+
|
|
692
|
+
// ── Cleanup stale rules ─────────────────────────────────────
|
|
693
|
+
cleanupStaleRules(writtenNames, projectDir);
|
|
694
|
+
|
|
695
|
+
// ── Write local index ───────────────────────────────────────
|
|
696
|
+
const index = buildLocalIndex(allSkills, writtenNames, syncResult.syncedAt);
|
|
697
|
+
atomicWrite(indexPath, JSON.stringify(index, null, 2) + "\n");
|
|
698
|
+
|
|
699
|
+
// ── One-time migration message ─────────────────────────────
|
|
700
|
+
// On the first run after upgrading from template-string hooks to
|
|
701
|
+
// rules-based delivery, rules files are written but won't take
|
|
702
|
+
// effect until the next session. Inform the user.
|
|
703
|
+
if (isUpgradeMigration && writtenNames.size > 0) {
|
|
704
|
+
const msg = JSON.stringify({
|
|
705
|
+
hookSpecificOutput: {
|
|
706
|
+
hookEventName: "SessionStart",
|
|
707
|
+
additionalContext: "[skillrepo] SkillRepo has been updated to rules-based delivery. Please restart this session to activate your skills.",
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
process.stdout.write(msg);
|
|
711
|
+
|
|
712
|
+
// Delete the migration marker so the message only fires once
|
|
713
|
+
removeFile(join(projectDir, ".claude", "skillrepo-migrated"));
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Re-run rules selection from an existing index (304 Not Modified case).
|
|
719
|
+
* Reads the index, loads SKILL.md content from cache, and writes rules.
|
|
720
|
+
*/
|
|
721
|
+
export async function writeRulesFromExistingIndex(config, projectDir, indexPath) {
|
|
722
|
+
let index;
|
|
723
|
+
try {
|
|
724
|
+
index = JSON.parse(readFileSync(indexPath, "utf-8"));
|
|
725
|
+
} catch { return; }
|
|
726
|
+
|
|
727
|
+
const profile = buildRepoProfile(projectDir);
|
|
728
|
+
|
|
729
|
+
// Rebuild skills with file content from local cache
|
|
730
|
+
const skillsWithContent = [];
|
|
731
|
+
for (const skill of index.skills ?? []) {
|
|
732
|
+
try {
|
|
733
|
+
const content = readFileSync(skill.localPath, "utf-8");
|
|
734
|
+
skillsWithContent.push({
|
|
735
|
+
...skill,
|
|
736
|
+
files: [{ path: "SKILL.md", content, encoding: "utf-8" }],
|
|
737
|
+
});
|
|
738
|
+
} catch { /* skill cache missing — skip */ }
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const selected = selectRulesSkills(skillsWithContent, profile, config);
|
|
742
|
+
const writtenNames = writeRulesFiles(selected, projectDir);
|
|
743
|
+
cleanupStaleRules(writtenNames, projectDir);
|
|
744
|
+
|
|
745
|
+
// Update index with current rules delivery status
|
|
746
|
+
const updatedIndex = {
|
|
747
|
+
...index,
|
|
748
|
+
skills: index.skills.map(s => ({
|
|
749
|
+
...s,
|
|
750
|
+
isRulesDelivered: writtenNames.has(rulesFileName(s.owner, s.name)),
|
|
751
|
+
})),
|
|
752
|
+
};
|
|
753
|
+
atomicWrite(indexPath, JSON.stringify(updatedIndex, null, 2) + "\n");
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ── Run ───────────────────────────────────────────────────────
|
|
757
|
+
// Only execute main when run as a script (not when imported for testing).
|
|
758
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
759
|
+
const isMainModule = process.argv[1] === __filename;
|
|
760
|
+
|
|
761
|
+
if (isMainModule) {
|
|
762
|
+
// Consume stdin (hook input) — SessionStart doesn't use the payload
|
|
763
|
+
for await (const _chunk of process.stdin) { /* drain */ }
|
|
764
|
+
|
|
765
|
+
main().catch((err) => {
|
|
766
|
+
process.stderr.write(`[skillrepo] Sync error: ${err.message}\n`);
|
|
767
|
+
process.exit(0); // Don't block session start
|
|
768
|
+
});
|
|
769
|
+
}
|