#!/usr/bin/env python3 """ Claude Plugin Manifest Validator Validates plugin.json files against the official schema. Usage: python validate_manifest.py """ 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 ") 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()