Files
getcargohq__cargo-skills/.github/scripts/skills-lint.mjs
replicas-connector[bot] 5680754126 Add cargo-mcp, and correct the router on the hosted MCP server (#116)
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>
2026-08-27 10:28:58 -07:00

599 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();