mirror of
https://github.com/redis/agent-skills.git
synced 2026-09-19 01:25:14 +08:00
f04e664b5f
A `with_skill` run passes `skills/<skill-name>/` to the model under test via addDirs and tells it to use the skill at that path. `evals.json` carries `expected_output` and the grader's `expectations`, so while it sat at `skills/<skill-name>/evals/<suite>/evals.json` the run handed the model the answer sheet for the question it was being asked, along with the committed baseline scores. Whether any past run read it cannot be established: only the final text, grading, and timing are persisted, not the tool calls. Everything under `skills/` is also what the marketplaces publish, so nesting suites and baselines there shipped them to every user installing via `npx skills add`. Move the suites to `evals/<skill-name>/<suite-name>/` and point the runner, aggregator, baseline promoter, and baseline validator at the new root. All 56 files move unchanged. Two workarounds go away with the nesting: skill-validator no longer needs `--allow-dirs=evals` to accept a skill directory, and the plugin sync no longer excludes `evals` (only `.cursor-plugin` remains, which Cursor reads from `skills/`). The published plugin is byte-identical, so this does not trigger a republish.
357 lines
10 KiB
JavaScript
357 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { appendFileSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
|
|
const repoRoot = process.cwd();
|
|
const skillsRoot = path.join(repoRoot, "skills");
|
|
|
|
// skill-validator is a Go binary, not an npm dependency, so a fresh clone won't
|
|
// have it. package.json is the single source for the version CI installs.
|
|
const VALIDATOR_MODULE = "github.com/agent-ecosystem/skill-validator/cmd/skill-validator";
|
|
|
|
const options = parseArgs(process.argv.slice(2));
|
|
|
|
if (!isValidatorInstalled()) {
|
|
reportMissingValidator();
|
|
}
|
|
|
|
const skillDirs = resolveSkillDirs(options);
|
|
|
|
if (skillDirs.length === 0) {
|
|
// --all resolving to nothing means the walk is broken, not an empty repo.
|
|
if (options.all) {
|
|
fail("No skill directories found under skills/.");
|
|
}
|
|
|
|
const message = options.changedBaseRef
|
|
? `No changed skill directories found against ${options.changedBaseRef}.`
|
|
: "No skill directories found.";
|
|
console.log(message);
|
|
writeStepSummary(`## Skill Structure Validation\n\n${message}\n`);
|
|
process.exit(0);
|
|
}
|
|
|
|
let totalErrors = 0;
|
|
let totalWarnings = 0;
|
|
const summaries = [];
|
|
|
|
for (const skillDir of skillDirs) {
|
|
const result = validateSkill(skillDir);
|
|
totalErrors += result.errors;
|
|
totalWarnings += result.warnings;
|
|
summaries.push(result);
|
|
|
|
for (const item of result.results) {
|
|
if (item.level !== "warning" && item.level !== "error") continue;
|
|
|
|
emitWarning({
|
|
file: item.file ? path.join(path.relative(repoRoot, skillDir), item.file) : path.relative(repoRoot, skillDir),
|
|
title: `${item.category || "skill-validator"} ${item.level}`,
|
|
message: item.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
writeSummary(summaries, { totalErrors, totalWarnings, enforce: options.enforce });
|
|
printSummary(summaries, { totalErrors, totalWarnings, enforce: options.enforce });
|
|
|
|
if (options.enforce && totalErrors > 0) {
|
|
process.exit(1);
|
|
}
|
|
|
|
process.exit(0);
|
|
|
|
function parseArgs(args) {
|
|
const parsed = {
|
|
all: false,
|
|
enforce: false,
|
|
changedBaseRef: null,
|
|
paths: [],
|
|
};
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
switch (arg) {
|
|
case "--all":
|
|
parsed.all = true;
|
|
break;
|
|
case "--enforce":
|
|
parsed.enforce = true;
|
|
break;
|
|
case "--changed":
|
|
case "--base-ref": {
|
|
const value = args[index + 1];
|
|
if (!value) {
|
|
fail(`Missing value for ${arg}.`);
|
|
}
|
|
parsed.changedBaseRef = value;
|
|
index += 1;
|
|
break;
|
|
}
|
|
case "--help":
|
|
case "-h":
|
|
printHelp();
|
|
process.exit(0);
|
|
break;
|
|
default:
|
|
if (arg.startsWith("--")) {
|
|
fail(`Unknown option: ${arg}`);
|
|
}
|
|
parsed.paths.push(arg);
|
|
}
|
|
}
|
|
|
|
if (!parsed.all && !parsed.changedBaseRef && parsed.paths.length === 0) {
|
|
parsed.all = true;
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`Validate Agent Skill package structure with skill-validator.
|
|
|
|
Usage:
|
|
node scripts/validate-skill-structure.mjs [options] [skill-dir...]
|
|
|
|
Options:
|
|
--all Validate all skill directories under skills/.
|
|
--changed <base-ref> Validate skill directories changed against origin/<base-ref>...HEAD.
|
|
--enforce Exit non-zero when skill-validator reports errors.
|
|
--help Show this help text.
|
|
`);
|
|
}
|
|
|
|
function resolveSkillDirs(parsed) {
|
|
const dirs = new Set();
|
|
|
|
if (parsed.all) {
|
|
for (const dirent of safeReadDir(skillsRoot)) {
|
|
if (!dirent.isDirectory()) continue;
|
|
const skillDir = path.join(skillsRoot, dirent.name);
|
|
if (hasSkillFile(skillDir)) {
|
|
dirs.add(skillDir);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (parsed.changedBaseRef) {
|
|
for (const skillDir of changedSkillDirs(parsed.changedBaseRef)) {
|
|
dirs.add(skillDir);
|
|
}
|
|
}
|
|
|
|
for (const inputPath of parsed.paths) {
|
|
const skillDir = path.resolve(repoRoot, inputPath);
|
|
if (!hasSkillFile(skillDir)) {
|
|
fail(`Not a skill directory with SKILL.md: ${inputPath}`);
|
|
}
|
|
dirs.add(skillDir);
|
|
}
|
|
|
|
return Array.from(dirs).sort((left, right) =>
|
|
path.relative(repoRoot, left).localeCompare(path.relative(repoRoot, right)),
|
|
);
|
|
}
|
|
|
|
function changedSkillDirs(baseRef) {
|
|
const diff = spawnSync(
|
|
"git",
|
|
["diff", "--name-only", `origin/${baseRef}...HEAD`, "--", "skills/"],
|
|
{ cwd: repoRoot, encoding: "utf8" },
|
|
);
|
|
|
|
if (diff.status !== 0) {
|
|
const detail = diff.stderr.trim() || diff.stdout.trim();
|
|
fail(`Could not diff changed skills against origin/${baseRef}: ${detail}`);
|
|
}
|
|
|
|
const skillNames = new Set();
|
|
for (const file of diff.stdout.split(/\r?\n/)) {
|
|
if (!file.trim()) continue;
|
|
const parts = file.split("/");
|
|
if (parts[0] === "skills" && parts[1]) {
|
|
skillNames.add(parts[1]);
|
|
}
|
|
}
|
|
|
|
return Array.from(skillNames)
|
|
.map((name) => path.join(skillsRoot, name))
|
|
.filter((skillDir) => hasSkillFile(skillDir));
|
|
}
|
|
|
|
function validateSkill(skillDir) {
|
|
const args = ["check", "-o", "json", skillDir];
|
|
|
|
const command = spawnSync("skill-validator", args, {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
});
|
|
|
|
if (command.error) {
|
|
fail(`Failed to run skill-validator: ${command.error.message}`);
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(command.stdout);
|
|
} catch (error) {
|
|
const output = [command.stdout, command.stderr].filter(Boolean).join("\n").trim();
|
|
fail(`skill-validator did not return JSON for ${path.relative(repoRoot, skillDir)}: ${error.message}\n${output}`);
|
|
}
|
|
|
|
return {
|
|
skillDir,
|
|
skillLabel: path.relative(repoRoot, skillDir),
|
|
errors: Number(parsed.errors ?? 0),
|
|
warnings: Number(parsed.warnings ?? 0),
|
|
results: Array.isArray(parsed.results) ? parsed.results : [],
|
|
};
|
|
}
|
|
|
|
function writeSummary(summariesToWrite, totals) {
|
|
const lines = [
|
|
"## Skill Structure Validation",
|
|
"",
|
|
totals.enforce
|
|
? "Mode: **enforced**. Validator errors fail this check."
|
|
: "Mode: **advisory**. Validator findings are reported as warnings and do not fail this check.",
|
|
"",
|
|
`Validated ${summariesToWrite.length} skill${summariesToWrite.length === 1 ? "" : "s"}.`,
|
|
"",
|
|
"| Skill | Validator Errors | Validator Warnings |",
|
|
"|---|---:|---:|",
|
|
];
|
|
|
|
for (const summary of summariesToWrite) {
|
|
lines.push(`| \`${summary.skillLabel}\` | ${summary.errors} | ${summary.warnings} |`);
|
|
}
|
|
|
|
lines.push("");
|
|
lines.push(`Total validator errors: **${totals.totalErrors}**`);
|
|
lines.push(`Total validator warnings: **${totals.totalWarnings}**`);
|
|
|
|
const findings = summariesToWrite.flatMap((summary) =>
|
|
summary.results
|
|
.filter((item) => item.level === "warning" || item.level === "error")
|
|
.map((item) => ({
|
|
...item,
|
|
skillLabel: summary.skillLabel,
|
|
})),
|
|
);
|
|
|
|
if (findings.length > 0) {
|
|
lines.push("");
|
|
lines.push("### Findings");
|
|
lines.push("");
|
|
lines.push("| Level | Category | File | Message |");
|
|
lines.push("|---|---|---|---|");
|
|
for (const finding of findings) {
|
|
lines.push(
|
|
`| ${escapeMarkdown(finding.level)} | ${escapeMarkdown(finding.category ?? "")} | ${escapeMarkdown(finding.file ? `${finding.skillLabel}/${finding.file}` : finding.skillLabel)} | ${escapeMarkdown(finding.message ?? "")} |`,
|
|
);
|
|
}
|
|
}
|
|
|
|
writeStepSummary(`${lines.join("\n")}\n`);
|
|
}
|
|
|
|
function printSummary(summariesToPrint, totals) {
|
|
console.log("Skill structure validation complete.");
|
|
for (const summary of summariesToPrint) {
|
|
console.log(`- ${summary.skillLabel}: ${summary.errors} errors, ${summary.warnings} warnings`);
|
|
}
|
|
console.log(`Total: ${totals.totalErrors} errors, ${totals.totalWarnings} warnings`);
|
|
if (!totals.enforce) {
|
|
console.log("Advisory mode: validator findings do not fail this command.");
|
|
}
|
|
}
|
|
|
|
function writeStepSummary(markdown) {
|
|
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
if (!summaryPath) return;
|
|
appendFileSync(summaryPath, markdown, "utf8");
|
|
}
|
|
|
|
function emitWarning({ file, title, message }) {
|
|
const properties = [
|
|
file ? `file=${escapeAnnotationProperty(file)}` : null,
|
|
title ? `title=${escapeAnnotationProperty(title)}` : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(",");
|
|
console.log(`::warning ${properties}::${escapeAnnotationMessage(message)}`);
|
|
}
|
|
|
|
function isValidatorInstalled() {
|
|
const probe = spawnSync("skill-validator", ["--version"], { encoding: "utf8" });
|
|
return !probe.error;
|
|
}
|
|
|
|
// Skipping locally keeps the pre-commit hook usable on a fresh clone; skipping in
|
|
// CI would let unvalidated skills merge, so there it fails instead.
|
|
function reportMissingValidator() {
|
|
const install = `go install ${VALIDATOR_MODULE}@${validatorVersion()}`;
|
|
const notice = `skill-validator was not found on PATH. Install it with:\n\n ${install}\n`;
|
|
|
|
if (process.env.CI) {
|
|
fail(`${notice}\nRefusing to skip skill structure validation in CI.`);
|
|
}
|
|
|
|
console.warn(`${notice}\nSkipping skill structure validation. CI enforces it on every PR.`);
|
|
writeStepSummary(`## Skill Structure Validation\n\nSkipped: skill-validator is not installed.\n`);
|
|
process.exit(0);
|
|
}
|
|
|
|
function validatorVersion() {
|
|
try {
|
|
const manifest = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
|
return manifest.skillValidatorVersion ?? "latest";
|
|
} catch {
|
|
return "latest";
|
|
}
|
|
}
|
|
|
|
function hasSkillFile(skillDir) {
|
|
try {
|
|
return statSync(path.join(skillDir, "SKILL.md")).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function safeReadDir(dir) {
|
|
try {
|
|
return readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function escapeAnnotationMessage(value) {
|
|
return String(value ?? "")
|
|
.replace(/%/g, "%25")
|
|
.replace(/\r/g, "%0D")
|
|
.replace(/\n/g, "%0A");
|
|
}
|
|
|
|
function escapeAnnotationProperty(value) {
|
|
return escapeAnnotationMessage(value)
|
|
.replace(/:/g, "%3A")
|
|
.replace(/,/g, "%2C");
|
|
}
|
|
|
|
function escapeMarkdown(value) {
|
|
return String(value ?? "")
|
|
.replace(/\|/g, "\\|")
|
|
.replace(/\n/g, "<br>");
|
|
}
|
|
|
|
function fail(message) {
|
|
console.error(message);
|
|
process.exit(1);
|
|
}
|