mirror of
https://github.com/getcargohq/cargo-skills.git
synced 2026-09-14 19:17:15 +08:00
5680754126
mcp.getcargo.io/mcp is live and serves thirteen platform tools plus whatever a workspace published with defineMcpServer. Nothing in the bundle covered it, and cargo/SKILL.md asserted "There is no first-party 'Cargo MCP server'" - true when written, wrong now, and the one claim an agent would act on. - cargo-mcp/SKILL.md (1.0.0): connect, the discover to price to execute to poll spine, and the routing table for MCP tool vs cargo-ai. - cargo/SKILL.md (1.22.0): the MCP section becomes a three-way disambiguation. Hosted server, workspace server served over stdio by cargo-ai mcp, and an MCP client attached to a Cargo agent are three things sharing a name and sharing no answers. - evals/routing.jsonl: "How do I use Cargo from ChatGPT?" now expects cargo-mcp; three cases added for the new surface. - Counts, catalogs, plugin manifests, SKILL_DIRS, llms.txt, metadata. skills-lint, routing-eval, sync-trigger-slugs and the cargo-gtm script fixtures all pass. Co-authored-by: Cargo FSD <fsd@getcargo.io> Co-authored-by: Auree Aubert <16732193+aureeaubert@users.noreply.github.com> Co-authored-by: replicas-connector[bot] <replicas-connector[bot]@users.noreply.github.com>
599 lines
22 KiB
JavaScript
599 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
||
// Skill bundle linter.
|
||
//
|
||
// Validates the repository invariants that consumers of these skills rely on:
|
||
// - Every SKILL.md has the required frontmatter fields and parses cleanly.
|
||
// - Every internal markdown link (`(./...)`, `(../...)`, `(referenced/path.md)`)
|
||
// resolves to a file that actually exists in the repo.
|
||
// - Every fenced JSON code block parses as valid JSON.
|
||
// - Every `cargo-ai` bash example references one of the known top-level domains.
|
||
// - Versions follow MAJOR.MINOR.PATCH and are non-empty.
|
||
//
|
||
// The script reads every file under the repo root that is either named SKILL.md
|
||
// or lives under a `references/`, `recipes/`, `guides/`, `provider-playbooks/`,
|
||
// or `agents/` directory beside a SKILL.md. Findings are printed grouped by file.
|
||
// Exit code is 1 on any error finding, 0 otherwise. Warnings do not fail the run.
|
||
|
||
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
||
import { join, relative, resolve, dirname, basename } from "node:path";
|
||
import { checkAllMetadata } from "./skills-metadata.mjs";
|
||
|
||
const repoRoot = resolve(process.argv[2] || ".");
|
||
|
||
const SKILL_DIRS = [
|
||
"cargo",
|
||
"cargo-quickstart",
|
||
"cargo-gtm",
|
||
"cargo-cdk",
|
||
"cargo-orchestration",
|
||
"cargo-storage",
|
||
"cargo-segmentation",
|
||
"cargo-connection",
|
||
"cargo-ai",
|
||
"cargo-content",
|
||
"cargo-context",
|
||
"cargo-analytics",
|
||
"cargo-billing",
|
||
"cargo-diagnostics",
|
||
"cargo-observability",
|
||
"cargo-hosting",
|
||
"cargo-mailbox-management",
|
||
"cargo-workspace-management",
|
||
"cargo-mcp",
|
||
];
|
||
|
||
const REQUIRED_FRONTMATTER_FIELDS = [
|
||
"name",
|
||
"description",
|
||
"version",
|
||
"compatibility",
|
||
];
|
||
|
||
// Top-level cargo-ai command domains. Update if a new domain ships.
|
||
const KNOWN_CLI_DOMAINS = new Set([
|
||
"ai",
|
||
"billing",
|
||
"cdk",
|
||
"manifest", // shipped in @cargo-ai/cli 1.0.57: `manifest init`
|
||
"doctor",
|
||
"mcp",
|
||
"connection",
|
||
"content",
|
||
"context",
|
||
"expression",
|
||
"hosting",
|
||
"init",
|
||
"observability",
|
||
"orchestration",
|
||
"revenue-organization",
|
||
"segmentation",
|
||
"storage",
|
||
"system-of-record",
|
||
"user-management",
|
||
"version",
|
||
"whoami",
|
||
"login",
|
||
"logout",
|
||
"mailboxManagement",
|
||
"workspaceManagement",
|
||
"--help",
|
||
"--version",
|
||
]);
|
||
|
||
/**
|
||
* Kept in sync by hand with DENIED_INTEGRATIONS in sync-trigger-slugs.ts. Two
|
||
* copies because they police different halves: that one filters the generated
|
||
* catalog, this one polices prose no generator writes.
|
||
*/
|
||
const DENIED_INTEGRATIONS = ["datachimp"];
|
||
|
||
const findings = []; // { file, line, severity: "error"|"warn", message }
|
||
|
||
function err(file, line, message) {
|
||
findings.push({ file, line, severity: "error", message });
|
||
}
|
||
function warn(file, line, message) {
|
||
findings.push({ file, line, severity: "warn", message });
|
||
}
|
||
|
||
function* walk(dir) {
|
||
for (const entry of readdirSync(dir)) {
|
||
if (entry.startsWith(".") || entry === "node_modules") continue;
|
||
const p = join(dir, entry);
|
||
const s = statSync(p);
|
||
if (s.isDirectory()) yield* walk(p);
|
||
else if (entry.endsWith(".md") || entry.endsWith(".mdx")) yield p;
|
||
}
|
||
}
|
||
|
||
function parseFrontmatter(content, file) {
|
||
if (!content.startsWith("---\n")) {
|
||
return null;
|
||
}
|
||
const end = content.indexOf("\n---", 4);
|
||
if (end < 0) {
|
||
err(file, 1, "Frontmatter block is not terminated by `---`.");
|
||
return null;
|
||
}
|
||
const body = content.slice(4, end);
|
||
// Permissive line-based parse — sufficient for the flat key:value frontmatter
|
||
// we use. We do not need full YAML semantics, just to confirm presence.
|
||
const fields = {};
|
||
let i = 0;
|
||
for (const rawLine of body.split("\n")) {
|
||
i++;
|
||
if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) continue;
|
||
// top-level keys are unindented `key: value` lines
|
||
if (/^[A-Za-z][A-Za-z0-9_-]*:/.test(rawLine)) {
|
||
const colon = rawLine.indexOf(":");
|
||
const key = rawLine.slice(0, colon).trim();
|
||
const value = rawLine.slice(colon + 1).trim();
|
||
fields[key] = value;
|
||
}
|
||
}
|
||
return { fields, bodyStart: end + 4 };
|
||
}
|
||
|
||
function validateFrontmatter(file, content) {
|
||
const result = parseFrontmatter(content, file);
|
||
if (!result) {
|
||
if (basename(file) === "SKILL.md") {
|
||
err(file, 1, "SKILL.md is missing required `---` frontmatter block.");
|
||
}
|
||
return;
|
||
}
|
||
if (basename(file) !== "SKILL.md") return;
|
||
|
||
for (const field of REQUIRED_FRONTMATTER_FIELDS) {
|
||
if (!(field in result.fields)) {
|
||
err(file, 1, `Frontmatter is missing required field \`${field}\`.`);
|
||
}
|
||
}
|
||
const version = (result.fields.version || "").replace(/^"|"$/g, "");
|
||
if (version && !/^\d+\.\d+\.\d+$/.test(version)) {
|
||
err(file, 1, `Frontmatter \`version\` must be MAJOR.MINOR.PATCH (got "${version}").`);
|
||
}
|
||
const name = (result.fields.name || "").replace(/^"|"$/g, "");
|
||
const expectedName = file.split("/").slice(-2, -1)[0];
|
||
if (name && name !== expectedName) {
|
||
err(
|
||
file,
|
||
1,
|
||
`Frontmatter \`name: ${name}\` does not match its directory \`${expectedName}\`.`
|
||
);
|
||
}
|
||
}
|
||
|
||
function extractFencedBlocks(content) {
|
||
const blocks = [];
|
||
const lines = content.split("\n");
|
||
let i = 0;
|
||
while (i < lines.length) {
|
||
const m = lines[i].match(/^```(\w+)?\s*$/);
|
||
if (m) {
|
||
const lang = (m[1] || "").toLowerCase();
|
||
const startLine = i + 1;
|
||
const buf = [];
|
||
i++;
|
||
while (i < lines.length && !lines[i].startsWith("```")) {
|
||
buf.push(lines[i]);
|
||
i++;
|
||
}
|
||
blocks.push({ lang, startLine, content: buf.join("\n") });
|
||
i++; // consume closing fence
|
||
continue;
|
||
}
|
||
i++;
|
||
}
|
||
return blocks;
|
||
}
|
||
|
||
function validateJsonBlocks(file, content) {
|
||
for (const block of extractFencedBlocks(content)) {
|
||
if (block.lang !== "json") continue;
|
||
const text = block.content.trim();
|
||
if (!text) continue;
|
||
// Documentation patterns we deliberately don't try to parse:
|
||
// - ellipsis placeholders (`...`, `…`) inside the block,
|
||
// - template / mustache expressions (`{{nodes.x.y}}`),
|
||
// - angle-bracket placeholders (`<uuid>`, `<slug>`),
|
||
// - inline `// comment` lines (multiple sibling examples).
|
||
if (
|
||
text.includes("...") ||
|
||
text.includes("…") ||
|
||
text.includes("{{") ||
|
||
/<[a-zA-Z][^>\n]*>/.test(text) ||
|
||
/^\s*\/\//m.test(text)
|
||
) {
|
||
continue;
|
||
}
|
||
// Skip JSON fragments that show a single key:value pair without the
|
||
// enclosing object (common when documenting one field of a larger shape).
|
||
if (!/^\s*[{[]/.test(text)) {
|
||
continue;
|
||
}
|
||
// Many reference docs enumerate multiple sibling JSON objects, one per
|
||
// line, in a single fenced block. Try the block as a single document
|
||
// first; on failure, try parsing each non-empty line independently.
|
||
try {
|
||
JSON.parse(text);
|
||
continue;
|
||
} catch (e) {
|
||
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
||
if (lines.length > 1 && lines.every((l) => {
|
||
try { JSON.parse(l); return true; } catch { return false; }
|
||
})) {
|
||
continue;
|
||
}
|
||
err(
|
||
file,
|
||
block.startLine,
|
||
`Fenced \`\`\`json block does not parse: ${e.message}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
function validateBashBlocks(file, content) {
|
||
for (const block of extractFencedBlocks(content)) {
|
||
if (block.lang !== "bash" && block.lang !== "sh" && block.lang !== "shell") continue;
|
||
const lines = block.content.split("\n");
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i].trim();
|
||
if (!line || line.startsWith("#")) continue;
|
||
// Look at every `cargo-ai <token>` invocation. The next token must be
|
||
// a known domain (or a flag like `--help`).
|
||
const re = /(?:^|[\s|;`$(])cargo-ai\s+([A-Za-z][A-Za-z0-9_-]*)/g;
|
||
let m;
|
||
while ((m = re.exec(lines[i])) !== null) {
|
||
const token = m[1];
|
||
if (!KNOWN_CLI_DOMAINS.has(token)) {
|
||
warn(
|
||
file,
|
||
block.startLine + i,
|
||
`\`cargo-ai ${token}\` references an unknown top-level domain — update the linter or fix the example.`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function validateInternalLinks(file, content) {
|
||
// Match markdown links: [text](target). Skip URLs (http(s)://, mailto:),
|
||
// pure anchors (#foo), and template placeholders.
|
||
const re = /\[([^\]]*)\]\(([^)]+)\)/g;
|
||
let m;
|
||
const fileDir = dirname(file);
|
||
while ((m = re.exec(content)) !== null) {
|
||
const target = m[2].trim();
|
||
if (!target) continue;
|
||
if (/^[a-z]+:\/\//i.test(target)) continue;
|
||
if (target.startsWith("mailto:")) continue;
|
||
if (target.startsWith("#")) continue;
|
||
// strip trailing anchor and query
|
||
const cleaned = target.split("#")[0].split("?")[0];
|
||
if (!cleaned) continue;
|
||
// Resolve relative to the file's directory.
|
||
const absTarget = resolve(fileDir, cleaned);
|
||
if (!existsSync(absTarget)) {
|
||
// Compute line number of the link
|
||
const upto = content.slice(0, m.index);
|
||
const line = upto.split("\n").length;
|
||
err(file, line, `Broken internal link: \`${target}\` — no file at ${relative(repoRoot, absTarget)}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function lintFile(file) {
|
||
const content = readFileSync(file, "utf8");
|
||
if (basename(file) === "SKILL.md") {
|
||
validateFrontmatter(file, content);
|
||
}
|
||
validateJsonBlocks(file, content);
|
||
validateBashBlocks(file, content);
|
||
validateInternalLinks(file, content);
|
||
}
|
||
|
||
function main() {
|
||
for (const dir of SKILL_DIRS) {
|
||
const skillRoot = join(repoRoot, dir);
|
||
if (!existsSync(skillRoot)) {
|
||
err(`${dir}/`, 0, `Listed skill directory \`${dir}/\` does not exist in the repo.`);
|
||
continue;
|
||
}
|
||
if (!existsSync(join(skillRoot, "SKILL.md"))) {
|
||
err(`${dir}/`, 0, `\`${dir}/SKILL.md\` is missing.`);
|
||
}
|
||
for (const f of walk(skillRoot)) {
|
||
lintFile(f);
|
||
}
|
||
}
|
||
// Also lint top-level docs.
|
||
for (const top of ["README.md", "AGENTS.md", "CHANGELOG.md", "CLAUDE.md"]) {
|
||
const p = join(repoRoot, top);
|
||
if (existsSync(p)) lintFile(p);
|
||
}
|
||
|
||
// Denied integrations — a product decision, enforced here so it survives the
|
||
// next person who adds a provider without knowing about it. The generated
|
||
// catalog is filtered at source in sync-trigger-slugs.ts; this catches the
|
||
// hand-written half (prose, recipes, playbooks, examples) that no generator
|
||
// touches.
|
||
for (const slug of DENIED_INTEGRATIONS) {
|
||
for (const file of [...walk(repoRoot)].filter(
|
||
(f) => f.endsWith(".md") || f.endsWith(".json"),
|
||
)) {
|
||
const rel = relative(repoRoot, file);
|
||
if (rel.startsWith(".git/") || rel.includes("node_modules/")) continue;
|
||
const lines = readFileSync(file, "utf8").split("\n");
|
||
lines.forEach((line, i) => {
|
||
if (new RegExp(`\\b${slug}\\b`, "i").test(line)) {
|
||
err(
|
||
rel,
|
||
i + 1,
|
||
`\`${slug}\` must not appear anywhere in this repo. It is on the denied-integrations list in .github/scripts/sync-trigger-slugs.ts; remove the mention rather than the check.`,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Catalog consistency — hand-maintained catalogs must not drift from disk.
|
||
// 1. Every skill directory on disk is listed in SKILL_DIRS (so it gets linted).
|
||
// 2. Every skill (except the router itself) is routed from cargo/SKILL.md.
|
||
// 3. Every cargo-gtm recipe on disk appears in the cargo-gtm/SKILL.md and
|
||
// README.md recipe tables.
|
||
const diskSkillDirs = readdirSync(repoRoot).filter((d) => {
|
||
if (d.startsWith(".") || d === "node_modules") return false;
|
||
const p = join(repoRoot, d);
|
||
return statSync(p).isDirectory() && existsSync(join(p, "SKILL.md"));
|
||
});
|
||
for (const dir of diskSkillDirs) {
|
||
if (!SKILL_DIRS.includes(dir)) {
|
||
err(
|
||
join(repoRoot, dir, "SKILL.md"),
|
||
0,
|
||
`Skill directory \`${dir}/\` exists on disk but is not listed in the linter's SKILL_DIRS — add it so it gets linted.`
|
||
);
|
||
}
|
||
}
|
||
const routerPath = join(repoRoot, "cargo", "SKILL.md");
|
||
if (existsSync(routerPath)) {
|
||
const router = readFileSync(routerPath, "utf8");
|
||
for (const dir of diskSkillDirs) {
|
||
if (dir === "cargo") continue;
|
||
if (!router.includes(`../${dir}/SKILL.md`)) {
|
||
err(
|
||
routerPath,
|
||
0,
|
||
`Router \`cargo/SKILL.md\` does not link \`../${dir}/SKILL.md\` — every skill must be routed from the router.`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
const recipesDir = join(repoRoot, "cargo-gtm", "recipes");
|
||
if (existsSync(recipesDir)) {
|
||
const gtmSkillPath = join(repoRoot, "cargo-gtm", "SKILL.md");
|
||
const gtmSkill = existsSync(gtmSkillPath) ? readFileSync(gtmSkillPath, "utf8") : "";
|
||
const readmePath = join(repoRoot, "README.md");
|
||
const readme = existsSync(readmePath) ? readFileSync(readmePath, "utf8") : "";
|
||
for (const f of readdirSync(recipesDir).filter((n) => n.endsWith(".md"))) {
|
||
if (!gtmSkill.includes(`recipes/${f}`)) {
|
||
err(
|
||
gtmSkillPath,
|
||
0,
|
||
`Recipe \`recipes/${f}\` exists on disk but is not referenced in cargo-gtm/SKILL.md — add it to the recipe table.`
|
||
);
|
||
}
|
||
if (!readme.includes(f)) {
|
||
err(
|
||
readmePath,
|
||
0,
|
||
`Recipe \`cargo-gtm/recipes/${f}\` is not mentioned in README.md — add it to the recipe table.`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// Same drift guard for provider playbooks: the router's read-first gate only
|
||
// works if every playbook on disk is discoverable from the SKILL.md catalog.
|
||
const playbooksDir = join(repoRoot, "cargo-gtm", "provider-playbooks");
|
||
if (existsSync(playbooksDir)) {
|
||
const gtmSkillPath = join(repoRoot, "cargo-gtm", "SKILL.md");
|
||
const gtmSkill = existsSync(gtmSkillPath) ? readFileSync(gtmSkillPath, "utf8") : "";
|
||
for (const f of readdirSync(playbooksDir).filter((n) => n.endsWith(".md"))) {
|
||
if (!gtmSkill.includes(`provider-playbooks/${f}`)) {
|
||
err(
|
||
gtmSkillPath,
|
||
0,
|
||
`Playbook \`provider-playbooks/${f}\` exists on disk but is not referenced in cargo-gtm/SKILL.md — add it to the playbook catalog.`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3a. The skill count is stated in prose in several places and silently rots
|
||
// every time a skill ships. Assert the numeral and the number word
|
||
// against what is actually on disk.
|
||
const NUMBER_WORDS = [
|
||
"zero", "one", "two", "three", "four", "five", "six", "seven", "eight",
|
||
"nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
|
||
"sixteen", "seventeen", "eighteen", "nineteen", "twenty",
|
||
];
|
||
const skillCount = diskSkillDirs.length;
|
||
const expectedWord = NUMBER_WORDS[skillCount];
|
||
for (const rel of ["cargo/SKILL.md", "README.md", "AGENTS.md"]) {
|
||
const p = join(repoRoot, rel);
|
||
if (!existsSync(p)) continue;
|
||
const src = readFileSync(p, "utf8");
|
||
// Any "<n> skills" or "<word> skills" claim must agree with the tree.
|
||
const claims = [...src.matchAll(/\b([a-z]+|\d+)\s+skills\b/gi)]
|
||
.map((m) => m[1].toLowerCase())
|
||
.filter((c) => /^\d+$/.test(c) || NUMBER_WORDS.includes(c));
|
||
for (const claim of new Set(claims)) {
|
||
const asNumber = /^\d+$/.test(claim) ? Number(claim) : NUMBER_WORDS.indexOf(claim);
|
||
if (asNumber !== skillCount) {
|
||
err(
|
||
p,
|
||
0,
|
||
`States "${claim} skills" but ${skillCount} skill directories exist on disk — say "${skillCount}" / "${expectedWord}".`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3b. Every skill must be bootstrappable on its own. `skills add … --skill
|
||
// <one>` is a supported install path and the registry lists each skill
|
||
// separately, so a SKILL.md whose only install/login instructions are a
|
||
// relative link to `../cargo/references/prerequisites.md` ships an agent a
|
||
// skill it cannot start. Require the three self-contained commands.
|
||
const BOOTSTRAP_COMMANDS = [
|
||
{ pattern: /npm install -g "?@cargo-ai\/cli/, label: "npm install -g @cargo-ai/cli" },
|
||
{ pattern: /cargo-ai login\b/, label: "cargo-ai login" },
|
||
{ pattern: /cargo-ai whoami\b/, label: "cargo-ai whoami" },
|
||
];
|
||
for (const dir of SKILL_DIRS) {
|
||
const p = join(repoRoot, dir, "SKILL.md");
|
||
if (!existsSync(p)) continue;
|
||
const src = readFileSync(p, "utf8");
|
||
// Only look at fenced shell blocks — a prose mention is not a runnable
|
||
// bootstrap.
|
||
const shell = extractFencedBlocks(src)
|
||
.filter((b) => ["bash", "sh", "shell"].includes(b.lang))
|
||
.map((b) => b.content)
|
||
.join("\n");
|
||
const missing = BOOTSTRAP_COMMANDS.filter((c) => !c.pattern.test(shell)).map((c) => c.label);
|
||
if (missing.length) {
|
||
err(
|
||
p,
|
||
0,
|
||
`Missing self-contained bootstrap: no shell block runs ${missing.join(", ")}. A single-skill install has no \`../cargo/\` sibling — inline install, login, and whoami.`
|
||
);
|
||
}
|
||
}
|
||
|
||
// 3c. A spend rule may never live *only* behind a cross-skill link. The `../`
|
||
// paths resolve because every install channel lays all skills down as
|
||
// siblings — but nothing in the manifests guarantees that, and a dangling
|
||
// Read fails silently: the agent proceeds without the rule instead of
|
||
// erroring. Cheap links are fine everywhere; a rule that costs money when
|
||
// skipped has to be stated where it applies. So any SKILL.md citing
|
||
// cost-discipline.md must also carry an inline safeguard of its own.
|
||
const SPEND_SAFEGUARDS = [
|
||
/10\s*[–-]\s*20\s+record/i, // the sample gate
|
||
/credit estimate/i, // the approval quote
|
||
/record count/i,
|
||
/re-bills?\b/i, // recurring-spend warning (alerts, scheduled plays)
|
||
/(credit|demo)\s+cap/i, // a hard ceiling instead of a gate
|
||
/guarded[-\s]lever/i, // the phone-escalation rule (cost-discipline §5)
|
||
];
|
||
for (const dir of SKILL_DIRS) {
|
||
const p = join(repoRoot, dir, "SKILL.md");
|
||
if (!existsSync(p)) continue;
|
||
const src = readFileSync(p, "utf8");
|
||
if (!/cost-discipline\.md/.test(src)) continue;
|
||
if (!SPEND_SAFEGUARDS.some((re) => re.test(src))) {
|
||
err(
|
||
p,
|
||
0,
|
||
"Links `../cargo-gtm/references/cost-discipline.md` but states no spend rule inline. A single-skill install has no `../cargo-gtm/` sibling and the Read fails silently — inline the gate (sample size + record count + credit estimate), keeping the link as the full reference."
|
||
);
|
||
}
|
||
}
|
||
|
||
// 4. The CLI pin: cargo/cli-version is the source of truth the SessionStart
|
||
// hook and install.sh consume. It must exist and be a bare semver line.
|
||
// The openclaw install blocks stay @latest on purpose (they only bootstrap
|
||
// the binary; the session hook converges to the pin) — assert nobody
|
||
// half-pins them, which would force 14 skill bumps per CLI release.
|
||
const pinPath = join(repoRoot, "cargo", "cli-version");
|
||
if (!existsSync(pinPath)) {
|
||
err(pinPath, 0, "`cargo/cli-version` is missing — it pins the @cargo-ai/cli version the session hooks install.");
|
||
} else if (!/^\d+\.\d+\.\d+\s*$/.test(readFileSync(pinPath, "utf8"))) {
|
||
err(pinPath, 1, "`cargo/cli-version` must be a single MAJOR.MINOR.PATCH line, nothing else.");
|
||
}
|
||
for (const dir of SKILL_DIRS) {
|
||
const p = join(repoRoot, dir, "SKILL.md");
|
||
if (!existsSync(p)) continue;
|
||
const src = readFileSync(p, "utf8");
|
||
const pkg = (src.match(/^\s*package:\s*"([^"]+)"/m) || [])[1];
|
||
if (pkg && pkg !== "@cargo-ai/cli@latest") {
|
||
err(p, 0, `openclaw install package is \`${pkg}\` — keep it \`@cargo-ai/cli@latest\` (the pin lives in cargo/cli-version, not in frontmatter).`);
|
||
}
|
||
}
|
||
|
||
// 5. The agent plugin manifests (Claude Code, Codex, Cursor) must parse and
|
||
// stay in lockstep with the router's version — the plugin version IS the
|
||
// bundle version, identical across all three targets.
|
||
const routerVersion = existsSync(routerPath)
|
||
? (readFileSync(routerPath, "utf8").match(/^version:\s*"([^"]+)"/m) || [])[1]
|
||
: null;
|
||
for (const rel of [
|
||
".claude-plugin/plugin.json",
|
||
".codex-plugin/plugin.json",
|
||
".cursor-plugin/plugin.json",
|
||
]) {
|
||
const pluginPath = join(repoRoot, rel);
|
||
if (!existsSync(pluginPath)) continue;
|
||
let plugin = null;
|
||
try {
|
||
plugin = JSON.parse(readFileSync(pluginPath, "utf8"));
|
||
} catch (e) {
|
||
err(pluginPath, 0, `${rel} does not parse as JSON: ${e.message}`);
|
||
}
|
||
if (plugin && routerVersion && plugin.version !== routerVersion) {
|
||
err(
|
||
pluginPath,
|
||
0,
|
||
`${rel} version \`${plugin.version}\` does not match cargo/SKILL.md version \`${routerVersion}\` — the plugin version mirrors the router's.`
|
||
);
|
||
}
|
||
}
|
||
// The marketplace manifests and hook-wiring files just need to parse.
|
||
for (const rel of [
|
||
".claude-plugin/marketplace.json",
|
||
".agents/plugins/marketplace.json",
|
||
".cursor-plugin/marketplace.json",
|
||
"hooks/codex-hooks.json",
|
||
"hooks/cursor-hooks.json",
|
||
]) {
|
||
const p = join(repoRoot, rel);
|
||
if (!existsSync(p)) continue;
|
||
try {
|
||
JSON.parse(readFileSync(p, "utf8"));
|
||
} catch (e) {
|
||
err(p, 0, `${rel} does not parse as JSON: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
// Generated skill-metadata.json catalogs must match a fresh generation —
|
||
// stale metadata means the doc catalog or content hash is lying to consumers.
|
||
for (const message of checkAllMetadata(repoRoot)) {
|
||
const [file, ...rest] = message.split(": ");
|
||
err(file, 0, rest.join(": "));
|
||
}
|
||
|
||
const errors = findings.filter((f) => f.severity === "error");
|
||
const warnings = findings.filter((f) => f.severity === "warn");
|
||
|
||
const byFile = new Map();
|
||
for (const f of findings) {
|
||
if (!byFile.has(f.file)) byFile.set(f.file, []);
|
||
byFile.get(f.file).push(f);
|
||
}
|
||
for (const [file, items] of byFile) {
|
||
process.stdout.write(`\n${relative(repoRoot, file)}\n`);
|
||
for (const item of items) {
|
||
const tag = item.severity === "error" ? "ERROR" : "warn ";
|
||
process.stdout.write(` ${tag} L${item.line} ${item.message}\n`);
|
||
}
|
||
}
|
||
|
||
process.stdout.write(
|
||
`\nSummary: ${errors.length} error(s), ${warnings.length} warning(s).\n`
|
||
);
|
||
process.exit(errors.length > 0 ? 1 : 0);
|
||
}
|
||
|
||
main();
|