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 = { name: string; purpose: string; whenToUse: string; whenNotToUse: string; requiredInput: string; expectedOutput: string; category: SkillCategory; version?: string; source?: string; }; export type AuditUsedSkill = { 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*$/; 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, }; } 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(); 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 sectionLines = [`## ${lines.at(0) ?? ""}`, ...lines.slice(1)]; const parsed = parseSection(sectionLines, index + 1); const normalizedName = parsed.name.trim().toLowerCase(); if (names.has(normalizedName)) { throw new Error(`Duplicate skill name "${parsed.name}" in skills registry.`); } names.add(normalizedName); entries.push(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 { return { name: skill.name, category: skill.category, version: skill.version, source: skill.source, }; }