import { readFile } from "node:fs/promises"; import { join } from "node:path"; export const SKILL_CATEGORIES = [ "design", "ux", "marketing", "copy", "seo", "offer", ] as const; export type SkillCategory = (typeof SKILL_CATEGORIES)[number]; export type SkillRegistryEntry = { id?: string; name: string; title?: string; purpose: string; whenToUse: string; whenNotToUse: string; requiredInput: string; expectedOutput: string; category?: SkillCategory; appliesWhen?: string; inputs?: string[]; outputs?: string; instructions?: string; version?: string; source?: string; }; export type AuditUsedSkill = { id?: string; name: string; category?: SkillCategory; version?: string; source?: string; }; type ParsedFieldName = | "Purpose" | "When to use" | "When not to use" | "Required input" | "Expected output" | "Category" | "Version" | "Source"; const REQUIRED_FIELDS: ParsedFieldName[] = [ "Purpose", "When to use", "When not to use", "Required input", "Expected output", "Category", ]; const FIELD_LABELS_RE = /^(Purpose|When to use|When not to use|Required input|Expected output|Category|Version|Source):\s*(.*?)\s*$/; const V3_META_BLOCK_RE = /```yaml\s*\n([\s\S]*?)\n```\s*\n?([\s\S]*)$/; function normalizeCategory(value: string): SkillCategory { const normalized = value.toLowerCase(); if (!isValidSkillCategory(normalized)) { throw new Error( `Unknown category "${value}". Valid categories are: ${SKILL_CATEGORIES.join(", ")}.`, ); } return normalized; } function isValidSkillCategory( value: string, ): value is SkillCategory { return (SKILL_CATEGORIES as ReadonlyArray).includes(value); } function parseSection(lines: string[], sectionIndex: number): SkillRegistryEntry { let name: string | null = null; const values: Partial> = {}; let currentField: ParsedFieldName | null = null; const sectionTitle = lines[0]; if (!sectionTitle.startsWith("##")) { throw new Error(`Expected section ${sectionIndex} to start with a skill header.`); } name = sectionTitle.replace(/^##\s*/, "").trim(); if (name.length === 0) { throw new Error(`Skill section ${sectionIndex} has an empty name.`); } for (let lineIndex = 1; lineIndex < lines.length; lineIndex += 1) { const line = lines[lineIndex]; const trimmedLine = line.trim(); if (trimmedLine.length === 0) { continue; } const match = trimmedLine.match(FIELD_LABELS_RE); if (match) { const field = match[1] as ParsedFieldName; currentField = field; values[field] = match[2].trim(); continue; } if (currentField === null) { throw new Error(`Unexpected line in section "${name}": ${line}`); } values[currentField] = `${values[currentField] ?? ""}\n${line.trim()}`.trim(); } for (const requiredField of REQUIRED_FIELDS) { const value = values[requiredField]?.trim(); if (!value) { throw new Error( `Missing required field "${requiredField}" for skill "${name}".`, ); } } const category = normalizeCategory(values.Category!.trim()); return { name, purpose: values["Purpose"]!, whenToUse: values["When to use"]!, whenNotToUse: values["When not to use"]!, requiredInput: values["Required input"]!, expectedOutput: values["Expected output"]!, category, version: values["Version"]?.trim() || undefined, source: values["Source"]?.trim() || undefined, }; } function parseV3List(value: string): string[] { const trimmed = value.trim(); if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) { return trimmed ? [trimmed] : []; } return trimmed .slice(1, -1) .split(",") .map((entry) => entry.trim()) .filter(Boolean); } function parseV3MetaBlock(metaSource: string): Record { const values: Record = {}; for (const line of metaSource.split("\n")) { const match = line.trim().match(/^([a-z_]+):\s*(.*?)\s*$/); if (match) { values[match[1]] = match[2].trim(); } } return values; } function parseV3Section( rawBody: string, sectionIndex: number, ): SkillRegistryEntry | null { const match = rawBody.match(V3_META_BLOCK_RE); if (!match) { return null; } const values = parseV3MetaBlock(match[1]); if (!values.id) { return null; } const requiredFields = ["id", "title", "applies_when", "inputs", "outputs"]; for (const field of requiredFields) { if (!values[field]) { throw new Error( `Missing required v3 field "${field}" for skill section ${sectionIndex}.`, ); } } const id = values.id; const title = values.title; const inputs = parseV3List(values.inputs); const instructions = match[2].trim(); if (instructions.length === 0) { throw new Error(`Missing instructions for v3 skill "${id}".`); } return { id, name: title, title, purpose: instructions, whenToUse: values.applies_when, whenNotToUse: "Use only when applies_when and inputs match.", requiredInput: inputs.join(", "), expectedOutput: values.outputs, appliesWhen: values.applies_when, inputs, outputs: values.outputs, instructions, }; } function addParsedEntry( entries: SkillRegistryEntry[], names: Set, ids: Set, parsed: SkillRegistryEntry, ) { const normalizedName = parsed.name.trim().toLowerCase(); if (names.has(normalizedName)) { throw new Error(`Duplicate skill name "${parsed.name}" in skills registry.`); } if (parsed.id) { const normalizedId = parsed.id.trim().toLowerCase(); if (ids.has(normalizedId)) { throw new Error(`Duplicate skill id "${parsed.id}" in skills registry.`); } ids.add(normalizedId); } names.add(normalizedName); entries.push(parsed); } function hasLegacyFieldLabels(source: string): boolean { return source .split("\n") .some((line) => FIELD_LABELS_RE.test(line.trim())); } export function parseSkillsRegistry(source: string): SkillRegistryEntry[] { const normalized = source.replace(/\r\n/g, "\n"); const rawSections = normalized .split(/^##\s+/m) .map((entry) => entry.trim()) .filter(Boolean); const entries: SkillRegistryEntry[] = []; const names = new Set(); const ids = new Set(); const v3Entries: SkillRegistryEntry[] = []; for (let index = 0; index < rawSections.length; index += 1) { const rawSection = rawSections[index]; const lines = rawSection .split("\n") .map((line) => line.trimEnd()) .filter((line, lineIndex) => line.length > 0 || lineIndex === 0); const sectionBody = lines.slice(1).join("\n"); const parsed = parseV3Section(sectionBody, index + 1); if (parsed && parsed.id !== "kebab-case-id") { v3Entries.push(parsed); } } if (v3Entries.length > 0) { for (let index = 0; index < rawSections.length; index += 1) { const rawSection = rawSections[index]; const lines = rawSection .split("\n") .map((line) => line.trimEnd()) .filter((line, lineIndex) => line.length > 0 || lineIndex === 0); const sectionTitle = lines.at(0) ?? ""; const sectionBody = lines.slice(1).join("\n"); const sectionLines = [`## ${sectionTitle}`, ...lines.slice(1)]; const parsed = parseV3Section(sectionBody, index + 1); if (parsed) { if (parsed.id !== "kebab-case-id") { addParsedEntry(entries, names, ids, parsed); } continue; } if (hasLegacyFieldLabels(sectionBody)) { addParsedEntry(entries, names, ids, parseSection(sectionLines, index + 1)); } } return entries; } for (let index = 0; index < rawSections.length; index += 1) { const rawSection = rawSections[index]; const lines = rawSection .split("\n") .map((line) => line.trimEnd()) .filter((line, lineIndex) => line.length > 0 || lineIndex === 0); const sectionTitle = lines.at(0) ?? ""; const sectionLines = [`## ${sectionTitle}`, ...lines.slice(1)]; const parsed = parseSection(sectionLines, index + 1); addParsedEntry(entries, names, ids, parsed); } return entries; } export async function loadSkillsRegistry( registryPath = join(process.cwd(), "skills.md"), ): Promise { const source = await readFile(registryPath, "utf8"); return parseSkillsRegistry(source); } export function toAuditUsedSkill(skill: SkillRegistryEntry): AuditUsedSkill { const usedSkill: AuditUsedSkill = { name: skill.name, version: skill.version, source: skill.source, }; if (skill.id) { usedSkill.id = skill.id; } if (skill.category) { usedSkill.category = skill.category; } if (!skill.version) { delete usedSkill.version; } if (!skill.source) { delete usedSkill.source; } return usedSkill; }