Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:26:37 +08:00
commit 5fdc9f2c12
67 changed files with 22481 additions and 0 deletions

View 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())

View 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())

View 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())

View 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
View 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())