#!/usr/bin/env node import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { ROOT, readJson } from "./lib/common.ts"; const NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; function walkSkills(dir: string, out: string[] = []): string[] { if (!existsSync(dir)) return out; const skill = join(dir, "SKILL.md"); if (existsSync(skill)) out.push(skill); for (const entry of readdirSync(dir, { withFileTypes: true })) if (entry.isDirectory()) walkSkills(join(dir, entry.name), out); return out; } function skillRoots(): string[] { const pkg = readJson(join(ROOT, "package.json")) as { pi?: { skills?: string[] }; }; return (pkg.pi?.skills ?? ["skills"]).map((path) => join(ROOT, path)); } function skillFiles() { return skillRoots() .flatMap((root) => walkSkills(root)) .sort(); } function frontmatter(path: string): string[] { const lines = readFileSync(path, "utf8").split(/\r?\n/); if (lines[0] !== "---") throw new Error("missing opening frontmatter fence"); const end = lines.indexOf("---", 1); if (end === -1) throw new Error("missing closing frontmatter fence"); return lines.slice(1, end); } function unquote(value: string): string { const trimmed = value.trim(); if ( (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ) { return trimmed.slice(1, -1); } return trimmed; } function readIndented(lines: string[], index: number): [string[], number] { const block: string[] = []; while (index + 1 < lines.length) { const next = lines[index + 1]; if (next === undefined || !/^\s+/.test(next)) break; block.push(next.trim()); index++; } return [block, index]; } function parseFrontmatter(lines: string[]): Record { const data: Record = {}; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line === undefined) continue; const nextLine = i + 1 < lines.length ? lines[i + 1] : undefined; const match = /^(\w[\w-]*):(?:\s*(.*))?$/.exec(line); if (!match) continue; const [, key = "", raw = ""] = match; const value = raw.trim(); if (value === ">" || value === "|") { const [block, next] = readIndented(lines, i); i = next; data[key] = block.join(value === ">" ? " " : "\n"); } else if (!value && nextLine !== undefined && /^\s+/.test(nextLine)) { const [block, next] = readIndented(lines, i); i = next; data[key] = unquote(block.join(" ")); } else { data[key] = unquote(raw); } } return data; } export function validate(path: string): string[] { let data: Record; try { data = parseFrontmatter(frontmatter(path)); } catch (error) { return [error instanceof Error ? error.message : String(error)]; } const errors: string[] = []; const name = data["name"]; const description = data["description"]; const dir = path.split(/[\\/]/).at(-2); if (typeof name !== "string" || !name) errors.push("missing string field: name"); else if (!NAME_RE.test(name)) errors.push(`invalid skill name: ${JSON.stringify(name)}`); else if (dir !== name) errors.push( `directory/name mismatch: ${JSON.stringify(dir)} != ${JSON.stringify(name)}`, ); if (typeof description !== "string" || !description.trim()) errors.push("missing string field: description"); else if (description.length > 1024) errors.push(`description too long: ${description.length} > 1024`); return errors; } export function main(): number { let failed = false; const files = skillFiles(); for (const path of files) { for (const error of validate(path)) { failed = true; console.log(`${relative(ROOT, path)}: ${error}`); } } if (!failed) console.log(`OK skills ${files.length}`); return failed ? 1 : 0; } if ( process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href ) process.exit(main());