#!/usr/bin/env bash # validate-plugin.generic.sh — generic, parameterized plugin structure validator (Step 7). # # A ported, plugin-agnostic copy of plugins/claude-design/tests/validate-plugin.sh, stripped of # claude-design's bespoke checks (.coverage.md, SKILL description length, forbidden command names, # operator-private grep). It gives the two test-less plugins — okr + human-friendly-style — a runnable # SC2 gate for the standalone-extraction harness (40-validate-standalone.sh vendors this file into the # clean room and runs `bash validate-plugin.generic.sh `). # # Checks (all generic): # (a) .claude-plugin/plugin.json is valid JSON with non-empty name/version/description [HARD] # (b) at least one user-facing surface dir is present + non-empty # (commands | agents | skills | output-styles | hooks) [HARD] # (c) every commands/*.md, agents/*.md, output-styles/*.md and skills/*/SKILL.md parses: # line 1 is the `---` frontmatter delimiter and the block carries a `name:` key [HARD] # # Usage: validate-plugin.generic.sh [plugin_root] # label used in the verdict line (e.g. "okr: STRUCTURE OK") # [plugin_root] plugin root to validate; defaults to $PWD (the clean-room cwd) # # Exit codes: 0 = STRUCTURE OK; 1 = STRUCTURE FAIL (>=1 hard check failed); 2 = usage error. set -uo pipefail KEY="${1:-}" [ -n "$KEY" ] || { echo "usage: validate-plugin.generic.sh [plugin_root]" >&2; exit 2; } PLUGIN_ROOT="${2:-$PWD}" PASS=0 FAIL=0 pass() { printf ' + %s\n' "$1"; PASS=$((PASS + 1)); } fail() { printf ' - %s\n' "$1"; FAIL=$((FAIL + 1)); } echo "=== $KEY structure validation (root: $PLUGIN_ROOT) ===" # --- (a) plugin.json valid JSON + required fields --- echo "--- (a) plugin.json ---" PJ="" for cand in "$PLUGIN_ROOT/.claude-plugin/plugin.json" "$PLUGIN_ROOT/plugin.json"; do [ -f "$cand" ] && { PJ="$cand"; break; } done if [ -z "$PJ" ]; then fail "plugin.json missing (.claude-plugin/plugin.json)" elif ! node -e "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" "$PJ" 2>/dev/null; then fail "plugin.json is invalid JSON" else pass "plugin.json is valid JSON" for field in name version description; do if node -e "const p=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));if(typeof p[process.argv[2]]!=='string'||p[process.argv[2]]==='')process.exit(1)" "$PJ" "$field" 2>/dev/null; then pass "plugin.json has '$field'" else fail "plugin.json missing or empty '$field'" fi done fi # --- (b) at least one non-empty user-facing surface dir --- echo "--- (b) user-facing surface ---" SURFACE=0 for d in commands agents skills output-styles hooks; do dir="$PLUGIN_ROOT/$d" [ -d "$dir" ] || continue if find "$dir" -type f 2>/dev/null | grep -q .; then pass "surface dir '$d/' present and non-empty" SURFACE=$((SURFACE + 1)) fi done if [ "$SURFACE" -eq 0 ]; then fail "no non-empty user-facing surface dir (commands/agents/skills/output-styles/hooks)" fi # --- (c) frontmatter parses on every surface markdown file --- echo "--- (c) frontmatter ---" check_frontmatter() { local f="$1" local rel="${f#"$PLUGIN_ROOT"/}" if [ "$(head -n 1 "$f")" != "---" ]; then fail "$rel: missing frontmatter delimiter on line 1" return fi local fm fm="$(awk 'NR==1{next} /^---$/{exit} {print}' "$f")" if printf '%s\n' "$fm" | grep -qE '^name:'; then pass "$rel: frontmatter has 'name:'" else fail "$rel: frontmatter missing 'name:'" fi } FM_SEEN=0 for f in "$PLUGIN_ROOT"/commands/*.md "$PLUGIN_ROOT"/agents/*.md \ "$PLUGIN_ROOT"/output-styles/*.md "$PLUGIN_ROOT"/skills/*/SKILL.md; do [ -f "$f" ] || continue FM_SEEN=$((FM_SEEN + 1)) check_frontmatter "$f" done [ "$FM_SEEN" -eq 0 ] && pass "no frontmatter-bearing surface files to check" echo "=== Summary: pass=$PASS fail=$FAIL ===" if [ "$FAIL" -gt 0 ]; then echo "$KEY: STRUCTURE FAIL" exit 1 fi echo "$KEY: STRUCTURE OK" exit 0