feat: build local skills registry

This commit is contained in:
2026-06-05 09:30:00 +02:00
parent f0a948aec9
commit 370aeec2a0
18 changed files with 1334 additions and 16 deletions

178
lib/skills-registry.ts Normal file
View File

@@ -0,0 +1,178 @@
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<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,
};
}
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>();
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<SkillRegistryEntry[]> {
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,
};
}