validation.ts
2473 bytes
1import { basename, dirname } from "node:path";
2import type { FrontmatterDocument, SkillDiagnostic } from "../types.ts";
3
4const FRONTMATTER_KEY_RE = /^([A-Za-z0-9_-]+)\s*:/;
5
6export function deriveSkillMetadata(filePath: string, doc: FrontmatterDocument): {
7 name: string;
8 description: string;
9 diagnostics: SkillDiagnostic[];
10} {
11 const diagnostics: SkillDiagnostic[] = [];
12 const parentDirName = basename(dirname(filePath));
13 const name = stringField(doc.fields.name) || parentDirName;
14 const description = stringField(doc.fields.description);
15
16 if (!doc.hasFrontmatter) {
17 diagnostics.push({ severity: "warning", message: "Missing YAML front matter" });
18 }
19
20 const duplicateKeys = getDuplicateFrontmatterKeys(doc);
21 if (duplicateKeys.length > 0) {
22 diagnostics.push({
23 severity: "warning",
24 message: `Duplicate frontmatter key${duplicateKeys.length === 1 ? "" : "s"}: ${duplicateKeys.join(", ")}`,
25 });
26 }
27
28 if (!description) {
29 diagnostics.push({ severity: "error", message: "Missing required description; Pi will not load this skill" });
30 }
31 if (name !== parentDirName && basename(filePath) === "SKILL.md") {
32 diagnostics.push({ severity: "warning", message: `Name does not match parent directory (${parentDirName})` });
33 }
34
35 return { name, description: description || "", diagnostics };
36}
37
38export function getDisableModelInvocation(doc: FrontmatterDocument): boolean {
39 return doc.fields["disable-model-invocation"] === true;
40}
41
42export function hasDuplicateDisableModelInvocation(doc: FrontmatterDocument): boolean {
43 return (getTopLevelFrontmatterKeyCounts(doc).get("disable-model-invocation") ?? 0) > 1;
44}
45
46export function getDuplicateFrontmatterKeys(doc: FrontmatterDocument): string[] {
47 return [...getTopLevelFrontmatterKeyCounts(doc)]
48 .filter(([, count]) => count > 1)
49 .map(([key]) => key)
50 .sort();
51}
52
53function getTopLevelFrontmatterKeyCounts(doc: FrontmatterDocument): Map<string, number> {
54 const counts = new Map<string, number>();
55 if (!doc.hasFrontmatter) return counts;
56
57 for (const rawLine of doc.frontmatterText.split(/\r?\n/)) {
58 const trimmed = rawLine.trim();
59 if (!trimmed || trimmed.startsWith("#")) continue;
60 const match = FRONTMATTER_KEY_RE.exec(rawLine);
61 if (!match?.[1]) continue;
62 counts.set(match[1], (counts.get(match[1]) ?? 0) + 1);
63 }
64
65 return counts;
66}
67
68function stringField(value: unknown): string {
69 return typeof value === "string" ? value.trim() : "";
70}