217 lines
7.6 KiB
Python
Executable File
217 lines
7.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate manifests, skill frontmatter, and MCP configs.
|
|
|
|
Stdlib-only. Exit 0 on success, non-zero on failure.
|
|
|
|
Usage:
|
|
python3 tools/validate.py # validate everything
|
|
python3 tools/validate.py --plugin X # validate one plugin
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
KEBAB_RE = re.compile(r"^[a-z][a-z0-9]+(-[a-z0-9]+)*$")
|
|
|
|
errors: list[str] = []
|
|
|
|
|
|
def error(msg: str) -> None:
|
|
errors.append(msg)
|
|
print(f" ERROR: {msg}", file=sys.stderr)
|
|
|
|
|
|
def validate_json(path: Path, required_keys: list[str]) -> dict | None:
|
|
"""Validate a JSON file exists, parses, and has required keys."""
|
|
if not path.exists():
|
|
error(f"Missing file: {path.relative_to(REPO_ROOT)}")
|
|
return None
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
except json.JSONDecodeError as e:
|
|
error(f"Invalid JSON in {path.relative_to(REPO_ROOT)}: {e}")
|
|
return None
|
|
for key in required_keys:
|
|
if key not in data:
|
|
error(f"Missing key '{key}' in {path.relative_to(REPO_ROOT)}")
|
|
return data
|
|
|
|
|
|
def validate_skill_frontmatter(skill_md: Path) -> None:
|
|
"""Validate SKILL.md has valid YAML frontmatter with name and description."""
|
|
text = skill_md.read_text()
|
|
if not text.startswith("---\n"):
|
|
error(f"Missing YAML frontmatter in {skill_md.relative_to(REPO_ROOT)}")
|
|
return
|
|
|
|
end = text.find("\n---\n", 4)
|
|
if end == -1:
|
|
error(f"Unterminated frontmatter in {skill_md.relative_to(REPO_ROOT)}")
|
|
return
|
|
|
|
frontmatter = text[4:end]
|
|
# Simple key: value parsing (stdlib-only, no yaml import)
|
|
# Handles multi-line YAML values (lines starting with spaces are continuations)
|
|
fm = {}
|
|
current_key = None
|
|
for line in frontmatter.splitlines():
|
|
if ":" in line and not line.startswith(" "):
|
|
key, _, value = line.partition(":")
|
|
value = value.strip().strip('"').strip("'")
|
|
# Handle YAML block scalar indicators (> or |)
|
|
if value in (">", "|", ">-", "|-"):
|
|
value = ""
|
|
fm[key.strip()] = value
|
|
current_key = key.strip()
|
|
elif current_key and line.startswith(" "):
|
|
# Continuation line — append to current key
|
|
fm[current_key] = (fm[current_key] + " " + line.strip()).strip()
|
|
|
|
name = fm.get("name")
|
|
desc = fm.get("description")
|
|
|
|
if not name:
|
|
error(f"Missing 'name' in frontmatter: {skill_md.relative_to(REPO_ROOT)}")
|
|
elif not KEBAB_RE.match(name):
|
|
error(f"Name '{name}' is not kebab-case in {skill_md.relative_to(REPO_ROOT)}")
|
|
elif len(name) > 64:
|
|
error(f"Name exceeds 64 chars in {skill_md.relative_to(REPO_ROOT)}")
|
|
else:
|
|
expected_dir = skill_md.parent.name
|
|
if name != expected_dir:
|
|
error(f"Name '{name}' does not match directory '{expected_dir}' in {skill_md.relative_to(REPO_ROOT)}")
|
|
|
|
if not desc:
|
|
error(f"Missing 'description' in frontmatter: {skill_md.relative_to(REPO_ROOT)}")
|
|
elif len(desc) < 20:
|
|
error(f"Description too short (<20 chars) in {skill_md.relative_to(REPO_ROOT)}")
|
|
|
|
|
|
def validate_marketplace(path: Path, label: str) -> None:
|
|
"""Validate a marketplace manifest and check plugin source paths."""
|
|
print(f"Validating {label} marketplace: {path.relative_to(REPO_ROOT)}")
|
|
data = validate_json(path, ["name", "plugins"])
|
|
if data is None:
|
|
return
|
|
for plugin in data.get("plugins", []):
|
|
if "name" not in plugin:
|
|
error(f"Plugin missing 'name' in {path.relative_to(REPO_ROOT)}")
|
|
continue
|
|
# Resolve source path (relative to repo root, not manifest location)
|
|
if isinstance(plugin.get("source"), dict):
|
|
source = plugin["source"].get("path", "")
|
|
else:
|
|
source = plugin.get("source", "")
|
|
if source:
|
|
resolved = (REPO_ROOT / source).resolve()
|
|
if not resolved.is_dir():
|
|
error(f"Plugin source '{source}' does not exist for '{plugin['name']}'")
|
|
|
|
|
|
def validate_plugin(plugin_dir: Path) -> None:
|
|
"""Validate a single plugin's manifests and skills."""
|
|
name = plugin_dir.name
|
|
print(f"Validating plugin: {name}")
|
|
|
|
# Claude Code manifest
|
|
validate_json(plugin_dir / ".claude-plugin" / "plugin.json", ["name"])
|
|
|
|
# Codex manifest
|
|
validate_json(
|
|
plugin_dir / ".codex-plugin" / "plugin.json",
|
|
["name", "version", "description", "author", "interface"],
|
|
)
|
|
|
|
# Cursor manifest (optional; only the name is required by the Cursor spec)
|
|
cursor_path = plugin_dir / ".cursor-plugin" / "plugin.json"
|
|
if cursor_path.exists():
|
|
cursor = validate_json(cursor_path, ["name"])
|
|
if cursor:
|
|
cursor_name = cursor.get("name")
|
|
if cursor_name and cursor_name != name:
|
|
error(
|
|
f"Name '{cursor_name}' does not match directory '{name}' "
|
|
f"in {cursor_path.relative_to(REPO_ROOT)}"
|
|
)
|
|
|
|
# MCP config
|
|
mcp_path = plugin_dir / ".mcp.json"
|
|
if mcp_path.exists():
|
|
data = validate_json(mcp_path, ["mcpServers"])
|
|
if data:
|
|
for srv_name, srv in data.get("mcpServers", {}).items():
|
|
srv_type = srv.get("type", "stdio")
|
|
if srv_type == "stdio" and "command" not in srv:
|
|
error(f"MCP server '{srv_name}' is stdio but missing 'command'")
|
|
elif srv_type == "http" and "url" not in srv:
|
|
error(f"MCP server '{srv_name}' is http but missing 'url'")
|
|
|
|
# Skills in this plugin
|
|
skills_dir = plugin_dir / "skills"
|
|
if skills_dir.is_dir():
|
|
for skill_dir in sorted(skills_dir.iterdir()):
|
|
skill_md = skill_dir / "SKILL.md"
|
|
if skill_dir.is_dir() and skill_md.exists():
|
|
validate_skill_frontmatter(skill_md)
|
|
|
|
|
|
def validate_top_level_skills() -> None:
|
|
"""Validate all skills in the top-level skills/ directory (recursive)."""
|
|
skills_dir = REPO_ROOT / "skills"
|
|
if not skills_dir.is_dir():
|
|
return
|
|
for skill_md in sorted(skills_dir.rglob("SKILL.md")):
|
|
skill_dir = skill_md.parent
|
|
print(f"Validating skill: {skill_dir.relative_to(REPO_ROOT)}")
|
|
validate_skill_frontmatter(skill_md)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Validate repo manifests and skills")
|
|
parser.add_argument("--plugin", help="Validate only this plugin")
|
|
args = parser.parse_args()
|
|
|
|
plugins_dir = REPO_ROOT / "plugins"
|
|
|
|
if args.plugin:
|
|
plugin_dir = plugins_dir / args.plugin
|
|
if not plugin_dir.is_dir():
|
|
print(f"Plugin not found: {args.plugin}", file=sys.stderr)
|
|
sys.exit(1)
|
|
validate_plugin(plugin_dir)
|
|
else:
|
|
# Marketplace manifests
|
|
validate_marketplace(
|
|
REPO_ROOT / ".claude-plugin" / "marketplace.json", "Claude Code"
|
|
)
|
|
validate_marketplace(
|
|
REPO_ROOT / ".agents" / "plugins" / "marketplace.json", "Codex"
|
|
)
|
|
validate_marketplace(
|
|
REPO_ROOT / ".cursor-plugin" / "marketplace.json", "Cursor"
|
|
)
|
|
|
|
# All plugins
|
|
if plugins_dir.is_dir():
|
|
for plugin_dir in sorted(plugins_dir.iterdir()):
|
|
if plugin_dir.is_dir():
|
|
validate_plugin(plugin_dir)
|
|
|
|
# Top-level skills
|
|
validate_top_level_skills()
|
|
|
|
if errors:
|
|
print(f"\nValidation failed with {len(errors)} error(s).", file=sys.stderr)
|
|
sys.exit(1)
|
|
else:
|
|
print("\nAll validations passed.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|