Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,5 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile=false --ignore-scripts

- name: Install pipx (for JSON schema validation)
run: |
python3 -m pip install --user pipx
python3 -m pipx ensurepath

- name: Run pre-commit hooks against the full tree
run: pnpm exec lefthook run pre-commit --all-files --force
45 changes: 8 additions & 37 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,50 +4,21 @@ pre-commit:
json-validity:
glob: "**/*.json"
run: |
has_schema_validator=0
if command -v pipx >/dev/null 2>&1; then
has_schema_validator=1
if ! command -v node >/dev/null 2>&1; then
echo "❌ node not available; cannot validate JSON"
exit 1
fi
for f in {files}; do
[ -f "$f" ] || continue
if ! jq empty "$f" >/dev/null 2>&1; then
echo "❌ invalid JSON: $f"
exit 1
fi
schema=""
case "$f" in
*.claude-plugin/plugin.json|*/.claude-plugin/plugin.json)
schema="https://www.schemastore.org/claude-code-plugin-manifest.json" ;;
*.claude-plugin/marketplace.json|*/.claude-plugin/marketplace.json)
schema="https://www.schemastore.org/claude-code-marketplace.json" ;;
*.claude/settings.json|*.claude/settings.local.json|*/.claude/settings.json|*/.claude/settings.local.json)
schema="https://www.schemastore.org/claude-code-settings.json" ;;
esac
if [ -n "$schema" ] && [ "$has_schema_validator" = "1" ]; then
if ! pipx run check-jsonschema --schemafile "$schema" "$f" >/dev/null 2>&1; then
pipx run check-jsonschema --schemafile "$schema" "$f"
echo "❌ schema validation failed: $f against $schema"
exit 1
fi
fi
done
node scripts/validate-json.mjs {files}
yaml-validity:
glob: "**/*.{yml,yaml}"
exclude:
- "plugins/*/skills/*/assets/**"
run: |
if ! command -v python3 >/dev/null 2>&1; then
echo "ℹ️ python3 not available; skipping yaml-validity"
exit 0
fi
if ! python3 -c "import yaml" >/dev/null 2>&1; then
echo "ℹ️ python3 yaml module not available; skipping yaml-validity"
exit 0
if ! command -v node >/dev/null 2>&1; then
echo "❌ node not available; cannot validate YAML"
exit 1
fi
for f in {files}; do
[ -f "$f" ] || continue
python3 -c "import yaml; yaml.safe_load(open('$f'))" 2>/dev/null || { echo "❌ invalid YAML: $f"; exit 1; }
done
node scripts/validate-yaml.mjs {files}
skill-frontmatter:
glob: "plugins/*/skills/*/SKILL.md"
run: |
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
"devDependencies": {
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"ajv": "8.20.0",
"ajv-formats": "3.0.1",
"js-yaml": "4.2.0",
"lefthook": "^2.1.9"
}
}
21 changes: 21 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

229 changes: 229 additions & 0 deletions scripts/validate-json.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
#!/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).`);
23 changes: 23 additions & 0 deletions scripts/validate-yaml.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env node
// Validates YAML syntax using the repository's Node dependency, avoiding Python in hooks.

import { readFile } from "node:fs/promises";
import yaml from "js-yaml";

const files = process.argv.slice(2).filter((file) => file !== "--");
const errors = [];

for (const file of files) {
try {
yaml.load(await readFile(file, "utf8"), { filename: file });
} catch (error) {
errors.push(`${file}: ${error.message}`);
}
}

if (errors.length > 0) {
console.error(errors.map((error) => `❌ ${error}`).join("\n"));
process.exit(1);
}

console.log(`YAML validation passed for ${files.length} file(s).`);