Adds every cross-compatible component between Claude Code and Copilot CLI: skills (shared), agents (per-tool .md vs .agent.md), hooks (per-tool paths), and .mcp.json (shared). Both plugin.json manifests now carry all shared identity fields (name, description, author, license, keywords) with Copilot-specific component paths only in the root manifest. Updates validate.sh sync check to verify shared identity fields rather than demanding content equality, since component path declarations legitimately diverge between tools. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
280 lines
9.6 KiB
Bash
Executable File
280 lines
9.6 KiB
Bash
Executable File
#!/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 <repo-root>
|
|
# validate.sh <repo-root> --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 <repo-root> [--plugin <path>]" >&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
|