mirror of
https://github.com/getcargohq/cargo-skills.git
synced 2026-09-14 19:17:15 +08:00
286aaaf981
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>
165 lines
5.9 KiB
JavaScript
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);
|
|
}
|
|
}
|