/** * Shared skill plumbing for all three harnesses. * * Canonical on-disk layout (single source of truth): * workspace/skills//SKILL.md — YAML frontmatter with `name` (= folder * name) and `description`. See SKILL_FORMAT_MIGRATION.md. * * Each harness consumes that one layout its own way: * - claude: mirrored into `workspace/.claude/skills` (the Agent SDK's * project-skill discovery root) and enabled via the `skills` option — * the SDK then lists name+description in context and lazy-loads bodies * through its native Skill tool. * - codex: mirrored into `workspace/.codex/skills` (codex's repo-scope * root) and primed via `skills/list` — codex's own router takes over. * - pi: no native skill machinery, so `buildSkillsIndex()` appends a * name+description index to the system prompt and the agent reads * `skills//SKILL.md` on demand (hermes-style progressive * disclosure: metadata always in context, body only when used). */ import fs from 'fs'; import path from 'path'; import { log } from '../../shared/logger.js'; import { WORKSPACE_DIR } from '../../shared/paths.js'; export const SKILLS_DIR = path.join(WORKSPACE_DIR, 'skills'); /** Sorted names of installed skill folders (dirs or symlinks under skills/). */ export function listSkillNames(): string[] { try { return fs.readdirSync(SKILLS_DIR, { withFileTypes: true }) .filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith('.')) .map((e) => e.name) .sort(); } catch { return []; // no skills dir — nothing installed } } /** * Mirror workspace/skills// as symlinks * (idempotent), and prune symlinks for skills that were uninstalled. * Returns the sorted list of mirrored skill names. * * Only symlinks are ever pruned — a real directory someone dropped into the * mirror root is left alone. */ export function mirrorSkillsInto(mirrorRoot: string, label: string): string[] { const names = listSkillNames(); if (names.length) { try { fs.mkdirSync(mirrorRoot, { recursive: true }); } catch {} } const mirrored: string[] = []; for (const name of names) { const target = path.join(SKILLS_DIR, name); const link = path.join(mirrorRoot, name); try { const cur = fs.existsSync(link) ? fs.realpathSync(link) : null; if (cur !== fs.realpathSync(target)) { try { fs.rmSync(link, { recursive: true, force: true }); } catch {} fs.symlinkSync(target, link, 'dir'); } mirrored.push(name); } catch (err: any) { log.warn(`[${label}] could not mirror skill "${name}" into ${path.basename(path.dirname(mirrorRoot))}/skills: ${err.message}`); } } // Prune stale symlinks (skill uninstalled) so dead links never reach the harness. try { for (const entry of fs.readdirSync(mirrorRoot, { withFileTypes: true })) { if (!entry.isSymbolicLink() || names.includes(entry.name)) continue; try { fs.unlinkSync(path.join(mirrorRoot, entry.name)); } catch {} } } catch {} return mirrored; } /** * Parse `name` and `description` from a SKILL.md YAML frontmatter block. * Handles plain, quoted, and folded/literal (`>-`, `|`) scalar styles — * enough for the two mandated keys without pulling in a YAML dependency. */ export function parseSkillFrontmatter(skillMdPath: string): { name?: string; description?: string } { let raw: string; try { raw = fs.readFileSync(skillMdPath, 'utf-8'); } catch { return {}; } if (!raw.startsWith('---')) return {}; const end = raw.indexOf('\n---', 3); if (end === -1) return {}; const lines = raw.slice(raw.indexOf('\n') + 1, end).split('\n'); const out: Record = {}; for (let i = 0; i < lines.length; i++) { const m = lines[i].match(/^(name|description):\s*(.*)$/); if (!m) continue; let value = m[2].trim(); if (/^[>|][+-]?$/.test(value)) { // Block scalar — collect indented continuation lines, fold with spaces. const parts: string[] = []; while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]) || lines[i + 1].trim() === '')) { i++; if (lines[i].trim()) parts.push(lines[i].trim()); } value = parts.join(' '); } else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1).replace(/\\"/g, '"'); } out[m[1]] = value; } return out; } /** * Compact installed-skills index for system-prompt injection (pi harness). * One name+description line per skill — the body stays on disk until the * agent actually opens it. Returns '' when no skills are installed. */ export function buildSkillsIndex(): string { const entries: string[] = []; for (const name of listSkillNames()) { const fm = parseSkillFrontmatter(path.join(SKILLS_DIR, name, 'SKILL.md')); const description = fm.description || '(no description — open the SKILL.md)'; entries.push(`- **${fm.name || name}** — ${description}`); } if (!entries.length) return ''; return `\n\n---\n# Installed Skills\n\nScan this list on every request. When a request matches a skill — even partially — read \`skills//SKILL.md\` before acting and follow it.\n\n${entries.join('\n')}`; }