#!/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 if [[ -f "$claude_manifest" && -f "$root_manifest" ]]; then if ! diff <(jq -S . "$claude_manifest") <(jq -S . "$root_manifest") >/dev/null 2>&1; then warn "$pd: .claude-plugin/plugin.json and plugin.json differ — keep them in sync" 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 ───────────────────────────────────────────────── MARKETPLACE_JSON="" for mp in "$ROOT/.claude-plugin/marketplace.json" "$ROOT/.github/plugin/marketplace.json"; do if [[ -f "$mp" ]]; then MARKETPLACE_JSON="$mp" validate_marketplace_json "$mp" break fi done if [[ -z "$MARKETPLACE_JSON" ]]; then warn "No marketplace.json found. Expected at .claude-plugin/marketplace.json" 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