Files
holocron/scripts/sync-plugin-content.sh
Defame1297 cf625229f7 fix(scripts): detect the .apm/ content the mirror loses silently
apm's bundle exporter drops symlinks entirely, so a symlink under .apm/ never
reaches the mirror -- and no gate could see it, because every existing check
diffs the live mirror against a bundle-derived copy and both sides lack the
file. It is an absence with nothing left to mismatch against, the only class of
.apm/ content that vanishes without a trace. check_apm_symlinks reads the .apm/
source tree, where the loss is visible, and fails both modes. Reported rather
than resolved: dereferencing would make a real sync emit content the bundle does
not contain, which is the reimplementation ADR-0017 rejects.

--check --all could also pass having verified fewer plugins than the marketplace
lists: a plugin whose .apm/ had gone was SKIPped rather than counted, and the
earlier floor only caught zero. The count is now checked against the marketplace's
own local-package list. There is no exempt state -- ADR-0015 makes .apm/ the sole
authoring source for every local plugin, so a listed plugin without one is drift.

On the Copilot hooks gap, the decision is to document, not implement. Copilot
declares no hooks path and apm emits none, which looks like the mcpServers case
-- but that exception holds because .mcp.json is one host-agnostic format both
ecosystems read, so a pointer to it is true whatever it contains. Hooks have no
shared format: Claude expects nested matcher groups under PascalCase events,
Copilot requires version: 1, camelCase, and a bash/powershell split. apm merges
.apm/hooks/*.json into exactly one file, at Claude's convention path. A pointer
would assert a Claude-shaped file is Copilot-shaped -- an incomplete manifest
traded for a wrong one -- and it is not inert today either, since {"hooks": {}}
lacks Copilot's mandatory version key. A test pins the decision, so restoring the
pointer fails until someone confronts the schema mismatch.

Tests: 77 -> 92 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-14 14:20:47 +00:00

814 lines
41 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Mirrors each plugin's .apm/{agents,skills,prompts,commands,instructions,extensions,hooks}
# into flat plugin-root directories (agents/, skills/, commands/, instructions/,
# extensions/, hooks/hooks.json) -- Claude Code's and GitHub Copilot's plugin loaders
# convention-scan those flat paths at the plugin root; neither has any awareness of
# apm's .apm/ nesting (confirmed via `strings` on the installed claude binary and a
# live `claude --plugin-dir <bundle> -p ...` discoverability test -- see issue #90).
# `apm pack --format plugin` already implements the correct .apm/ -> plugin-convention
# mapping (it's built for distributable bundles under build/, a directory nothing in
# marketplace.json points at); this script reuses that mapping and copies the relevant
# subset back into the plugin root as compiled output -- same status as
# .claude-plugin/plugin.json, never hand-edited when .apm/ is present.
#
# plugin.json, apm.lock.yaml, and .mcp.json from the bundle are deliberately NOT
# copied: .claude-plugin/plugin.json + .github/plugin/plugin.json are already
# generated in-place at the plugin root by a separate apm code path
# (core/plugin_manifest.py, run as part of the same `apm pack` invocation, keyed off
# cwd rather than -o), and .mcp.json is hand-authored at the plugin root per ADR-0015
# (it is not an .apm/ primitive). Both real and check-mode syncs pass --force so that
# path actually refreshes both files from current apm.yml/.apm/ content -- apm pack
# silently skips regenerating an existing plugin.json otherwise ("already exists;
# skipping plugin.json generation"), which would let them go stale after a
# name/version/description edit (real mode) or let --check compare a copy against
# itself and never see the drift (check mode; see sync_plugin_manifest below).
#
# apm's Copilot-ecosystem plugin.json builder omits mcpServers entirely -- its own
# docstring calls it out-of-schema for Copilot, but this repo's researched Copilot
# plugin schema docs (plugins/kyberforge/docs/research/docs/github-copilot-plugins/
# configuration.md) document mcpServers as valid there. Both modes re-inject it into
# .github/plugin/plugin.json from the plugin's own .mcp.json after apm pack runs (see
# reinject_mcp_servers below) -- real mode into the plugin root directly, check mode
# into the throwaway copy first so the manifest diff below sees the same content a
# real sync would actually produce.
#
# apm pack also writes .claude-plugin/plugin.json and .github/plugin/plugin.json into
# cwd whenever those files don't already exist yet -- regardless of --force -- so
# --check (which must never mutate the real plugin root) never cds into plugin_dir
# directly. It packs a throwaway copy instead (see sync_one's pack_cwd), forces
# regeneration of both manifest files inside that copy, then diffs them
# (sync_plugin_manifest) against the real plugin root's committed manifests to catch
# drift in name/version/description/mcpServers -- only the copy's manifest files,
# never the real ones, can get created as a first-write.
#
# The merged hooks file is mirrored like the other MIRROR_DIRS content: synced when
# .apm/hooks/ produces one, and removed (real mode) / flagged as drift (--check) when
# it no longer does but a mirrored copy is still sitting there from a prior sync.
# The hooks/ directory holding it is owned outright by the mirror the same way every
# MIRROR_DIRS destination is -- a real sync wipes and rebuilds it, so a stray file
# dropped inside, or the empty directory left behind once .apm/hooks/ stops producing
# a hooks.json, is cleaned up rather than preserved. (A stray at the PLUGIN ROOT is a
# different matter and stays out of scope by design: README.md, docs/, bin/, .mcp.json
# and friends are hand-authored there.)
# It lands at hooks/hooks.json, not at the plugin root: Claude Code convention-scans
# `hooks/hooks.json` "at the plugin root, not inside .claude-plugin/"
# (plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md's "Plugin
# Directory Layout" table; quoted verbatim in ADR-0017's own root-cause analysis), and
# the compiled plugin.json carries no `hooks` pointer to override that. apm emits none:
# `hooks` is not in build_plugin_manifest's strip list at all (that list is
# agents/skills/commands/instructions, and it is dead code besides -- see ADR-0017's
# "Considered options"); apm.yml simply has no key that produces one. A root-level
# hooks.json (this script's own pre-fix output shape) is therefore scanned by nothing at
# all, and is deleted as stale by a real sync / reported as drift by --check.
#
# NO `hooks` POINTER IS RE-INJECTED into .github/plugin/plugin.json, deliberately, and
# this is NOT the same call as mcpServers above. Copilot types `hooks` "string or object"
# with no default, exactly like mcpServers, so Copilot resolves no hooks from any plugin
# here -- but the two ecosystems' hooks FILE FORMATS are mutually incompatible (Claude:
# `{"hooks": {"PreToolUse": [{matcher, hooks:[...]}]}}`; Copilot: `{"version": 1,
# "hooks": {"sessionStart": [{type, bash, powershell, ...}]}}`), and apm's exporter
# merges .apm/hooks/*.json into exactly ONE hooks.json with no per-target shaping
# (_collect_hooks_from_apm, apm_cli/bundle/plugin_exporter.py). A pointer would therefore
# assert that a Claude-shaped file is Copilot-shaped. .mcp.json carries no such claim --
# it is one host-agnostic format both ecosystems read. See ADR-0017's 2026-08-14
# "no `hooks` pointer" amendment; plugins/kyberforge/docs/hooks.md carries the
# author-facing version.
#
# SYMLINKS UNDER .apm/ ARE NOT MIRRORED and cannot be: apm's bundle exporter filters
# every symlink out of the bundle it produces (`f.is_file() and not f.is_symlink()` in
# _collect_flat/_collect_recursive, and the same test in _collect_hooks_from_apm), with
# no warning. Nothing downstream of the bundle can see the omission -- both sides of
# --check's diff are built from that same bundle, so sync and --check agree the symlink
# never existed. check_apm_symlinks below therefore reads the .apm/ SOURCE tree directly,
# which is the only place the loss is visible, and reports it in both modes.
#
# tests/ subdirectories (e.g. .apm/skills/<name>/tests/*.bats) are excluded from the
# mirror -- they are dev-time fixtures a plugin host never needs to discover, and several
# reference their own repo root via a hardcoded relative walk-up (e.g.
# `../../../../../../`) sized for the .apm/-nested depth. Mirroring them verbatim would
# duplicate each file one directory level shallower than that walk-up expects, breaking
# the duplicate and double-running the original under any repo-wide bats/test discovery.
# The exclusion is depth-scoped to <category>/<name>/tests, because a skill may legitimately
# ship a directory literally named tests as a template asset it scaffolds FROM
# (skills/skill-author/assets/templates/tests) -- stripping that breaks the shipped
# scaffolder, which sed's its way through the template tree file by file.
#
# --check does NOT reimplement any of the above. It runs the real sync functions against
# a throwaway copy of the plugin root and diffs the result against the live mirror with
# no exclusions, so the tests/ scoping and the hooks path exist in exactly one place and
# check mode is structurally incapable of disagreeing with what a real sync produces.
usage() {
echo "Usage: $0 [--check] (--all | <plugin-dir> [<plugin-dir> ...])" >&2
exit 1
}
CHECK=0
if [[ "${1:-}" == "--check" ]]; then
CHECK=1
shift
fi
ALL=0
if [[ "${1:-}" == "--all" ]]; then
ALL=1
shift
fi
if [[ "$ALL" -eq 1 ]]; then
[[ $# -eq 0 ]] || usage
else
[[ $# -ge 1 ]] || usage
fi
if ! command -v apm &>/dev/null; then
echo "Error: apm is required but not installed (see kyberforge:apm-install)" >&2
exit 1
fi
if ! command -v jq &>/dev/null; then
echo "Error: jq is required but not installed" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Both source= paths below are repo-root-relative, not script-dir-relative -- see
# tests/run-tests.sh for why the script-dir spelling silently fails to resolve.
# shellcheck source=scripts/lib/marketplace-plugins.sh
source "$SCRIPT_DIR/lib/marketplace-plugins.sh"
# shellcheck source=scripts/lib/batch-run.sh
source "$SCRIPT_DIR/lib/batch-run.sh"
# Convention subdirectories apm's plugin exporter can populate from .apm/.
#
# These are DESTINATION directory names at the plugin root, not .apm/ source
# directory names -- the two lists are deliberately not the same, so do not "fix"
# this by pasting in the header comment's .apm/ list. In particular .apm/prompts/
# has no destination of its own: apm's exporter folds it into commands/ together
# with .apm/commands/, renaming *.prompt.md to *.md (ADR-0017's mapping table;
# re-verified empirically against apm 0.28.0, and pinned by the prompts test in
# tests/test-sync-plugin-content.sh). Adding `prompts` here would name an output
# directory apm never emits and no plugin host ever scans.
MIRROR_DIRS=(agents skills commands instructions extensions)
# The .apm/ SOURCE directories apm's exporter reads to build the bundle -- the input
# side of MIRROR_DIRS, and deliberately a different list: `prompts` is here because
# apm reads it (folding it into commands/), and `hooks` is here because
# _collect_hooks_from_apm reads it. Used only by check_apm_symlinks, which needs to
# know which parts of .apm/ are mirror INPUT: a symlink under a directory apm never
# reads loses nothing and must not be reported as loss.
APM_SOURCE_DIRS=(agents skills prompts commands instructions extensions hooks)
# The generated hooks directory, the merged hooks file inside it (Claude Code's
# convention-scanned path), and the pre-fix root-level path a real sync now cleans
# up as stale.
HOOKS_DIR_REL="hooks"
HOOKS_REL="$HOOKS_DIR_REL/hooks.json"
LEGACY_HOOKS_REL="hooks.json"
FAIL=0
SCRATCH_ROOT="$(mktemp -d)"
trap 'rm -rf "$SCRATCH_ROOT"' EXIT
# The manifest comparison in check_path_modes needs octal permission bits, and
# GNU coreutils and BSD/macOS stat disagree on both the flag and the format
# specifier. Probe once at startup against a path known to exist rather than
# branching on `uname` (which says nothing about which coreutils is installed --
# GNU stat is perfectly common on macOS via Homebrew).
declare -a STAT_MODE_ARGS=()
if [[ "$(stat -c '%a' "$SCRIPT_DIR" 2>/dev/null)" =~ ^[0-7]+$ ]]; then
STAT_MODE_ARGS=(-c '%a')
elif [[ "$(stat -f '%Lp' "$SCRIPT_DIR" 2>/dev/null)" =~ ^[0-7]+$ ]]; then
STAT_MODE_ARGS=(-f '%Lp')
else
# Hard error rather than degrading to a no-mode manifest: silently checking
# less than advertised is the exact failure mode this gate exists to prevent.
echo "Error: cannot read octal file modes -- neither \`stat -c '%a'\` (GNU coreutils) nor \`stat -f '%Lp'\` (BSD/macOS) works here" >&2
exit 1
fi
if [[ "$ALL" -eq 1 ]]; then
# Derives the plugin list from marketplace.json via the shared
# list_marketplace_local_plugins helper (scripts/lib/marketplace-plugins.sh),
# the same one scripts/check-manifests.sh uses, instead of hand-maintaining a
# duplicate walk at every call site (see .pre-commit-config.yaml's
# check-plugin-content-sync).
#
# Hard error rather than a `|| pwd` fallback, on scripts/sync-marketplace-mirror.sh's
# reasoning: --all's entire work list hangs off REPO_ROOT, so a REPO_ROOT pointing at
# something that is not this repo checks a plugin set that is not this repo's. Run
# from outside a worktree the fallback happens to hit the `--all requires ...` error
# below instead -- but only by accident, because $PWD had no marketplace.json in it;
# $PWD holding an unrelated one is the case that would silently "pass".
if ! REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || [[ -z "$REPO_ROOT" ]]; then
echo "Error: not inside a git worktree -- cannot locate the repository root, and guessing \$PWD would let --all derive its plugin list from a marketplace.json that is not this repo's. Run this from within the repository." >&2
exit 1
fi
MARKETPLACE="$REPO_ROOT/.claude-plugin/marketplace.json"
if [[ ! -f "$MARKETPLACE" ]]; then
echo "Error: --all requires $MARKETPLACE" >&2
exit 1
fi
# Called from this shell, NOT from inside the `< <(...)` below -- that process
# substitution is its own subshell, so a `set -e` abort or a jq parse failure in
# there kills only the subshell and the `while read` loop simply gets no input.
# An unparseable marketplace.json then reads exactly like "declares no plugins",
# plugin_dirs comes back empty, batch_run dispatches nothing, and --check --all
# exits 0 having verified nothing at all (see the floor below).
assert_marketplace_manifest_usable "$MARKETPLACE"
declare -a plugin_dirs=()
while IFS=$'\t' read -r _name plugin_dir; do
plugin_dirs+=("$plugin_dir")
done < <(list_marketplace_local_plugins "$REPO_ROOT" "$MARKETPLACE")
# Floor: --all is a gate whose work list comes from a GENERATED file, so an
# empty derived set is drift, not a pass -- regenerating marketplace.json badly
# would otherwise silence the very hook that guards it. assert_... above rejects
# the malformed shapes; this rejects the well-formed-but-empty one.
if [[ ${#plugin_dirs[@]} -eq 0 ]]; then
echo "Error: $MARKETPLACE declares no local (string-source) plugin entries -- --all would check nothing and report success. Expected at least one; recompile it with \`apm pack\` if it is stale." >&2
exit 1
fi
else
declare -a plugin_dirs=("$@")
fi
# Fail fast on a basename collision rather than letting two plugin_dir arguments
# silently share (and corrupt) the same $name.log/$name.status/$name.checkcopy
# scratch paths below.
#
# The same loop rejects `.` and `..`, because every scratch path here is built by
# pasting this basename onto $SCRATCH_ROOT. `basename ..` is `..`, so
# "$SCRATCH_ROOT/$name" resolves to $SCRATCH_ROOT's PARENT -- apm pack then writes
# its bundle into a directory this script neither owns nor cleans up (the EXIT
# trap only removes $SCRATCH_ROOT itself), and sync_one's
# `find "$scratch" -mindepth 1 -maxdepth 1 -type d | head -1` picks whatever
# unrelated directory readdir happens to hand back first as the "bundle" -- whose
# agents/ and skills/ a real sync then cp -a's into the plugin root, after an
# rm -rf. The collision check below cannot catch this: a single `..` argument
# collides with nothing.
declare -a seen_names=()
for plugin_dir in ${plugin_dirs[@]+"${plugin_dirs[@]}"}; do
name="$(basename "${plugin_dir%/}")"
if [[ "$name" == "." || "$name" == ".." ]]; then
echo "Error: plugin dir '$plugin_dir' has basename '$name' -- scratch paths built from it would escape the scratch root. Pass the plugin directory by name, not by a relative traversal." >&2
exit 1
fi
for seen in ${seen_names[@]+"${seen_names[@]}"}; do
if [[ "$seen" == "$name" ]]; then
echo "Error: duplicate plugin basename '$name' among arguments -- scratch paths would collide" >&2
exit 1
fi
done
seen_names+=("$name")
done
normalize_trailing_newline() {
# apm's bundle exporter writes hooks.json without a trailing newline, which
# end-of-file-fixer (pre-commit) would flag on every regeneration -- normalize
# instead of fighting that hook on every sync.
printf '%s\n' "$(cat "$1")" >"$2"
}
# Reports every symlink under the .apm/ directories apm's exporter reads. Runs in
# BOTH modes, and is the one check here that reads the .apm/ source tree rather than
# comparing two bundle-derived trees.
#
# It has to: apm drops symlinks from the bundle silently (see the header), so by the
# time either mode has a bundle the symlink is already gone from both sides of every
# comparison. check_dir, check_file and check_path_modes all diff the live mirror
# against a freshly synced copy -- neither side has the file, they agree, and --check
# exits 0 while the author's content is simply not there. That is the entire failure
# mode: not a mismatch, an absence with nothing left to mismatch against. Verified on
# a fixture -- `ln -s real.md link.md` under .apm/skills/hello/ produced a mirror with
# no link.md and a --check at exit 0.
#
# Reported rather than resolved (no dereference-and-copy): the mirror's contract is
# that it is `apm pack`'s output, and materializing a file apm chose not to export
# would make a real sync produce content the bundle does not contain -- exactly the
# "reimplement the mapping outside apm" that ADR-0017 rejects. Telling the author is
# the cheap, in-contract half.
#
# <name>/tests is carved out to match sync_dir's own depth-scoped exclusion: that
# subtree is not mirrored whether or not it holds a symlink, so nothing is lost there.
# The carve-out is on the SECOND path segment specifically, mirroring sync_dir's
# `-mindepth 2 -maxdepth 2`; a `tests` deeper than that (assets/templates/tests) IS
# mirrored, so a symlink in it is real loss and is reported.
check_apm_symlinks() {
local apm_dir="$1"
local d src link rel rest
for d in "${APM_SOURCE_DIRS[@]}"; do
src="$apm_dir/$d"
[[ -d "$src" ]] || continue
while IFS= read -r link; do
rel="${link#"$src"/}"
rest="${rel#*/}"
if [[ "$rest" != "$rel" ]] && { [[ "$rest" == "tests" ]] || [[ "$rest" == tests/* ]]; }; then
continue
fi
echo "FAIL $link: symlink under .apm/ -- apm's bundle exporter drops symlinks from the bundle entirely, so this content never reaches the mirror and no diff can see it missing. Replace it with a regular file." >&2
FAIL=1
done < <(find "$src" -type l -print 2>/dev/null | LC_ALL=C sort)
done
}
# Real-mode mirror write. There is no --check branch here on purpose: check mode
# calls this same function against a throwaway copy of the plugin root and diffs
# the result (see sync_one), so the tests/ exclusion below is the only copy of
# that rule anywhere in this script.
sync_dir() {
local target_dir="$1" bundle_dir="$2" d="$3"
# ${target_dir:?} for the same reason sync_hooks_json spells it out: `set -u`
# aborts on an UNSET variable but not an empty one, and an empty $target_dir
# would make the rm -rf calls below `rm -rf /agents`, `/skills`, `/commands`,
# `/instructions`, `/extensions`. Unreachable from today's two call sites
# (both pass either a `[[ -d ]]`-validated plugin_dir or a scratch path), but
# the guard costs nothing and the next caller added here gets it for free.
local src="$bundle_dir/$d" dst="${target_dir:?}/$d"
if [[ -d "$src" ]]; then
rm -rf "$dst"
mkdir -p "$dst"
cp -a "$src/." "$dst/"
# Depth-scoped to <category>/<name>/tests (depth 2 relative to $dst), which is
# where every dev-time fixture lives. A depth-agnostic `-name tests` also matched
# template assets a skill ships for its own scaffolder to copy FROM -- e.g.
# skills/skill-author/assets/templates/tests at depth 4, whose removal made the
# mirrored new-skill.sh die on `sed: can't read .../tests/README.md` midway
# through writing a scaffold.
find "$dst" -mindepth 2 -maxdepth 2 -type d -name tests -exec rm -rf {} +
elif [[ -d "$dst" ]]; then
rm -rf "$dst"
fi
}
# Real-mode merged-hooks write; same single-implementation contract as sync_dir.
sync_hooks_json() {
local target_dir="$1" bundle_dir="$2"
local src="$bundle_dir/hooks.json"
local dst="$target_dir/$HOOKS_REL" legacy="$target_dir/$LEGACY_HOOKS_REL"
# Earlier revisions of this script wrote the merged hooks file to the plugin
# root. Nothing scans it there (see the header comment), so it is stale output
# regardless of whether .apm/hooks/ still produces one -- clean it up first.
if [[ -f "$legacy" ]]; then
rm -f "$legacy"
fi
# hooks/ is generated output the mirror owns outright, exactly like every
# MIRROR_DIRS destination -- so wipe it wholesale and rebuild, rather than
# editing hooks.json in place and leaving whatever else happens to be in there.
# sync_dir gets this for free from its own rm -rf; hooks/ has to spell it out
# because its one legitimate occupant is written by name instead of copied as a
# tree. The wipe covers both a stray file someone dropped alongside hooks.json
# and the case where .apm/hooks/ stops producing a hooks.json at all: the
# directory then ends up absent, not present-and-empty.
#
# ${target_dir:?} rather than a bare expansion: `set -u` aborts on an UNSET
# variable but not an empty one, and an empty $target_dir would make this
# `rm -rf /hooks`. sync_dir above builds its own $dst from the same
# caller-supplied parameter and carries the identical guard for the identical
# reason -- neither function is special here.
rm -rf "${target_dir:?}/$HOOKS_DIR_REL"
if [[ -f "$src" ]]; then
mkdir -p "$target_dir/$HOOKS_DIR_REL"
normalize_trailing_newline "$src" "$dst"
fi
}
# --- check mode: compare the real plugin root against a synced throwaway copy ---
#
# Every function below is pure comparison. None of them knows what the mirror
# rules are; $expected_dir is the output of the real sync functions above.
check_dir() {
local plugin_dir="$1" expected_dir="$2" d="$3"
local expected="$expected_dir/$d" actual="$plugin_dir/$d"
if [[ -d "$expected" ]]; then
if [[ ! -d "$actual" ]]; then
echo "DRIFT $actual: missing (would be created from .apm/)" >&2
FAIL=1
elif ! diff -rq "$expected" "$actual" >/dev/null 2>&1; then
echo "DRIFT $actual: out of sync with .apm/" >&2
# `|| true`: pipefail (set -o at the top) turns diff's exit 1 into a failed
# pipeline, and set -e would abort sync_one right here -- reporting only the
# first drifted directory per plugin and turning one push into N fix cycles.
diff -rq "$expected" "$actual" 2>&1 | sed 's/^/ /' >&2 || true
FAIL=1
fi
elif [[ -d "$actual" ]]; then
echo "DRIFT $actual: stale, no longer produced from .apm/" >&2
FAIL=1
fi
}
check_file() {
local plugin_dir="$1" expected_dir="$2" rel="$3" desc="$4"
local expected="$expected_dir/$rel" actual="$plugin_dir/$rel"
if [[ -f "$expected" ]]; then
if [[ ! -f "$actual" ]]; then
echo "DRIFT $actual: missing (would be created from $desc)" >&2
FAIL=1
elif ! diff -q "$expected" "$actual" >/dev/null 2>&1; then
echo "DRIFT $actual: out of sync with $desc" >&2
FAIL=1
fi
elif [[ -f "$actual" ]]; then
echo "DRIFT $actual: stale, no longer produced from $desc" >&2
FAIL=1
fi
}
# Relative paths whose mode the manifest below deliberately does NOT record. See
# path_manifest's comment for the rule; this is the list of paths it applies to --
# every file this pipeline WRITES rather than `cp -a`s.
NO_MODE_PATHS=("$HOOKS_REL" "$LEGACY_HOOKS_REL")
# Prints "<kind> <octal-mode> <relative-path>" for every entry under the given
# relative paths. `find` walks; $STAT_MODE_ARGS (probed once at startup) reads the
# mode, because stat's flags for mode formatting are incompatible between GNU and
# BSD/macOS.
#
# THE RULE: a mode is recorded for a path this pipeline COPIES, and not for one it
# WRITES. The two sides of the comparison are a git checkout (actual) and a fresh
# apm-pack-plus-mirror (expected), so a copied path's mode traces to the same
# checkout on both sides and comparing it is meaningful; a written path's mode is
# `0666 & ~umask` of whichever process wrote it -- the runtime umask on the expected
# side, the umask of the checkout that produced the committed file on the actual
# side. Those two are independent, git records neither, and no sync can make them
# converge, so comparing them reports the runner's umask instead of a property of
# the mirror.
#
# Full permission bits on COPIED FILES, not just the exec bit: an earlier revision
# emitted a bare `exec`/`file` kind, so `chmod 444` on a mirrored SKILL.md left
# --check at exit 0 while a real sync restored 644 -- check and sync disagreeing
# again, in the same shape the exec-bit case already proved. Git tracks only the exec
# bit, so this cannot arrive via a clone, but the gate's contract is that it agrees
# with a real sync about everything a real sync writes.
#
# DIRECTORIES record no mode: nothing here sets one, they come from `mkdir -p` and
# `cp -a`, and git tracks no directory mode. Verified concretely -- extracting this
# repo with `git archive | tar -x` (which restores 0775/0664 when run as root) makes
# a --check against the extracted tree report drift on every mirrored directory,
# while the same check against the real 0755 tree is silent.
#
# $NO_MODE_PATHS record no mode for the identical reason, and this is where the
# "copied files are immune because both sides trace to the same checkout" premise
# stops holding. hooks/hooks.json is not copied: sync_hooks_json writes it with
# `printf '%s\n' >`, at the RUNTIME umask. Widening the file comparison from the exec
# bit to full permission bits therefore made the gate umask-dependent -- on a
# umask-002 machine, `--check --all` over a umask-022 checkout reported
# `< file 664 hooks/hooks.json` / `> file 644` for every plugin with hooks, and it
# was not fixable by committing: a real sync writes 664, `git status` stays empty
# because git tracks no non-exec mode, and the next --check from a umask-022 machine
# fails in the opposite direction.
#
# The two generated plugin.json manifests are not in this manifest AT ALL -- see
# sync_one's checked_paths for why listing them measured nothing.
path_manifest() {
local root="$1"
shift
local rel f kind mode no_mode
for rel in "$@"; do
if [[ ! -e "$root/$rel" ]] && [[ ! -L "$root/$rel" ]]; then
continue
fi
find "$root/$rel" -print 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
if [[ -L "$f" ]]; then
# A symlink's own lstat mode is 0777 on Linux and 0755 on macOS and is
# not something either side controls -- the type difference is the whole
# signal here, so record no mode for it.
kind="symlink"
mode="-"
elif [[ -d "$f" ]]; then
kind="dir"
mode="-"
else
kind="file"
mode="$(stat "${STAT_MODE_ARGS[@]}" "$f")"
for no_mode in "${NO_MODE_PATHS[@]}"; do
if [[ "${f#"$root"/}" == "$no_mode" ]]; then
mode="-"
break
fi
done
fi
printf '%s %s %s\n' "$kind" "$mode" "${f#"$root"/}"
done || true
done
}
# `diff -r` compares content only: it dereferences symlinks and ignores file modes
# entirely. So `chmod -x` on a mirrored script, or swapping a mirrored file for a
# symlink to identical content, both leave --check at exit 0 while a real sync
# silently repairs them -- check and sync disagreeing, which is the one thing this
# gate exists to prevent. Compare an explicit type+permission manifest as well.
#
# The manifest is a full recursive listing, so it is also what makes an entry that
# exists on only one side visible. That is the sole coverage the generated hooks/
# directory gets for anything other than hooks.json itself (which check_file above
# handles by name): a stray file inside hooks/, or hooks/ still sitting there empty
# after .apm/hooks/ stopped producing anything, shows up here as a manifest line
# present on one side only. Both are things sync_hooks_json's rm -rf removes, so
# both have to be drift.
check_path_modes() {
local plugin_dir="$1" expected_dir="$2"
shift 2
local expected actual
expected="$(mktemp)"
actual="$(mktemp)"
path_manifest "$expected_dir" "$@" >"$expected"
path_manifest "$plugin_dir" "$@" >"$actual"
if ! diff -q "$expected" "$actual" >/dev/null 2>&1; then
echo "DRIFT $plugin_dir: mirrored paths/types/modes differ from a fresh sync" >&2
diff "$expected" "$actual" 2>&1 | sed 's/^/ /' >&2 || true
FAIL=1
fi
rm -f "$expected" "$actual"
}
# Sets `mcpServers` on the generated Copilot manifest to the STRING ".mcp.json" --
# the path form of the field, not the resolved server objects. Copilot's schema
# types the field "string or object -- MCP server config path or inline
# definitions" (plugins/kyberforge/docs/research/docs/github-copilot-plugins/
# configuration.md), so both are valid there; only one of them is safe.
#
# An earlier revision inlined the objects with
# `jq --slurpfile mcp '.mcpServers = $mcp[0].mcpServers'`. That copies .mcp.json
# verbatim into a committed, marketplace-distributed file, bypassing apm's own
# _sanitize_mcp_servers() (apm_cli/core/plugin_manifest.py), which drops
# env/environment/headers/authorization and any key matching
# token/secret/password/credential/apikey/key at any depth before writing the
# Claude manifest. Proven with a fixture: an `env` block holding a token-shaped
# value produced a sanitized .claude-plugin/plugin.json and a
# .github/plugin/plugin.json carrying the live value. A path reference cannot
# carry a secret at all -- the manifest names a file and the host resolves it at
# load time -- and it preserves the ${VAR} indirection apm documents as the
# posture for MCP secrets, rather than stripping it. See ADR-0017's 2026-08-14
# amendment.
reinject_mcp_servers() {
local plugin_dir="$1" target_dir="$2"
local mcp_src="$plugin_dir/.mcp.json" dst="$target_dir/.github/plugin/plugin.json"
[[ -f "$mcp_src" ]] || return 0
[[ -f "$dst" ]] || return 0
# Match apm's own Claude-ecosystem plugin.json builder: mcpServers is omitted
# entirely when the plugin declares none, not written out as an empty object.
# A plugin whose .mcp.json is `{"mcpServers": {}}` gets no key at all -- not a
# ".mcp.json" pointer at an empty file.
local count
count="$(jq '(.mcpServers // {}) | length' "$mcp_src")"
[[ "$count" -gt 0 ]] || return 0
local tmp
tmp="$(mktemp)"
jq '.mcpServers = ".mcp.json"' "$dst" >"$tmp"
# Write THROUGH the existing file rather than `mv`-ing the mktemp over it:
# mktemp creates 0600, and mv carries that mode onto a tracked, published
# manifest. Git records only the exec bit, so the demotion survived every
# commit and review unnoticed -- plugins/bin/.github/plugin/plugin.json really
# was 0600 on disk while its five siblings were 0644. Redirecting into $dst
# keeps its inode, owner and mode, which is the whole fix: whatever mode apm
# pack gave the manifest a moment ago is exactly the mode it still has.
#
# Deliberately NO `chmod 644` after it. A hardcoded mode here does not pin
# anything a re-sync could converge on -- apm pack created $dst at the runtime
# umask, and the committed file carries the umask of the checkout that produced
# it -- it only makes those two disagree. It did: on a umask-002 checkout,
# --check reported `< file 644 .github/plugin/plugin.json` / `> file 664` with
# nothing wrong. See path_manifest's $NO_MODE_PATHS comment for the rule.
cat "$tmp" >"$dst"
rm -f "$tmp"
}
# --check-only: diffs a freshly-regenerated manifest file (in the throwaway
# pack_cwd copy, produced by a --force'd apm pack) against the one actually
# committed at the real plugin root. Real-mode syncs never need this -- there
# pack_cwd IS plugin_dir, so --force already refreshed the real file in place.
sync_plugin_manifest() {
local plugin_dir="$1" pack_cwd="$2" rel="$3"
local src="$pack_cwd/$rel" dst="$plugin_dir/$rel"
# A manifest that is a symlink is not a cosmetic difference: apm pack opens it
# for writing and reinject_mcp_servers redirects into it, and both follow the
# link -- so a real sync silently rewrites whatever it points at instead of the
# manifest. It has to be asserted against the real plugin root like this, not via
# check_path_modes: that compares against a `cp -a` of this same root, which
# reproduces the symlink on the expected side and reports the two as equal.
if [[ -L "$dst" ]]; then
echo "DRIFT $dst: is a symlink -- apm pack and the mcpServers re-injection both write THROUGH it, so a real sync would overwrite its target instead of the manifest. Replace it with a regular file." >&2
FAIL=1
return 0
fi
if [[ -f "$src" ]]; then
if [[ ! -f "$dst" ]]; then
echo "DRIFT $dst: missing (would be created by apm pack from apm.yml/.mcp.json)" >&2
FAIL=1
elif ! diff -q "$src" "$dst" >/dev/null 2>&1; then
echo "DRIFT $dst: out of sync with apm.yml/.mcp.json" >&2
# `|| true` for the same pipefail/set -e reason as check_dir's diff above.
diff "$src" "$dst" 2>&1 | sed 's/^/ /' >&2 || true
FAIL=1
fi
elif [[ -f "$dst" ]]; then
echo "DRIFT $dst: stale, no longer produced by apm pack" >&2
FAIL=1
fi
}
# Runs entirely inside a backgrounded subshell (see the dispatch loop below), so
# FAIL here is that subshell's own copy -- it never touches the parent's FAIL
# and must be handed back via status_file instead.
sync_one() {
local plugin_dir="${1%/}" status_file="$2" verified_file="$3"
local apm_dir="$plugin_dir/.apm"
FAIL=0
# "This plugin's mirror was actually synced/checked." Flipped to 1 only at the
# very bottom, so every early return below -- nonexistent directory, no .apm/,
# apm pack failure, no bundle -- leaves it 0. --all compares the count of these
# against the number of plugins marketplace.json declared (see the dispatch
# loop's aggregation); an exit-status-only handshake cannot express "ran, but
# verified nothing", which is exactly what the SKIP below is.
echo 0 >"$verified_file"
if [[ ! -d "$plugin_dir" ]]; then
echo "FAIL $plugin_dir: plugin directory does not exist" >&2
FAIL=1
echo "$FAIL" >"$status_file"
return 0
fi
if [[ ! -d "$apm_dir" ]]; then
echo "SKIP $plugin_dir: no .apm/ directory" >&2
echo "$FAIL" >"$status_file"
return 0
fi
# Before the pack, not after: this reads the .apm/ source tree, and it is the
# only report that survives apm's silent symlink filtering (see the header).
check_apm_symlinks "$apm_dir"
local name scratch bundle_dir pack_log pack_cwd
name="$(basename "$plugin_dir")"
scratch="$SCRATCH_ROOT/$name"
mkdir -p "$scratch"
pack_log="$(mktemp)"
# --force always: in real mode it refreshes the real plugin.json in place
# (pack_cwd IS plugin_dir there); in check mode it forces regeneration inside
# the throwaway pack_cwd copy so sync_plugin_manifest below has a genuinely
# fresh manifest to diff against the real one -- without --force, apm pack
# would silently skip regenerating a plugin.json that already exists in the
# copy (it was seeded from the real plugin_dir), so --check would always
# compare the copy against itself and never see drift.
local force_flag=(--force)
if [[ "$CHECK" -eq 0 ]]; then
pack_cwd="$plugin_dir"
else
pack_cwd="$SCRATCH_ROOT/$name.checkcopy"
mkdir -p "$pack_cwd"
cp -a "$plugin_dir/." "$pack_cwd/"
fi
if ! (cd "$pack_cwd" && apm pack --format plugin "${force_flag[@]+"${force_flag[@]}"}" -o "$scratch") >"$pack_log" 2>&1; then
echo "FAIL $plugin_dir: apm pack failed:" >&2
sed 's/^/ /' "$pack_log" >&2
rm -f "$pack_log"
FAIL=1
echo "$FAIL" >"$status_file"
return 0
fi
rm -f "$pack_log"
bundle_dir="$(find "$scratch" -mindepth 1 -maxdepth 1 -type d | head -1)"
if [[ -z "$bundle_dir" ]]; then
echo "FAIL $plugin_dir: apm pack produced no bundle directory under $scratch" >&2
FAIL=1
echo "$FAIL" >"$status_file"
return 0
fi
# Both modes run the identical real-mode mirror write; only the target differs.
# In real mode that target IS the plugin root. In check mode it's the throwaway
# pack_cwd copy, which then gets diffed against the plugin root below -- so
# --check can only ever report what a real sync would actually change.
local d
for d in "${MIRROR_DIRS[@]}"; do
sync_dir "$pack_cwd" "$bundle_dir" "$d"
done
sync_hooks_json "$pack_cwd" "$bundle_dir"
if [[ "$CHECK" -eq 0 ]]; then
reinject_mcp_servers "$plugin_dir" "$plugin_dir"
else
# Reinject into the throwaway copy too, so the manifest diff below compares
# against what a real sync would actually produce (mcpServers included),
# not apm's own Copilot-ecosystem output (which omits it).
reinject_mcp_servers "$plugin_dir" "$pack_cwd"
local -a checked_paths
# $HOOKS_DIR_REL, not $HOOKS_REL: the manifest comparison has to see the whole
# generated directory (see check_path_modes), and listing it recursively already
# covers hooks/hooks.json.
#
# Neither generated plugin.json is listed here, and adding one back measures
# nothing on any of the three axes this manifest compares. Mode: in check mode
# the expected side is the seeded pack_cwd COPY of the real plugin root, where
# apm pack rewrites a file that is already there and open-for-write preserves
# the existing inode's mode -- so the expected mode is inherited from the actual
# mode by construction. Verified: `chmod 600 plugins/bin/.claude-plugin/
# plugin.json` left --check at exit 0 the entire time that entry was listed.
# Type: a symlinked manifest survives the same `cp -a` as a symlink and apm pack
# writes straight through it, so both sides record `symlink` -- also verified at
# exit 0. Presence: sync_plugin_manifest already reports both directions, with a
# message naming apm.yml as the thing to fix. A symlinked manifest IS a real
# hazard (apm pack and reinject_mcp_servers both write through it, corrupting
# whatever it points at), so it is asserted where it can actually be seen --
# against the real plugin root, in sync_plugin_manifest.
checked_paths=("${MIRROR_DIRS[@]}" "$HOOKS_DIR_REL" "$LEGACY_HOOKS_REL")
for d in "${MIRROR_DIRS[@]}"; do
check_dir "$plugin_dir" "$pack_cwd" "$d"
done
check_file "$plugin_dir" "$pack_cwd" "$HOOKS_REL" ".apm/hooks/"
check_file "$plugin_dir" "$pack_cwd" "$LEGACY_HOOKS_REL" \
".apm/hooks/ (Claude Code convention-scans $HOOKS_REL, not the plugin root)"
check_path_modes "$plugin_dir" "$pack_cwd" "${checked_paths[@]}"
sync_plugin_manifest "$plugin_dir" "$pack_cwd" ".claude-plugin/plugin.json"
sync_plugin_manifest "$plugin_dir" "$pack_cwd" ".github/plugin/plugin.json"
fi
echo 1 >"$verified_file"
echo "$FAIL" >"$status_file"
}
# Each plugin's `apm pack` is an independent CLI invocation dominated by fixed
# process-startup cost, not by per-plugin work -- run them concurrently rather
# than paying that startup cost N times serially. Output is buffered per plugin
# (not streamed) so concurrent DRIFT/FAIL messages from different plugins never
# interleave; it's flushed in stable $@ order once every job has finished.
# Dispatch/throttling itself is scripts/lib/batch-run.sh's batch_run (shared
# with tests/run-tests.sh and tests/run-bats.sh) -- see that file for why this
# is batched rather than a rolling `wait -n` pool.
declare -a batch_args=()
for plugin_dir in ${plugin_dirs[@]+"${plugin_dirs[@]}"}; do
name="$(basename "${plugin_dir%/}")"
cmd="$(printf 'sync_one %q %q %q' "$plugin_dir" "$SCRATCH_ROOT/$name.status" \
"$SCRATCH_ROOT/$name.verified")"
batch_args+=("$name" "$cmd")
done
batch_run "$SCRATCH_ROOT" ${batch_args[@]+"${batch_args[@]}"}
declare -a unverified=()
for plugin_dir in ${plugin_dirs[@]+"${plugin_dirs[@]}"}; do
name="$(basename "${plugin_dir%/}")"
cat "$SCRATCH_ROOT/$name.log" >&2
status="$(cat "$SCRATCH_ROOT/$name.status" 2>/dev/null || echo 1)"
[[ "$status" -ne 0 ]] && FAIL=1
# Missing file reads as 0 (unverified), matching the status file's `|| echo 1`
# default: a job whose marker never got written did not verify anything.
if [[ "$(cat "$SCRATCH_ROOT/$name.verified" 2>/dev/null || echo 0)" != "1" ]]; then
unverified+=("$plugin_dir")
fi
done
# --all's second floor, and the one the zero-plugin floor above cannot express.
# That floor rejects "the marketplace yielded no plugins"; this rejects "the
# marketplace yielded N and only M were actually verified". The gap between them
# is sync_one's SKIP path: a plugin directory with no .apm/ reports status 0 and
# checks nothing, so --all printed one SKIP line among the noise and exited 0
# having verified fewer plugins than it listed. --all is a pre-push gate over a
# GENERATED work list, so "checked fewer than declared" has to be a failure.
#
# There is no legitimate state in this repo where a listed local plugin lacks
# .apm/: ADR-0015 made .apm/ the sole authoring source for every plugin here, and
# ADR-0017's mirror is defined as that directory's compiled output, so a local
# marketplace entry without one is drift in one of the two -- either the directory
# lost its .apm/, or marketplace.json still lists a package that is no longer one.
# Both need a human, and neither is fixed by re-running the sync, so this is
# reported separately from the drift hint below rather than folded into it.
#
# SKIP stays a skip when plugin directories are named EXPLICITLY on the command
# line: there the caller chose the work list and a non-apm directory is their
# business, not a generated file's drift.
UNVERIFIED=0
if [[ "$ALL" -eq 1 ]] && [[ ${#unverified[@]} -gt 0 ]]; then
echo "Error: --all verified $(( ${#plugin_dirs[@]} - ${#unverified[@]} )) of the ${#plugin_dirs[@]} local plugin entries $MARKETPLACE declares; unverified: ${unverified[*]}. A listed plugin that cannot be checked (typically: its .apm/ is gone, which sync_one skips) is drift, not a pass -- restore its .apm/, or drop the entry from root apm.yml's marketplace.packages[] and recompile." >&2
UNVERIFIED=1
fi
if [[ "$FAIL" -ne 0 ]]; then
if [[ "$CHECK" -eq 1 ]]; then
if [[ "$ALL" -eq 1 ]]; then
echo "Plugin content mirror is out of sync with .apm/. Fix: bash scripts/sync-plugin-content.sh --all" >&2
else
echo "Plugin content mirror is out of sync with .apm/. Fix: bash scripts/sync-plugin-content.sh $*" >&2
fi
fi
exit 1
fi
[[ "$UNVERIFIED" -eq 0 ]] || exit 1