Playbook: building a plugin that builds plugin marketplaces¶
A field guide for authoring a Claude Code meta-plugin — a plugin whose job is to scaffold, audit, and maintain plugin-marketplace monorepos. It distills the conventions this repo converged on (see the commit trail referenced throughout) into a reusable blueprint: the marketplace anatomy your plugin must understand, the skills/commands it should provide, the invariants it must enforce, and how to build and test the plugin itself.
Scope: this is about building the tool (a marketplace-builder plugin), not a single marketplace. For a one-off hardening pass on an existing marketplace, use
docs/companion material / theshipped_script_refs_test.batssensor directly.
1. What this plugin produces¶
A healthy marketplace monorepo, every time:
.claude-plugin/marketplace.json # the catalog (lists every plugin + its git-subdir source)
plugins/<name>/ # one shipped plugin per dir
├── .claude-plugin/plugin.json # manifest: name, version, description, depends-on
├── agents/ skills/ commands/ # behavioral surface (loaded on demand)
├── hooks/ settings.json # PreToolUse/PostToolUse/SessionStart wiring
├── knowledge/ templates/ prompts/ # reference data + scaffolds
├── install.sh # prerequisite checker (ships)
└── CLAUDE.md # plugin instructions (ships)
tests/ scripts/ # gates + dev tooling — repo root, NEVER shipped
evals/ docs/ plans/ # corpus, dev docs, design — repo root, NEVER shipped
.github/workflows/ # CI: structural + portability + tests
release-please-config.json # automated versioning + catalog sync
requirements-dev.txt scripts/dev-setup.sh
The plugin's value is that it bakes in the two hard-won facts about marketplaces:
- A plugin ships wholesale via its git-subdir
source— every tracked file underplugins/<name>/reaches end users. Build/test tooling inside that tree is shipped by accident. - Installed plugins run with
${CLAUDE_PLUGIN_ROOT}set, but the agent's cwd is the user's project, not the plugin root — so a skill that runs a barescripts/x.shcannot find it once installed.
Everything below exists to make those two facts impossible to get wrong.
2. Marketplace anatomy your plugin must model¶
2.1 The catalog — .claude-plugin/marketplace.json¶
{
"name": "<owner-handle>",
"owner": { "name": "..." },
"plugins": [
{
"name": "dev-team",
"version": "6.7.0",
"source": {
"source": "git-subdir",
"url": "https://github.com/<org>/<repo>.git",
"path": "plugins/dev-team",
"ref": "dev-team-v6.7.0"
}
}
]
}
Key invariant: each catalog entry's version and source.ref must stay in lock-step
with the plugin's own plugin.json version and its release tag. Do not hand-edit
these — automate them (§6).
2.2 The manifest — plugins/<name>/.claude-plugin/plugin.json¶
name, version (this plugin's own independent semver — §6), description,
and — when plugins couple — depends-on (a per-companion minimum-version
floor) and required-primitives-contract (a semver range against a shared
versioned contract). The two dependency knobs are the subject of §7.
2.3 Shipped vs not-shipped (the load-bearing distinction)¶
Ships (under plugins/<name>/) |
Never ships (repo root) |
|---|---|
agents/ skills/ commands/ hooks/ knowledge/ templates/ prompts/ |
tests/ (all bats + *.test.sh + fixtures) |
settings.json install.sh CLAUDE.md |
scripts/ (CI/eval/build tooling) |
harness/ (executable app code, if any) |
evals/ docs/ plans/ reports/ |
Your plugin must refuse to leave a test/build script inside a plugin dir, and
must reference every runtime helper as ${CLAUDE_PLUGIN_ROOT}/<path> (§5).
3. Skills & commands the plugin should provide¶
Each is a user-invocable skill (SKILL.md with user-invocable: true). Build them
to emit files, run gates, and report — not to narrate.
| Command | Role | What it does |
|---|---|---|
/new-marketplace <owner> |
scaffold | Create marketplace.json, the repo-root tests/ scripts/ docs/ trees, CI workflows, release-please-config.json, requirements-dev.txt, scripts/dev-setup.sh, the hygiene sensor test, and a root CLAUDE.md documenting the conventions. |
/add-plugin <name> |
scaffold | Create plugins/<name>/ with the shipped dir skeleton, plugin.json, install.sh (with the Git-Bash check), settings.json, and CLAUDE.md; register it in the catalog and in release-please-config.json packages with the catalog extra-files sync. |
/audit-plugin [name] |
audit | Run the shipping-hygiene sensor (§4) + structural checks (every catalog entry has a dir, every dir has a manifest, versions in sync) + the portability sweep (§5). Report findings; offer fixes. |
/portability-check |
audit | shellcheck -x every shipped + dev script; flag bash-4/GNU-only constructs; verify shebangs; check the Git-Bash install.sh guard exists. |
/release-setup |
scaffold | Wire release-please with per-plugin packages and the marketplace.json extra-files jsonpath sync (§6). |
/cloud-setup |
scaffold | Generate the gated SessionStart install hook + cloud-setup.sh and the "use skills directly" fallback (see the companion cloud guide). |
Implementation note: model each skill on the matching artifact already in this repo (cited in §8) rather than inventing the format.
4. The enforcement sensor it must ship/scaffold¶
The backbone is a bats sensor that auto-discovers plugins/* and proves four
invariants. /new-marketplace drops it into the repo-root test tree and wires it
into CI; /audit-plugin runs it. The four invariants:
- Every
${CLAUDE_PLUGIN_ROOT}/<file>reference resolves inside the same plugin (discoverability once installed). - No shipped file escapes its plugin via
${CLAUDE_PLUGIN_ROOT}/../..(resolves in the dev monorepo, breaks once installed) — minus an explicit maintainer allowlist. - Every
settings.jsonhook command resolves to a shipped file. (Hooks run from the plugin root, so the barebash hooks/x.shform is correct here.) - No build/test tooling ships inside a plugin (
*.test.sh,*.bats,run-all*.sh, atests/dir, …).
A portable, parameterized implementation lives in the hygiene kit
(shipped_script_refs_test.bats); ship that as the plugin's reference template.
5. Invariants to bake into every generated/audited plugin¶
Encode these as knowledge the plugin's skills enforce — they are the difference between "a plugin" and "a healthy, tested plugin."
- Shipping hygiene. Runtime files only under
plugins/<name>/; tests/build at repo root. Reference every executed helper as${CLAUDE_PLUGIN_ROOT}/<path>. Scripts the plugin actually runs at runtime must ship and be discoverable (both halves matter — see the build-wave scripts fix,#261, and the discoverability fix,#263). - Portability — macOS bash 3.2 / BSD coreutils / Windows Git Bash. All shell
is
#!/usr/bin/env bashand must run on all three: - bash 3.2-safe: no
mapfile/readarray,declare -A,${var,,},wait -n; expand possibly-empty arrays with${arr[@]+"${arr[@]}"}(bare"${arr[@]}"underset -uaborts on 3.2 — and CI on bash 5 won't catch it; cf.#220). - BSD-vs-GNU: guard or avoid
readlink -f,sed -i,date +%N,stat -c,find -printf,timeout,base64 -wwith fallbacks (cf.#197). - Windows = Git Bash; each
install.shdetects Windows-without-Git-Bash and tells the user to install it (native cmd/PowerShell are not targets). - Python invoked as a module: make it cwd-independent and spawn cross-platform
(
subprocess, notos.exec*). - Tested. Ship a bats sensor (§4) + targeted unit/smoke tests; wire them into
CI as model-free gates; mirror them in a local pre-push gate; keep the gates
parallel and fast (cf.
#247). A runnable component (e.g. a harness) gets a lightweight smoke test + its own CI job. - Versioned + released. Conventional commits →
release-please→ tag + catalog sync (§6)./versionis a mechanical, deterministic lookup, not a guess (cf.#259). - Onboarding. A
scripts/dev-setup.shthat validates and installs the toolchain (brew/apt +requirements-dev.txt), and aninstall.shprerequisite checker per plugin. - Cloud-aware. A gated
SessionStartinstall hook + a skill-file fallback so the plugin is usable in web sessions (see the companion cloud guide).
6. Independent versioning + catalog sync (automate, never hand-edit)¶
Each plugin versions independently. There is no repo-wide version. Every
plugin carries its own semver in plugins/<name>/.claude-plugin/plugin.json and
ships on its own cadence. release-please models this with one package per
plugin — each with its own component, package-name, tag prefix, and
.release-please-manifest.json entry — so a fix: that touches only
plugins/dev-team/** bumps just dev-team (→ tag dev-team-vX.Y.Z) and leaves
security-assessment at its current version. release-please attributes a commit
to a plugin by the path it modifies; a commit spanning two plugin dirs bumps
both, so keep commits plugin-scoped when you want independent bumps. (Tags are
<component>-v<version> via include-component-in-tag + include-v-in-tag.)
extra-files then rewrite the catalog entry on every release — so plugin.json,
the release tag, and marketplace.json can never drift (cf. #210):
"plugins/<name>": {
"release-type": "simple",
"package-name": "<name>",
"component": "<name>",
"extra-files": [
".claude-plugin/plugin.json",
{ "type": "json", "path": "/.claude-plugin/marketplace.json",
"jsonpath": "$.plugins[?(@.name=='<name>')].version" },
{ "type": "json", "path": "/.claude-plugin/marketplace.json",
"jsonpath": "$.plugins[?(@.name=='<name>')].source.ref" }
]
}
/release-setup generates this block per plugin and the matching
.release-please-manifest.json entry. feat: → minor, fix: → patch, feat!:/
BREAKING CHANGE → major.
7. Cross-plugin dependencies (plugin A consumes plugin B at a pinned version)¶
Once plugins version independently (§6), the question is how plugin A says
"I need plugin B, and not just any B." The naive answer — pin A to an exact
release of B — is the wrong one: B's version bumps for reasons that don't touch
A (internal refactors, unrelated features), so an exact pin forces needless
churn and tells you nothing about what A actually relies on. This repo uses
two complementary, coarse-to-fine mechanisms, both declared in A's
plugin.json:
7.1 depends-on — a presence + floor declaration (coarse)¶
A names the companion it needs and the minimum version that carries the capability it relies on:
Use a floor, never an exact pin, so B can ship patches and additive features without breaking A. This answers "is B installed, and is it new enough?" — it does not describe the actual data/behavior A consumes. That's §7.2's job.
7.2 A versioned shared contract — the real coupling (fine; preferred)¶
Pin A to the contract B publishes, not to B's release number. The contract is
a standalone versioned document describing the exact surface the two plugins
exchange — data envelopes, JSON schemas, agent IDs, skill IDs — with its own
semver and its own semver policy. In this repo that's
plugins/dev-team/knowledge/security-primitives-contract.md (version: in
frontmatter). Consumers declare a semver range, not a point:
^1.0.0 = "any 1.x": accept PATCH (clarifications) and MINOR (additive — new
optional fields, new enum values, new agent/skill IDs) automatically; reject
MAJOR (renamed/removed fields, new required fields, changed semantics). The
contract's frontmatter spells out exactly what each level means so producers and
consumers share one rule.
Why this beats pinning to B's version: the contract decouples the dependency
from B's release cadence. B can refactor internals and ship unrelated features
freely; A only has to react when the shared surface changes — and a MAJOR bump
is the unambiguous, machine-checkable signal that it did. The contract is
versioned independently of either plugin: it does not ride dev-team's or
security-assessment's release number, even though it physically lives in the
producer's knowledge/ tree.
7.3 Enforcement (make drift unmergeable)¶
- Contract can't change silently. A PreToolUse hook
(
hooks/contract-version-guard.sh) blocks any body change to the contract file that doesn't also bumpversion:in its frontmatter, with a bypass only for release-please commits (and an audit-log entry on bypass). "Changed the shared surface but forgot to version it" therefore can't land by hand. /add-pluginwrites adepends-onfloor when a companion is named, and arequired-primitives-contractrange when the new plugin consumes a shared contract./audit-pluginverifies, as blocker-level findings: (a) everydepends-onnames a plugin that exists in the catalog at a version>=the floor; (b) everyrequired-primitives-contractrange is satisfiable by the contract's currentversion:— a consumer stuck on^1.0.0after the contract moved to2.0.0is a hard failure that must be resolved by updating the consumer (and, if its behavior changed, bumping the consumer's own version per §6).
7.4 Which knob, when¶
| Need | Mechanism | Form |
|---|---|---|
| "B must be installed, and recent enough to have feature X" | depends-on |
minimum-version floor |
| "A and B exchange data/IDs and must agree on its shape" | shared contract | required-primitives-contract semver range (^1.x) |
| "A relies on a specific B release artifact" | (avoid) — extract the shared surface into a contract instead | — |
Use them together: the depends-on floor guarantees B is present and roughly
current; the contract range guarantees the two actually agree on the wire.
8. Building the plugin itself (step by step)¶
- Bootstrap with your own conventions. The marketplace-builder plugin is a
plugin — so it must pass its own sensor. Lay it out as
plugins/marketplace-builder/in a marketplace repo; put its tests at repo root. - Author the knowledge base first. A
knowledge/marketplace-conventions.mdthat encodes §2–§6 (progressive disclosure: skills load it on demand). This is the plugin's "source of truth"; the skills reference it. - Author the skills (§3). Each
skills/<cmd>/SKILL.mdis a procedure that reads the conventions knowledge and emits/edits files. Templates for the files it scaffolds (catalog, plugin.json, CI, release config, sensor, install.sh, dev-setup) live undertemplates/. - Ship the sensor + a self-test. Include
shipped_script_refs_test.batsas a template and add a test that runs it against a fixture marketplace to prove the scaffolder produces a passing repo. - Wire CI + dev-setup for the plugin's own repo. Structural gate, portability
sweep, bats sensor, and
scripts/dev-setup.sh. - Add
install.sh(with the Git-Bash guard) andCLAUDE.md. Document what each/commanddoes and the conventions it enforces. - Release via release-please with the catalog
extra-filessync (§6).
Reference templates (this repo)¶
| Pattern | Canonical file(s) here |
|---|---|
| Catalog | .claude-plugin/marketplace.json |
| Manifest + deps | plugins/security-assessment/.claude-plugin/plugin.json |
| Hygiene sensor | tests/repo/shipped_script_refs_security_assessment_test.bats |
| Portability fallbacks | plugins/dev-team/scripts/recon-inventory.sh, plugins/security-assessment/scripts/_lib.sh, plugins/dev-team/hooks/mutation-adapters/lib.sh |
Git-Bash install.sh guard |
plugins/dev-team/install.sh, plugins/security-assessment/install.sh |
| Toolchain installer | scripts/dev-setup.sh |
| Smoke test + CI job | tests/security-assessment/harness/smoke_test.py, .github/workflows/plugin-tests.yml (harness-smoke) |
| Release + per-plugin packages | release-please-config.json, .release-please-manifest.json |
| Manifest dependency declaration | plugins/security-assessment/.claude-plugin/plugin.json (depends-on, required-primitives-contract) |
| Versioned cross-plugin contract | plugins/dev-team/knowledge/security-primitives-contract.md |
| Contract version-bump guard | plugins/dev-team/hooks/contract-version-guard.sh |
| Cloud install hook | .claude/install-dev-team.sh, .claude/settings.json |
| CI gate layout | .github/workflows/plugin-tests.yml, scripts/ci-local.sh |
9. Acceptance checklist for the plugin you build¶
A marketplace produced (or audited-clean) by your plugin must pass all of:
- [ ] Every catalog entry maps to a
plugins/<name>/with aplugin.json; versions/refs in sync. - [ ] The hygiene sensor (§4) is green — no shipped test/build scripts, all refs discoverable.
- [ ]
shellcheck -xclean (warning severity) over shipped + dev scripts; all shebangsenv bash. - [ ] Each
install.shhas the Git-Bash-on-Windows guard;scripts/dev-setup.shprovisions the toolchain. - [ ] CI runs structural + portability + bats gates; a local pre-push gate mirrors them.
- [ ] Each plugin versions independently: one
release-pleasepackage per plugin (own component/tag/manifest entry) + catalogextra-filessync (§6). - [ ] Every
depends-onfloor names a catalog plugin at a version>=the floor; everyrequired-primitives-contractrange is satisfiable by the contract's currentversion:(§7). - [ ] Any shared cross-plugin contract is independently versioned and guarded against body-without-bump edits (§7).
- [ ] A gated
SessionStartcloud hook + skill-file fallback exist.