|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Validate all plugin.json manifests in the repository. |
| 3 | +# Exits 1 if any plugin fails validation. |
| 4 | + |
| 5 | +set -euo pipefail |
| 6 | + |
| 7 | +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| 8 | +FAILED=0 |
| 9 | +CHECKED=0 |
| 10 | + |
| 11 | +validate_plugin() { |
| 12 | + local manifest="$1" |
| 13 | + local plugin_dir |
| 14 | + plugin_dir="$(dirname "$(dirname "$manifest")")" |
| 15 | + local plugin_name |
| 16 | + plugin_name="$(basename "$plugin_dir")" |
| 17 | + |
| 18 | + # 1. Valid JSON |
| 19 | + if ! python3 -c "import json, sys; json.load(open('$manifest'))" 2>/dev/null; then |
| 20 | + echo "❌ $plugin_name: invalid JSON" |
| 21 | + return 1 |
| 22 | + fi |
| 23 | + |
| 24 | + local errors=() |
| 25 | + |
| 26 | + # 2. Run all checks via Python |
| 27 | + python3 - "$manifest" "$plugin_dir" <<'PYEOF' |
| 28 | +import json, sys, os, re |
| 29 | +
|
| 30 | +manifest_path = sys.argv[1] |
| 31 | +plugin_dir = sys.argv[2] |
| 32 | +errors = [] |
| 33 | +
|
| 34 | +with open(manifest_path) as f: |
| 35 | + data = json.load(f) |
| 36 | +
|
| 37 | +# Required string fields |
| 38 | +for field in ("name", "version", "description"): |
| 39 | + if field not in data: |
| 40 | + errors.append(f"missing required field: '{field}'") |
| 41 | + elif not isinstance(data[field], str) or not data[field].strip(): |
| 42 | + errors.append(f"'{field}' must be a non-empty string") |
| 43 | +
|
| 44 | +# Semver: version must match X.Y.Z |
| 45 | +if "version" in data and isinstance(data["version"], str): |
| 46 | + if not re.fullmatch(r"\d+\.\d+\.\d+", data["version"]): |
| 47 | + errors.append(f"'version' must be semver (X.Y.Z), got: {data['version']!r}") |
| 48 | +
|
| 49 | +# Author must be object with a 'name' key |
| 50 | +author = data.get("author") |
| 51 | +if author is None: |
| 52 | + errors.append("missing required field: 'author'") |
| 53 | +elif isinstance(author, str): |
| 54 | + errors.append(f"'author' must be an object with a 'name' key, got string: {author!r}") |
| 55 | +elif not isinstance(author, dict): |
| 56 | + errors.append(f"'author' must be an object, got: {type(author).__name__}") |
| 57 | +elif "name" not in author or not isinstance(author["name"], str) or not author["name"].strip(): |
| 58 | + errors.append("'author.name' must be a non-empty string") |
| 59 | +
|
| 60 | +# Skills array validation (optional field) |
| 61 | +skills = data.get("skills") |
| 62 | +if skills is not None: |
| 63 | + if not isinstance(skills, list): |
| 64 | + errors.append("'skills' must be an array") |
| 65 | + else: |
| 66 | + for i, skill in enumerate(skills): |
| 67 | + if not isinstance(skill, dict): |
| 68 | + errors.append(f"skills[{i}]: must be an object") |
| 69 | + continue |
| 70 | + if "name" not in skill or not isinstance(skill["name"], str) or not skill["name"].strip(): |
| 71 | + errors.append(f"skills[{i}]: missing 'name'") |
| 72 | + if "description" not in skill or not isinstance(skill["description"], str) or not skill["description"].strip(): |
| 73 | + errors.append(f"skills[{i}]: missing 'description'") |
| 74 | + if "path" in skill: |
| 75 | + skill_path = os.path.join(plugin_dir, skill["path"]) |
| 76 | + if not os.path.isfile(skill_path): |
| 77 | + errors.append(f"skills[{i}]: path '{skill['path']}' does not exist") |
| 78 | +
|
| 79 | +if errors: |
| 80 | + for e in errors: |
| 81 | + print(f" - {e}", file=sys.stderr) |
| 82 | + sys.exit(1) |
| 83 | +PYEOF |
| 84 | +} |
| 85 | + |
| 86 | +echo "Validating plugin manifests..." |
| 87 | +echo "" |
| 88 | + |
| 89 | +while IFS= read -r -d '' manifest; do |
| 90 | + plugin_dir="$(dirname "$(dirname "$manifest")")" |
| 91 | + plugin_name="$(basename "$plugin_dir")" |
| 92 | + CHECKED=$((CHECKED + 1)) |
| 93 | + |
| 94 | + if validate_plugin "$manifest" 2>/tmp/plugin-validate-err; then |
| 95 | + echo "✅ $plugin_name" |
| 96 | + else |
| 97 | + echo "❌ $plugin_name" |
| 98 | + while IFS= read -r line; do |
| 99 | + echo " $line" |
| 100 | + done < /tmp/plugin-validate-err |
| 101 | + FAILED=$((FAILED + 1)) |
| 102 | + fi |
| 103 | +done < <(find "$REPO_ROOT" -path "*/.claude-plugin/plugin.json" -not -path "*/node_modules/*" -print0 | sort -z) |
| 104 | + |
| 105 | +echo "" |
| 106 | +echo "Checked $CHECKED plugin(s). $FAILED failed." |
| 107 | + |
| 108 | +if [[ $FAILED -gt 0 ]]; then |
| 109 | + exit 1 |
| 110 | +fi |
0 commit comments