Files
leo-claude-mktplace/.claude/skills/claude-plugin-developer/scripts/validate_manifest.py

298 lines
11 KiB
Python

#!/usr/bin/env python3
"""
Claude Plugin Manifest Validator
Validates plugin.json files against the official schema.
Usage:
python validate_manifest.py <path-to-plugin.json>
"""
import json
import sys
import re
from pathlib import Path
from typing import Dict, List, Tuple, Any
class ManifestValidator:
def __init__(self):
self.errors: List[str] = []
self.warnings: List[str] = []
def validate(self, manifest_path: str) -> Tuple[bool, List[str], List[str]]:
"""Validate a plugin manifest file."""
self.errors = []
self.warnings = []
# Check if file exists
path = Path(manifest_path)
if not path.exists():
self.errors.append(f"File not found: {manifest_path}")
return False, self.errors, self.warnings
# Load and parse JSON
try:
with open(path, 'r') as f:
manifest = json.load(f)
except json.JSONDecodeError as e:
self.errors.append(f"Invalid JSON: {e}")
return False, self.errors, self.warnings
# Validate required fields
self._validate_required_fields(manifest)
# Validate field formats
if "name" in manifest:
self._validate_name(manifest["name"])
if "version" in manifest:
self._validate_version(manifest["version"])
if "description" in manifest:
self._validate_description(manifest["description"])
if "author" in manifest:
self._validate_author(manifest["author"])
# Validate optional fields
if "dependencies" in manifest:
self._validate_dependencies(manifest["dependencies"])
if "config" in manifest:
self._validate_config(manifest["config"])
if "permissions" in manifest:
self._validate_permissions(manifest["permissions"])
if "keywords" in manifest:
self._validate_keywords(manifest["keywords"])
# Check for unknown fields
self._check_unknown_fields(manifest)
is_valid = len(self.errors) == 0
return is_valid, self.errors, self.warnings
def _validate_required_fields(self, manifest: Dict[str, Any]):
"""Check for required fields."""
required_fields = ["name", "version", "description", "author"]
for field in required_fields:
if field not in manifest:
self.errors.append(f"Missing required field: {field}")
def _validate_name(self, name: str):
"""Validate plugin name format."""
if not isinstance(name, str):
self.errors.append("Name must be a string")
return
if len(name) < 2 or len(name) > 40:
self.errors.append("Name must be between 2 and 40 characters")
pattern = r"^[a-z][a-z0-9-]*[a-z0-9]$"
if not re.match(pattern, name):
self.errors.append(
"Name must start with lowercase letter, "
"contain only lowercase letters, numbers, and hyphens, "
"and not end with a hyphen"
)
if "--" in name:
self.errors.append("Name cannot contain consecutive hyphens")
def _validate_version(self, version: str):
"""Validate semantic version format."""
if not isinstance(version, str):
self.errors.append("Version must be a string")
return
# Basic semver pattern
pattern = r"^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$"
if not re.match(pattern, version):
self.errors.append(
"Version must follow semantic versioning (e.g., 1.0.0)"
)
def _validate_description(self, description: str):
"""Validate description field."""
if not isinstance(description, str):
self.errors.append("Description must be a string")
return
if len(description) > 200:
self.errors.append("Description must be 200 characters or less")
if len(description) < 10:
self.warnings.append("Description should be at least 10 characters")
def _validate_author(self, author: Any):
"""Validate author field."""
if isinstance(author, str):
# Legacy format - just a name
self.warnings.append(
"Author as string is deprecated. "
"Use object format: {\"name\": \"...\", \"email\": \"...\"}"
)
elif isinstance(author, dict):
if "name" not in author:
self.errors.append("Author object must have 'name' field")
elif not isinstance(author["name"], str):
self.errors.append("Author name must be a string")
if "email" in author:
if not isinstance(author["email"], str):
self.errors.append("Author email must be a string")
elif not self._is_valid_email(author["email"]):
self.errors.append("Invalid email format")
if "url" in author:
if not isinstance(author["url"], str):
self.errors.append("Author url must be a string")
elif not self._is_valid_url(author["url"]):
self.errors.append("Invalid URL format")
else:
self.errors.append("Author must be string or object")
def _validate_dependencies(self, dependencies: Dict[str, Any]):
"""Validate dependencies field."""
if not isinstance(dependencies, dict):
self.errors.append("Dependencies must be an object")
return
for dep, version in dependencies.items():
if not isinstance(dep, str):
self.errors.append(f"Dependency key must be string: {dep}")
if not isinstance(version, str):
self.errors.append(
f"Dependency version must be string: {dep}"
)
else:
# Basic version constraint validation
if not re.match(r"^[><=~^]", version) and not re.match(r"^\d", version):
self.warnings.append(
f"Unusual version constraint for {dep}: {version}"
)
def _validate_config(self, config: Dict[str, Any]):
"""Validate config field."""
if not isinstance(config, dict):
self.errors.append("Config must be an object")
return
# Validate known config options
if "default_shell" in config:
if config["default_shell"] not in ["bash", "sh", "zsh", "fish"]:
self.warnings.append(
f"Unusual shell: {config['default_shell']}"
)
if "timeout" in config:
if not isinstance(config["timeout"], (int, float)):
self.errors.append("Config timeout must be a number")
elif config["timeout"] <= 0:
self.errors.append("Config timeout must be positive")
if "environment" in config:
if not isinstance(config["environment"], dict):
self.errors.append("Config environment must be an object")
def _validate_permissions(self, permissions: Dict[str, Any]):
"""Validate permissions field."""
if not isinstance(permissions, dict):
self.errors.append("Permissions must be an object")
return
valid_permissions = {
"file_access": ["read", "write", "execute"],
"network_access": [True, False],
"shell_access": [True, False],
"env_access": list # List of patterns
}
for perm, value in permissions.items():
if perm not in valid_permissions:
self.warnings.append(f"Unknown permission: {perm}")
continue
expected = valid_permissions[perm]
if isinstance(expected, list) and not isinstance(value, type(expected[0])):
if expected == list:
self.errors.append(f"Permission {perm} must be a list")
else:
self.errors.append(
f"Permission {perm} must be one of: {expected}"
)
def _validate_keywords(self, keywords: List[str]):
"""Validate keywords field."""
if not isinstance(keywords, list):
self.errors.append("Keywords must be an array")
return
for keyword in keywords:
if not isinstance(keyword, str):
self.errors.append("All keywords must be strings")
elif len(keyword) > 20:
self.warnings.append(
f"Keyword too long (max 20 chars): {keyword}"
)
def _check_unknown_fields(self, manifest: Dict[str, Any]):
"""Check for unknown fields."""
known_fields = {
"name", "version", "description", "author", "license",
"keywords", "homepage", "repository", "bugs", "dependencies",
"config", "permissions", "scripts", "engines"
}
unknown_fields = set(manifest.keys()) - known_fields
for field in unknown_fields:
self.warnings.append(f"Unknown field: {field}")
def _is_valid_email(self, email: str) -> bool:
"""Check if email format is valid."""
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
return bool(re.match(pattern, email))
def _is_valid_url(self, url: str) -> bool:
"""Check if URL format is valid."""
pattern = r"^https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
return bool(re.match(pattern, url))
def main():
if len(sys.argv) != 2:
print("Usage: python validate_manifest.py <path-to-plugin.json>")
sys.exit(1)
manifest_path = sys.argv[1]
validator = ManifestValidator()
print(f"Validating: {manifest_path}\n")
is_valid, errors, warnings = validator.validate(manifest_path)
if errors:
print("ERRORS:")
for error in errors:
print(f"{error}")
print()
if warnings:
print("WARNINGS:")
for warning in warnings:
print(f"{warning}")
print()
if is_valid:
print("✓ Manifest is valid")
sys.exit(0)
else:
print("✗ Manifest validation failed")
sys.exit(1)
if __name__ == "__main__":
main()