@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
text/typescript
// 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();
}