Luigit
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());