Problem
Today APM's marketplace has two disjoint sides:
- Authoring (
apm.yml -> marketplace:): each packages[] entry's source must be a single git repo (owner/repo) or a local path. There is no way to express "include a plugin from another marketplace".
- Consumption (
~/.apm/marketplaces.json): registers external marketplace.json URLs for browse / search / update. Read-only from the curator's perspective.
A curator (e.g. an enterprise running an internal marketplace) wants to selectively re-expose plugins from external marketplaces (e.g. a Claude Code marketplace such as abhigyanpatwari/GitNexus), with curator-side allow-list control over which plugins reach their consumers.
Proposed solution: marketplace upstreams
Add a first-class upstream concept to the curator authoring surface:
- New
upstreams: block in apm.yml -> marketplace: registers external marketplace pointers under a curator-chosen alias.
- New
upstream: field on packages[] entries declares an allow-listed entry that resolves to a plugin from a registered upstream.
- The builder fetches the upstream
marketplace.json (atomic read at a pinned manifest SHA), strict-parses it, looks up the plugin, resolves its source to an immutable commit SHA, and emits a vanilla plugin entry into the curator's marketplace.json.
v1 product contract
v1 is curated upstream / governance-only, not artifact custody:
- Curator controls which plugins are exposed and at what version.
- Curator's lockfile pins manifest SHA + plugin SHA for reproducible curator builds.
- Consumers still fetch plugin content from the upstream git host at install time.
- Never called "mirror" in v1 prose -- "mirror" is reserved for a future v2
distribution: rehost mode that would add true artifact custody.
One-line pitch: "Allow-list governance over external plugins, pinned to immutable commits, with provenance baked into every build -- without running an artifact server."
Trust model
| Concern |
v1 status |
| Allow-list governance |
YES (curator picks which plugins are exposed) |
| Build-time commit pinning |
YES (curator's lockfile pins manifest SHA + plugin SHA) |
| Reproducible curator builds |
YES (rebuild from lock = byte-identical output) |
| Consumer-side artifact custody |
NO -- consumer clones from upstream git host at install |
| Resilience to upstream takedown / force-push |
NO -- consumer install fails opaquely if upstream rewrites history |
| Defence against upstream repo rename / takeover |
YES (canonical-owner check) |
Sketch of the surface
marketplace:
upstreams:
- alias: gitnexus
repo: abhigyanpatwari/GitNexus
path: .claude-plugin/marketplace.json # default
ref: <sha-or-tag> # required for reproducibility
branch: main # used only when ref absent and allow_head=true
host: github.com # default
allow_head: false # default
packages:
# Direct package (existing shape)
- name: my-skill
source: owner/repo
version: ">=1.0.0"
# Upstream-sourced package (new shape)
- name: my-renamed-skill # display name in curator's marketplace
upstream: gitnexus # references upstreams[].alias
plugin: original-name # name in upstream marketplace; defaults to `name`
version: ">=2.0.0" # optional curator override
ref: <sha-or-tag> # optional curator override
description: "ACME-curated ..." # optional override
tags: [acme, approved] # optional override
allow_head: false # opt-in to mutable refs (per-entry)
apm marketplace upstream add <owner/repo> [--alias --path --ref --branch --host --allow-head]
apm marketplace upstream list [--verbose]
apm marketplace upstream remove <alias> [--yes]
apm marketplace upstream refresh [<alias>] # shows diff before advancing lock pins
apm marketplace upstream browse <alias> # lists plugins available in upstream
apm marketplace package add --upstream <alias> <plugin-name> [--name --version --ref --tags]
Key design decisions (panel-reviewed)
The plan was reviewed by an APM panel (supply-chain, architect, devx, primitives, test-coverage, CEO). Findings folded in:
- Provenance lives in
apm.lock.yaml only. No metadata.apm.* keys in marketplace.json -- preserves the Anthropic-conformance hard rule at src/apm_cli/marketplace/builder.py:13-15.
- Type-level discriminated union:
DirectPackageEntry / UpstreamPackageEntry as separate frozen dataclasses (not optional fields on a single class).
- Module decomposition: new
marketplace/upstream_parser.py (strict parser, separate from lenient consumer parser), marketplace/upstream_resolver.py (extracted resolver -- avoids MarketplaceBuilder god-class), marketplace/upstream_cache.py (separate from consumer client.py).
- Cache key uses
__ (double-underscore), not :: -- colons are illegal in Windows filenames (CI runs on windows-latest).
- Per-upstream-host auth:
AuthResolver.resolve(upstream_host, org=upstream_owner) with unauth_first=True for public repos, never inheriting curator's marketplace-source PAT.
- Repo-rename / takeover guard: every fetch verifies GitHub Contents API
repository.full_name matches configured upstreams[alias].repo; lockfile records canonical name.
- Cross-shape
name uniqueness enforced builder-side (prevents dependency-confusion / name shadowing).
- Round-trip invariant: every emitted plugin must survive
parse_marketplace_json (catches lenient-vs-strict parser drift).
- Atomic single-fetch invariant: manifest is fetched once at the pinned SHA; no re-fetch during resolution.
- Governance gate: new policy key
marketplace.upstream.allow_unpinned_refs lets enterprises forbid allow_head: true.
security.md update required in same PR: the existing "no intermediary registry, proxy, or mirror" claim becomes inaccurate.
apm doctor integration: upstream reachability + pinned-vs-HEAD drift + manifest-SHA round-trip checks.
- Consumer-side passthrough notice: when
apm install resolves a plugin with upstream provenance in the lockfile, output names the upstream so the trust boundary is visible.
- DevX:
upstream_name renamed to plugin; CLI mutex enforcement (source xor --upstream); dual-shape help text; copy-pasteable next-step hints; exit code 2 for resolution errors; _authoring_commands registration.
- Cut from v1:
apm marketplace upstream search (browse covers it).
- Terminology: "upstream" in user-facing copy; never "mirror" in v1 (reserved for v2 rehost).
Out of scope (deferred)
- Re-hosting / artifact custody / true air-gap (
distribution: rehost) -- v2.
- Transitive upstreams (curator upstreams marketplace B which upstreams marketplace C).
- Install-time auto-sync of upstream changes.
- Cross-git-host upstreams (upstream and plugins on different host families).
- Mirroring non-APM/non-Claude formats (npm, PyPI, etc.).
Implementation plan (high-level, dependency-ordered)
- Schema: type-level union dataclasses +
upstreams: block + strict validation.
upstream_parser.py: strict parser with named rejection reasons.
upstream_cache.py: Windows-safe content-addressed cache with per-host auth.
upstream_resolver.py: extracted resolver (atomic fetch, precedence ladder, repo-rename guard).
MarketplaceBuilder integration: dispatch on union, name-shadowing check, round-trip invariant, exit code 2.
apm.lock.yaml upstream section: writer + reader; refresh diff.
- CLI surface:
upstream add/list/remove/refresh/browse + package add --upstream.
apm doctor upstream health checks.
- Governance policy key
marketplace.upstream.allow_unpinned_refs.
- Consumer-side passthrough notice in
apm install output.
- Integration tests in
scripts/test-integration.sh (incl. lock-holds, reproducibility).
- Docs: new upstreams guide,
security.md rewrite, policy-reference update, apm-guide skill resources, CHANGELOG.
- Final lint + format gate.
Alternatives considered
- Mirror / proxy: rejected as v1 framing because no artifacts are re-hosted; reserve "mirror" for the future
distribution: rehost mode.
- Optional
source on PackageEntry: rejected -- breaks unguarded entry.source access across the builder; type-level discriminated union is the correct pattern.
- Provenance keys in
marketplace.json: rejected -- violates the Anthropic-conformant emission contract documented in builder.py:13-15. All provenance lives in the lockfile.
- Extending consumer
client.py for upstream cache: rejected -- consumer cache is wired to MarketplaceSource + consumer auth context. Sibling upstream_cache.py keeps boundaries clean.
Additional context
Plan validated with APM Panel
Problem
Today APM's marketplace has two disjoint sides:
apm.yml -> marketplace:): eachpackages[]entry'ssourcemust be a single git repo (owner/repo) or a local path. There is no way to express "include a plugin from another marketplace".~/.apm/marketplaces.json): registers externalmarketplace.jsonURLs forbrowse/search/update. Read-only from the curator's perspective.A curator (e.g. an enterprise running an internal marketplace) wants to selectively re-expose plugins from external marketplaces (e.g. a Claude Code marketplace such as
abhigyanpatwari/GitNexus), with curator-side allow-list control over which plugins reach their consumers.Proposed solution: marketplace upstreams
Add a first-class upstream concept to the curator authoring surface:
upstreams:block inapm.yml -> marketplace:registers external marketplace pointers under a curator-chosen alias.upstream:field onpackages[]entries declares an allow-listed entry that resolves to a plugin from a registered upstream.marketplace.json(atomic read at a pinned manifest SHA), strict-parses it, looks up the plugin, resolves its source to an immutable commit SHA, and emits a vanilla plugin entry into the curator'smarketplace.json.v1 product contract
v1 is curated upstream / governance-only, not artifact custody:
distribution: rehostmode that would add true artifact custody.One-line pitch: "Allow-list governance over external plugins, pinned to immutable commits, with provenance baked into every build -- without running an artifact server."
Trust model
Sketch of the surface
Key design decisions (panel-reviewed)
The plan was reviewed by an APM panel (supply-chain, architect, devx, primitives, test-coverage, CEO). Findings folded in:
apm.lock.yamlonly. Nometadata.apm.*keys inmarketplace.json-- preserves the Anthropic-conformance hard rule atsrc/apm_cli/marketplace/builder.py:13-15.DirectPackageEntry/UpstreamPackageEntryas separate frozen dataclasses (not optional fields on a single class).marketplace/upstream_parser.py(strict parser, separate from lenient consumer parser),marketplace/upstream_resolver.py(extracted resolver -- avoidsMarketplaceBuildergod-class),marketplace/upstream_cache.py(separate from consumerclient.py).__(double-underscore), not::-- colons are illegal in Windows filenames (CI runs onwindows-latest).AuthResolver.resolve(upstream_host, org=upstream_owner)withunauth_first=Truefor public repos, never inheriting curator's marketplace-source PAT.repository.full_namematches configuredupstreams[alias].repo; lockfile records canonical name.nameuniqueness enforced builder-side (prevents dependency-confusion / name shadowing).parse_marketplace_json(catches lenient-vs-strict parser drift).marketplace.upstream.allow_unpinned_refslets enterprises forbidallow_head: true.security.mdupdate required in same PR: the existing "no intermediary registry, proxy, or mirror" claim becomes inaccurate.apm doctorintegration: upstream reachability + pinned-vs-HEAD drift + manifest-SHA round-trip checks.apm installresolves a plugin with upstream provenance in the lockfile, output names the upstream so the trust boundary is visible.upstream_namerenamed toplugin; CLI mutex enforcement (sourcexor--upstream); dual-shape help text; copy-pasteable next-step hints; exit code 2 for resolution errors;_authoring_commandsregistration.apm marketplace upstream search(browse covers it).Out of scope (deferred)
distribution: rehost) -- v2.Implementation plan (high-level, dependency-ordered)
upstreams:block + strict validation.upstream_parser.py: strict parser with named rejection reasons.upstream_cache.py: Windows-safe content-addressed cache with per-host auth.upstream_resolver.py: extracted resolver (atomic fetch, precedence ladder, repo-rename guard).MarketplaceBuilderintegration: dispatch on union, name-shadowing check, round-trip invariant, exit code 2.apm.lock.yamlupstream section: writer + reader;refreshdiff.upstream add/list/remove/refresh/browse+package add --upstream.apm doctorupstream health checks.marketplace.upstream.allow_unpinned_refs.apm installoutput.scripts/test-integration.sh(incl. lock-holds, reproducibility).security.mdrewrite, policy-reference update, apm-guide skill resources, CHANGELOG.Alternatives considered
distribution: rehostmode.sourceonPackageEntry: rejected -- breaks unguardedentry.sourceaccess across the builder; type-level discriminated union is the correct pattern.marketplace.json: rejected -- violates the Anthropic-conformant emission contract documented inbuilder.py:13-15. All provenance lives in the lockfile.client.pyfor upstream cache: rejected -- consumer cache is wired toMarketplaceSource+ consumer auth context. Siblingupstream_cache.pykeeps boundaries clean.Additional context
Plan validated with APM Panel