Initial commit
This commit is contained in:
83
hooks/scripts/sync_marketplace_to_plugins.py
Executable file
83
hooks/scripts/sync_marketplace_to_plugins.py
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync marketplace.json plugin entries to individual plugin.json files."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_edited_file_path():
|
||||
"""Extract file path from hook input."""
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
return input_data.get("tool_input", {}).get("file_path", "")
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return ""
|
||||
|
||||
|
||||
def sync_marketplace_to_plugins():
|
||||
"""Sync marketplace.json entries to individual plugin.json files."""
|
||||
edited_path = get_edited_file_path()
|
||||
|
||||
# Only trigger for marketplace.json edits
|
||||
if not edited_path.endswith("marketplace.json"):
|
||||
return 0
|
||||
|
||||
marketplace_path = Path(edited_path)
|
||||
if not marketplace_path.exists():
|
||||
return 0
|
||||
|
||||
try:
|
||||
marketplace = json.loads(marketplace_path.read_text())
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f"❌ Failed to read marketplace.json: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
plugins = marketplace.get("plugins", [])
|
||||
if not plugins:
|
||||
return 0
|
||||
|
||||
marketplace_dir = marketplace_path.parent.parent # Go up from .claude-plugin/
|
||||
synced = []
|
||||
|
||||
for plugin in plugins:
|
||||
source = plugin.get("source")
|
||||
if not source:
|
||||
continue
|
||||
|
||||
# Resolve plugin directory relative to marketplace root
|
||||
plugin_dir = (marketplace_dir / source).resolve()
|
||||
plugin_json_dir = plugin_dir / ".claude-plugin"
|
||||
plugin_json_path = plugin_json_dir / "plugin.json"
|
||||
|
||||
# Build plugin.json content from marketplace entry
|
||||
plugin_data = {"name": plugin.get("name", "")}
|
||||
|
||||
# Add optional fields if present in marketplace
|
||||
for field in ["version", "description", "author", "homepage", "repository", "license"]:
|
||||
if field in plugin:
|
||||
plugin_data[field] = plugin[field]
|
||||
|
||||
# Create directory if needed
|
||||
plugin_json_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Check if update needed
|
||||
current_data = {}
|
||||
if plugin_json_path.exists():
|
||||
try:
|
||||
current_data = json.loads(plugin_json_path.read_text())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if current_data != plugin_data:
|
||||
plugin_json_path.write_text(json.dumps(plugin_data, indent=2) + "\n")
|
||||
synced.append(plugin.get("name", source))
|
||||
|
||||
if synced:
|
||||
print(f"✓ Synced {len(synced)} plugin manifest(s): {', '.join(synced)}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(sync_marketplace_to_plugins())
|
||||
91
hooks/scripts/validate_mcp_hook_locations.py
Executable file
91
hooks/scripts/validate_mcp_hook_locations.py
Executable file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate MCP and hook file locations in plugin.
|
||||
|
||||
Checks:
|
||||
- .mcp.json exists at plugin root if referenced in plugin.json
|
||||
- hooks/hooks.json exists if hooks are configured
|
||||
- Hook scripts referenced in hooks.json exist
|
||||
- File paths use ${CLAUDE_PLUGIN_ROOT} variable reference
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def validate_mcp_hook_locations():
|
||||
"""Check MCP and hook file locations."""
|
||||
errors = []
|
||||
plugin_root = Path(os.environ.get("CLAUDE_PLUGIN_ROOT", "."))
|
||||
|
||||
# Check .mcp.json if referenced in plugin.json
|
||||
plugin_json = plugin_root / ".claude-plugin" / "plugin.json"
|
||||
if plugin_json.exists():
|
||||
try:
|
||||
with open(plugin_json) as f:
|
||||
plugin_config = json.load(f)
|
||||
|
||||
# Check if MCPs are mentioned in plugin description
|
||||
if "mcp" in plugin_config or "mcp" in str(plugin_config.get("description", "")).lower():
|
||||
mcp_config = plugin_root / ".mcp.json"
|
||||
if not mcp_config.exists():
|
||||
errors.append(".mcp.json not found (mentioned in plugin.json)")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
errors.append(f".claude-plugin/plugin.json: Invalid JSON - {e}")
|
||||
|
||||
# Check hooks.json if exists
|
||||
hooks_json = plugin_root / "hooks" / "hooks.json"
|
||||
if hooks_json.exists():
|
||||
try:
|
||||
with open(hooks_json) as f:
|
||||
hooks_config = json.load(f)
|
||||
|
||||
if "hooks" in hooks_config:
|
||||
# Check all referenced script files
|
||||
for hook_type, hook_list in hooks_config["hooks"].items():
|
||||
if not isinstance(hook_list, list):
|
||||
continue
|
||||
|
||||
for hook_entry in hook_list:
|
||||
if not isinstance(hook_entry, dict):
|
||||
continue
|
||||
|
||||
hooks = hook_entry.get("hooks", [])
|
||||
if not isinstance(hooks, list):
|
||||
continue
|
||||
|
||||
for hook in hooks:
|
||||
if hook.get("type") == "command":
|
||||
cmd = hook.get("command", "")
|
||||
|
||||
# Check if using variable reference
|
||||
if cmd and not cmd.startswith("${CLAUDE_PLUGIN_ROOT}"):
|
||||
if os.path.isabs(cmd):
|
||||
errors.append(
|
||||
f"hooks/hooks.json: Absolute path '{cmd}' "
|
||||
"should use ${CLAUDE_PLUGIN_ROOT} variable"
|
||||
)
|
||||
|
||||
# Expand path and check file exists
|
||||
if cmd and "${CLAUDE_PLUGIN_ROOT}" in cmd:
|
||||
expanded = cmd.replace("${CLAUDE_PLUGIN_ROOT}", str(plugin_root))
|
||||
if not Path(expanded).exists():
|
||||
errors.append(f"hooks/hooks.json: Referenced script not found: {cmd}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
errors.append(f"hooks/hooks.json: Invalid JSON - {e}")
|
||||
|
||||
if errors:
|
||||
print("❌ MCP/Hook Location Validation Failed:")
|
||||
for error in errors:
|
||||
print(f" • {error}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(validate_mcp_hook_locations())
|
||||
93
hooks/scripts/validate_plugin_paths.py
Executable file
93
hooks/scripts/validate_plugin_paths.py
Executable file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate plugin paths alignment between marketplace.json and plugin structure.
|
||||
|
||||
Checks:
|
||||
- .claude-plugin/plugin.json exists
|
||||
- marketplace.json (if used at project root) paths match actual plugin directories
|
||||
- Plugin name in plugin.json matches directory name
|
||||
- Required plugin fields are present
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def validate_plugin_paths():
|
||||
"""Check plugin paths in marketplace and plugin structure."""
|
||||
errors = []
|
||||
plugin_root = Path(os.environ.get("CLAUDE_PLUGIN_ROOT", "."))
|
||||
|
||||
# Check .claude-plugin/plugin.json exists
|
||||
plugin_json = plugin_root / ".claude-plugin" / "plugin.json"
|
||||
if not plugin_json.exists():
|
||||
errors.append(".claude-plugin directory or plugin.json not found")
|
||||
print("❌ Plugin Path Validation Failed:")
|
||||
for error in errors:
|
||||
print(f" • {error}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
with open(plugin_json) as f:
|
||||
plugin_config = json.load(f)
|
||||
|
||||
# Check required fields (only 'name' is required per Claude Code docs)
|
||||
if "name" not in plugin_config:
|
||||
errors.append(".claude-plugin/plugin.json: Missing 'name' field")
|
||||
|
||||
# Verify plugin name matches directory (if not at root)
|
||||
plugin_name = plugin_config.get("name")
|
||||
if plugin_name and plugin_root.name != ".":
|
||||
dir_name = plugin_root.name
|
||||
if plugin_name != dir_name:
|
||||
errors.append(
|
||||
f"Plugin name '{plugin_name}' in plugin.json does not match "
|
||||
f"directory name '{dir_name}'"
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
errors.append(f".claude-plugin/plugin.json: Invalid JSON - {e}")
|
||||
|
||||
# Check marketplace.json at project root if this is a plugin directory
|
||||
marketplace_root = plugin_root.parent.parent / ".claude-plugin" / "marketplace.json"
|
||||
if marketplace_root.exists():
|
||||
try:
|
||||
with open(marketplace_root) as f:
|
||||
marketplace = json.load(f)
|
||||
|
||||
if "plugins" in marketplace:
|
||||
plugin_name = plugin_config.get("name")
|
||||
for plugin_entry in marketplace["plugins"]:
|
||||
if plugin_entry.get("name") == plugin_name:
|
||||
# Check path matches
|
||||
path = plugin_entry.get("path")
|
||||
if path:
|
||||
# Resolve relative path
|
||||
expected_path = plugin_root / "plugin.json"
|
||||
if "${CLAUDE_PLUGIN_ROOT}" in path:
|
||||
expected = path.replace("${CLAUDE_PLUGIN_ROOT}", str(plugin_root))
|
||||
else:
|
||||
expected = str(expected_path)
|
||||
|
||||
actual = plugin_root / ".claude-plugin" / "plugin.json"
|
||||
if not actual.exists():
|
||||
errors.append(
|
||||
f"marketplace.json references path that doesn't exist: {path}"
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
errors.append(f"marketplace.json: Invalid JSON - {e}")
|
||||
|
||||
if errors:
|
||||
print("❌ Plugin Path Validation Failed:")
|
||||
for error in errors:
|
||||
print(f" • {error}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(validate_plugin_paths())
|
||||
114
hooks/scripts/validate_plugin_structure.py
Executable file
114
hooks/scripts/validate_plugin_structure.py
Executable file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate overall plugin structure and organization.
|
||||
|
||||
Checks:
|
||||
- skills/ directory exists if skills are mentioned
|
||||
- agents/, commands/, hooks/ directories follow naming conventions
|
||||
- No invalid file/directory names
|
||||
- Required metadata files present
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def validate_plugin_structure():
|
||||
"""Check plugin directory structure and naming."""
|
||||
errors = []
|
||||
plugin_root = Path(os.environ.get("CLAUDE_PLUGIN_ROOT", "."))
|
||||
|
||||
# Check for valid component directories
|
||||
valid_dirs = {"skills", "agents", "commands", "hooks", ".claude-plugin"}
|
||||
|
||||
for item in plugin_root.iterdir():
|
||||
if item.is_dir() and item.name.startswith("."):
|
||||
continue # Skip hidden dirs
|
||||
|
||||
if item.is_dir() and item.name not in valid_dirs:
|
||||
# Check if it's a generated dir like __pycache__
|
||||
if item.name.startswith("__"):
|
||||
continue
|
||||
# Check if it's a valid plugin component
|
||||
if not re.match(r"^[a-z0-9_-]+$", item.name):
|
||||
errors.append(f"Invalid directory name: {item.name} (use lowercase, hyphens, underscores)")
|
||||
|
||||
# Check skills structure if skills directory exists
|
||||
skills_dir = plugin_root / "skills"
|
||||
if skills_dir.exists():
|
||||
for skill_path in skills_dir.iterdir():
|
||||
if not skill_path.is_dir():
|
||||
continue
|
||||
|
||||
# Check SKILL.md exists
|
||||
skill_md = skill_path / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
errors.append(f"skills/{skill_path.name}/: Missing SKILL.md file")
|
||||
|
||||
# Check skill directory name format
|
||||
if not re.match(r"^[a-z0-9-]+$", skill_path.name):
|
||||
errors.append(f"skills/{skill_path.name}/: Invalid directory name (use lowercase with hyphens only)")
|
||||
|
||||
# Check agents directory if exists
|
||||
agents_dir = plugin_root / "agents"
|
||||
if agents_dir.exists():
|
||||
for agent_file in agents_dir.iterdir():
|
||||
if agent_file.is_file() and agent_file.suffix == ".md":
|
||||
# Agent files should use kebab-case
|
||||
name = agent_file.stem
|
||||
if not re.match(r"^[a-z0-9-]+$", name):
|
||||
errors.append(
|
||||
f"agents/{agent_file.name}: Invalid agent name (use kebab-case: lowercase with hyphens)"
|
||||
)
|
||||
|
||||
# Check commands directory if exists
|
||||
commands_dir = plugin_root / "commands"
|
||||
if commands_dir.exists():
|
||||
for cmd_file in commands_dir.iterdir():
|
||||
if cmd_file.is_file() and cmd_file.suffix == ".md":
|
||||
# Command files should use kebab-case
|
||||
name = cmd_file.stem
|
||||
if not re.match(r"^[a-z0-9-]+$", name):
|
||||
errors.append(
|
||||
f"commands/{cmd_file.name}: Invalid command name (use kebab-case: lowercase with hyphens)"
|
||||
)
|
||||
|
||||
# Check hooks directory if exists
|
||||
hooks_dir = plugin_root / "hooks"
|
||||
if hooks_dir.exists():
|
||||
hooks_json = hooks_dir / "hooks.json"
|
||||
if not hooks_json.exists():
|
||||
errors.append("hooks/: Missing hooks.json file")
|
||||
|
||||
# Check scripts directory
|
||||
scripts_dir = hooks_dir / "scripts"
|
||||
if scripts_dir.exists():
|
||||
for script in scripts_dir.iterdir():
|
||||
if script.is_file():
|
||||
# Scripts should be executable
|
||||
if not os.access(script, os.X_OK):
|
||||
# Note: We don't error here, just validate naming
|
||||
pass
|
||||
|
||||
# Check script naming
|
||||
if script.suffix in {".py", ".sh"}:
|
||||
name = script.stem
|
||||
if not re.match(r"^[a-z0-9_]+$", name):
|
||||
errors.append(
|
||||
f"hooks/scripts/{script.name}: Invalid script name "
|
||||
"(use snake_case: lowercase with underscores)"
|
||||
)
|
||||
|
||||
if errors:
|
||||
print("❌ Plugin Structure Validation Failed:")
|
||||
for error in errors:
|
||||
print(f" • {error}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(validate_plugin_structure())
|
||||
122
hooks/scripts/validate_skill.py
Executable file
122
hooks/scripts/validate_skill.py
Executable file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate SKILL.md files for structure, name format, and description length."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_simple_yaml(text):
|
||||
"""Parse simple key-value YAML frontmatter."""
|
||||
result = {}
|
||||
for line in text.strip().split("\n"):
|
||||
if ":" in line:
|
||||
key, _, value = line.partition(":")
|
||||
result[key.strip()] = value.strip().strip('"').strip("'")
|
||||
return result
|
||||
|
||||
|
||||
def get_edited_file_path():
|
||||
"""Extract file path from hook input."""
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
return input_data.get("tool_input", {}).get("file_path", "")
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return ""
|
||||
|
||||
|
||||
def validate_skill():
|
||||
"""Validate all SKILL.md files in skills directory."""
|
||||
edited_path = get_edited_file_path()
|
||||
|
||||
# Exit early if not editing a skill-related file
|
||||
if edited_path and "/skills/" not in edited_path and not edited_path.endswith("SKILL.md"):
|
||||
return 0
|
||||
|
||||
errors = []
|
||||
plugin_root = Path(os.environ.get("CLAUDE_PLUGIN_ROOT", "."))
|
||||
skills_dir = plugin_root / "skills"
|
||||
|
||||
if not skills_dir.exists():
|
||||
return 0
|
||||
|
||||
for skill_path in skills_dir.iterdir():
|
||||
if not skill_path.is_dir():
|
||||
continue
|
||||
|
||||
skill_md = skill_path / "SKILL.md"
|
||||
prefix = f"{skill_path.name}/SKILL.md"
|
||||
|
||||
# Check SKILL.md exists
|
||||
if not skill_md.exists():
|
||||
errors.append(f"Missing SKILL.md in {skill_path.name}")
|
||||
continue
|
||||
|
||||
# Read and parse file
|
||||
try:
|
||||
content = skill_md.read_text()
|
||||
except Exception as e:
|
||||
errors.append(f"{prefix}: Error reading file - {e}")
|
||||
continue
|
||||
|
||||
# Check frontmatter markers
|
||||
if not content.startswith("---"):
|
||||
errors.append(f"{prefix}: Missing YAML frontmatter")
|
||||
continue
|
||||
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
errors.append(f"{prefix}: Invalid frontmatter format")
|
||||
continue
|
||||
|
||||
# Parse YAML
|
||||
try:
|
||||
frontmatter = parse_simple_yaml(parts[1])
|
||||
except Exception as e:
|
||||
errors.append(f"{prefix}: Invalid YAML - {e}")
|
||||
continue
|
||||
|
||||
if not frontmatter or not isinstance(frontmatter, dict):
|
||||
errors.append(f"{prefix}: Frontmatter must be valid YAML object")
|
||||
continue
|
||||
|
||||
# Validate name field
|
||||
if "name" not in frontmatter:
|
||||
errors.append(f"{prefix}: Missing 'name' field")
|
||||
else:
|
||||
name = frontmatter["name"]
|
||||
if not isinstance(name, str):
|
||||
errors.append(f"{prefix}: 'name' must be a string")
|
||||
elif not name:
|
||||
errors.append(f"{prefix}: 'name' cannot be empty")
|
||||
else:
|
||||
if len(name) > 64:
|
||||
errors.append(f"{prefix}: 'name' exceeds 64 characters ({len(name)})")
|
||||
if not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", name):
|
||||
errors.append(f"{prefix}: 'name' must use kebab-case: '{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 a string")
|
||||
elif not desc:
|
||||
errors.append(f"{prefix}: 'description' cannot be empty")
|
||||
elif len(desc) > 300:
|
||||
errors.append(f"{prefix}: 'description' exceeds 300 characters ({len(desc)})")
|
||||
|
||||
if errors:
|
||||
print("❌ Skill Validation Failed:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f" • {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(validate_skill())
|
||||
Reference in New Issue
Block a user