import { readFileSync, readdirSync, existsSync, lstatSync } from 'fs'; import { join } from 'path'; import { logger } from '../logger.js'; import matter from 'gray-matter'; export const VALID_SKILL_NAME = /^[a-z0-9_-]+$/; export interface SkillEntry { name: string; description: string; triggers: string[]; source: 'system' | 'user'; filePath: string; dirPath: string | null; } function parseSkillFile(filePath: string, source: 'system' | 'user', dirPath: string | null = null): SkillEntry | null { try { const raw = readFileSync(filePath, 'utf-8'); const { data } = matter(raw); if (!data || typeof data.name !== 'string' || !data.name) return null; if (!VALID_SKILL_NAME.test(data.name)) { logger.warn(`[skill-catalog] skipping skill with invalid name: ${data.name} in ${filePath}`); return null; } return { name: data.name, description: typeof data.description === 'string' ? data.description : '', triggers: Array.isArray(data.triggers) ? data.triggers : [], source, filePath, dirPath, }; } catch (e) { logger.warn(`[skill-catalog] failed to parse ${filePath}: ${e}`); return null; } } function scanDir(dir: string, source: 'system' | 'user'): SkillEntry[] { if (!existsSync(dir)) return []; const entries = readdirSync(dir); const results: SkillEntry[] = []; const dirNames = new Set(); // Pass 1: directories — look for {dir}/SKILL.md for (const entry of entries) { const fullPath = join(dir, entry); let stat; try { stat = lstatSync(fullPath); } catch { continue; } // Skip symlinks if (stat.isSymbolicLink()) continue; if (!stat.isDirectory()) continue; const skillMdPath = join(fullPath, 'SKILL.md'); if (!existsSync(skillMdPath)) continue; // Skip if SKILL.md itself is a symlink try { if (lstatSync(skillMdPath).isSymbolicLink()) continue; } catch { continue; } const parsed = parseSkillFile(skillMdPath, source, fullPath); if (parsed) { dirNames.add(entry); results.push(parsed); } } // Pass 2: flat .md files — skip if same-name directory was found in pass 1 for (const entry of entries) { if (!entry.endsWith('.md')) continue; const baseName = entry.slice(0, -3); if (dirNames.has(baseName)) continue; const fullPath = join(dir, entry); let stat; try { stat = lstatSync(fullPath); } catch { continue; } // Skip symlinks if (stat.isSymbolicLink()) continue; if (!stat.isFile()) continue; const parsed = parseSkillFile(fullPath, source, null); if (parsed) { results.push(parsed); } } return results; } export class SkillCatalog { private systemSkills: SkillEntry[] = []; private cache = new Map(); private readonly ttlMs = 60_000; constructor( private readonly systemDir: string, private readonly userRoot: string, ) { this.systemSkills = scanDir(systemDir, 'system'); if (this.systemSkills.length > 0) { logger.info(`[skill-catalog] loaded ${this.systemSkills.length} system skills from ${systemDir}`); } } getForUser(userId: string): SkillEntry[] { const cached = this.cache.get(userId); if (cached && Date.now() - cached.ts < this.ttlMs) return cached.entries; const userDir = join(this.userRoot, userId, 'skills'); const userSkills = scanDir(userDir, 'user'); const byName = new Map(this.systemSkills.map(s => [s.name, s])); for (const u of userSkills) byName.set(u.name, u); const entries = Array.from(byName.values()); this.cache.set(userId, { ts: Date.now(), entries }); return entries; } getSkillContent(name: string, userId: string): { content: string; dirPath: string | null } | null { const entries = this.getForUser(userId); const entry = entries.find(e => e.name === name); if (!entry) return null; try { const raw = readFileSync(entry.filePath, 'utf-8'); const { content } = matter(raw); return { content: content.trim(), dirPath: entry.dirPath }; } catch { return null; } } buildIndex(userId: string, maxChars: number = 2000): string { const entries = this.getForUser(userId); if (entries.length === 0) return ''; const lines: string[] = []; let totalLen = 0; let included = 0; for (const e of entries) { const line = `- **${e.name}**: ${e.description}`; if (totalLen + line.length + 1 > maxChars && included > 0) break; lines.push(line); totalLen += line.length + 1; included++; } const remaining = entries.length - included; if (remaining > 0) { lines.push(`... and ${remaining} more skills (use ListSkills to see all)`); } return lines.join('\n'); } invalidate(userId: string): void { this.cache.delete(userId); this.systemSkills = scanDir(this.systemDir, 'system'); } getSkillBinds(userId: string): Array<{ src: string; dest: string }> { const binds: Array<{ src: string; dest: string }> = []; if (existsSync(this.systemDir)) { binds.push({ src: this.systemDir, dest: '/skills' }); } const userDir = join(this.userRoot, userId, 'skills'); if (existsSync(userDir)) { binds.push({ src: userDir, dest: '/user-skills' }); } return binds; } getSystemDir(): string { return this.systemDir; } getUserSkillDir(userId: string): string { return join(this.userRoot, userId, 'skills'); } refreshSystem(): void { this.systemSkills = scanDir(this.systemDir, 'system'); this.cache.clear(); } }