179 lines
4.6 KiB
TypeScript
179 lines
4.6 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 = {
|
|
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,
|
|
};
|
|
}
|