Files
wehub-resource-sync e011bb26cd
Lint skills / Validate catalog structure (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:36:55 +08:00

282 lines
9.9 KiB
JavaScript

#!/usr/bin/env node
// build-pi.mjs - Build a Pi-compatible distribution of the skills catalog.
//
// Pi (pi.dev, github.com/earendil-works/pi) discovers skills from
// `.agents/skills/<name>/SKILL.md`. Pi accepts arbitrary frontmatter keys,
// so this build copies every source skill directory byte-for-byte into
// dist/pi/.agents/skills/ with native frontmatter preserved intact. No
// trimming, no sidecar (this is the key difference from the Codex and
// Antigravity dists, which trim frontmatter into a sidecar file).
//
// Dependency-free: Node ESM, fs and path only.
//
// Usage:
// node scripts/build-pi.mjs Build dist/pi/.agents/skills/
// node scripts/build-pi.mjs --check Rebuild to a temp dir and diff
// against committed output; exit
// nonzero if anything differs.
// node scripts/build-pi.mjs --validate Validate emitted output against
// the Pi skill rules and source.
import {
readdirSync,
readFileSync,
writeFileSync,
mkdirSync,
rmSync,
existsSync,
} from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join, relative } from 'path';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = dirname(SCRIPT_DIR);
const SRC_SKILLS = join(REPO_ROOT, 'skills');
const DIST_ROOT = join(REPO_ROOT, 'dist', 'pi');
const DIST_SKILLS = join(DIST_ROOT, '.agents', 'skills');
// --- small fs helpers (sorted, deterministic) --------------------------------
function listDirsSorted(dir) {
return readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
}
// Return all file paths under `dir`, relative to `dir`, sorted. Uses forward
// slashes so output is identical across platforms (determinism).
function listFilesSorted(dir) {
const out = [];
function walk(current) {
const entries = readdirSync(current, { withFileTypes: true });
for (const e of entries) {
const full = join(current, e.name);
if (e.isDirectory()) walk(full);
else if (e.isFile()) out.push(relative(dir, full).split('\\').join('/'));
}
}
walk(dir);
return out.sort();
}
function copyFileBytes(srcFile, destFile) {
mkdirSync(dirname(destFile), { recursive: true });
// Buffer copy: no encoding transform, byte-for-byte identical.
writeFileSync(destFile, readFileSync(srcFile));
}
// --- build --------------------------------------------------------------------
// Build the .agents/skills tree under `destSkills`. Returns counts.
function build(destSkills) {
// Clean target so removed source skills never linger (determinism).
if (existsSync(destSkills)) rmSync(destSkills, { recursive: true, force: true });
mkdirSync(destSkills, { recursive: true });
const skills = listDirsSorted(SRC_SKILLS);
let referenceFiles = 0;
let skillFiles = 0;
for (const name of skills) {
const srcDir = join(SRC_SKILLS, name);
const destDir = join(destSkills, name);
const files = listFilesSorted(srcDir);
for (const rel of files) {
copyFileBytes(join(srcDir, rel), join(destDir, rel));
if (rel === 'SKILL.md') skillFiles++;
if (rel.startsWith('references/')) referenceFiles++;
}
}
return { skills: skills.length, skillFiles, referenceFiles };
}
// --- check (staleness guard) --------------------------------------------------
function check() {
const tmp = join(REPO_ROOT, '.pi-check-tmp');
if (existsSync(tmp)) rmSync(tmp, { recursive: true, force: true });
const tmpSkills = join(tmp, '.agents', 'skills');
build(tmpSkills);
const committed = DIST_SKILLS;
if (!existsSync(committed)) {
console.error('FAIL: committed output not found at ' + rel(committed));
rmSync(tmp, { recursive: true, force: true });
process.exit(1);
}
const freshFiles = new Set(listFilesSorted(tmpSkills));
const committedFiles = new Set(listFilesSorted(committed));
const differing = [];
for (const f of freshFiles) {
if (!committedFiles.has(f)) {
differing.push('missing in committed: ' + f);
continue;
}
const a = readFileSync(join(tmpSkills, f));
const b = readFileSync(join(committed, f));
if (!a.equals(b)) differing.push('content differs: ' + f);
}
for (const f of committedFiles) {
if (!freshFiles.has(f)) differing.push('stale in committed (no source): ' + f);
}
rmSync(tmp, { recursive: true, force: true });
if (differing.length) {
console.error('FAIL: committed dist/pi is stale. ' + differing.length + ' difference(s):');
for (const d of differing) console.error(' ' + d);
process.exit(1);
}
console.log('PASS: committed dist/pi/.agents/skills matches a fresh build (byte-identical).');
}
// --- validate -----------------------------------------------------------------
// Parse the frontmatter block (between the first two `---` lines) into a flat
// map of top-level keys. Quotes are stripped from values for length checks.
function parseFrontmatter(text) {
const lines = text.split(/\r?\n/);
if (lines[0].trim() !== '---') return null;
let end = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '---') {
end = i;
break;
}
}
if (end === -1) return null;
const map = {};
for (let i = 1; i < end; i++) {
const line = lines[i];
const m = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*):\s?(.*)$/);
if (!m) continue;
let val = m[2].trim();
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
map[m[1]] = val;
}
return map;
}
const PI_NAME_RE = /^[a-z0-9-]{1,64}$/;
function validate() {
const results = [];
const pass = (label) => results.push(['PASS', label]);
const fail = (label) => results.push(['FAIL', label]);
// WARN is reported loudly but does not fail the run. Used where Pi itself
// only warns (and still loads the skill) and where this build is forbidden
// from fixing the source (preserve-native frontmatter, no trimming).
const warn = (label) => results.push(['WARN', label]);
if (!existsSync(DIST_SKILLS)) {
console.error('FAIL: ' + rel(DIST_SKILLS) + ' does not exist. Run the build first.');
process.exit(1);
}
const emitted = listDirsSorted(DIST_SKILLS);
const source = listDirsSorted(SRC_SKILLS);
// 1. Frontmatter validity + non-empty name/description.
const badFrontmatter = [];
const emptyFields = [];
// 2. Name rules.
const badNames = [];
// 3. Description length.
const longDescriptions = [];
for (const name of emitted) {
const text = readFileSync(join(DIST_SKILLS, name, 'SKILL.md'), 'utf8');
const fm = parseFrontmatter(text);
if (!fm) {
badFrontmatter.push(name);
continue;
}
if (!fm.name || !fm.name.trim()) emptyFields.push(name + ' (name)');
if (!fm.description || !fm.description.trim()) emptyFields.push(name + ' (description)');
const n = fm.name || '';
if (!PI_NAME_RE.test(n) || n.includes('--')) badNames.push(name + ' -> "' + n + '"');
if ((fm.description || '').length > 1024) {
longDescriptions.push(name + ' (' + fm.description.length + ' chars)');
}
}
if (badFrontmatter.length === 0) pass('Every emitted SKILL.md has valid --- frontmatter');
else fail('Invalid frontmatter: ' + badFrontmatter.join(', '));
if (emptyFields.length === 0) pass('Every emitted SKILL.md has non-empty name and description');
else fail('Empty name/description: ' + emptyFields.join(', '));
if (badNames.length === 0) pass('Every name conforms to Pi rules (lowercase a-z/0-9/hyphen, <=64, no consecutive hyphens)');
else fail('Names violating Pi rules (reported, not renamed): ' + badNames.join('; '));
if (longDescriptions.length === 0) pass('Every description is <= 1024 chars');
else warn('Descriptions over 1024 chars (Pi loads with a warning; preserved as-is from source, not trimmed): ' + longDescriptions.join(', '));
// 4. Emitted count equals source.
if (emitted.length === source.length) pass('Emitted skill count equals source (' + emitted.length + ')');
else fail('Emitted count ' + emitted.length + ' != source count ' + source.length);
// 5. Every source references file has an emitted counterpart.
const missingRefs = [];
let refTotal = 0;
for (const name of source) {
const srcRefDir = join(SRC_SKILLS, name, 'references');
if (!existsSync(srcRefDir)) continue;
for (const rf of listFilesSorted(srcRefDir)) {
refTotal++;
const counterpart = join(DIST_SKILLS, name, 'references', rf);
if (!existsSync(counterpart)) missingRefs.push(name + '/references/' + rf);
}
}
if (missingRefs.length === 0) pass('Every source references file has an emitted counterpart (' + refTotal + ' files)');
else fail('Missing emitted references: ' + missingRefs.join(', '));
// Report.
let failed = 0;
let warned = 0;
for (const [status, label] of results) {
console.log(status + ': ' + label);
if (status === 'FAIL') failed++;
if (status === 'WARN') warned++;
}
console.log('');
const suffix = warned ? ' (' + warned + ' warning(s))' : '';
console.log(failed === 0 ? 'VALIDATION PASS' + suffix : 'VALIDATION FAIL (' + failed + ' check(s) failed)');
if (failed) process.exit(1);
}
function rel(p) {
return relative(REPO_ROOT, p).split('\\').join('/');
}
// --- entrypoint ---------------------------------------------------------------
const arg = process.argv[2];
if (arg === '--check') {
check();
} else if (arg === '--validate') {
validate();
} else if (arg) {
console.error('Unknown flag: ' + arg);
console.error('Usage: node scripts/build-pi.mjs [--check|--validate]');
process.exit(2);
} else {
const { skills, skillFiles, referenceFiles } = build(DIST_SKILLS);
console.log('Pi build complete -> ' + rel(DIST_SKILLS));
console.log(' skills copied: ' + skills);
console.log(' SKILL.md files copied: ' + skillFiles);
console.log(' reference files copied: ' + referenceFiles);
}