Files
holocron/docs/adr/0017-plugin-content-mirror-bridges-apm-to-host-discovery.md
Defame1297 38f1ba4e03 fix(kyberforge): bridge apm content to Claude Code's flat plugin discovery
Claude Code's (and Copilot's) native plugin installer has zero awareness of
.apm/ nesting -- it convention-scans only flat skills/, agents/, commands/,
hooks.json at each plugin's root. Confirmed via strings on the installed
claude binary and live installs of git@holocron/gitea@holocron/kyberforge@
holocron, all reporting Skills(0) Agents(0) Hooks(0) post ADR-0015's apm
conversion. Root cause (apm_cli/core/plugin_manifest.py): apm's plugin.json
compiler deliberately strips skills/agents/commands keys, assuming the host
already auto-discovers those convention directories -- it has no model of
.apm/ being host-visible at all. Separately, apm's own bundle exporter
(apm_cli/bundle/plugin_exporter.py, behind `apm pack --format plugin`)
implements the correct .apm/ -> flat mapping, but only ever targeted
build/<name>-<version>/, a path nothing in marketplace.json's source: points
at.

scripts/sync-plugin-content.sh wraps that bundle exporter and copies its
agents/, skills/, commands/, instructions/, extensions/, and merged
hooks.json back into each plugin's own root as a second tracked
compiled-output category -- same governance status as
.claude-plugin/plugin.json: generated from .apm/, never hand-edited. tests/
subdirectories are excluded from the mirror (dev fixtures, not host-visible
runtime content; several hardcode a relative repo-root walk-up sized for the
.apm/-nested depth, which breaks when duplicated one level shallower).
Applied for real across all 6 plugins and verified two ways: `claude plugin
validate --strict` passes on every real plugin directory, and a live
`claude --plugin-dir <path> -p "list skills/agents"` behavioral test
confirms content is now actually discovered.

Also, from the same issue #90 review round:
- scripts/check-manifests.sh pointed at each plugin's root-level plugin.json
  (checking skills/hooks/mcpServers/agents pointer fields) -- that file was a
  stale near-duplicate of .claude-plugin/plugin.json nothing else read or
  wrote, now deleted across all 6 plugins. check-manifests.sh is rewritten to
  validate .claude-plugin/plugin.json instead, and drops the pointer-field
  checks entirely (nothing to check -- those fields are correctly absent by
  design). Content-presence drift is now check-plugin-content-sync's job, a
  new pre-push hook wired in .pre-commit-config.yaml.

docs/adr/0017 records the root cause and decision in full, including two
rejected alternatives (patching plugin.json's path fields directly -- apm's
compiler strips them on every run; pointing marketplace.json at apm pack's
build/ output -- a version-suffixed non-source directory nothing can install
from without an extra build step). ADR-0015 and CONTEXT.md are updated to
point at it.

Refs: #90
2026-08-13 16:59:03 +00:00

9.9 KiB
Raw Blame History

Plugin roots gain a compiled flat-directory mirror of .apm/ content so Claude Code can discover it

This ADR is a follow-on correction to ADR-0015 (Microsoft APM replaces hand-authored plugin/marketplace authoring), discovered during issue #90's post-execution review. It does not restate ADR-0015's rationale for adopting .apm/ as the authoring source of truth — see that ADR for the parent decision. It resolves the one question ADR-0015's own execution flagged as open but did not block on: whether Claude Code's installer can actually load content out of .apm/. It could not.

Status: executed (2026-08-13, issue #90). scripts/sync-plugin-content.sh has been run against all 6 plugins; flat agents/, skills/, commands/ (etc., wherever .apm/ populates them), and a merged hooks.json now exist at each plugin root as tracked, generated files.

Context

ADR-0015's execution comment on issue #90 (2026-08-12) flagged, before merge: "it's currently unverified whether Claude Code can actually discover any skill/agent content in these plugins... This needs to be checked... before treating this conversion as functionally complete, not just manifest-complete." That caveat did not block ADR-0015 from shipping "Status: executed" — the manifest-compilation deliverable (.claude-plugin/marketplace.json/plugin.json generated from apm.yml + .apm/) was genuinely complete, and every automated gate (apm audit --ci, claude plugin validate --strict ×6, apm marketplace check) passed clean — so the ADR merged with the caveat noted but unresolved.

The caveat turned out to be a real defect, not a formality. claude plugin install against all three plugins tested (git@holocron, gitea@holocron, kyberforge@holocron) reported Skills (0) Agents (0) Hooks (0). Root cause, confirmed two independent ways:

  1. Claude Code's installer scans flat convention directories only. strings on the installed claude binary finds zero references to .apm/ or apm.yml anywhere. The installed plugin cache (~/.claude/plugins/cache/holocron/kyberforge/1.3.1/) mirrors the pre-conversion flat skills//agents//hooks/ layout verbatim — that is what the installer actually copies and reads. plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md's own "Plugin Directory Layout" table documents the same flat convention (skills/<name>/SKILL.md, agents/, hooks/hooks.json, all "at the plugin root, not inside .claude-plugin/") — this was accurate before ADR-0015 and never stopped being accurate; ADR-0015 moved plugin content without adding a bridge to it.
  2. apm's own manifest compiler has no .apm/ → host-path bridge, by design. apm_cli/core/plugin_manifest.py's build_plugin_manifest docstring states directly: "Convention directories (agents/, skills/, commands/) are auto-discovered by the host, so they are never listed explicitly in the manifest." apm's Claude/Copilot compiler assumes plugin content already lives in those flat root-level directories; it has no model of .apm/ nesting being host-visible at all, so it never emits anything that would point a host at .apm/.

Separately, apm_cli/bundle/plugin_exporter.py's export_plugin_bundle (the engine behind apm pack --format plugin) does implement the correct mapping — .apm/agents → agents/, .apm/skills → skills/ (subdirs preserved), .apm/prompts + .apm/commands → commands/ (*.prompt.md renamed to *.md), .apm/instructions → instructions/, .apm/extensions → extensions/, and .apm/hooks/*.json merged into one hooks.json. But it was only ever wired to produce a distributable bundle under build/<name>-<version>/ — a path nothing in root apm.yml's per-package marketplace.packages[].source: fields (e.g. ./plugins/bin) or marketplace.json's equivalent points at. The correct mapping existed in apm's own codebase the whole time; it was simply never connected to the path this repo's marketplace actually installs plugins from.

Decision

Each plugin root gains a second, generated content category, produced by scripts/sync-plugin-content.sh (wraps apm pack --format plugin, copies the resulting bundle's agents/, skills/, commands/, instructions/, extensions/, and merged hooks.json back to the plugin root) — same governance status as .claude-plugin/plugin.json/marketplace.json: compiled output of .apm/, never hand-edited.

  • .apm/ remains the sole hand-edited authoring source, unchanged from ADR-0015.
  • The flat mirror is what Claude Code's (and Copilot's) installer actually convention-scans at install time — it exists purely to satisfy the host's discovery contract, a contract apm's own manifest compiler deliberately does not bridge.
  • plugin.json/apm.lock.yaml/.mcp.json from the bundle are excluded from the copy: plugin.json is already correctly generated by a separate, already-verified apm code path (build_plugin_manifest, run in the same apm pack invocation); .mcp.json is hand-authored at the plugin root per ADR-0015 and is not an .apm/ primitive.
  • Drift is enforced by a pre-push gate (scripts/sync-plugin-content.sh --check, wired into .pre-commit-config.yaml as hook id check-plugin-content-sync by a parallel workstream on issue #90) — the same enforcement model check-manifests.sh already applies to the other compiled-output category.
  • Verified two ways before landing: claude plugin validate --strict passes on all 6 real (non-scratch) plugin directories, and a live behavioral test (claude --plugin-dir plugins/kyberforge -p "list your skills and agents") against the real committed directory confirms kyberforge:* skills and the kyberforge:apm-orchestrate agent are now actually discovered — they were not, before this fix.
  • The stale root-level plugins/<name>/plugin.json files (a near-duplicate of .claude-plugin/plugin.json that nothing read or wrote, flagged separately in issue #90's review) were deleted across all 6 plugins as part of the same cleanup.

Considered options

Patch plugin.json's skills/agents/commands/hooks fields to point directly at .apm/ paths (rejected). Claude Code's manifest schema documents these as legitimate override fields that accept custom paths — plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md shows a real example ("skills": "./custom/skills/", "agents": ["./custom/agents/reviewer.md"]), so the host side of this would work. Rejected because apm's compiler is not a passive pass-through: build_plugin_manifest unconditionally strips these keys from every manifest it generates, on the stated assumption that convention directories are always host-auto-discovered and therefore never need an explicit pointer. Honoring this option would mean post-processing apm's compiled output on every apm pack run to re-inject fields apm actively removes — fighting a stable, intentional apm code path indefinitely — rather than reusing plugin_exporter.py's bundle-export mapping, which already does the right thing and only needed its output redirected to a path the installer reads.

Point marketplace.json's source: at apm pack's build/<name>-<version>/ output directly (rejected). Would reuse the bundle exporter's correct mapping without adding a new script. Rejected: build/ is a version-suffixed, regenerate-on-every-pack directory — pointing the marketplace at it would mean either committing a moving-target build artifact to version control (defeating the point of it being generated) or requiring every consumer's marketplace to run apm pack before install, a build step Claude Code's installer has no hook for — it clones/fetches source and scans directories; it does not execute a package manager's build command first. Copying the relevant subset back to the stable plugins/<name>/ path — where marketplace.json already points — needed no change to the marketplace source model at all.

Consequences

  • Git now tracks real, visible duplication: .apm/skills/<name>/SKILL.md and skills/<name>/SKILL.md both exist and must match, likewise .apm/agents/*.agent.md vs. agents/*.agent.md, and .apm/hooks/*.json vs. the merged hooks.json. This is an accepted tradeoff of bridging a gap apm itself doesn't close, not a bug — .apm/ stays the single hand-edited source, and the drift gate (check-plugin-content-sync) is what keeps the mirror honest rather than trusting authors to remember to regenerate it by hand.
  • scripts/check-manifests.sh's existing blind spot (flagged in the same issue #90 review round: it validated plugin.json fields that ADR-0015 already stopped populating, so a plugin shipping zero content could pass it silently) is fixed as part of the same workstream: those field checks are removed (nothing to check — the fields are correctly absent by design), and the content-presence question they were standing in for is now answered by check-plugin-content-sync, not re-implemented inside check-manifests.sh.
  • ADR-0015's "Status: executed" now carries a pointer to this ADR (see that ADR's Consequences) rather than being rewritten — the manifest-compilation half of its execution was correct and stands; this ADR fixes the second, previously-unverified half.
  • CONTEXT.md's "Plugin" and "Plugin marketplace" glossary entries are updated to describe the flat mirror as a second compiled-output category, alongside the existing .claude-plugin/plugin.json/marketplace.json description.
  • A future apm release that ships a native .apm/-aware plugin.json compiler (closing this gap upstream) would let sync-plugin-content.sh and its drift gate be deleted outright — nothing in this ADR's decision depends on the flat mirror existing beyond satisfying the current installer's convention-scan contract.
  • Reference: issue #90 (https://git.dev.rkdr.net/Defame1297/holocron/issues/90).