Parent directory

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}