repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
scripts/check_skills.ts
Raw#!/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<string, string> {
const data: Record<string, string> = {};
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<string, string>;
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());