v1.4.0: Major Skills Expansion - 75 Total Skills
This commit is contained in:
368
skills/claude-codex-settings/.github/scripts/validate_plugins.py
vendored
Normal file
368
skills/claude-codex-settings/.github/scripts/validate_plugins.py
vendored
Normal file
@@ -0,0 +1,368 @@
|
||||
#!/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())
|
||||
28
skills/claude-codex-settings/.github/workflows/validate-plugins.yml
vendored
Normal file
28
skills/claude-codex-settings/.github/workflows/validate-plugins.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Validate Plugins
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "plugins/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "plugins/**"
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Validate plugins
|
||||
run: python .github/scripts/validate_plugins.py
|
||||
Reference in New Issue
Block a user