336 lines
8.7 KiB
TypeScript
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;
|
|
}
|