Files
getcargohq__cargo-skills/.github/scripts/skills-metadata.mjs
Auree Aubert 286aaaf981 feat: prompt library + generated skill metadata (#55)
Rebased onto main (post #53/#54 squash): 38 named parameterized LLM prompts in
six sharded files with a grep-first index (temperature guidance scoped to the
bulk tier; judgment-tier models that reject non-default sampling get no
override), plus a generated skill-metadata.json per skill — typed doc catalog
+ deterministic content hash (code-unit ordering, locale-independent),
produced by .github/scripts/skills-metadata.mjs (--write) and verified by
skills-lint.mjs so stale metadata fails CI. Metadata regenerated over main's
tree, which is why lint was failing before: the catalogs predated main's
email_status recipe fix. cargo-gtm -> 1.4.0.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 00:26:04 -07:00

165 lines
5.9 KiB
JavaScript

#!/usr/bin/env node
// skills-metadata.mjs — generate and verify per-skill `skill-metadata.json`.
//
// Each skill directory gets a machine-readable catalog of its documents
// (kind / title / provider) plus a content hash over every catalogued file,
// so agents can route programmatically and consumers can detect drift without
// diffing the whole tree. The file is GENERATED — never hand-edit it:
//
// node .github/scripts/skills-metadata.mjs --write . # regenerate all
// node .github/scripts/skills-metadata.mjs . # check (CI mode)
//
// skills-lint.mjs imports checkAllMetadata() so the single lint entrypoint
// covers metadata drift too. Output is fully deterministic (sorted paths, no
// timestamps) — the same tree always produces byte-identical metadata.
import { createHash } from "node:crypto";
import { readdirSync, readFileSync, statSync, existsSync, writeFileSync } from "node:fs";
import { join, relative, sep } from "node:path";
const METADATA_FILENAME = "skill-metadata.json";
// Directory (or path prefix) → document kind. First match wins; evaluated on
// the path relative to the skill root, using "/" separators.
const KIND_RULES = [
["SKILL.md", "entrypoint"],
["provider-playbooks/", "provider-playbook"],
["recipes/", "recipe"],
["guides/", "guide"],
["agents/", "agent"],
["references/examples/", "example"],
["examples/", "example"],
["references/prompt-library/", "prompt-library"],
["references/", "reference"],
["scripts/lib/", "script-library"],
["scripts/fixtures_", "fixture"],
["scripts/", "script"],
];
function classify(relPath) {
for (const [prefix, kind] of KIND_RULES) {
if (relPath === prefix || relPath.startsWith(prefix)) return kind;
}
return "asset";
}
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
function firstHeading(text) {
const match = text.match(/^#\s+(.+)$/m);
return match ? match[1].trim() : null;
}
function frontmatterField(text, field) {
if (!text.startsWith("---")) return null;
const end = text.indexOf("\n---", 3);
if (end === -1) return null;
const match = text
.slice(0, end)
.match(new RegExp(`^${field}:\\s*["']?(.+?)["']?\\s*$`, "m"));
return match ? match[1].trim() : null;
}
function* walk(dir) {
// Code-unit comparison, NOT localeCompare — locale-dependent ordering would
// break the byte-identical guarantee across machines (a catalog generated
// under one locale would read as stale under another).
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
)) {
if (entry.name.startsWith(".")) continue;
const path = join(dir, entry.name);
if (entry.isDirectory()) yield* walk(path);
else yield path;
}
}
/** Build the metadata object for one skill directory. */
export function generateMetadata(skillDir) {
const skillMd = readFileSync(join(skillDir, "SKILL.md"), "utf8");
const documents = [];
const hashInput = [];
for (const path of walk(skillDir)) {
const relPath = relative(skillDir, path).split(sep).join("/");
if (relPath === METADATA_FILENAME) continue;
const buffer = readFileSync(path);
hashInput.push(`${relPath}\0${sha256(buffer)}`);
const doc = { path: relPath, kind: classify(relPath) };
if (relPath.endsWith(".md") || relPath.endsWith(".mdx")) {
const text = buffer.toString("utf8");
const title = firstHeading(text);
if (title) doc.title = title;
const provider = frontmatterField(text, "provider");
if (provider) doc.provider = provider;
}
documents.push(doc);
}
return {
$comment: "Generated by .github/scripts/skills-metadata.mjs — do not hand-edit. Regenerate with: node .github/scripts/skills-metadata.mjs --write .",
name: frontmatterField(skillMd, "name"),
version: frontmatterField(skillMd, "version"),
documents,
contentHash: sha256(hashInput.sort().join("\n")),
};
}
function skillDirs(repoRoot) {
return readdirSync(repoRoot, { withFileTypes: true })
.filter((e) => e.isDirectory() && existsSync(join(repoRoot, e.name, "SKILL.md")))
.map((e) => join(repoRoot, e.name))
.sort();
}
function render(metadata) {
return JSON.stringify(metadata, null, 2) + "\n";
}
/**
* Verify every skill's skill-metadata.json matches a fresh generation.
* Returns a list of error strings (empty = clean). Used by skills-lint.mjs.
*/
export function checkAllMetadata(repoRoot) {
const errors = [];
for (const dir of skillDirs(repoRoot)) {
const expected = render(generateMetadata(dir));
const metadataPath = join(dir, METADATA_FILENAME);
if (!existsSync(metadataPath)) {
errors.push(`${metadataPath}: missing — run \`node .github/scripts/skills-metadata.mjs --write .\``);
} else if (readFileSync(metadataPath, "utf8") !== expected) {
errors.push(`${metadataPath}: stale (content changed since generation) — run \`node .github/scripts/skills-metadata.mjs --write .\``);
}
}
return errors;
}
export function writeAllMetadata(repoRoot) {
for (const dir of skillDirs(repoRoot)) {
writeFileSync(join(dir, METADATA_FILENAME), render(generateMetadata(dir)));
console.log(`wrote ${join(dir, METADATA_FILENAME)}`);
}
}
// CLI entrypoint (skills-lint.mjs imports the functions instead).
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const write = args.includes("--write");
const repoRoot = args.find((a) => !a.startsWith("--")) ?? ".";
if (!statSync(repoRoot).isDirectory()) {
console.error(`not a directory: ${repoRoot}`);
process.exit(2);
}
if (write) {
writeAllMetadata(repoRoot);
} else {
const errors = checkAllMetadata(repoRoot);
for (const error of errors) console.error(error);
console.log(errors.length === 0 ? "metadata: all fresh" : `metadata: ${errors.length} stale/missing`);
process.exit(errors.length === 0 ? 0 : 1);
}
}