UNPKG

@withpica/mcp-server

Version:

MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog

264 lines (242 loc) • 8.39 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. /** * ADR-140 Phase 2b — generator for `skills.generated.ts`. * * Reads `src/skills/<name>/SKILL.md` files, parses YAML frontmatter, and emits * a TypeScript constant consumed by SkillsRegistry. Generation at build time * (not runtime FS reads) so npm-published `dist/` ships bundled bytes — `.md` * files are excluded by `.npmignore`. * * Frontmatter shape: * --- * name: kebab-case-name * description: one-line * triggers: * - phrase one * - phrase two * audience: manager / artist / etc * tools_required: * - tool_a * - tool_b * output: one-line description * --- * * Hand-rolled YAML parser (frontmatter only — no nested objects, no anchors). * Adding a `js-yaml` dep would be overkill for the constrained shape. * * Re-runs are deterministic: same .md files produce a byte-identical output. */ import { readFileSync, readdirSync, writeFileSync, existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const MCP_SERVER_ROOT = join(__dirname, ".."); const SKILLS_ROOT = join(MCP_SERVER_ROOT, "src/skills"); const OUTPUT_PATH = join(SKILLS_ROOT, "skills.generated.ts"); interface SkillFrontmatter { name: string; description: string; triggers: string[]; audience: string; tools_required: string[]; output: string; definition_of_done?: string; when_not_to_use?: string; } interface ParsedSkill extends SkillFrontmatter { body: string; } function parseFrontmatter(src: string, sourceFile: string): ParsedSkill { // Strip leading HTML comment (copyright header) if present, then expect --- let i = 0; if (src.startsWith("<!--")) { const end = src.indexOf("-->"); if (end === -1) { throw new Error(`${sourceFile}: unterminated HTML comment header`); } i = end + 3; // Skip whitespace/newlines while (i < src.length && /\s/.test(src[i])) i++; } if (src.slice(i, i + 3) !== "---") { throw new Error(`${sourceFile}: missing --- frontmatter opener`); } i += 3; // Skip newline after opener while (i < src.length && src[i] !== "\n") i++; i++; const closeIdx = src.indexOf("\n---", i); if (closeIdx === -1) { throw new Error(`${sourceFile}: missing --- frontmatter closer`); } const frontmatterText = src.slice(i, closeIdx); const bodyStart = closeIdx + 4; // skip \n--- const body = src.slice(bodyStart).replace(/^\n+/, "").trimEnd() + "\n"; // Parse: key: value OR key: (list follows) const fm: Record<string, string | string[]> = {}; const lines = frontmatterText.split("\n"); let currentListKey: string | null = null; for (const line of lines) { if (line.trim() === "") { currentListKey = null; continue; } const listItemMatch = line.match(/^\s+-\s+(.+)$/); if (listItemMatch && currentListKey) { (fm[currentListKey] as string[]).push(listItemMatch[1].trim()); continue; } const kvMatch = line.match(/^([a-z_][a-z0-9_]*)\s*:\s*(.*)$/i); if (!kvMatch) continue; const key = kvMatch[1]; const value = kvMatch[2].trim(); if (value === "") { // List follows fm[key] = []; currentListKey = key; } else { fm[key] = value; currentListKey = null; } } // Validate required fields const required = [ "name", "description", "triggers", "audience", "tools_required", "output", ]; for (const field of required) { if (!(field in fm)) { throw new Error( `${sourceFile}: missing required frontmatter field "${field}"`, ); } } if (!Array.isArray(fm.triggers) || fm.triggers.length === 0) { throw new Error(`${sourceFile}: triggers must be a non-empty list`); } if (!Array.isArray(fm.tools_required) || fm.tools_required.length === 0) { throw new Error(`${sourceFile}: tools_required must be a non-empty list`); } return { name: fm.name as string, description: fm.description as string, triggers: fm.triggers as string[], audience: fm.audience as string, tools_required: fm.tools_required as string[], output: fm.output as string, ...(typeof fm.definition_of_done === "string" ? { definition_of_done: fm.definition_of_done } : {}), ...(typeof fm.when_not_to_use === "string" ? { when_not_to_use: fm.when_not_to_use } : {}), body, }; } /** Exported for unit testing — do not call directly in production paths. */ export { parseFrontmatter }; function discoverSkills(): ParsedSkill[] { if (!existsSync(SKILLS_ROOT)) { return []; } const entries = readdirSync(SKILLS_ROOT, { withFileTypes: true }); const skills: ParsedSkill[] = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const skillFile = join(SKILLS_ROOT, entry.name, "SKILL.md"); if (!existsSync(skillFile)) continue; const src = readFileSync(skillFile, "utf-8"); const parsed = parseFrontmatter(src, `src/skills/${entry.name}/SKILL.md`); if (parsed.name !== entry.name) { throw new Error( `src/skills/${entry.name}/SKILL.md: frontmatter name "${parsed.name}" must match directory name "${entry.name}"`, ); } skills.push(parsed); } skills.sort((a, b) => a.name.localeCompare(b.name)); return skills; } function emit(skills: ParsedSkill[]): string { const header = `// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. /** * GENERATED FILE — DO NOT EDIT BY HAND. * * Regenerated by \`scripts/build-skills.ts\` from \`src/skills/<name>/SKILL.md\`. * Runs in \`prebuild\` and \`prepublishOnly\`. See ADR-140 Phase 2b. */ export interface Skill { /** Stable kebab-case identifier matching the directory name. */ name: string; /** One-line description shown in pica_skill_list. */ description: string; /** Trigger phrases an agent should recognise as invoking this skill. */ triggers: string[]; /** Audience tag (manager / artist / sync supervisor / etc). */ audience: string; /** Tools the skill body chains. Lint-checkable. */ tools_required: string[]; /** One-line output shape description. */ output: string; /** Optional: the evidence required before this skill is "done" (ADR-271). */ definition_of_done?: string; /** Optional: when the agent should NOT run this skill (ADR-271). */ when_not_to_use?: string; /** Full skill methodology body — markdown, exposed by pica_skill_get. */ body: string; } `; const lines: string[] = [ header, "export const SKILLS: Record<string, Skill> = {", ]; for (const skill of skills) { lines.push(` ${JSON.stringify(skill.name)}: {`); lines.push(` name: ${JSON.stringify(skill.name)},`); lines.push(` description: ${JSON.stringify(skill.description)},`); lines.push(` triggers: ${JSON.stringify(skill.triggers)},`); lines.push(` audience: ${JSON.stringify(skill.audience)},`); lines.push(` tools_required: ${JSON.stringify(skill.tools_required)},`); lines.push(` output: ${JSON.stringify(skill.output)},`); if (skill.definition_of_done !== undefined) { lines.push( ` definition_of_done: ${JSON.stringify(skill.definition_of_done)},`, ); } if (skill.when_not_to_use !== undefined) { lines.push( ` when_not_to_use: ${JSON.stringify(skill.when_not_to_use)},`, ); } lines.push(` body: ${JSON.stringify(skill.body)},`); lines.push(` },`); } lines.push("};"); lines.push(""); lines.push( `export const SKILL_NAMES: ReadonlyArray<string> = ${JSON.stringify(skills.map((s) => s.name))};`, ); lines.push(""); return lines.join("\n"); } function main(): void { const skills = discoverSkills(); const output = emit(skills); writeFileSync(OUTPUT_PATH, output, "utf-8"); console.log( `[build-skills] Wrote ${skills.length} skill(s) to src/skills/skills.generated.ts`, ); for (const skill of skills) { console.log(` - ${skill.name} (${skill.body.length} bytes body)`); } } // Guard: only run when executed directly (not when imported for testing). // ESM equivalent of `if (require.main === module)`. if (process.argv[1] === fileURLToPath(import.meta.url)) { main(); }