initial project setup: added plugin skill
This commit is contained in:
275
.claude/skills/claude-plugin-developer/scripts/init_plugin.py
Normal file
275
.claude/skills/claude-plugin-developer/scripts/init_plugin.py
Normal file
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Claude Plugin Initializer
|
||||
Creates a new plugin with proper structure and example files.
|
||||
|
||||
Usage:
|
||||
python init_plugin.py <plugin-name> [--path <output-dir>]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
def create_plugin_structure(plugin_name: str, output_path: str = "."):
|
||||
"""Create a new plugin with standard structure."""
|
||||
|
||||
# Validate plugin name
|
||||
if not plugin_name.replace("-", "").replace("_", "").isalnum():
|
||||
print(f"Error: Plugin name must be alphanumeric with hyphens or underscores")
|
||||
return False
|
||||
|
||||
# Create plugin directory
|
||||
plugin_dir = Path(output_path) / plugin_name
|
||||
if plugin_dir.exists():
|
||||
print(f"Error: Directory {plugin_dir} already exists")
|
||||
return False
|
||||
|
||||
# Create directory structure
|
||||
directories = [
|
||||
plugin_dir / ".claude-plugin",
|
||||
plugin_dir / "commands",
|
||||
plugin_dir / "agents",
|
||||
plugin_dir / "hooks",
|
||||
plugin_dir / "scripts",
|
||||
plugin_dir / "docs"
|
||||
]
|
||||
|
||||
for directory in directories:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create plugin.json
|
||||
plugin_manifest = {
|
||||
"name": plugin_name,
|
||||
"version": "1.0.0",
|
||||
"description": f"Description for {plugin_name}",
|
||||
"author": {
|
||||
"name": "Your Name",
|
||||
"email": "your.email@example.com"
|
||||
},
|
||||
"license": "MIT",
|
||||
"keywords": [],
|
||||
"config": {
|
||||
"default_shell": "bash"
|
||||
}
|
||||
}
|
||||
|
||||
manifest_path = plugin_dir / ".claude-plugin" / "plugin.json"
|
||||
with open(manifest_path, "w") as f:
|
||||
json.dump(plugin_manifest, f, indent=2)
|
||||
|
||||
# Create example command
|
||||
example_command = f"""---
|
||||
_type: command
|
||||
_command: hello
|
||||
_description: Example command that greets the user
|
||||
---
|
||||
|
||||
# Hello Command
|
||||
|
||||
This is an example command for the {plugin_name} plugin.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/{plugin_name} hello
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
This command demonstrates the basic structure of a plugin command.
|
||||
It will greet the user and show the current time.
|
||||
|
||||
## Example
|
||||
|
||||
User: `/{plugin_name} hello`
|
||||
|
||||
Response: Hello from {plugin_name}! The current time is [current time].
|
||||
"""
|
||||
|
||||
with open(plugin_dir / "commands" / "hello.md", "w") as f:
|
||||
f.write(example_command)
|
||||
|
||||
# Create example agent
|
||||
example_agent = f"""---
|
||||
_type: agent
|
||||
_name: {plugin_name}-assistant
|
||||
_description: Example agent for the {plugin_name} plugin
|
||||
---
|
||||
|
||||
# {plugin_name.replace("-", " ").title()} Assistant
|
||||
|
||||
You are a specialized assistant for the {plugin_name} plugin.
|
||||
|
||||
## Your Role
|
||||
|
||||
- Help users understand how to use the {plugin_name} plugin
|
||||
- Provide guidance on best practices
|
||||
- Assist with troubleshooting common issues
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. Always be helpful and concise
|
||||
2. Provide code examples when appropriate
|
||||
3. Reference the plugin documentation when needed
|
||||
"""
|
||||
|
||||
with open(plugin_dir / "agents" / f"{plugin_name}-assistant.md", "w") as f:
|
||||
f.write(example_agent)
|
||||
|
||||
# Create example hook configuration
|
||||
hooks_config = {
|
||||
"hooks": [
|
||||
{
|
||||
"event": "file-changed",
|
||||
"pattern": f"**/*.{plugin_name}",
|
||||
"script": "hooks/process_file.sh",
|
||||
"description": f"Process {plugin_name} files when changed"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with open(plugin_dir / "hooks" / "hooks.json", "w") as f:
|
||||
json.dump(hooks_config, f, indent=2)
|
||||
|
||||
# Create example hook script
|
||||
hook_script = f"""#!/bin/bash
|
||||
# Process {plugin_name} files when changed
|
||||
|
||||
set -e
|
||||
|
||||
FILE_PATH="${{CHANGED_FILE}}"
|
||||
FILE_EXTENSION="${{FILE_EXTENSION}}"
|
||||
|
||||
echo "Processing ${{FILE_PATH}}..."
|
||||
|
||||
# Add your processing logic here
|
||||
# Example: validate file format, run linter, etc.
|
||||
|
||||
echo "✓ Successfully processed ${{FILE_PATH}}"
|
||||
"""
|
||||
|
||||
hook_script_path = plugin_dir / "hooks" / "process_file.sh"
|
||||
with open(hook_script_path, "w") as f:
|
||||
f.write(hook_script)
|
||||
|
||||
# Make hook script executable
|
||||
os.chmod(hook_script_path, 0o755)
|
||||
|
||||
# Create README
|
||||
readme_content = f"""# {plugin_name.replace("-", " ").title()}
|
||||
|
||||
## Description
|
||||
|
||||
{plugin_name} is a Claude plugin that [describe what your plugin does].
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
claude plugin install {plugin_name}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- `/{plugin_name} hello` - Example greeting command
|
||||
|
||||
## Configuration
|
||||
|
||||
This plugin supports the following configuration options:
|
||||
|
||||
- `example_option`: Description of the option
|
||||
|
||||
## Development
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Test the plugin locally
|
||||
claude --debug plugin install file://$(pwd)
|
||||
```
|
||||
|
||||
### Contributing
|
||||
|
||||
[Add contribution guidelines here]
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
"""
|
||||
|
||||
with open(plugin_dir / "README.md", "w") as f:
|
||||
f.write(readme_content)
|
||||
|
||||
# Create .gitignore
|
||||
gitignore_content = """# Dependencies
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
.coverage
|
||||
"""
|
||||
|
||||
with open(plugin_dir / ".gitignore", "w") as f:
|
||||
f.write(gitignore_content)
|
||||
|
||||
print(f"✓ Created plugin structure at: {plugin_dir}")
|
||||
print(f"\nNext steps:")
|
||||
print(f"1. cd {plugin_dir}")
|
||||
print(f"2. Edit .claude-plugin/plugin.json with your plugin details")
|
||||
print(f"3. Add your commands to the commands/ directory")
|
||||
print(f"4. Test with: claude --debug plugin install file://$(pwd)")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Initialize a new Claude plugin with standard structure"
|
||||
)
|
||||
parser.add_argument("plugin_name", help="Name of the plugin (kebab-case)")
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
default=".",
|
||||
help="Output directory (default: current directory)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate plugin name format
|
||||
if not args.plugin_name.replace("-", "").isalnum():
|
||||
print("Error: Plugin name must contain only letters, numbers, and hyphens")
|
||||
sys.exit(1)
|
||||
|
||||
success = create_plugin_structure(args.plugin_name, args.path)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
404
.claude/skills/claude-plugin-developer/scripts/test_commands.py
Normal file
404
.claude/skills/claude-plugin-developer/scripts/test_commands.py
Normal file
@@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Claude Plugin Command Tester
|
||||
Automated testing for plugin commands.
|
||||
|
||||
Usage:
|
||||
python test_commands.py <plugin-directory> [--command <specific-command>]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Any
|
||||
import re
|
||||
import time
|
||||
|
||||
class CommandTester:
|
||||
def __init__(self, plugin_dir: str):
|
||||
self.plugin_dir = Path(plugin_dir)
|
||||
self.plugin_name = self._get_plugin_name()
|
||||
self.test_results: List[Dict[str, Any]] = []
|
||||
|
||||
def _get_plugin_name(self) -> str:
|
||||
"""Extract plugin name from manifest."""
|
||||
manifest_path = self.plugin_dir / ".claude-plugin" / "plugin.json"
|
||||
if not manifest_path.exists():
|
||||
raise FileNotFoundError(f"No plugin.json found at {manifest_path}")
|
||||
|
||||
with open(manifest_path) as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
return manifest.get("name", "unknown")
|
||||
|
||||
def discover_commands(self) -> List[str]:
|
||||
"""Discover all commands in the plugin."""
|
||||
commands = []
|
||||
commands_dir = self.plugin_dir / "commands"
|
||||
|
||||
if not commands_dir.exists():
|
||||
return commands
|
||||
|
||||
for md_file in commands_dir.rglob("*.md"):
|
||||
# Skip index files
|
||||
if md_file.name == "_index.md":
|
||||
continue
|
||||
|
||||
# Extract command from frontmatter
|
||||
command = self._extract_command_from_file(md_file)
|
||||
if command:
|
||||
commands.append(command)
|
||||
|
||||
return sorted(commands)
|
||||
|
||||
def _extract_command_from_file(self, file_path: Path) -> str:
|
||||
"""Extract command name from markdown file."""
|
||||
with open(file_path) as f:
|
||||
content = f.read()
|
||||
|
||||
# Look for _command in frontmatter
|
||||
match = re.search(r'^_command:\s*(.+)$', content, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
# Fallback to filename
|
||||
return file_path.stem
|
||||
|
||||
def test_command(self, command: str) -> Dict[str, Any]:
|
||||
"""Test a specific command."""
|
||||
print(f"Testing command: /{self.plugin_name} {command}")
|
||||
|
||||
result = {
|
||||
"command": command,
|
||||
"status": "unknown",
|
||||
"tests": []
|
||||
}
|
||||
|
||||
# Test 1: Check if command file exists
|
||||
command_file = self._find_command_file(command)
|
||||
if not command_file:
|
||||
result["status"] = "failed"
|
||||
result["error"] = "Command file not found"
|
||||
return result
|
||||
|
||||
result["tests"].append({
|
||||
"name": "file_exists",
|
||||
"passed": True,
|
||||
"message": f"Found at {command_file}"
|
||||
})
|
||||
|
||||
# Test 2: Validate command metadata
|
||||
metadata_valid, metadata_errors = self._validate_command_metadata(command_file)
|
||||
result["tests"].append({
|
||||
"name": "metadata_validation",
|
||||
"passed": metadata_valid,
|
||||
"errors": metadata_errors
|
||||
})
|
||||
|
||||
# Test 3: Check for required sections
|
||||
sections_valid, missing_sections = self._check_required_sections(command_file)
|
||||
result["tests"].append({
|
||||
"name": "required_sections",
|
||||
"passed": sections_valid,
|
||||
"missing": missing_sections
|
||||
})
|
||||
|
||||
# Test 4: Validate examples
|
||||
examples_valid, example_errors = self._validate_examples(command_file)
|
||||
result["tests"].append({
|
||||
"name": "examples_validation",
|
||||
"passed": examples_valid,
|
||||
"errors": example_errors
|
||||
})
|
||||
|
||||
# Test 5: Check for common issues
|
||||
issues = self._check_common_issues(command_file)
|
||||
result["tests"].append({
|
||||
"name": "common_issues",
|
||||
"passed": len(issues) == 0,
|
||||
"issues": issues
|
||||
})
|
||||
|
||||
# Determine overall status
|
||||
all_passed = all(test["passed"] for test in result["tests"])
|
||||
result["status"] = "passed" if all_passed else "failed"
|
||||
|
||||
return result
|
||||
|
||||
def _find_command_file(self, command: str) -> Path:
|
||||
"""Find the markdown file for a command."""
|
||||
commands_dir = self.plugin_dir / "commands"
|
||||
|
||||
# Direct file
|
||||
direct = commands_dir / f"{command}.md"
|
||||
if direct.exists():
|
||||
return direct
|
||||
|
||||
# Check subdirectories
|
||||
for md_file in commands_dir.rglob("*.md"):
|
||||
if self._extract_command_from_file(md_file) == command:
|
||||
return md_file
|
||||
|
||||
return None
|
||||
|
||||
def _validate_command_metadata(self, file_path: Path) -> Tuple[bool, List[str]]:
|
||||
"""Validate command frontmatter metadata."""
|
||||
errors = []
|
||||
|
||||
with open(file_path) as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for frontmatter
|
||||
if not content.startswith("---"):
|
||||
errors.append("Missing frontmatter")
|
||||
return False, errors
|
||||
|
||||
# Extract frontmatter
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
errors.append("Invalid frontmatter format")
|
||||
return False, errors
|
||||
|
||||
frontmatter = parts[1].strip()
|
||||
|
||||
# Check required fields
|
||||
required_fields = ["_type", "_command", "_description"]
|
||||
for field in required_fields:
|
||||
if f"{field}:" not in frontmatter:
|
||||
errors.append(f"Missing required field: {field}")
|
||||
|
||||
# Validate _type
|
||||
if "_type: command" not in frontmatter:
|
||||
errors.append("_type must be 'command'")
|
||||
|
||||
# Validate _description length
|
||||
desc_match = re.search(r'^_description:\s*(.+)$', frontmatter, re.MULTILINE)
|
||||
if desc_match:
|
||||
description = desc_match.group(1).strip()
|
||||
if len(description) < 10:
|
||||
errors.append("Description too short (min 10 chars)")
|
||||
elif len(description) > 100:
|
||||
errors.append("Description too long (max 100 chars)")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def _check_required_sections(self, file_path: Path) -> Tuple[bool, List[str]]:
|
||||
"""Check for required documentation sections."""
|
||||
with open(file_path) as f:
|
||||
content = f.read()
|
||||
|
||||
# Remove frontmatter
|
||||
if content.startswith("---"):
|
||||
content = content.split("---", 2)[2]
|
||||
|
||||
required_sections = ["Usage", "What it does"]
|
||||
missing = []
|
||||
|
||||
for section in required_sections:
|
||||
# Check for section header
|
||||
if not re.search(rf'^#{1,3}\s*{section}', content, re.MULTILINE | re.IGNORECASE):
|
||||
missing.append(section)
|
||||
|
||||
return len(missing) == 0, missing
|
||||
|
||||
def _validate_examples(self, file_path: Path) -> Tuple[bool, List[str]]:
|
||||
"""Validate command examples."""
|
||||
errors = []
|
||||
|
||||
with open(file_path) as f:
|
||||
content = f.read()
|
||||
|
||||
# Look for example section
|
||||
example_match = re.search(r'^#{1,3}\s*Examples?.*?(?=^#{1,3}|\Z)',
|
||||
content, re.MULTILINE | re.IGNORECASE | re.DOTALL)
|
||||
|
||||
if not example_match:
|
||||
# Examples are recommended but not required
|
||||
return True, []
|
||||
|
||||
example_section = example_match.group(0)
|
||||
|
||||
# Check for code blocks
|
||||
code_blocks = re.findall(r'```[\s\S]*?```', example_section)
|
||||
if not code_blocks:
|
||||
errors.append("Example section has no code blocks")
|
||||
|
||||
# Check that examples use the plugin name
|
||||
plugin_ref = f"/{self.plugin_name}"
|
||||
if plugin_ref not in example_section:
|
||||
errors.append(f"Examples should reference the plugin: {plugin_ref}")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def _check_common_issues(self, file_path: Path) -> List[str]:
|
||||
"""Check for common issues in command files."""
|
||||
issues = []
|
||||
|
||||
with open(file_path) as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for TODO/FIXME
|
||||
if "TODO" in content or "FIXME" in content:
|
||||
issues.append("Contains TODO/FIXME markers")
|
||||
|
||||
# Check for broken markdown links
|
||||
broken_links = re.findall(r'\[([^\]]+)\]\(\s*\)', content)
|
||||
if broken_links:
|
||||
issues.append(f"Broken markdown links: {broken_links}")
|
||||
|
||||
# Check for very long lines
|
||||
lines = content.split('\n')
|
||||
long_lines = [i for i, line in enumerate(lines, 1) if len(line) > 120]
|
||||
if long_lines:
|
||||
issues.append(f"Very long lines (>120 chars) at: {long_lines[:3]}...")
|
||||
|
||||
# Check for trailing whitespace
|
||||
trailing_ws = [i for i, line in enumerate(lines, 1) if line.rstrip() != line]
|
||||
if trailing_ws:
|
||||
issues.append(f"Trailing whitespace at lines: {trailing_ws[:3]}...")
|
||||
|
||||
return issues
|
||||
|
||||
def run_all_tests(self) -> Dict[str, Any]:
|
||||
"""Run tests on all commands."""
|
||||
commands = self.discover_commands()
|
||||
|
||||
if not commands:
|
||||
return {
|
||||
"plugin": self.plugin_name,
|
||||
"total_commands": 0,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"results": []
|
||||
}
|
||||
|
||||
print(f"Found {len(commands)} commands in {self.plugin_name}\n")
|
||||
|
||||
results = []
|
||||
for command in commands:
|
||||
result = self.test_command(command)
|
||||
results.append(result)
|
||||
|
||||
# Print summary
|
||||
status_icon = "✓" if result["status"] == "passed" else "✗"
|
||||
print(f"{status_icon} {command}")
|
||||
|
||||
# Print failures
|
||||
for test in result["tests"]:
|
||||
if not test["passed"]:
|
||||
print(f" - {test['name']}: FAILED")
|
||||
if "errors" in test:
|
||||
for error in test["errors"]:
|
||||
print(f" • {error}")
|
||||
if "issues" in test:
|
||||
for issue in test["issues"]:
|
||||
print(f" • {issue}")
|
||||
print()
|
||||
|
||||
# Summary
|
||||
passed = sum(1 for r in results if r["status"] == "passed")
|
||||
failed = len(results) - passed
|
||||
|
||||
return {
|
||||
"plugin": self.plugin_name,
|
||||
"total_commands": len(commands),
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"results": results
|
||||
}
|
||||
|
||||
def generate_report(self, output_file: str = None):
|
||||
"""Generate a detailed test report."""
|
||||
results = self.run_all_tests()
|
||||
|
||||
report = f"""# Test Report: {results['plugin']}
|
||||
|
||||
## Summary
|
||||
- Total Commands: {results['total_commands']}
|
||||
- Passed: {results['passed']}
|
||||
- Failed: {results['failed']}
|
||||
- Success Rate: {results['passed'] / max(1, results['total_commands']) * 100:.1f}%
|
||||
|
||||
## Detailed Results
|
||||
"""
|
||||
|
||||
for cmd_result in results['results']:
|
||||
report += f"\n### Command: `{cmd_result['command']}`\n"
|
||||
report += f"Status: **{cmd_result['status'].upper()}**\n\n"
|
||||
|
||||
for test in cmd_result['tests']:
|
||||
status = "✓" if test['passed'] else "✗"
|
||||
report += f"- {status} {test['name'].replace('_', ' ').title()}\n"
|
||||
|
||||
if not test['passed']:
|
||||
if 'errors' in test:
|
||||
for error in test['errors']:
|
||||
report += f" - {error}\n"
|
||||
if 'issues' in test:
|
||||
for issue in test['issues']:
|
||||
report += f" - {issue}\n"
|
||||
if 'missing' in test:
|
||||
report += f" - Missing: {', '.join(test['missing'])}\n"
|
||||
|
||||
if output_file:
|
||||
with open(output_file, 'w') as f:
|
||||
f.write(report)
|
||||
print(f"\nReport saved to: {output_file}")
|
||||
else:
|
||||
print("\n" + report)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Test Claude plugin commands"
|
||||
)
|
||||
parser.add_argument("plugin_dir", help="Path to plugin directory")
|
||||
parser.add_argument(
|
||||
"--command",
|
||||
help="Test specific command only"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--report",
|
||||
help="Save detailed report to file"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
tester = CommandTester(args.plugin_dir)
|
||||
|
||||
if args.command:
|
||||
# Test single command
|
||||
result = tester.test_command(args.command)
|
||||
|
||||
if result["status"] == "passed":
|
||||
print(f"\n✓ Command '{args.command}' passed all tests")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f"\n✗ Command '{args.command}' failed tests")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# Test all commands
|
||||
if args.report:
|
||||
tester.generate_report(args.report)
|
||||
else:
|
||||
results = tester.run_all_tests()
|
||||
|
||||
print(f"\nOverall Results:")
|
||||
print(f"- Commands tested: {results['total_commands']}")
|
||||
print(f"- Passed: {results['passed']}")
|
||||
print(f"- Failed: {results['failed']}")
|
||||
|
||||
sys.exit(0 if results['failed'] == 0 else 1)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,297 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user