Files
pitchfast/lib/skills-registry.ts

336 lines
8.7 KiB
TypeScript

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<string>).includes(value);
}
function parseSection(lines: string[], sectionIndex: number): SkillRegistryEntry {
let name: string | null = null;
const values: Partial<Record<ParsedFieldName, string>> = {};
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<string, string> {
const values: Record<string, string> = {};
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<string>,
ids: Set<string>,
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<string>();
const ids = new Set<string>();
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<SkillRegistryEntry[]> {
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;
}