369 lines
13 KiB
Python
369 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate all Claude Code plugins conform to specs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
|
|
def parse_frontmatter(content: str) -> tuple[dict | None, str]:
|
|
"""Parse YAML frontmatter from markdown content."""
|
|
if not content.startswith("---"):
|
|
return None, content
|
|
parts = content.split("---", 2)
|
|
if len(parts) < 3:
|
|
return None, content
|
|
try:
|
|
frontmatter = yaml.safe_load(parts[1])
|
|
return frontmatter, parts[2].strip()
|
|
except yaml.YAMLError:
|
|
return None, content
|
|
|
|
|
|
def validate_plugin_json(plugin_dir: Path) -> list[str]:
|
|
"""Validate .claude-plugin/plugin.json exists and is valid."""
|
|
errors = []
|
|
plugin_json = plugin_dir / ".claude-plugin" / "plugin.json"
|
|
|
|
if not plugin_json.exists():
|
|
errors.append(f"{plugin_dir.name}: Missing .claude-plugin/plugin.json")
|
|
return errors
|
|
|
|
try:
|
|
with open(plugin_json) as f:
|
|
config = json.load(f)
|
|
|
|
if "name" not in config:
|
|
errors.append(f"{plugin_dir.name}: plugin.json missing 'name' field")
|
|
elif config["name"] != plugin_dir.name:
|
|
errors.append(f"{plugin_dir.name}: plugin.json name '{config['name']}' doesn't match directory name")
|
|
except json.JSONDecodeError as e:
|
|
errors.append(f"{plugin_dir.name}: Invalid plugin.json - {e}")
|
|
|
|
return errors
|
|
|
|
|
|
def validate_skills(plugin_dir: Path) -> list[str]:
|
|
"""Validate skills conform to Claude Code specs."""
|
|
errors = []
|
|
skills_dir = plugin_dir / "skills"
|
|
if not skills_dir.exists():
|
|
return errors
|
|
|
|
for skill_path in skills_dir.iterdir():
|
|
if not skill_path.is_dir():
|
|
continue
|
|
|
|
prefix = f"{plugin_dir.name}/skills/{skill_path.name}"
|
|
|
|
# Check directory name is kebab-case
|
|
if not re.match(r"^[a-z0-9-]+$", skill_path.name):
|
|
errors.append(f"{prefix}: Directory must be kebab-case")
|
|
|
|
skill_md = skill_path / "SKILL.md"
|
|
if not skill_md.exists():
|
|
errors.append(f"{prefix}: Missing SKILL.md")
|
|
continue
|
|
|
|
content = skill_md.read_text()
|
|
frontmatter, body = parse_frontmatter(content)
|
|
|
|
if not frontmatter:
|
|
errors.append(f"{prefix}/SKILL.md: Missing YAML frontmatter")
|
|
continue
|
|
|
|
# Validate name field
|
|
if "name" not in frontmatter:
|
|
errors.append(f"{prefix}/SKILL.md: Missing 'name' field")
|
|
else:
|
|
name = frontmatter["name"]
|
|
if not isinstance(name, str):
|
|
errors.append(f"{prefix}/SKILL.md: 'name' must be string")
|
|
elif len(name) > 64:
|
|
errors.append(f"{prefix}/SKILL.md: 'name' exceeds 64 chars ({len(name)})")
|
|
elif not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", name):
|
|
errors.append(f"{prefix}/SKILL.md: 'name' must be kebab-case: '{name}'")
|
|
|
|
# Validate description field
|
|
if "description" not in frontmatter:
|
|
errors.append(f"{prefix}/SKILL.md: Missing 'description' field")
|
|
else:
|
|
desc = frontmatter["description"]
|
|
if not isinstance(desc, str):
|
|
errors.append(f"{prefix}/SKILL.md: 'description' must be string")
|
|
elif len(desc) > 600:
|
|
errors.append(f"{prefix}/SKILL.md: 'description' exceeds 600 chars ({len(desc)})")
|
|
|
|
# Check body exists
|
|
if not body or len(body.strip()) < 20:
|
|
errors.append(f"{prefix}/SKILL.md: Body content too short")
|
|
|
|
return errors
|
|
|
|
|
|
def validate_agents(plugin_dir: Path) -> list[str]:
|
|
"""Validate agents conform to Claude Code specs."""
|
|
errors = []
|
|
agents_dir = plugin_dir / "agents"
|
|
if not agents_dir.exists():
|
|
return errors
|
|
|
|
valid_models = {"inherit", "sonnet", "opus", "haiku"}
|
|
valid_colors = {"blue", "cyan", "green", "yellow", "magenta", "red"}
|
|
|
|
for agent_file in agents_dir.iterdir():
|
|
if not agent_file.is_file() or agent_file.suffix != ".md":
|
|
continue
|
|
|
|
prefix = f"{plugin_dir.name}/agents/{agent_file.name}"
|
|
name = agent_file.stem
|
|
|
|
# Check filename is kebab-case
|
|
if not re.match(r"^[a-z0-9-]+$", name):
|
|
errors.append(f"{prefix}: Filename must be kebab-case")
|
|
|
|
content = agent_file.read_text()
|
|
frontmatter, body = parse_frontmatter(content)
|
|
|
|
if not frontmatter:
|
|
errors.append(f"{prefix}: Missing YAML frontmatter")
|
|
continue
|
|
|
|
# Validate name field
|
|
if "name" not in frontmatter:
|
|
errors.append(f"{prefix}: Missing 'name' field")
|
|
else:
|
|
agent_name = frontmatter["name"]
|
|
if not isinstance(agent_name, str):
|
|
errors.append(f"{prefix}: 'name' must be string")
|
|
elif len(agent_name) < 3 or len(agent_name) > 50:
|
|
errors.append(f"{prefix}: 'name' must be 3-50 chars ({len(agent_name)})")
|
|
elif not re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$", agent_name):
|
|
errors.append(
|
|
f"{prefix}: 'name' must be lowercase with hyphens, start/end alphanumeric: '{agent_name}'"
|
|
)
|
|
|
|
# Validate description field
|
|
if "description" not in frontmatter:
|
|
errors.append(f"{prefix}: Missing 'description' field")
|
|
else:
|
|
desc = frontmatter["description"]
|
|
if not isinstance(desc, str):
|
|
errors.append(f"{prefix}: 'description' must be string")
|
|
elif len(desc) < 10 or len(desc) > 5000:
|
|
errors.append(f"{prefix}: 'description' must be 10-5000 chars ({len(desc)})")
|
|
|
|
# Validate model field
|
|
if "model" not in frontmatter:
|
|
errors.append(f"{prefix}: Missing 'model' field")
|
|
elif frontmatter["model"] not in valid_models:
|
|
errors.append(f"{prefix}: 'model' must be one of {valid_models}: '{frontmatter['model']}'")
|
|
|
|
# Validate color field
|
|
if "color" not in frontmatter:
|
|
errors.append(f"{prefix}: Missing 'color' field")
|
|
elif frontmatter["color"] not in valid_colors:
|
|
errors.append(f"{prefix}: 'color' must be one of {valid_colors}: '{frontmatter['color']}'")
|
|
|
|
# Validate tools field if present
|
|
if "tools" in frontmatter:
|
|
tools = frontmatter["tools"]
|
|
if not isinstance(tools, list):
|
|
errors.append(f"{prefix}: 'tools' must be array")
|
|
|
|
# Check body exists
|
|
if not body or len(body.strip()) < 20:
|
|
errors.append(f"{prefix}: System prompt too short (<20 chars)")
|
|
elif len(body.strip()) > 10000:
|
|
errors.append(f"{prefix}: System prompt too long (>10000 chars)")
|
|
|
|
return errors
|
|
|
|
|
|
def validate_commands(plugin_dir: Path) -> list[str]:
|
|
"""Validate commands conform to Claude Code specs."""
|
|
errors = []
|
|
commands_dir = plugin_dir / "commands"
|
|
if not commands_dir.exists():
|
|
return errors
|
|
|
|
valid_models = {"sonnet", "opus", "haiku"}
|
|
|
|
for cmd_file in commands_dir.rglob("*.md"):
|
|
prefix = f"{plugin_dir.name}/commands/{cmd_file.relative_to(commands_dir)}"
|
|
name = cmd_file.stem
|
|
|
|
# Check filename is kebab-case
|
|
if not re.match(r"^[a-z0-9-]+$", name):
|
|
errors.append(f"{prefix}: Filename must be kebab-case")
|
|
|
|
content = cmd_file.read_text()
|
|
frontmatter, body = parse_frontmatter(content)
|
|
|
|
# Frontmatter is optional for commands
|
|
if frontmatter:
|
|
# Validate model if present
|
|
if "model" in frontmatter and frontmatter["model"] not in valid_models:
|
|
errors.append(f"{prefix}: 'model' must be one of {valid_models}: '{frontmatter['model']}'")
|
|
|
|
# Validate disable-model-invocation if present
|
|
if "disable-model-invocation" in frontmatter:
|
|
if not isinstance(frontmatter["disable-model-invocation"], bool):
|
|
errors.append(f"{prefix}: 'disable-model-invocation' must be boolean")
|
|
|
|
# Check body exists
|
|
if not body and not (frontmatter and body == ""):
|
|
# If no frontmatter, content is the body
|
|
if not content.strip():
|
|
errors.append(f"{prefix}: Command body is empty")
|
|
|
|
return errors
|
|
|
|
|
|
def validate_hooks(plugin_dir: Path) -> list[str]:
|
|
"""Validate hooks conform to Claude Code specs."""
|
|
errors = []
|
|
hooks_dir = plugin_dir / "hooks"
|
|
if not hooks_dir.exists():
|
|
return errors
|
|
|
|
hooks_json = hooks_dir / "hooks.json"
|
|
if not hooks_json.exists():
|
|
errors.append(f"{plugin_dir.name}/hooks: Missing hooks.json")
|
|
return errors
|
|
|
|
try:
|
|
with open(hooks_json) as f:
|
|
config = json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
errors.append(f"{plugin_dir.name}/hooks/hooks.json: Invalid JSON - {e}")
|
|
return errors
|
|
|
|
# Check for wrapper format
|
|
if "hooks" not in config:
|
|
errors.append(f"{plugin_dir.name}/hooks/hooks.json: Must use wrapper format with 'hooks' field")
|
|
return errors
|
|
|
|
valid_events = {
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"Stop",
|
|
"SubagentStop",
|
|
"SessionStart",
|
|
"SessionEnd",
|
|
"UserPromptSubmit",
|
|
"PreCompact",
|
|
"Notification",
|
|
}
|
|
|
|
hooks_config = config["hooks"]
|
|
for event, hook_list in hooks_config.items():
|
|
if event not in valid_events:
|
|
errors.append(f"{plugin_dir.name}/hooks/hooks.json: Invalid event '{event}'. Must be one of {valid_events}")
|
|
continue
|
|
|
|
if not isinstance(hook_list, list):
|
|
errors.append(f"{plugin_dir.name}/hooks/hooks.json: '{event}' must be array")
|
|
continue
|
|
|
|
for i, hook_entry in enumerate(hook_list):
|
|
if not isinstance(hook_entry, dict):
|
|
continue
|
|
|
|
hooks = hook_entry.get("hooks", [])
|
|
for j, hook in enumerate(hooks):
|
|
if not isinstance(hook, dict):
|
|
continue
|
|
|
|
hook_type = hook.get("type")
|
|
if hook_type == "command":
|
|
cmd = hook.get("command", "")
|
|
# Check for ${CLAUDE_PLUGIN_ROOT} usage (only for script paths, not inline commands)
|
|
is_inline_cmd = any(op in cmd for op in [" ", "|", ";", "&&", "||", "$("])
|
|
if cmd and not cmd.startswith("${CLAUDE_PLUGIN_ROOT}") and not is_inline_cmd:
|
|
if "/" in cmd and not cmd.startswith("$"):
|
|
errors.append(
|
|
f"{plugin_dir.name}/hooks/hooks.json: "
|
|
f"{event}[{i}].hooks[{j}] should use ${{CLAUDE_PLUGIN_ROOT}}"
|
|
)
|
|
|
|
# Check script exists
|
|
if cmd and "${CLAUDE_PLUGIN_ROOT}" in cmd:
|
|
script_path = cmd.replace("${CLAUDE_PLUGIN_ROOT}", str(plugin_dir))
|
|
if not Path(script_path).exists():
|
|
errors.append(f"{plugin_dir.name}/hooks/hooks.json: Script not found: {cmd}")
|
|
|
|
elif hook_type == "prompt":
|
|
if "prompt" not in hook:
|
|
errors.append(
|
|
f"{plugin_dir.name}/hooks/hooks.json: {event}[{i}].hooks[{j}] missing 'prompt' field"
|
|
)
|
|
|
|
# Validate script naming in hooks/scripts/
|
|
scripts_dir = hooks_dir / "scripts"
|
|
if scripts_dir.exists():
|
|
for script in scripts_dir.iterdir():
|
|
if script.is_file() and script.suffix in {".py", ".sh"}:
|
|
name = script.stem
|
|
if not re.match(r"^[a-z0-9_]+$", name):
|
|
errors.append(f"{plugin_dir.name}/hooks/scripts/{script.name}: Script name must use snake_case")
|
|
|
|
return errors
|
|
|
|
|
|
def validate_mcp(plugin_dir: Path) -> list[str]:
|
|
"""Validate MCP configuration if present."""
|
|
errors = []
|
|
mcp_json = plugin_dir / ".mcp.json"
|
|
if not mcp_json.exists():
|
|
return errors
|
|
|
|
try:
|
|
with open(mcp_json) as f:
|
|
json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
errors.append(f"{plugin_dir.name}/.mcp.json: Invalid JSON - {e}")
|
|
|
|
return errors
|
|
|
|
|
|
def main():
|
|
"""Validate all plugins and return exit code."""
|
|
plugins_dir = Path("plugins")
|
|
if not plugins_dir.exists():
|
|
print("No plugins directory found")
|
|
return 0
|
|
|
|
all_errors = []
|
|
|
|
for plugin_dir in sorted(plugins_dir.iterdir()):
|
|
if not plugin_dir.is_dir():
|
|
continue
|
|
if plugin_dir.name.startswith("."):
|
|
continue
|
|
|
|
all_errors.extend(validate_plugin_json(plugin_dir))
|
|
all_errors.extend(validate_skills(plugin_dir))
|
|
all_errors.extend(validate_agents(plugin_dir))
|
|
all_errors.extend(validate_commands(plugin_dir))
|
|
all_errors.extend(validate_hooks(plugin_dir))
|
|
all_errors.extend(validate_mcp(plugin_dir))
|
|
|
|
if all_errors:
|
|
print("Plugin Validation Failed:")
|
|
for error in all_errors:
|
|
print(f" - {error}")
|
|
return 1
|
|
|
|
print("All plugins validated successfully")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|