skillrepo 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }