feat: build local skills registry
This commit is contained in:
178
lib/skills-registry.ts
Normal file
178
lib/skills-registry.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user