Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

scripts/check_skills.mjs

Raw
#!/usr/bin/env node
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { ROOT, readJson } from "./lib/common.mjs";

const NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;

function walkSkills(dir, out = []) {
	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() {
	return (readJson(join(ROOT, "package.json")).pi?.skills ?? ["skills"]).map(
		(path) => join(ROOT, path),
	);
}

function skillFiles() {
	return skillRoots()
		.flatMap((root) => walkSkills(root))
		.sort();
}

function frontmatter(path) {
	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) {
	const trimmed = value.trim();
	if (
		(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
		(trimmed.startsWith("'") && trimmed.endsWith("'"))
	) {
		return trimmed.slice(1, -1);
	}
	return trimmed;
}

function readIndented(lines, index) {
	const block = [];
	while (index + 1 < lines.length && /^\s+/.test(lines[index + 1]))
		block.push(lines[++index].trim());
	return [block, index];
}

function parseFrontmatter(lines) {
	const data = {};
	for (let i = 0; i < lines.length; i++) {
		const line = lines[i];
		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 && i + 1 < lines.length && /^\s+/.test(lines[i + 1])) {
			const [block, next] = readIndented(lines, i);
			i = next;
			data[key] = unquote(block.join(" "));
		} else {
			data[key] = unquote(raw);
		}
	}
	return data;
}

export function validate(path) {
	let data;
	try {
		data = parseFrontmatter(frontmatter(path));
	} catch (error) {
		return [error.message];
	}
	const errors = [];
	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() {
	let failed = false;
	for (const path of skillFiles()) {
		for (const error of validate(path)) {
			failed = true;
			console.log(`${relative(ROOT, path)}: ${error}`);
		}
	}
	return failed ? 1 : 0;
}

if (import.meta.url === `file://${process.argv[1]}`) process.exit(main());