import { promises as fs } from 'node:fs'; import { join, isAbsolute, basename } from 'node:path'; import { pathGuard, PathScopeError } from './path_guard.js'; // Batch 9.6: read-only skill library. Folders under /data/skills/// // contain a SKILL.md with YAML frontmatter (name + description) and a markdown // body. Three tools expose the library: skill_find (search), skill_use (load // body), skill_resource (read a support file inside the folder). // // Layout is intentionally uniform — scan /data/skills/*/*/SKILL.md at fixed // depth 3. Group folders (depth 1) hold LICENSE + ATTRIBUTION.md + skill // subfolders and are NOT themselves skills. Support files inside skill // folders are reachable via skill_resource, never auto-parsed. // // Cache model mirrors agents.ts: walk on first access, TTL re-walk to pick up // new skills, per-entry mtime check on body access so a hot-edited SKILL.md // is re-read without a restart. No watcher. const SKILLS_ROOT = process.env.SKILLS_ROOT ?? '/data/skills'; const MAX_RESOURCE_BYTES = 5 * 1024 * 1024; const LIST_CACHE_TTL_MS = 60_000; export interface Skill { name: string; description: string; path: string; mtime: number; } interface CachedSkill extends Skill { body: string; } const cache = new Map(); let lastWalkedAt = 0; // ---- Frontmatter parser ---------------------------------------------------- // Minimal `---\n...\n---` extractor. Only `name` and `description` keys are // honored; other frontmatter keys are silently ignored for forward-compat // with the anthropics/skills upstream spec. interface Frontmatter { name?: string; description?: string; } function stripQuotes(s: string): string { if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[0] === s[s.length - 1]) { return s.slice(1, -1); } return s; } function parseFrontmatter(yaml: string): Frontmatter { const fm: Frontmatter = {}; for (const raw of yaml.split('\n')) { const line = raw.trim(); if (line.length === 0) continue; const colon = line.indexOf(':'); if (colon < 0) continue; const key = line.slice(0, colon).trim(); const val = stripQuotes(line.slice(colon + 1).trim()); if (key === 'name') fm.name = val; else if (key === 'description') fm.description = val; } return fm; } interface ParsedSkillFile { name: string; description: string; body: string; } function parseSkillFile(content: string): ParsedSkillFile { const lines = content.split('\n'); let openIdx = -1; for (let i = 0; i < lines.length; i++) { const t = lines[i]!.trim(); if (t === '') continue; if (t === '---') openIdx = i; break; } if (openIdx < 0) throw new Error('missing opening --- fence'); let closeIdx = -1; for (let i = openIdx + 1; i < lines.length; i++) { if (lines[i]!.trim() === '---') { closeIdx = i; break; } } if (closeIdx < 0) throw new Error('missing closing --- fence'); const yamlText = lines.slice(openIdx + 1, closeIdx).join('\n'); const body = lines.slice(closeIdx + 1).join('\n'); const fm = parseFrontmatter(yamlText); if (!fm.name) throw new Error('frontmatter missing name'); if (!fm.description) throw new Error('frontmatter missing description'); return { name: fm.name, description: fm.description, body }; } // ---- Tree walk ------------------------------------------------------------- // Fixed depth-3 scan: /data/skills///SKILL.md. Two layers of // readdir, no recursion. Group folders without SKILL.md are skipped silently; // LICENSE / ATTRIBUTION.md / other non-SKILL.md files are ignored entirely. // Returns all parseable skills as-found — dedup + collision logging happens // in ensureCache where the sort order is established. async function walkSkills(root: string): Promise { const found: CachedSkill[] = []; let groups; try { groups = await fs.readdir(root, { withFileTypes: true }); } catch { return found; } for (const group of groups) { if (!group.isDirectory() || group.name.startsWith('.')) continue; const groupPath = join(root, group.name); let entries; try { entries = await fs.readdir(groupPath, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith('.')) continue; const skillFolder = join(groupPath, entry.name); const skillFile = join(skillFolder, 'SKILL.md'); let stat; try { stat = await fs.stat(skillFile); } catch { continue; // folder without SKILL.md — silent skip } if (!stat.isFile()) continue; try { const content = await fs.readFile(skillFile, 'utf8'); const parsed = parseSkillFile(content); found.push({ name: parsed.name, description: parsed.description, path: skillFolder, mtime: stat.mtimeMs, body: parsed.body, }); } catch (err) { const reason = err instanceof Error ? err.message : String(err); console.warn(`skills: failed to parse ${skillFile} — ${reason}`); } } } return found; } // ---- Cache ---------------------------------------------------------------- async function ensureCache(): Promise { const now = Date.now(); if (cache.size > 0 && now - lastWalkedAt < LIST_CACHE_TTL_MS) return; let stat; try { stat = await fs.stat(SKILLS_ROOT); } catch { cache.clear(); lastWalkedAt = now; return; } if (!stat.isDirectory()) { cache.clear(); lastWalkedAt = now; return; } const found = await walkSkills(SKILLS_ROOT); // Sort by name asc, then path asc — gives alphabetically-first-wins on // collision and stable, deterministic ordering for /api/skills + skill_find. found.sort((a, b) => { const n = a.name.localeCompare(b.name); return n !== 0 ? n : a.path.localeCompare(b.path); }); cache.clear(); const winnerPath = new Map(); for (const skill of found) { const prev = winnerPath.get(skill.name); if (prev) { console.warn( `skills: name collision "${skill.name}" — kept ${prev}, skipped ${skill.path}`, ); continue; } winnerPath.set(skill.name, skill.path); cache.set(skill.name, skill); } lastWalkedAt = now; } // ---- Public API ----------------------------------------------------------- export async function listSkills(): Promise { await ensureCache(); return Array.from(cache.values()).map((s) => ({ name: s.name, description: s.description, path: s.path, mtime: s.mtime, })); } export interface SkillSummary { name: string; description: string; } export async function findSkills(query: string): Promise { await ensureCache(); const all = Array.from(cache.values()); const q = (query ?? '').trim().toLowerCase(); if (q === '' || q === '*') { return all.map((s) => ({ name: s.name, description: s.description })); } // name match weighted 2x description match. No fancy ranking — substring // scoring is enough for ≤20 skills. const scored = all .map((s) => { let score = 0; if (s.name.toLowerCase().includes(q)) score += 2; if (s.description.toLowerCase().includes(q)) score += 1; return { s, score }; }) .filter((x) => x.score > 0) .sort((a, b) => b.score - a.score) .slice(0, 5); return scored.map(({ s }) => ({ name: s.name, description: s.description })); } // Returns the SKILL.md body with frontmatter stripped, or null if the skill // is unknown. Single-entry mtime refresh: a hot edit shows up on next call. export async function getSkillBody(name: string): Promise { await ensureCache(); const cached = cache.get(name); if (!cached) return null; let stat; try { stat = await fs.stat(join(cached.path, 'SKILL.md')); } catch { cache.delete(name); return null; } if (stat.mtimeMs === cached.mtime) return cached.body; try { const raw = await fs.readFile(join(cached.path, 'SKILL.md'), 'utf8'); const parsed = parseSkillFile(raw); if (parsed.name !== name) { // Skill renamed itself; drop the stale entry. Next listSkills() walks. cache.delete(name); return null; } cached.body = parsed.body; cached.description = parsed.description; cached.mtime = stat.mtimeMs; return cached.body; } catch (err) { const reason = err instanceof Error ? err.message : String(err); console.warn(`skills: re-parse failed for ${name} — ${reason}`); cache.delete(name); return null; } } export type SkillResourceErrorCode = 'unknown_skill' | 'unknown_resource' | 'path_escape'; export type SkillResourceResult = | { ok: true; content: string } | { ok: false; code: SkillResourceErrorCode; message: string }; export async function getSkillResource( name: string, relativePath: string, ): Promise { await ensureCache(); const cached = cache.get(name); if (!cached) { return { ok: false, code: 'unknown_skill', message: `unknown skill: ${name}` }; } if (typeof relativePath !== 'string' || relativePath.trim() === '') { return { ok: false, code: 'unknown_resource', message: 'path is required' }; } // Syntactic pre-check — catches the common "../../etc/passwd" attempt // before realpath dereferences any symlinks. if (isAbsolute(relativePath) || relativePath.split(/[\\/]/).some((seg) => seg === '..')) { return { ok: false, code: 'path_escape', message: `path escapes skill folder: ${relativePath}` }; } // SKILL.md is the manifest — skill_use is the right tool to read it. if (basename(relativePath) === 'SKILL.md') { return { ok: false, code: 'unknown_resource', message: 'use skill_use to read SKILL.md' }; } let real: string; try { real = await pathGuard(cached.path, relativePath); } catch (err) { if (err instanceof PathScopeError) { const code: SkillResourceErrorCode = err.message.includes('escapes') ? 'path_escape' : 'unknown_resource'; return { ok: false, code, message: err.message }; } throw err; } const stat = await fs.stat(real); if (!stat.isFile()) { return { ok: false, code: 'unknown_resource', message: 'not a file' }; } if (stat.size > MAX_RESOURCE_BYTES) { return { ok: false, code: 'unknown_resource', message: `file too large (${stat.size} bytes, max ${MAX_RESOURCE_BYTES})`, }; } const content = await fs.readFile(real, 'utf8'); return { ok: true, content }; }