initial project setup: added plugin skill

This commit is contained in:
2025-11-05 12:45:20 -05:00
parent 8fcaf6d974
commit 817cc3d7bf
13 changed files with 4449 additions and 0 deletions

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

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

View File

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