|
| 1 | +#!/bin/bash |
| 2 | +# Validates .claude-plugin/plugin.json: |
| 3 | +# 1. Valid JSON syntax |
| 4 | +# 2. Required field: name (non-empty, kebab-case) |
| 5 | +# 3. Metadata field types (version, description, author, homepage, etc.) |
| 6 | +# 4. Component path field types (skills, hooks, mcpServers, etc.) |
| 7 | +# 5. No unknown top-level keys |
| 8 | +# 6. Referenced paths exist on disk |
| 9 | + |
| 10 | +MANIFEST=".claude-plugin/plugin.json" |
| 11 | +errors=0 |
| 12 | + |
| 13 | +# --- Prerequisite: jq must be available --- |
| 14 | +if ! command -v jq &>/dev/null; then |
| 15 | + echo "::error::jq is required but not installed" |
| 16 | + exit 1 |
| 17 | +fi |
| 18 | + |
| 19 | +# --- Check 1: JSON syntax --- |
| 20 | +if ! jq empty "$MANIFEST" 2>/dev/null; then |
| 21 | + echo "::error file=$MANIFEST::Invalid JSON syntax" |
| 22 | + exit 1 |
| 23 | +fi |
| 24 | + |
| 25 | +manifest=$(cat "$MANIFEST") |
| 26 | + |
| 27 | +# --- Check 2: name field (required, non-empty, kebab-case) --- |
| 28 | +name=$(echo "$manifest" | jq -r '.name // empty') |
| 29 | +if [[ -z "$name" ]]; then |
| 30 | + echo "::error file=$MANIFEST::Missing or empty required field 'name'" |
| 31 | + errors=$((errors + 1)) |
| 32 | +elif [[ ! "$name" =~ ^[a-z0-9-]+$ ]]; then |
| 33 | + echo "::error file=$MANIFEST::Invalid name '$name' — must match ^[a-z0-9-]+$" |
| 34 | + errors=$((errors + 1)) |
| 35 | +fi |
| 36 | + |
| 37 | +# --- Check 3: Metadata field types --- |
| 38 | + |
| 39 | +# String fields (if present) |
| 40 | +for field in version description homepage repository license; do |
| 41 | + type=$(echo "$manifest" | jq -r "if has(\"$field\") then (.${field} | type) else \"absent\" end") |
| 42 | + if [[ "$type" != "absent" && "$type" != "string" ]]; then |
| 43 | + echo "::error file=$MANIFEST::'$field' must be a string, got $type" |
| 44 | + errors=$((errors + 1)) |
| 45 | + fi |
| 46 | +done |
| 47 | + |
| 48 | +# author (object with optional string fields, if present) |
| 49 | +author_type=$(echo "$manifest" | jq -r 'if has("author") then (.author | type) else "absent" end') |
| 50 | +if [[ "$author_type" != "absent" ]]; then |
| 51 | + if [[ "$author_type" != "object" ]]; then |
| 52 | + echo "::error file=$MANIFEST::'author' must be an object, got $author_type" |
| 53 | + errors=$((errors + 1)) |
| 54 | + else |
| 55 | + for sub in name email url; do |
| 56 | + sub_type=$(echo "$manifest" | jq -r "if .author | has(\"$sub\") then (.author.${sub} | type) else \"absent\" end") |
| 57 | + if [[ "$sub_type" != "absent" && "$sub_type" != "string" ]]; then |
| 58 | + echo "::error file=$MANIFEST::'author.$sub' must be a string, got $sub_type" |
| 59 | + errors=$((errors + 1)) |
| 60 | + fi |
| 61 | + done |
| 62 | + fi |
| 63 | +fi |
| 64 | + |
| 65 | +# keywords (array of strings, if present) |
| 66 | +keywords_type=$(echo "$manifest" | jq -r 'if has("keywords") then (.keywords | type) else "absent" end') |
| 67 | +if [[ "$keywords_type" != "absent" ]]; then |
| 68 | + if [[ "$keywords_type" != "array" ]]; then |
| 69 | + echo "::error file=$MANIFEST::'keywords' must be an array, got $keywords_type" |
| 70 | + errors=$((errors + 1)) |
| 71 | + else |
| 72 | + non_string_count=$(echo "$manifest" | jq '[.keywords[] | type != "string"] | map(select(.)) | length') |
| 73 | + if [[ "$non_string_count" -gt 0 ]]; then |
| 74 | + echo "::error file=$MANIFEST::'keywords' must contain only strings, found $non_string_count non-string element(s)" |
| 75 | + errors=$((errors + 1)) |
| 76 | + fi |
| 77 | + fi |
| 78 | +fi |
| 79 | + |
| 80 | +# --- Check 4: Component path field types --- |
| 81 | + |
| 82 | +# Fields that accept string, array of strings, or object |
| 83 | +for field in skills hooks mcpServers lspServers outputStyles; do |
| 84 | + type=$(echo "$manifest" | jq -r "if has(\"$field\") then (.${field} | type) else \"absent\" end") |
| 85 | + if [[ "$type" == "absent" ]]; then |
| 86 | + continue |
| 87 | + fi |
| 88 | + if [[ "$type" != "string" && "$type" != "array" && "$type" != "object" ]]; then |
| 89 | + echo "::error file=$MANIFEST::'$field' must be a string, array of strings, or object — got $type" |
| 90 | + errors=$((errors + 1)) |
| 91 | + elif [[ "$type" == "array" ]]; then |
| 92 | + non_string_count=$(echo "$manifest" | jq "[.${field}[] | type != \"string\"] | map(select(.)) | length") |
| 93 | + if [[ "$non_string_count" -gt 0 ]]; then |
| 94 | + echo "::error file=$MANIFEST::'$field' array must contain only strings, found $non_string_count non-string element(s)" |
| 95 | + errors=$((errors + 1)) |
| 96 | + fi |
| 97 | + fi |
| 98 | +done |
| 99 | + |
| 100 | +# Fields that accept string or array of strings only |
| 101 | +for field in commands agents; do |
| 102 | + type=$(echo "$manifest" | jq -r "if has(\"$field\") then (.${field} | type) else \"absent\" end") |
| 103 | + if [[ "$type" == "absent" ]]; then |
| 104 | + continue |
| 105 | + fi |
| 106 | + if [[ "$type" != "string" && "$type" != "array" ]]; then |
| 107 | + echo "::error file=$MANIFEST::'$field' must be a string or array of strings — got $type" |
| 108 | + errors=$((errors + 1)) |
| 109 | + elif [[ "$type" == "array" ]]; then |
| 110 | + non_string_count=$(echo "$manifest" | jq "[.${field}[] | type != \"string\"] | map(select(.)) | length") |
| 111 | + if [[ "$non_string_count" -gt 0 ]]; then |
| 112 | + echo "::error file=$MANIFEST::'$field' array must contain only strings, found $non_string_count non-string element(s)" |
| 113 | + errors=$((errors + 1)) |
| 114 | + fi |
| 115 | + fi |
| 116 | +done |
| 117 | + |
| 118 | +# --- Check 5: No unknown top-level keys --- |
| 119 | +known_keys='["name","version","description","author","homepage","repository","license","keywords","commands","agents","skills","hooks","mcpServers","outputStyles","lspServers"]' |
| 120 | +unknown=$(echo "$manifest" | jq -r --argjson known "$known_keys" 'keys[] | select(. as $k | $known | index($k) | not)') |
| 121 | +if [[ -n "$unknown" ]]; then |
| 122 | + while IFS= read -r key; do |
| 123 | + echo "::warning file=$MANIFEST::Unknown top-level key '$key'" |
| 124 | + done <<< "$unknown" |
| 125 | +fi |
| 126 | + |
| 127 | +# --- Check 6: Path validation --- |
| 128 | +# Helper: check a single path exists relative to project root |
| 129 | +check_path() { |
| 130 | + local field="$1" |
| 131 | + local path="$2" |
| 132 | + if [[ ! -e "$path" ]]; then |
| 133 | + echo "::error file=$MANIFEST::Path '$path' referenced by '$field' does not exist" |
| 134 | + errors=$((errors + 1)) |
| 135 | + fi |
| 136 | +} |
| 137 | + |
| 138 | +for field in skills hooks mcpServers lspServers outputStyles commands agents; do |
| 139 | + type=$(echo "$manifest" | jq -r "if has(\"$field\") then (.${field} | type) else \"absent\" end") |
| 140 | + if [[ "$type" == "absent" ]]; then |
| 141 | + continue |
| 142 | + fi |
| 143 | + |
| 144 | + if [[ "$type" == "string" ]]; then |
| 145 | + path=$(echo "$manifest" | jq -r ".${field}") |
| 146 | + check_path "$field" "$path" |
| 147 | + elif [[ "$type" == "array" ]]; then |
| 148 | + while IFS= read -r path; do |
| 149 | + check_path "$field" "$path" |
| 150 | + done < <(echo "$manifest" | jq -r ".${field}[]") |
| 151 | + fi |
| 152 | + # Objects (inline configs) — skip path validation |
| 153 | +done |
| 154 | + |
| 155 | +# --- Summary --- |
| 156 | +echo "" |
| 157 | +if [[ $errors -gt 0 ]]; then |
| 158 | + echo "❌ Manifest validation failed with $errors error(s)." |
| 159 | + exit 1 |
| 160 | +else |
| 161 | + echo "✅ Plugin manifest is valid." |
| 162 | +fi |
0 commit comments