#!/usr/bin/env bash # Validate plugin marketplace manifests for Claude Code and GitHub Copilot CLI. # Wraps `claude plugin validate` (Claude-side) and runs manual checks (Copilot-side). # # Usage: # validate.sh # validate.sh --plugin plugins/my-plugin set -euo pipefail RESERVED_NAMES="claude-code-marketplace claude-code-plugins claude-plugins-official claude-plugins-community claude-community anthropic-marketplace anthropic-plugins agent-skills anthropic-agent-skills knowledge-work-plugins life-sciences claude-for-legal claude-for-financial-services financial-services-plugins" RESERVED_PATTERNS="official-claude anthropic-tools claude-official" ERRORS=0 WARNINGS=0 error() { echo "ERROR: $1"; ((ERRORS++)) || true; } warn() { echo "WARN: $1"; ((WARNINGS++)) || true; } is_kebab_case() { [[ "$1" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; } is_reserved() { local name="$1" for n in $RESERVED_NAMES; do [[ "$name" == "$n" ]] && return 0; done for p in $RESERVED_PATTERNS; do [[ "$name" == "$p"* ]] && return 0; done return 1 } validate_name() { local name="$1" context="$2" [[ -z "$name" ]] && { error "$context: name is missing or empty"; return 0; } is_kebab_case "$name" || error "$context: name '$name' is not kebab-case" if is_reserved "$name"; then error "$context: name '$name' is reserved for official Anthropic use" fi return 0 } valid_json() { local path="$1" if ! jq -e . "$path" >/dev/null 2>&1; then error "Invalid JSON in $path" return 1 fi return 0 } validate_marketplace_json() { local path="$1" [[ -f "$path" ]] || return 0 valid_json "$path" || return 0 local name name=$(jq -r '.name // empty' "$path") [[ -z "$name" ]] && error "$path: 'name' field is required" || validate_name "$name" "$path" local plugins_type plugins_type=$(jq -r 'if .plugins | type == "array" then "ok" else "bad" end' "$path") if [[ "$plugins_type" != "ok" ]]; then error "$path: 'plugins' must be an array" return 0 fi # Check each plugin entry local seen_names=() while IFS= read -r pname; do # Duplicate check for seen in "${seen_names[@]+"${seen_names[@]}"}"; do if [[ "$seen" == "$pname" ]]; then error "$path: duplicate plugin name '$pname'"; fi done seen_names+=("$pname") validate_name "$pname" "$path plugin '$pname'" # Source path check local src src=$(jq -r --arg n "$pname" '.plugins[] | select(.name==$n) | .source // empty' "$path") if [[ -n "$src" && "$src" != ./* && "$src" != "github" && "$src" != "npm" && "$src" != "url" && "$src" != "git-subdir" ]]; then warn "$path plugin '$pname': relative source '$src' should start with './' for Claude Code compatibility" fi # Version duplication warning local has_ver has_ver=$(jq -r --arg n "$pname" '.plugins[] | select(.name==$n) | .version // empty' "$path") if [[ -n "$has_ver" ]]; then warn "$path plugin '$pname': version set in marketplace entry. If also set in plugin.json, plugin.json wins silently." fi done < <(jq -r '.plugins[].name // empty' "$path") return 0 } validate_plugin_json() { local path="$1" marketplace_json="${2:-}" [[ -f "$path" ]] || return 0 valid_json "$path" || return 0 local name name=$(jq -r '.name // empty' "$path") if [[ -z "$name" ]]; then warn "$path: 'name' field missing (plugin dir name will be used)" else validate_name "$name" "$path" # Version duplication check if [[ -n "$marketplace_json" && -f "$marketplace_json" ]]; then local pver mver pver=$(jq -r '.version // empty' "$path") mver=$(jq -r --arg n "$name" '.plugins[]? | select(.name==$n) | .version // empty' "$marketplace_json") if [[ -n "$pver" && -n "$mver" ]]; then error "$path: version '$pver' set in both plugin.json and marketplace entry — plugin.json wins silently. Remove one." fi fi fi return 0 } validate_skill_md() { local path="$1" local content content=$(cat "$path") if [[ "$content" != ---* ]]; then warn "$path: SKILL.md has no YAML frontmatter" return fi if ! echo "$content" | awk 'NR>1 && /^---/' | grep -q '^---'; then error "$path: SKILL.md frontmatter not closed" return fi if ! echo "$content" | awk '/^---/{n++; if(n==2) exit} n==1' | grep -q 'description:'; then warn "$path: SKILL.md frontmatter missing 'description' field" fi } validate_plugin_dir() { local pd="$1" marketplace_json="${2:-}" local claude_manifest="$pd/.claude-plugin/plugin.json" local root_manifest="$pd/plugin.json" if [[ ! -f "$claude_manifest" && ! -f "$root_manifest" ]]; then warn "$pd: no plugin.json found (will auto-discover components)" else validate_plugin_json "$claude_manifest" "$marketplace_json" validate_plugin_json "$root_manifest" "$marketplace_json" # Sync check — shared identity fields must match; component path fields legitimately diverge if [[ -f "$claude_manifest" && -f "$root_manifest" ]]; then local field cv rv for field in name description version license; do cv=$(jq -r ".$field // empty" "$claude_manifest") rv=$(jq -r ".$field // empty" "$root_manifest") if [[ ( -n "$cv" || -n "$rv" ) && "$cv" != "$rv" ]]; then error "$pd: '$field' differs between .claude-plugin/plugin.json ('$cv') and plugin.json ('$rv')" fi done cv=$(jq -r '.author.name // empty' "$claude_manifest") rv=$(jq -r '.author.name // empty' "$root_manifest") if [[ ( -n "$cv" || -n "$rv" ) && "$cv" != "$rv" ]]; then error "$pd: 'author.name' differs between .claude-plugin/plugin.json ('$cv') and plugin.json ('$rv')" fi cv=$(jq -r '.keywords // [] | sort | join(",")' "$claude_manifest") rv=$(jq -r '.keywords // [] | sort | join(",")' "$root_manifest") if [[ "$cv" != "$rv" ]]; then error "$pd: 'keywords' differs between .claude-plugin/plugin.json and plugin.json" fi fi fi # Components must not be inside .claude-plugin/ for bad_dir in skills agents hooks commands; do if [[ -d "$pd/.claude-plugin/$bad_dir" ]]; then error "$pd/.claude-plugin/$bad_dir: only plugin.json belongs in .claude-plugin/; move $bad_dir/ to plugin root" fi done # Validate SKILL.md files while IFS= read -r -d '' skill_md; do validate_skill_md "$skill_md" done < <(find "$pd" -name "SKILL.md" -print0 2>/dev/null) # Cross-reference check while IFS= read -r -d '' f; do if grep -q '\.\.\/' "$f" 2>/dev/null; then local rel="${f#"$pd/"}" error "$rel: contains '../' reference — plugins cannot access files outside their directory after caching" fi done < <(find "$pd" \( -name "*.md" -o -name "*.json" \) -print0 2>/dev/null) } run_claude_validate() { local path="$1" if command -v claude >/dev/null 2>&1; then if ! claude plugin validate "$path" 2>&1; then error "claude plugin validate failed for $path" fi else warn "'claude' CLI not found — skipping claude plugin validate" fi } # ── parse args ──────────────────────────────────────────────────────────────── ROOT="" PLUGIN_ONLY="" while [[ $# -gt 0 ]]; do case "$1" in --plugin) PLUGIN_ONLY="$2"; shift 2 ;; -*) echo "Unknown option: $1" >&2; exit 1 ;; *) ROOT="$1"; shift ;; esac done if [[ -z "$ROOT" ]]; then echo "Usage: validate.sh [--plugin ]" >&2 exit 1 fi ROOT="$(cd "$ROOT" && pwd)" # ── validate marketplace.json ───────────────────────────────────────────────── CLAUDE_MARKETPLACE="$ROOT/.claude-plugin/marketplace.json" COPILOT_MARKETPLACE="$ROOT/.github/plugin/marketplace.json" MARKETPLACE_JSON="" for mp in "$CLAUDE_MARKETPLACE" "$COPILOT_MARKETPLACE"; do if [[ -f "$mp" ]]; then [[ -z "$MARKETPLACE_JSON" ]] && MARKETPLACE_JSON="$mp" validate_marketplace_json "$mp" fi done if [[ -z "$MARKETPLACE_JSON" ]]; then warn "No marketplace.json found. Expected at .claude-plugin/marketplace.json" fi # Marketplace sync check — shared identity fields must match; description/version # legitimately differ in structure (Claude: top-level; Copilot: under metadata) if [[ -f "$CLAUDE_MARKETPLACE" && -f "$COPILOT_MARKETPLACE" ]]; then cm_val=$(jq -r '.name // empty' "$CLAUDE_MARKETPLACE") cp_val=$(jq -r '.name // empty' "$COPILOT_MARKETPLACE") if [[ "$cm_val" != "$cp_val" ]]; then error "marketplace: 'name' differs — .claude-plugin ('$cm_val') vs .github/plugin ('$cp_val')" fi cm_val=$(jq -r '.owner.name // empty' "$CLAUDE_MARKETPLACE") cp_val=$(jq -r '.owner.name // empty' "$COPILOT_MARKETPLACE") if [[ ( -n "$cm_val" || -n "$cp_val" ) && "$cm_val" != "$cp_val" ]]; then error "marketplace: 'owner.name' differs — '$cm_val' vs '$cp_val'" fi # description: Claude top-level, Copilot under metadata — compare values regardless of path cm_val=$(jq -r '.description // .metadata.description // empty' "$CLAUDE_MARKETPLACE") cp_val=$(jq -r '.metadata.description // .description // empty' "$COPILOT_MARKETPLACE") if [[ ( -n "$cm_val" || -n "$cp_val" ) && "$cm_val" != "$cp_val" ]]; then error "marketplace: description differs between .claude-plugin/marketplace.json and .github/plugin/marketplace.json" fi # version: same structural divergence as description cm_val=$(jq -r '.version // .metadata.version // empty' "$CLAUDE_MARKETPLACE") cp_val=$(jq -r '.metadata.version // .version // empty' "$COPILOT_MARKETPLACE") if [[ ( -n "$cm_val" || -n "$cp_val" ) && "$cm_val" != "$cp_val" ]]; then error "marketplace: version differs — '$cm_val' vs '$cp_val'" fi # Plugin catalog must be identical across both files cm_plugins=$(jq -r '.plugins[].name' "$CLAUDE_MARKETPLACE" 2>/dev/null | sort) cp_plugins=$(jq -r '.plugins[].name' "$COPILOT_MARKETPLACE" 2>/dev/null | sort) if [[ "$cm_plugins" != "$cp_plugins" ]]; then error "marketplace: plugin lists differ between .claude-plugin/marketplace.json and .github/plugin/marketplace.json" else while IFS= read -r pname; do [[ -z "$pname" ]] && continue cm_val=$(jq -r --arg n "$pname" '.plugins[] | select(.name==$n) | .source // empty' "$CLAUDE_MARKETPLACE") cp_val=$(jq -r --arg n "$pname" '.plugins[] | select(.name==$n) | .source // empty' "$COPILOT_MARKETPLACE") if [[ "$cm_val" != "$cp_val" ]]; then error "marketplace plugin '$pname': source differs — '$cm_val' vs '$cp_val'" fi cm_val=$(jq -r --arg n "$pname" '.plugins[] | select(.name==$n) | .description // empty' "$CLAUDE_MARKETPLACE") cp_val=$(jq -r --arg n "$pname" '.plugins[] | select(.name==$n) | .description // empty' "$COPILOT_MARKETPLACE") if [[ ( -n "$cm_val" || -n "$cp_val" ) && "$cm_val" != "$cp_val" ]]; then error "marketplace plugin '$pname': description differs between the two marketplace.json files" fi done <<< "$cm_plugins" fi fi # ── validate plugins ────────────────────────────────────────────────────────── if [[ -n "$PLUGIN_ONLY" ]]; then validate_plugin_dir "$(cd "$PLUGIN_ONLY" && pwd)" "$MARKETPLACE_JSON" run_claude_validate "$(cd "$PLUGIN_ONLY" && pwd)" else plugins_path="$ROOT/plugins" if [[ -d "$plugins_path" ]]; then while IFS= read -r -d '' pd; do validate_plugin_dir "$pd" "$MARKETPLACE_JSON" run_claude_validate "$pd" done < <(find "$plugins_path" -mindepth 1 -maxdepth 1 -type d ! -name '.*' -print0 | sort -z) else warn "No plugins/ directory found at $ROOT" fi fi # ── check source paths resolve ──────────────────────────────────────────────── if [[ -n "$MARKETPLACE_JSON" ]]; then while IFS= read -r src; do [[ "$src" != ./* ]] && continue src_path="$ROOT/${src#./}" if [[ ! -d "$src_path" ]]; then error "Marketplace source path '$src' does not exist at $src_path"; fi done < <(jq -r '.plugins[]?.source | strings' "$MARKETPLACE_JSON" 2>/dev/null) fi # ── report ──────────────────────────────────────────────────────────────────── echo "" if [[ $ERRORS -eq 0 && $WARNINGS -eq 0 ]]; then echo "✓ All checks passed." exit 0 elif [[ $ERRORS -eq 0 ]]; then echo "Passed with $WARNINGS warning(s)." exit 0 else echo "Failed. Fix $ERRORS error(s) before proceeding." exit 1 fi