/** * SkillLoader - SKILL.md capability bridge. * Loads skill directories into Capability manifests without executing skill content. */ import { existsSync, readFileSync, statSync, readdirSync } from 'fs' import { resolve, relative, basename } from 'path' import type { CapabilityManifest } from './CapabilityManifestValidator.js' export interface SkillDefinition { id: string name: string description: string directory: string content: string frontmatter: Record manifest: CapabilityManifest } export function loadSkillDirectory(skill_dir: string, trusted_roots: string[]): SkillDefinition { const directory = resolve(skill_dir) ensureTrusted(directory, trusted_roots) const skill_path = resolve(directory, 'SKILL.md') if (!existsSync(skill_path) || !statSync(skill_path).isFile()) { throw new Error(`SKILL.md not found in ${directory}`) } const raw = readFileSync(skill_path, 'utf-8') const parsed = parseSkillMarkdown(raw) const name = slug(String(parsed.frontmatter.name || basename(directory))) const description = String(parsed.frontmatter.description || firstParagraph(parsed.body) || `Skill ${name}`) const toolName = `skill.${name}` const manifest: CapabilityManifest = { schema_version: 1, capability_id: `skill-${name}`, display_name: name, version: String(parsed.frontmatter.version || '1.0.0'), description, trust_level: 'project_local', tools: [{ name: toolName, category: 'internal', permissions: { read: true, write: false, network: false }, input_schema: { type: 'object', properties: { task: { type: 'string' }, skill_directory: { type: 'string' }, }, required: ['task'], }, }], } return { id: name, name, description, directory, content: parsed.body, frontmatter: parsed.frontmatter, manifest } } export function loadSkillsFromRoots(roots: string[]): SkillDefinition[] { const skills: SkillDefinition[] = [] for (const root of roots.map((r) => resolve(r))) { if (!existsSync(root) || !statSync(root).isDirectory()) continue const direct = resolve(root, 'SKILL.md') if (existsSync(direct)) { skills.push(loadSkillDirectory(root, roots)) continue } const entries = Array.from(new Set(readDirectoryNames(root))) for (const entry of entries) { const dir = resolve(root, entry) if (existsSync(resolve(dir, 'SKILL.md'))) skills.push(loadSkillDirectory(dir, roots)) } } return skills } function ensureTrusted(path: string, roots: string[]): void { const trusted = roots.map((root) => resolve(root)).some((root) => { const rel = relative(root, path) return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/')) }) if (!trusted) throw new Error(`Skill path is outside trusted roots: ${path}`) } function parseSkillMarkdown(raw: string): { frontmatter: Record; body: string } { if (!raw.startsWith('---\n')) return { frontmatter: {}, body: raw.trim() } const end = raw.indexOf('\n---\n', 4) if (end === -1) return { frontmatter: {}, body: raw.trim() } const frontmatter = parseFrontmatter(raw.slice(4, end)) return { frontmatter, body: raw.slice(end + 5).trim() } } function parseFrontmatter(text: string): Record { const out: Record = {} for (const line of text.split(/\r?\n/)) { const idx = line.indexOf(':') if (idx <= 0) continue const key = line.slice(0, idx).trim() const value = line.slice(idx + 1).trim().replace(/^['"]|['"]$/g, '') out[key] = value } return out } function firstParagraph(text: string): string { return text.split(/\n\s*\n/).map((p) => p.trim()).find(Boolean) || '' } function slug(value: string): string { const next = value.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') return next || 'skill' } function readDirectoryNames(root: string): string[] { return readdirSync(root).filter((name) => { const path = resolve(root, name) return statSync(path).isDirectory() }) }