Files
probo/packages/skills/scripts/validate.mjs
Cursor Agent f1995f2c8f Validate marketplace JSON structure in skills package
Parse and check Claude and Codex marketplace catalogs for
required fields, plugin sources, and npm package name alignment.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
2026-07-10 13:37:25 +00:00

228 lines
6.7 KiB
JavaScript

// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const requiredPaths = [
".claude-plugin/plugin.json",
".claude-plugin/marketplace.json",
".codex-plugin/plugin.json",
".agents/plugins/marketplace.json",
".mcp.json",
"commands/access-review.md",
"commands/missing-signatures.md",
"skills/access-review/SKILL.md",
"skills/missing-signatures/SKILL.md",
"skills/open-source-compliance/SKILL.md",
"skills/access-review/references/mcp-tools.md",
"skills/access-review/references/decision-rubric.md",
"skills/access-review/references/notes-format.md",
"skills/missing-signatures/references/mcp-tools.md",
"skills/missing-signatures/references/report-format.md",
"skills/missing-signatures/references/notes-format.md",
"COMPATIBILITY.md",
];
let failed = false;
function fail(message) {
console.error(message);
failed = true;
}
function readJson(label, path) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
fail(`${label} is not valid JSON: ${error.message}`);
return null;
}
}
function requireNonEmptyString(label, field, value) {
if (typeof value !== "string" || value.length === 0) {
fail(`${label}: ${field} must be a non-empty string`);
return false;
}
return true;
}
for (const relativePath of requiredPaths) {
const absolutePath = join(root, relativePath);
if (!existsSync(absolutePath)) {
fail(`missing required file: ${relativePath}`);
}
}
const manifestPath = join(root, ".claude-plugin/plugin.json");
const codexManifestPath = join(root, ".codex-plugin/plugin.json");
for (const [label, path] of [
["plugin.json", manifestPath],
[".codex-plugin/plugin.json", codexManifestPath],
]) {
if (!existsSync(path)) {
continue;
}
const manifest = readJson(label, path);
if (manifest == null) {
continue;
}
requireNonEmptyString(label, "name", manifest.name);
if (manifest.repository != null && typeof manifest.repository !== "string") {
fail(`${label}: repository must be a string URL, not an object`);
}
if (manifest.bugs != null && typeof manifest.bugs !== "string") {
fail(`${label}: bugs must be a string URL, not an object`);
}
}
const packageJsonPath = join(root, "package.json");
const packageName = existsSync(packageJsonPath)
? readJson("package.json", packageJsonPath)?.name
: null;
function validateClaudeMarketplace(label, path) {
if (!existsSync(path)) {
return;
}
const marketplace = readJson(label, path);
if (marketplace == null) {
return;
}
requireNonEmptyString(label, "name", marketplace.name);
if (!Array.isArray(marketplace.plugins) || marketplace.plugins.length === 0) {
fail(`${label}: plugins must be a non-empty array`);
return;
}
for (const [index, plugin] of marketplace.plugins.entries()) {
const pluginLabel = `${label} plugins[${index}]`;
requireNonEmptyString(pluginLabel, "name", plugin?.name);
const source = plugin?.source;
if (typeof source === "string") {
if (!source.startsWith("./")) {
fail(`${pluginLabel}: source path must start with "./"`);
}
continue;
}
if (source == null || typeof source !== "object") {
fail(`${pluginLabel}: source must be a path string or npm source object`);
continue;
}
if (source.source === "npm") {
requireNonEmptyString(pluginLabel, "source.package", source.package);
if (packageName != null && source.package !== packageName) {
fail(
`${pluginLabel}: source.package must match package.json name (${packageName})`,
);
}
continue;
}
fail(
`${pluginLabel}: source.source must be "npm" or use a "./" path string`,
);
}
}
function validateCodexMarketplace(label, path) {
if (!existsSync(path)) {
return;
}
const marketplace = readJson(label, path);
if (marketplace == null) {
return;
}
requireNonEmptyString(label, "name", marketplace.name);
if (!Array.isArray(marketplace.plugins) || marketplace.plugins.length === 0) {
fail(`${label}: plugins must be a non-empty array`);
return;
}
for (const [index, plugin] of marketplace.plugins.entries()) {
const pluginLabel = `${label} plugins[${index}]`;
requireNonEmptyString(pluginLabel, "name", plugin?.name);
const source = plugin?.source;
if (source == null || typeof source !== "object") {
fail(`${pluginLabel}: source must be an object`);
continue;
}
if (source.source !== "local") {
fail(`${pluginLabel}: source.source must be "local"`);
}
if (typeof source.path !== "string" || !source.path.startsWith("./")) {
fail(`${pluginLabel}: source.path must be a "./"-prefixed string`);
}
const policy = plugin?.policy;
if (policy == null || typeof policy !== "object") {
fail(`${pluginLabel}: policy must be an object`);
continue;
}
requireNonEmptyString(pluginLabel, "policy.installation", policy.installation);
requireNonEmptyString(
pluginLabel,
"policy.authentication",
policy.authentication,
);
requireNonEmptyString(pluginLabel, "category", plugin.category);
}
}
validateClaudeMarketplace(
".claude-plugin/marketplace.json",
join(root, ".claude-plugin/marketplace.json"),
);
validateCodexMarketplace(
".agents/plugins/marketplace.json",
join(root, ".agents/plugins/marketplace.json"),
);
const mcpPath = join(root, ".mcp.json");
if (existsSync(mcpPath)) {
const mcpConfig = readJson(".mcp.json", mcpPath);
if (mcpConfig != null) {
const servers = mcpConfig.mcpServers ?? {};
for (const [name, config] of Object.entries(servers)) {
if (config?.headers?.Authorization != null) {
fail(`.mcp.json: ${name} must use OAuth 2.0, not headers.Authorization`);
}
}
}
}
if (failed) {
process.exit(1);
}
console.log("@probo/skills validation passed");