#!/usr/bin/env bash set -euo pipefail # Validates that marketplace.json's local plugin entries resolve to a real directory # containing a .claude-plugin/plugin.json. Run from repo root or pass REPO_ROOT as arg. # # Per ADR-0015, apm.yml is the authoring source and .claude-plugin/plugin.json is # compiled output with no skills/hooks/mcpServers/agents pointer fields (apm's plugin.json # builder deliberately omits them -- Claude Code auto-discovers those convention # directories, so listing them would be redundant/invalid). For a plugin with an .apm/ # directory, this script no longer checks those pointer fields itself; that's # scripts/sync-plugin-content.sh --check's job (drift between .apm/ and the flat # plugin-root mirror), wired as its own pre-push hook. # # sync-plugin-content.sh --check explicitly skips any plugin directory lacking .apm/ # (an apm-native package it has nothing to compile), so that delegation leaves a real # gap for a non-apm plugin whose hand-authored plugin.json still uses the old # skills/hooks/mcpServers/agents pointer-field convention: nothing would check whether # those paths resolve. The fallback block below restores that check, but only for # plugins without .apm/ -- apm-native plugins keep relying on the delegation above so # the two checks don't duplicate (and disagree) on the same manifest. # # Both of the above walk marketplace.json -> disk. Nothing walked disk -> marketplace, # so a plugins// directory that never made it into marketplace.json was invisible # to every marketplace-derived gate at once (this script and sync-plugin-content.sh # --all both derive their plugin set from marketplace.json). The final block below # closes that direction: per ADR-0015 marketplace.json is compiled output of root # apm.yml's marketplace.packages[], so an on-disk apm package with no entry is # compiled-output drift of exactly the kind ADR-0017 wires pre-push gates for -- and it # is the same plugin set the validate-plugins pre-commit hook already globs as # plugins/*/. REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" FAIL=0 err() { echo " FAIL: $1" >&2; FAIL=$((FAIL + 1)); } if ! command -v jq &>/dev/null; then echo "Error: jq is required but not installed" >&2 exit 1 fi MARKETPLACE="$REPO_ROOT/.claude-plugin/marketplace.json" if [[ ! -f "$MARKETPLACE" ]]; then exit 0 fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Repo-root-relative, not script-dir-relative -- see tests/run-tests.sh for why. # shellcheck source=scripts/lib/marketplace-plugins.sh source "$SCRIPT_DIR/lib/marketplace-plugins.sh" # Every local plugin directory marketplace.json claimed, canonicalized, so the # disk -> marketplace pass below can tell "listed" from "unlisted" regardless of how # the `source:` string was spelled (./plugins/x, plugins/x, plugins/x/). SEEN_PLUGIN_DIRS=() while IFS=$'\t' read -r name plugin_dir; do source_rel="${plugin_dir#"$REPO_ROOT"/}" if [[ ! -d "$plugin_dir" ]]; then err "plugin '$name': source directory not found: $source_rel" continue fi # -P so a plugin directory reached through a symlink compares equal to the same # directory reached directly; the disk-side walk below resolves the same way. SEEN_PLUGIN_DIRS+=("$(cd "$plugin_dir" && pwd -P)") manifest="$plugin_dir/.claude-plugin/plugin.json" if [[ ! -f "$manifest" ]]; then err "plugin '$name': .claude-plugin/plugin.json not found in $source_rel" continue fi # apm-native plugin: pointer-field validation is sync-plugin-content.sh --check's # job (see header comment above). [[ -d "$plugin_dir/.apm" ]] && continue # Fallback for a non-apm plugin: validate that any skills/hooks/mcpServers/agents # pointer fields in its hand-authored plugin.json still resolve to real paths. skill_count=$(jq '.skills | if . then length else 0 end' "$manifest") for ((s = 0; s < skill_count; s++)); do skill_path=$(jq -r ".skills[$s]" "$manifest") full_path="$plugin_dir/$skill_path" full_path="${full_path%/}" if [[ ! -d "$full_path" ]]; then err "plugin '$name': skills path not found: $skill_path" fi done for field in hooks mcpServers agents; do ref=$(jq -r ".${field} // empty" "$manifest") [[ -z "$ref" ]] && continue full_path="$plugin_dir/$ref" full_path="${full_path%/}" if [[ ! -e "$full_path" ]]; then err "plugin '$name': $field path not found: $ref" fi done done < <(list_marketplace_local_plugins "$REPO_ROOT" "$MARKETPLACE") # Disk -> marketplace. The trigger is any of the three markers that make a directory # a plugin rather than scratch -- apm.yml (the ADR-0015 authoring source), .apm/ (its # content tree), or a compiled .claude-plugin/plugin.json. Matching all three keeps # this set aligned with the plugins/*/ glob the validate-plugins pre-commit hook uses, # which is the disagreement this check exists to close; a directory with none of them # is scratch and stays out of scope. # # A candidate counts as listed if it is either a directory some local entry pointed at # (path match, canonicalized above) or a directory whose name matches a REMOTE entry's # name. The name axis exists only for a plugin vendored on disk but declared with the # remote-object `source:` shape: list_marketplace_local_plugins deliberately skips those, # so a path-only match would report a missing entry that is in fact already there. # # It is restricted to non-string sources on purpose. Applied to local entries too, the # name axis silently rescues genuine orphans, because a local entry's name need not equal # the basename of the directory it points at: an entry named "beta" pointing at # ./plugins/alpha would mark an unrelated, entirely unlisted plugins/beta/ as listed. # Local entries already have an exact path to match on, so they need no name fallback. MARKETPLACE_NAMES=() while IFS= read -r entry_name; do [[ -n "$entry_name" ]] && MARKETPLACE_NAMES+=("$entry_name") done < <(jq -r '.plugins[]? | select((.source | type) != "string") | .name // empty' "$MARKETPLACE") for candidate in "$REPO_ROOT"/plugins/*/; do candidate="${candidate%/}" [[ -d "$candidate" ]] || continue if [[ ! -f "$candidate/apm.yml" && ! -d "$candidate/.apm" && ! -f "$candidate/.claude-plugin/plugin.json" ]]; then continue fi candidate_abs="$(cd "$candidate" && pwd -P)" candidate_name="$(basename "$candidate")" listed=0 for seen in ${SEEN_PLUGIN_DIRS[@]+"${SEEN_PLUGIN_DIRS[@]}"}; do if [[ "$seen" == "$candidate_abs" ]]; then listed=1 break fi done if [[ $listed -eq 0 ]]; then for entry_name in ${MARKETPLACE_NAMES[@]+"${MARKETPLACE_NAMES[@]}"}; do if [[ "$entry_name" == "$candidate_name" ]]; then listed=1 break fi done fi if [[ $listed -eq 0 ]]; then err "plugin directory '${candidate#"$REPO_ROOT"/}' has no entry in .claude-plugin/marketplace.json — it is skipped by every marketplace-derived check (this one, and sync-plugin-content.sh --all) while still being globbed by the validate-plugins hook. Add it to root apm.yml's marketplace.packages[] and recompile the manifests." fi done if [[ $FAIL -gt 0 ]]; then echo "Manifest check failed: $FAIL error(s)" >&2 exit 1 fi