547 lines
16 KiB
Bash
Executable File
547 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
|
||
# validate-plugin.sh - Comprehensive Claude Code plugin validation
|
||
|
||
set -euo pipefail
|
||
|
||
# Colors for output
|
||
RED='\033[0;31m'
|
||
GREEN='\033[0;32m'
|
||
YELLOW='\033[1;33m'
|
||
BLUE='\033[0;34m'
|
||
CYAN='\033[0;36m'
|
||
NC='\033[0m' # No Color
|
||
|
||
# Counters
|
||
ERRORS=0
|
||
WARNINGS=0
|
||
CHECKS=0
|
||
|
||
# Options
|
||
STRICT_MODE=false
|
||
QUIET_MODE=false
|
||
FIX_MODE=false
|
||
|
||
# Print functions
|
||
print_error() {
|
||
((ERRORS++))
|
||
echo -e "${RED}✗ ERROR:${NC} $1"
|
||
}
|
||
|
||
print_warning() {
|
||
((WARNINGS++))
|
||
echo -e "${YELLOW}⚠ WARNING:${NC} $1"
|
||
}
|
||
|
||
print_info() {
|
||
[[ "$QUIET_MODE" == "false" ]] && echo -e "${BLUE}ℹ INFO:${NC} $1"
|
||
}
|
||
|
||
print_success() {
|
||
[[ "$QUIET_MODE" == "false" ]] && echo -e "${GREEN}✓ PASS:${NC} $1"
|
||
}
|
||
|
||
print_check() {
|
||
((CHECKS++))
|
||
[[ "$QUIET_MODE" == "false" ]] && echo -e "${CYAN}[CHECK $CHECKS]${NC} $1"
|
||
}
|
||
|
||
# Usage
|
||
usage() {
|
||
cat << EOF
|
||
Usage: $0 [options] <plugin-directory>
|
||
|
||
Comprehensive validation for Claude Code plugins.
|
||
|
||
Arguments:
|
||
plugin-directory Path to plugin root directory
|
||
|
||
Options:
|
||
-s, --strict Treat warnings as errors
|
||
-q, --quiet Only show errors and warnings
|
||
-f, --fix Auto-fix issues where possible
|
||
-h, --help Show this help
|
||
|
||
Examples:
|
||
# Validate current plugin
|
||
$0 .
|
||
|
||
# Validate specific plugin
|
||
$0 /path/to/my-plugin
|
||
|
||
# Strict validation
|
||
$0 --strict .
|
||
|
||
# Auto-fix common issues
|
||
$0 --fix .
|
||
|
||
Exit Codes:
|
||
0 - No errors
|
||
1 - Validation errors found
|
||
2 - Invalid arguments or plugin not found
|
||
|
||
EOF
|
||
exit 2
|
||
}
|
||
|
||
# Parse arguments
|
||
PLUGIN_DIR=""
|
||
|
||
while [[ $# -gt 0 ]]; do
|
||
case $1 in
|
||
-s|--strict)
|
||
STRICT_MODE=true
|
||
shift
|
||
;;
|
||
-q|--quiet)
|
||
QUIET_MODE=true
|
||
shift
|
||
;;
|
||
-f|--fix)
|
||
FIX_MODE=true
|
||
shift
|
||
;;
|
||
-h|--help)
|
||
usage
|
||
;;
|
||
-*)
|
||
echo -e "${RED}Error: Unknown option $1${NC}"
|
||
usage
|
||
;;
|
||
*)
|
||
PLUGIN_DIR="$1"
|
||
shift
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# Validate arguments
|
||
if [[ -z "$PLUGIN_DIR" ]]; then
|
||
echo -e "${RED}Error: Plugin directory required${NC}"
|
||
usage
|
||
fi
|
||
|
||
if [[ ! -d "$PLUGIN_DIR" ]]; then
|
||
echo -e "${RED}Error: Directory not found: $PLUGIN_DIR${NC}"
|
||
exit 2
|
||
fi
|
||
|
||
# Convert to absolute path
|
||
PLUGIN_DIR=$(cd "$PLUGIN_DIR" && pwd)
|
||
|
||
# Print header
|
||
if [[ "$QUIET_MODE" == "false" ]]; then
|
||
echo -e "${BLUE}╔════════════════════════════════════════════╗${NC}"
|
||
echo -e "${BLUE}║ Claude Code Plugin Validation ║${NC}"
|
||
echo -e "${BLUE}╚════════════════════════════════════════════╝${NC}"
|
||
echo
|
||
echo -e "${CYAN}Plugin Directory:${NC} $PLUGIN_DIR"
|
||
echo
|
||
fi
|
||
|
||
# Check 1: plugin.json exists
|
||
print_check "Checking for plugin.json"
|
||
PLUGIN_JSON="$PLUGIN_DIR/.claude-plugin/plugin.json"
|
||
|
||
if [[ ! -f "$PLUGIN_JSON" ]]; then
|
||
print_error "plugin.json not found at .claude-plugin/plugin.json"
|
||
exit 1
|
||
else
|
||
print_success "plugin.json exists"
|
||
fi
|
||
|
||
# Check 2: plugin.json is valid JSON
|
||
print_check "Validating plugin.json syntax"
|
||
if ! jq empty "$PLUGIN_JSON" 2>/dev/null; then
|
||
print_error "plugin.json contains invalid JSON"
|
||
exit 1
|
||
else
|
||
print_success "plugin.json is valid JSON"
|
||
fi
|
||
|
||
# Check 3: Required fields in plugin.json
|
||
print_check "Validating plugin.json required fields"
|
||
|
||
PLUGIN_NAME=$(jq -r '.name // empty' "$PLUGIN_JSON")
|
||
PLUGIN_VERSION=$(jq -r '.version // empty' "$PLUGIN_JSON")
|
||
PLUGIN_DESC=$(jq -r '.description // empty' "$PLUGIN_JSON")
|
||
|
||
if [[ -z "$PLUGIN_NAME" ]]; then
|
||
print_error "plugin.json missing required field: name"
|
||
else
|
||
print_success "Plugin name: $PLUGIN_NAME"
|
||
|
||
# Validate name format
|
||
if [[ ! "$PLUGIN_NAME" =~ ^[a-z][a-z0-9-]*$ ]]; then
|
||
print_error "Plugin name must be kebab-case: $PLUGIN_NAME"
|
||
fi
|
||
fi
|
||
|
||
if [[ -z "$PLUGIN_VERSION" ]]; then
|
||
print_error "plugin.json missing required field: version"
|
||
else
|
||
print_success "Plugin version: $PLUGIN_VERSION"
|
||
|
||
# Validate semantic versioning
|
||
if [[ ! "$PLUGIN_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
||
print_warning "Version should follow semantic versioning (e.g., 1.0.0)"
|
||
fi
|
||
fi
|
||
|
||
if [[ -z "$PLUGIN_DESC" ]]; then
|
||
print_warning "plugin.json missing recommended field: description"
|
||
else
|
||
print_success "Plugin description present"
|
||
|
||
# Check description length
|
||
DESC_LEN=${#PLUGIN_DESC}
|
||
if [[ $DESC_LEN -lt 20 ]]; then
|
||
print_warning "Description is very short ($DESC_LEN chars)"
|
||
elif [[ $DESC_LEN -gt 200 ]]; then
|
||
print_warning "Description is very long ($DESC_LEN chars), consider shortening"
|
||
fi
|
||
fi
|
||
|
||
# Check 4: Author info
|
||
print_check "Validating author information"
|
||
AUTHOR_NAME=$(jq -r '.author.name // empty' "$PLUGIN_JSON")
|
||
AUTHOR_EMAIL=$(jq -r '.author.email // empty' "$PLUGIN_JSON")
|
||
|
||
if [[ -z "$AUTHOR_NAME" ]]; then
|
||
print_warning "plugin.json missing recommended field: author.name"
|
||
else
|
||
print_success "Author: $AUTHOR_NAME"
|
||
fi
|
||
|
||
if [[ -z "$AUTHOR_EMAIL" ]]; then
|
||
print_warning "plugin.json missing recommended field: author.email"
|
||
elif [[ ! "$AUTHOR_EMAIL" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
|
||
print_warning "Author email appears invalid: $AUTHOR_EMAIL"
|
||
fi
|
||
|
||
# Check 5: License
|
||
print_check "Checking license"
|
||
PLUGIN_LICENSE=$(jq -r '.license // empty' "$PLUGIN_JSON")
|
||
|
||
if [[ -z "$PLUGIN_LICENSE" ]]; then
|
||
print_warning "plugin.json missing recommended field: license"
|
||
else
|
||
print_success "License: $PLUGIN_LICENSE"
|
||
|
||
# Check for LICENSE file
|
||
if [[ ! -f "$PLUGIN_DIR/LICENSE" ]] && [[ ! -f "$PLUGIN_DIR/LICENSE.md" ]]; then
|
||
print_warning "No LICENSE file found in plugin root"
|
||
fi
|
||
fi
|
||
|
||
# Check 6: README.md
|
||
print_check "Checking README.md"
|
||
if [[ ! -f "$PLUGIN_DIR/README.md" ]]; then
|
||
print_warning "No README.md found in plugin root"
|
||
else
|
||
print_success "README.md exists"
|
||
|
||
# Check README length
|
||
README_LINES=$(wc -l < "$PLUGIN_DIR/README.md")
|
||
if [[ $README_LINES -lt 10 ]]; then
|
||
print_warning "README.md is very short ($README_LINES lines)"
|
||
fi
|
||
fi
|
||
|
||
# Check 7: Validate skills
|
||
print_check "Validating skills"
|
||
SKILLS_DIR="$PLUGIN_DIR/skills"
|
||
|
||
if [[ -d "$SKILLS_DIR" ]]; then
|
||
SKILL_COUNT=$(find "$SKILLS_DIR" -name "SKILL.md" | wc -l | xargs)
|
||
if [[ $SKILL_COUNT -eq 0 ]]; then
|
||
print_warning "skills/ directory exists but contains no SKILL.md files"
|
||
else
|
||
print_success "Found $SKILL_COUNT skill(s)"
|
||
|
||
# Validate each skill
|
||
while IFS= read -r skill_file; do
|
||
SKILL_NAME=$(dirname "$skill_file" | xargs basename)
|
||
print_info "Validating skill: $SKILL_NAME"
|
||
|
||
# Check for frontmatter
|
||
if ! head -n 1 "$skill_file" | grep -q '^---$'; then
|
||
print_warning "Skill $SKILL_NAME missing frontmatter"
|
||
else
|
||
# Validate required frontmatter fields
|
||
SKILL_FRONTMATTER=$(awk '/^---$/{flag=!flag; next} flag' "$skill_file" | head -n 20)
|
||
|
||
if ! echo "$SKILL_FRONTMATTER" | grep -q '^name:'; then
|
||
print_error "Skill $SKILL_NAME missing 'name' in frontmatter"
|
||
fi
|
||
|
||
if ! echo "$SKILL_FRONTMATTER" | grep -q '^description:'; then
|
||
print_error "Skill $SKILL_NAME missing 'description' in frontmatter"
|
||
fi
|
||
|
||
if ! echo "$SKILL_FRONTMATTER" | grep -q '^version:'; then
|
||
print_warning "Skill $SKILL_NAME missing 'version' in frontmatter"
|
||
fi
|
||
fi
|
||
|
||
# Check file size (skills should have substantial content)
|
||
SKILL_SIZE=$(wc -c < "$skill_file")
|
||
if [[ $SKILL_SIZE -lt 500 ]]; then
|
||
print_warning "Skill $SKILL_NAME is very small ($SKILL_SIZE bytes)"
|
||
fi
|
||
done < <(find "$SKILLS_DIR" -name "SKILL.md")
|
||
fi
|
||
else
|
||
print_info "No skills/ directory found"
|
||
fi
|
||
|
||
# Check 8: Validate commands
|
||
print_check "Validating slash commands"
|
||
COMMANDS_DIR="$PLUGIN_DIR/commands"
|
||
|
||
if [[ -d "$COMMANDS_DIR" ]]; then
|
||
COMMAND_COUNT=$(find "$COMMANDS_DIR" -name "*.md" | wc -l | xargs)
|
||
if [[ $COMMAND_COUNT -eq 0 ]]; then
|
||
print_warning "commands/ directory exists but contains no .md files"
|
||
else
|
||
print_success "Found $COMMAND_COUNT command(s)"
|
||
|
||
# Validate each command
|
||
while IFS= read -r cmd_file; do
|
||
CMD_NAME=$(basename "$cmd_file" .md)
|
||
print_info "Validating command: $CMD_NAME"
|
||
|
||
# Check filename format
|
||
if [[ ! "$CMD_NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||
print_warning "Command name should be kebab-case: $CMD_NAME"
|
||
fi
|
||
|
||
# Check if file is empty
|
||
if [[ ! -s "$cmd_file" ]]; then
|
||
print_error "Command $CMD_NAME is empty"
|
||
continue
|
||
fi
|
||
|
||
# Check for frontmatter
|
||
if head -n 1 "$cmd_file" | grep -q '^---$'; then
|
||
# Validate frontmatter syntax
|
||
FRONTMATTER=$(awk '/^---$/{flag=!flag; next} flag' "$cmd_file" | head -n 20)
|
||
|
||
# Check for description
|
||
if echo "$FRONTMATTER" | grep -q '^description:'; then
|
||
DESC=$(echo "$FRONTMATTER" | grep '^description:' | sed 's/^description: *//')
|
||
if [[ -z "$DESC" ]]; then
|
||
print_warning "Command $CMD_NAME has empty description"
|
||
fi
|
||
fi
|
||
|
||
# Check for tabs in frontmatter (YAML doesn't allow tabs)
|
||
if echo "$FRONTMATTER" | grep -q $'\t'; then
|
||
print_error "Command $CMD_NAME frontmatter contains tabs (use spaces)"
|
||
fi
|
||
fi
|
||
done < <(find "$COMMANDS_DIR" -name "*.md")
|
||
fi
|
||
else
|
||
print_info "No commands/ directory found"
|
||
fi
|
||
|
||
# Check 9: Validate agents
|
||
print_check "Validating custom agents"
|
||
AGENTS_DIR="$PLUGIN_DIR/agents"
|
||
|
||
if [[ -d "$AGENTS_DIR" ]]; then
|
||
AGENT_COUNT=$(find "$AGENTS_DIR" -name "*.md" | wc -l | xargs)
|
||
if [[ $AGENT_COUNT -eq 0 ]]; then
|
||
print_warning "agents/ directory exists but contains no .md files"
|
||
else
|
||
print_success "Found $AGENT_COUNT agent(s)"
|
||
|
||
# Check if agents are referenced in plugin.json
|
||
AGENTS_IN_JSON=$(jq -r '.agents // [] | length' "$PLUGIN_JSON")
|
||
if [[ $AGENTS_IN_JSON -eq 0 ]]; then
|
||
print_warning "Agents found but not referenced in plugin.json"
|
||
fi
|
||
|
||
# Validate each agent
|
||
while IFS= read -r agent_file; do
|
||
AGENT_NAME=$(basename "$agent_file" .md)
|
||
print_info "Validating agent: $AGENT_NAME"
|
||
|
||
# Check for frontmatter
|
||
if ! head -n 1 "$agent_file" | grep -q '^---$'; then
|
||
print_warning "Agent $AGENT_NAME missing frontmatter"
|
||
else
|
||
AGENT_FRONTMATTER=$(awk '/^---$/{flag=!flag; next} flag' "$agent_file" | head -n 20)
|
||
|
||
if ! echo "$AGENT_FRONTMATTER" | grep -q '^name:'; then
|
||
print_error "Agent $AGENT_NAME missing 'name' in frontmatter"
|
||
fi
|
||
|
||
if ! echo "$AGENT_FRONTMATTER" | grep -q '^description:'; then
|
||
print_error "Agent $AGENT_NAME missing 'description' in frontmatter"
|
||
fi
|
||
fi
|
||
done < <(find "$AGENTS_DIR" -name "*.md")
|
||
fi
|
||
else
|
||
print_info "No agents/ directory found"
|
||
fi
|
||
|
||
# Check 10: Validate hooks
|
||
print_check "Validating event hooks"
|
||
HOOKS_DIR="$PLUGIN_DIR/hooks"
|
||
|
||
if [[ -d "$HOOKS_DIR" ]]; then
|
||
HOOK_COUNT=$(find "$HOOKS_DIR" -type f | wc -l | xargs)
|
||
if [[ $HOOK_COUNT -eq 0 ]]; then
|
||
print_warning "hooks/ directory exists but contains no files"
|
||
else
|
||
print_success "Found $HOOK_COUNT hook file(s)"
|
||
|
||
# Check if hooks are referenced in plugin.json
|
||
HOOKS_IN_JSON=$(jq -r '.hooks // {} | length' "$PLUGIN_JSON")
|
||
if [[ $HOOKS_IN_JSON -eq 0 ]]; then
|
||
print_warning "Hook files found but not configured in plugin.json"
|
||
fi
|
||
|
||
# Validate each hook script
|
||
while IFS= read -r hook_file; do
|
||
HOOK_NAME=$(basename "$hook_file")
|
||
print_info "Validating hook: $HOOK_NAME"
|
||
|
||
# Check if file is executable
|
||
if [[ ! -x "$hook_file" ]]; then
|
||
if [[ "$FIX_MODE" == "true" ]]; then
|
||
chmod +x "$hook_file"
|
||
print_info "Fixed: Made $HOOK_NAME executable"
|
||
else
|
||
print_warning "Hook $HOOK_NAME is not executable (use chmod +x)"
|
||
fi
|
||
fi
|
||
|
||
# Check for shebang
|
||
if ! head -n 1 "$hook_file" | grep -q '^#!'; then
|
||
print_warning "Hook $HOOK_NAME missing shebang line"
|
||
fi
|
||
done < <(find "$HOOKS_DIR" -type f)
|
||
fi
|
||
else
|
||
print_info "No hooks/ directory found"
|
||
fi
|
||
|
||
# Check 11: Validate MCP servers
|
||
print_check "Validating MCP servers"
|
||
SERVERS_DIR="$PLUGIN_DIR/servers"
|
||
|
||
if [[ -d "$SERVERS_DIR" ]]; then
|
||
SERVER_COUNT=$(find "$SERVERS_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l | xargs)
|
||
if [[ $SERVER_COUNT -eq 0 ]]; then
|
||
print_warning "servers/ directory exists but contains no server directories"
|
||
else
|
||
print_success "Found $SERVER_COUNT MCP server(s)"
|
||
|
||
# Check if servers are referenced in plugin.json
|
||
SERVERS_IN_JSON=$(jq -r '.mcpServers // {} | length' "$PLUGIN_JSON")
|
||
if [[ $SERVERS_IN_JSON -eq 0 ]]; then
|
||
print_warning "MCP servers found but not configured in plugin.json"
|
||
fi
|
||
|
||
# Validate each server
|
||
while IFS= read -r server_dir; do
|
||
SERVER_NAME=$(basename "$server_dir")
|
||
print_info "Validating MCP server: $SERVER_NAME"
|
||
|
||
# Check for server implementation
|
||
if [[ -f "$server_dir/server.py" ]]; then
|
||
print_success "Found Python server implementation"
|
||
|
||
# Check for pyproject.toml
|
||
if [[ ! -f "$server_dir/pyproject.toml" ]]; then
|
||
print_warning "Server $SERVER_NAME missing pyproject.toml"
|
||
fi
|
||
elif [[ -f "$server_dir/index.js" ]] || [[ -f "$server_dir/index.ts" ]]; then
|
||
print_success "Found Node.js server implementation"
|
||
|
||
# Check for package.json
|
||
if [[ ! -f "$server_dir/package.json" ]]; then
|
||
print_warning "Server $SERVER_NAME missing package.json"
|
||
fi
|
||
else
|
||
print_warning "Server $SERVER_NAME missing server implementation file"
|
||
fi
|
||
done < <(find "$SERVERS_DIR" -mindepth 1 -maxdepth 1 -type d)
|
||
fi
|
||
else
|
||
print_info "No servers/ directory found"
|
||
fi
|
||
|
||
# Check 12: Check for common files
|
||
print_check "Checking for common files"
|
||
|
||
if [[ ! -f "$PLUGIN_DIR/.gitignore" ]]; then
|
||
print_warning "No .gitignore found"
|
||
else
|
||
print_success ".gitignore exists"
|
||
fi
|
||
|
||
if [[ ! -f "$PLUGIN_DIR/CHANGELOG.md" ]]; then
|
||
print_warning "No CHANGELOG.md found (recommended for versioning)"
|
||
else
|
||
print_success "CHANGELOG.md exists"
|
||
fi
|
||
|
||
# Check 13: Git repository
|
||
print_check "Checking git repository"
|
||
if [[ ! -d "$PLUGIN_DIR/.git" ]]; then
|
||
print_info "Not a git repository"
|
||
else
|
||
print_success "Git repository initialized"
|
||
|
||
# Check for uncommitted changes
|
||
if ! git -C "$PLUGIN_DIR" diff-index --quiet HEAD -- 2>/dev/null; then
|
||
print_info "Repository has uncommitted changes"
|
||
fi
|
||
fi
|
||
|
||
# Summary
|
||
echo
|
||
echo -e "${BLUE}╔════════════════════════════════════════════╗${NC}"
|
||
echo -e "${BLUE}║ Validation Summary ║${NC}"
|
||
echo -e "${BLUE}╚════════════════════════════════════════════╝${NC}"
|
||
echo
|
||
|
||
echo -e "${CYAN}Checks Performed:${NC} $CHECKS"
|
||
|
||
if [[ $ERRORS -gt 0 ]]; then
|
||
echo -e "${RED}Errors Found:${NC} $ERRORS"
|
||
fi
|
||
|
||
if [[ $WARNINGS -gt 0 ]]; then
|
||
echo -e "${YELLOW}Warnings Found:${NC} $WARNINGS"
|
||
fi
|
||
|
||
echo
|
||
|
||
# Convert warnings to errors in strict mode
|
||
if [[ "$STRICT_MODE" == "true" && $WARNINGS -gt 0 ]]; then
|
||
ERRORS=$((ERRORS + WARNINGS))
|
||
WARNINGS=0
|
||
echo -e "${YELLOW}(Strict mode: warnings treated as errors)${NC}"
|
||
echo
|
||
fi
|
||
|
||
# Exit with appropriate code
|
||
if [[ $ERRORS -eq 0 && $WARNINGS -eq 0 ]]; then
|
||
echo -e "${GREEN}✓ Validation passed! Plugin is ready to use.${NC}"
|
||
exit 0
|
||
elif [[ $ERRORS -eq 0 ]]; then
|
||
echo -e "${YELLOW}⚠ Validation passed with warnings.${NC}"
|
||
echo -e "${YELLOW} Consider addressing warnings before distribution.${NC}"
|
||
exit 0
|
||
else
|
||
echo -e "${RED}✗ Validation failed!${NC}"
|
||
echo -e "${RED} Please fix errors before using this plugin.${NC}"
|
||
exit 1
|
||
fi
|