#!/usr/bin/env node
// JSON validator for framework files. It always checks JSON syntax, validates
// Claude metadata against SchemaStore when available, and falls back to local
// structural checks if remote schemas cannot be loaded.
import { access, readFile } from "node:fs/promises";
import path from "node:path";
import Ajv from "ajv";
import addFormats from "ajv-formats";
const ROOT = process.cwd();
const INPUTS = process.argv.slice(2).filter((file) => file !== "--");
const SCHEMA_TIMEOUT_MS = 10_000;
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
const errors = [];
const warnings = [];
const schemaCache = new Map();
const SCHEMAS = {
pluginManifest: "https://www.schemastore.org/claude-code-plugin-manifest.json",
marketplace: "https://www.schemastore.org/claude-code-marketplace.json",
claudeSettings: "https://www.schemastore.org/claude-code-settings.json",
};
function fail(file, message) {
errors.push(`${file}: ${message}`);
}
function warn(file, message) {
warnings.push(`${file}: ${message}`);
}
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function requireString(file, object, key) {
if (typeof object[key] !== "string" || object[key].trim() === "") {
fail(file, `missing or invalid string field '${key}'`);
}
}
function requireStringArray(file, object, key) {
if (!Array.isArray(object[key]) || object[key].some((value) => typeof value !== "string" || value.trim() === "")) {
fail(file, `missing or invalid string array '${key}'`);
}
}
async function pathExists(file, relativePath, label, baseDir = path.dirname(path.join(ROOT, file))) {
try {
await access(path.resolve(baseDir, relativePath));
} catch {
fail(file, `${label} does not exist: ${relativePath}`);
}
}
function schemaFor(file) {
if (file.endsWith(".claude-plugin/plugin.json") || file.includes("/.claude-plugin/plugin.json")) {
return { type: "pluginManifest", url: SCHEMAS.pluginManifest };
}
if (file.endsWith(".claude-plugin/marketplace.json") || file.includes("/.claude-plugin/marketplace.json")) {
return { type: "marketplace", url: SCHEMAS.marketplace };
}
if (file.endsWith(".claude/settings.json") || file.endsWith(".claude/settings.local.json") || file.includes("/.claude/settings.")) {
return { type: "claudeSettings", url: SCHEMAS.claudeSettings };
}
return null;
}
async function loadSchemaValidator(url) {
if (schemaCache.has(url)) return schemaCache.get(url);
const validatorPromise = (async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SCHEMA_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const schema = await response.json();
return ajv.compile(schema);
} finally {
clearTimeout(timeout);
}
})();
schemaCache.set(url, validatorPromise);
return validatorPromise;
}
function formatSchemaError(error) {
const location = error.instancePath || "/";
const message = error.message ?? "schema validation failed";
return `${location} ${message}`;
}
async function validateAgainstRemoteSchema(file, data, schema) {
let validator;
try {
validator = await loadSchemaValidator(schema.url);
} catch (error) {
warn(file, `could not load ${schema.url}; using local fallback (${error.message})`);
return false;
}
if (!validator(data)) {
for (const error of validator.errors ?? []) {
fail(file, formatSchemaError(error));
}
}
return true;
}
async function validatePluginManifestFallback(file, data) {
for (const key of ["name", "version", "description", "repository", "homepage", "license"]) {
requireString(file, data, key);
}
if (!isObject(data.author)) {
fail(file, "missing or invalid object field 'author'");
} else {
requireString(file, data.author, "name");
}
requireStringArray(file, data, "skills");
const pluginRoot = path.dirname(path.dirname(path.join(ROOT, file)));
for (const skillPath of data.skills ?? []) {
await pathExists(file, skillPath, "skill path", pluginRoot);
}
if (data.agents !== undefined) {
requireStringArray(file, data, "agents");
for (const agentPath of data.agents ?? []) {
await pathExists(file, agentPath, "agent path", pluginRoot);
}
}
if (data.keywords !== undefined) {
requireStringArray(file, data, "keywords");
}
}
async function validateMarketplaceFallback(file, data) {
for (const key of ["name", "version", "description"]) {
requireString(file, data, key);
}
if (!isObject(data.owner)) {
fail(file, "missing or invalid object field 'owner'");
} else {
requireString(file, data.owner, "name");
}
if (!Array.isArray(data.plugins) || data.plugins.length === 0) {
fail(file, "missing or invalid non-empty array 'plugins'");
return;
}
const names = new Set();
for (const [index, plugin] of data.plugins.entries()) {
const label = `plugins[${index}]`;
if (!isObject(plugin)) {
fail(file, `${label} must be an object`);
continue;
}
for (const key of ["name", "version", "source", "description"]) {
if (typeof plugin[key] !== "string" || plugin[key].trim() === "") {
fail(file, `${label}.${key} must be a non-empty string`);
}
}
if (names.has(plugin.name)) fail(file, `duplicate plugin name: ${plugin.name}`);
names.add(plugin.name);
if (typeof plugin.strict !== "boolean") fail(file, `${label}.strict must be boolean`);
if (typeof plugin.recommended !== "boolean") fail(file, `${label}.recommended must be boolean`);
if (typeof plugin.source === "string") await pathExists(file, plugin.source, `${label}.source`, ROOT);
}
}
function validateClaudeSettingsFallback(file, data) {
if (data.extraKnownMarketplaces !== undefined && !isObject(data.extraKnownMarketplaces)) {
fail(file, "extraKnownMarketplaces must be an object when present");
}
if (data.enabledPlugins !== undefined) {
if (!isObject(data.enabledPlugins)) {
fail(file, "enabledPlugins must be an object when present");
} else {
for (const [name, enabled] of Object.entries(data.enabledPlugins)) {
if (typeof enabled !== "boolean") fail(file, `enabledPlugins.${name} must be boolean`);
}
}
}
}
async function validateWithLocalFallback(file, data, type) {
if (type === "pluginManifest") {
await validatePluginManifestFallback(file, data);
} else if (type === "marketplace") {
await validateMarketplaceFallback(file, data);
} else if (type === "claudeSettings") {
validateClaudeSettingsFallback(file, data);
}
}
async function validate(file) {
let data;
try {
data = JSON.parse(await readFile(path.join(ROOT, file), "utf8"));
} catch (error) {
fail(file, `invalid JSON (${error.message})`);
return;
}
const schema = schemaFor(file);
if (!schema) return;
const usedRemoteSchema = await validateAgainstRemoteSchema(file, data, schema);
if (!usedRemoteSchema) await validateWithLocalFallback(file, data, schema.type);
}
for (const file of INPUTS) {
await validate(file);
}
if (errors.length > 0) {
console.error(errors.map((error) => ` ${error}`).join("\n"));
process.exit(1);
}
if (warnings.length > 0) {
console.warn(warnings.map((warning) => ` ${warning}`).join("\n"));
}
console.log(`JSON validation passed for ${INPUTS.length} file(s).`);