- Organization — one plugin per cohesive concern
- Naming
- Extensibility model — what works today
- Extensibility contract v2.1 — the four seams
- Convention-resolution ladder
- Setup action — required iff the criteria hold
- Upstream sync — every upstream-sourced plugin ships an update path
- Evals — warrant policy and consumer-verify recipe
- Shared tools and scripts seam
- Version pinning and update delivery
- Retiring a published plugin
- Persistence, configuration & external integration
- MCP servers as a plugin component — carry decision
- Plugin-form caveats (works in-repo, breaks as a plugin)
- Per-plugin migration gate
- Migration order, PRs & parallelization
- Plugin-acceptance security review
- Local development loop
- Fresh-consumer onboarding
- Reintegration — a consumer adopts the published plugin
- What to wait on / avoid for now
- Decision records
How skills, hooks, and agents become reusable plugins in this marketplace. One plugin is migrated at a time: lift it out, make it work in plugin form and in any repo, build in configuration and extensibility, vet it against best practices, then publish.
The durable design policy is Plugin philosophy. This playbook applies that policy to migration, validation, cutover, and release; it does not redefine the policy.
All schema and behavior claims below were verified against the official docs on 2026-06-22 (the
"Reintegration" section's marketplace-settings claims — extraKnownMarketplaces / enabledPlugins in a
project's settings.json — on 2026-06-29, against the discover-plugins "Configure team marketplaces"
guide; the "Extensibility contract v2.1" sections and their smoke tests on 2026-07-12 against Claude
Code 2.1.207; the Organization and Naming sections' skill-namespace and skill-listing claims on
2026-07-15, and the decomposition/trigger-continuity procedure on 2026-07-16, against the skills doc).
Re-verify fresh before acting — see CLAUDE.md "Fresh-docs mandate".
The philosophy's "one cohesive capability" is also the packaging boundary: one plugin per cohesive
concern or capability, grouped in the catalog through category / tags rather than by splitting.
A cohesive plugin MAY hold several units — a first-party plugin bundles many skills of one concern, a
hooks plugin bundles many hooks of one concern. One-unit-per-plugin is not the norm; do not ship a
plugin per hook.
- Skills group by capability. Distinct capabilities are distinct plugins; a single capability's
always-together facets bundle (e.g. a prototyping capability's
logicanduiskills ship together). - A skill splits only on distinct discovery intent, never per subcommand. Two skills are
warranted when their trigger vocabularies differ — a user reaching for each says different things;
a capability's subcommands stay action arguments of one skill. The restraint has a context-cost
basis: the listing of skill names and descriptions loads into every session, and each entry's
combined description text is truncated at 1,536 characters in that listing
(skills, fetched 2026-07-15) — every extra skill is an
always-paid context line. The standing exception is the
setuplane, always its own skill withdisable-model-invocation: true— see the philosophy's "Setup is explicit and repeatable". - Hooks group by concern. Per-hook selectivity comes from a
userConfigtoggle (read through the hook-processCLAUDE_PLUGIN_OPTION_<KEY>mirror), amatcher, or anifguard — author-managed control inside the bundle. - Whole-product / vendor-brand bundles driven by distribution are a separate, allowed shape.
Migration is the point to correct a mega-skill boundary, not preserve it accidentally. Apply this procedure before choosing plugin and skill directories:
- Inventory the source contract. Record each responsibility, output, supporting asset,
cross-skill reference, eval, and auto-invocation phrase from
descriptionpluswhen_to_use. Claude Code uses that listing text to decide whether to load a skill, so trigger phrases are behavior, not marketing copy (skills, fetched 2026-07-16). - Classify the seams by discovery intent. Facets of one capability stay in one plugin but may become focused sibling skills when users reach for them with different vocabulary. Capabilities with independent purpose, lifecycle, or trust surface become separate plugins. Subcommands and depth variants remain arguments; they are not decomposition seams.
- Name the focused skills by KIND. Action skills take focused action verbs; knowledge skills
take focused noun phrases. When the split is facets-of-one-capability, keep the parent concept as
the plugin name and move only the focused leaves below it.
prototypeis the worked precedent: one throwaway-prototyping capability, distinctlogicanduidiscovery intents, shared discipline at plugin scope. - Preserve common policy once. Put genuinely shared instructions/assets at plugin scope and have each sibling skill cite them. Do not duplicate a former mega-skill preamble into every leaf.
- Prove trigger continuity. Build a migration table mapping every old trigger phrase to one
successor skill's quoted
Use when:phrase. Add explicit negative routing boundaries where sibling intent could overlap. Run/skill-quality:checkwithCHECK_SKILL_BASE_REFfor every same-path rewrite; check 3 fails when a quoted trigger disappears. A rename or split creates new paths, so the checker deliberately skips them — the cross-skill migration table and focused routing evals are the required evidence that the union of successor descriptions still covers the source. - Update callers and exercise routing. Rewrite slash references for the new namespaced leaves, validate every manifest and eval file, then exercise both automatic invocation and explicit slash invocation from a clean consumer repo. Do not retire the source skill until each mapped trigger routes to its intended successor and ambiguous prompts choose the correct facet.
The result is a smaller loading surface per invocation without losing discovery behavior. Do not split merely to shorten a file: progressive disclosure into supporting files handles size when the skill still has one discovery intent.
Why capability, not grab-bag. Enabling and disabling happen at the plugin level, and the
skillOverrides setting explicitly excludes plugin skills (those are managed through /plugin), so
there is no clean per-skill à-la-carte toggle. Bundling several skills is therefore acceptable only
within one cohesive capability you would never split — it forbids lumping distinct capabilities into
a single plugin. Hooks differ: a per-hook userConfig toggle gives clean per-hook control inside a
bundle. The discriminating axis is silent-always-on components (hooks — keep atomic, or toggle via
userConfig) versus opt-in-per-invocation components (skills — group by capability).
Buckets are catalog metadata, never structure. Category grouping lives in marketplace.json
category / tags and catalog docs only: the disk layout stays flat (plugins/<name>, no
plugins/<bucket>/<name> nesting), and a bucket never appears in a plugin name or namespace.
Namespaces name capability domains; categories are curation.
Boundaries are defended by design arguments, never incumbency. A plugin's shape is justified by
change-together, useful-alone, and distinct discovery intent — not by the fact that it already ships
that way ("current state is evidence, never justification" — melodic-software/standards
conventions/engineering/engineering-philosophy.md).
Name a plugin and its units by this precedence — an earlier rule wins on conflict:
- Semantic accuracy, zero confusion. The capability is unambiguous from the name; qualify an
overloaded generic term (a bare
auditis collision bait). - Official docs + ecosystem precedent. kebab-case, no spaces; the namespace is the plugin's own
name(not the marketplace name); mirror established Claude Code patterns. - Explicit naming. A domain-noun plugin name; no noise suffix (
-plugin/-tool/-helper); no unit-type suffix (-hook/-skill) unless load-bearing; names track their semantic scope.
Applying that precedence, the grammar of an invocation is /<namespace>:<skill>:
- The namespace (plugin
name) is a noun, kebab-case — never a bare verb. A gerund for an activity domain (planning,debugging,testing); a plain noun for a subject domain (source-control,architecture,work-items). Semantic accuracy binds the whole namespace: the noun must be true of every skill under it. - Skill name follows its KIND. An action / user-invoked skill is an action verb
(
create-plugin,review-pr); a knowledge / model-invoked skill may be a noun-phrase (principles,methodology). The verb heuristic scopes to action skills only — anoun:nouninvocation is correct for a knowledge skill. /name:namedoubling is a naming defect, not idiomatic. A stutter means one of the two names is failing at its job — the namespace is not naming the domain, or the skill is not naming its action. Fix it by, in preference order: rename the skill to its real action verb; rename the plugin to its domain noun; decompose, when the single skill actually hides distinct discovery intents (per the Organization section's split rule above). Two exemptions: root-echo — the domain's core action shares the domain's root word (implementation:implement,code-tidying:tidy,work-items:work) — and wrapper-echo — a single-skill vendor-CLI wrapper whose one router skill repeats the tool name (firecrawl:firecrawl,playwright:playwright), per the philosophy's Naming section. Both are honest naming, not true doubling, and are accepted.- Skill families order base-concept-first. Sibling skills sharing a base concept put the base
first (
design,design-handoff,implement,implement-dispatch) so prefix typeahead and sorted listings group the family. A standalone skill keeps natural English order (batch-simplify,quality-gate). A structural variant earns a new sibling name; a depth/intensity variant takes an argument, never a sibling. Execution tier counts as structural when the tier is genuinely not reachable from the base skill's execution path:discovery'sresearch-deepis a sibling because its heaviest tier needs theWorkflowtool and its multi-topic path needs theAgenttool, neither of which a dispatched context can reach — so the tier cannot be selected at runtime byresearchitself. The-deepsuffix names that isolation tier, not a depth knob on the same execution path; a true effort knob on one execution path still takes an argument. The converse is equally binding: a tier the base skill CAN reach at runtime does not earn a sibling.discoveryretiredexplore-deepfor exactly this reason — once/discovery:exploredispatched a named agent by default, the-deepvariant was a second door onto an execution path the base skill already had, and the test is same-execution-path vs. a genuinely second one. - A vendor-CLI plugin that decomposes names its skills after the vendor's own CLI verbs. When a
tool-scoped plugin splits into multiple skills, it mirrors that CLI's verb vocabulary —
/playwright:testwould mirrornpx playwright test; a firecrawl decomposition would usescrape/crawl/mapperfirecrawl-cli— the consumer already knows the vendor's verbs. While it remains a single-skill router, the wrapper-echo exemption above applies instead. - Generic skill names are safe under namespacing (
help,list,update) — the overloaded-term caution governs plugin identity, not a namespaced skill leaf. - Tool-scope shows up as brand-in-name, not a structural split. A branded name signals a tool-scoped plugin; a plain domain-noun signals a tool-agnostic one. No marketplace separates plugins by tool-scope — do not formalize such a split.
Built-in collisions never force a plugin skill's name. "Plugin skills use a
plugin-name:skill-name namespace, so they cannot conflict with other levels"
(skills, fetched 2026-07-15) — a shadow-dodge name is never
required. The catalog's historical dodge names (quality-gate, fanout,
batch-simplify, research-deep) stand or evolve on their own merits, not out of collision fear.
The one residual caution is model-side: avoid a skill leaf name identical to a bundled skill's
(auto-invocation ambiguity when the model matches descriptions); similarity alone is fine.
That namespace guarantee covers invocation, and the listing now carries it too. The picker
labels a row with the namespaced command it resolves, so a leaf name shared across plugins is told
apart by its prefix rather than read identically; the plugin name reaches the reader a second time
through the description, which renders as (<plugin-name>) <description>. See the philosophy's
Naming section for the full display contract. Two consequences
for this playbook: prefix typeahead groups a family by its leaf name, which is what the
base-concept-first rule above is buying; and a deliberately shared leaf name (setup across every
plugin that ships one) still asks the reader to scan prefixes, so each description's first clause
has to name its object.
These are the proven, documented mechanisms for consumer customization that do not confuse the agent. Prefer them in this order; the earlier ones are simplest and least surprising.
| Mechanism | What it does | Use for |
|---|---|---|
Consumer CLAUDE.md / .claude/rules |
The skill reads the consuming project's own context and rules | Project-specific conventions, naming, policies — the default extension surface |
${CLAUDE_PROJECT_DIR} |
Path to the consumer's project root, substituted in hook/MCP/monitor commands and exported to subprocesses | Referencing project-local scripts/config |
userConfig → ${user_config.KEY} |
Values Claude Code prompts for at enable time (typed: string/number/boolean/directory/file, optional sensitive). Substitutes as ${user_config.KEY} in MCP/LSP configs and exec-form hook commands; non-sensitive values also substitute into skill/agent content. Shell-form hook commands, monitor commands, and MCP headersHelper reject this substitution. Hook processes receive every value as CLAUDE_PLUGIN_OPTION_<KEY>; a Bash tool call made by a skill does not (see the smoke-test record). Non-sensitive values are stored under pluginConfigs[<id>].options in user settings and read from user, --settings, or managed settings; project/local entries are ignored. Sensitive values use the macOS Keychain or ~/.claude/.credentials.json where no supported keychain exists |
Endpoints, toggles, tokens — personal or administrator-supplied config without editing the plugin |
${CLAUDE_PLUGIN_ROOT} |
Path to the plugin's own installed directory | Referencing bundled scripts/assets (mandatory under cache isolation) |
${CLAUDE_SKILL_DIR} |
Path to the current skill's subdirectory within the plugin (not the plugin root); substituted in skill and agent content per the skills reference | Pre-compute blocks and allowed-tools paths that must resolve to skill-local scripts without hardcoding the plugin root |
${CLAUDE_PLUGIN_DATA} |
Persistent per-plugin directory that survives updates (~/.claude/plugins/data/<id>/) |
Installed deps, caches, generated state |
hooks/hooks.json |
Event handlers the plugin ships | Behavior consumers opt into by enabling the plugin |
Design a skill so its variable parts route through the table above. "If you need to customize X, set
userConfig Y / add it to your project rules" — never "open an issue" or "fork the skill".
The table above is the raw mechanism inventory ordered simplest-first; this contract is the adopted
policy for how a plugin exposes consumer variability, organizing those mechanisms into four seams. Each
seam matches a kind of variability — typed scalar, rich prose/rules, project convention, machine
state — not a rung on a preference ladder: choose the seam that fits the need, and within that choice
the table's simplest-first ordering still applies. Where the table's ordering and a seam's fit point
differently, fit governs — a typed token belongs in userConfig (seam 1) even though the table
lists consumer CLAUDE.md first. Each seam is tagged by its
authority — [SPEC] (documented Claude Code behavior), [PRECEDENT] (an official first-party
plugin does it, not written up as a spec), or [PRECEDENT-EXTENSION] (a documented shape extended
one increment past the precedent). Behavioral gaps the docs leave open are resolved empirically in the
smoke-test record.
- Typed scalars →
userConfig→pluginConfigs. [SPEC] Declarestring/number/boolean/directory/fileoptions (astringmay setmultiplefor an array — there is nostring[]type); mark a credentialsensitiveso it lands in Claude Code's secure credential storage, neversettings.json. Non-sensitive values store underpluginConfigs[<id>].optionsin user settings and are read from user settings,--settings, or managed settings only; project and local entries are ignored since Claude Code 2.1.207. Use for endpoints, toggles, tokens, and personal path knobs. Thedirectory/filetype is a UI hint, not a validator — a--configvalue is stored verbatim with no existence check and no normalization to absolute (smoke-test A). - Tracked rich config under
${CLAUDE_PROJECT_DIR}. [first-party PRECEDENT; folder form is a PRECEDENT-EXTENSION] When configuration outgrows typed scalars — prose guidance, rule lists, threat models, structured rulesets — read a checked-in file instead of piling onuserConfigknobs. The proven shape is a single tracked file.claude/<plugin>.md(Markdown, for model-facing guidance) or.claude/<plugin>.yaml(structured rules), each with a gitignored*.local.*personal overlay and an optional~/.claude/<plugin>.mduser-global. The precedent is the official security-guidance plugin (https://code.claude.com/docs/en/security-guidance): it reads.claude/claude-security-guidance.md(project),~/.claude/claude-security-guidance.md(user), and.claude/claude-security-guidance.local.md(gitignored personal override), loading every location that exists and concatenating them. The folder form.claude/<plugin>/**(many files, one per concern) is the PRECEDENT-EXTENSION: the first-party precedent uses single files, so a plugin that needs a directory of config extends the shape by one increment, keeping the same overlay and resolution rules.- Concern-named folder for multi-plugin-consumed config. When a tracked-config concern is
consumed by MORE THAN ONE plugin, name the folder by the concern, not a plugin
(
.claude/<concern>/**) — plugin-naming would couple the other consumers and the consumer repo's tracked files to one plugin's name, and plugin boundaries are the volatile axis across restructures. A further one-increment PRECEDENT-EXTENSION; each instance records its schema and resolution rules as a versioned contract underdocs/conventions/<concern>/(template:docs/conventions/hook-telemetry/; first instance:docs/conventions/ecosystem-commands/; second instance:docs/conventions/topic-docs/). - Profiled folder for audience/deployment variants. When ONE plugin's tracked config varies by
audience or deployment — a different framing, ranking lens, or branding per team / client /
context — add a profile axis to the folder form. Files at
.claude/<plugin>/are the default profile; each.claude/<plugin>/<profile-name>/subfolder is a named profile that overlays the default per key (the same additive semantics the layering contract below fixes — a named profile refines the root, absent keys fall through). A single-config consumer never nests: its files sit at the root, which is the default profile, so growing a profile later is additive (drop a sibling subfolder), never a reorg — and there is no reserveddefault//team/name to collide with. Pick the active profile by the convention-resolution ladder: exactly one named profile subfolder present → use it; several → anactive_profileuserConfigscalar (seam 1) or a per-invocation--profile <name>argument selects; none → the root default. This is a one-increment PRECEDENT-EXTENSION of the folder form, for a plugin that could ever profile — ship the folder form, since the single-file form cannot grow a profile without a file→folder reorg. Distinct from the concern-named folder above: that splits config across plugins (concern axis); this splits it across audiences within one plugin (profile axis) — the two compose (.claude/<concern>/<profile-name>/). Reference adopter:ai-briefing. - Resolution + override semantics, overlay naming, and the recommended consumer
.gitignoreline are owned bydocs/conventions/config-cascade/— the layering axis is cross-cutting, so it is contracted once there rather than restated per seam. A surface declares its own keys and schema here or in its own owner doc, and points there for how its layers merge.
- Concern-named folder for multi-plugin-consumed config. When a tracked-config concern is
consumed by MORE THAN ONE plugin, name the folder by the concern, not a plugin
(
- Consumer
CLAUDE.md/.claude/rulessteering. [SPEC] A plugin's skill and agent components run in the model's context and already read the consuming project's own rules — the default surface for project conventions, naming, and policy, requiring no plugin-side wiring. (Hook scripts do not seeCLAUDE.md; they read env vars and file-based config only.) ${CLAUDE_PLUGIN_DATA}for machine state only. [SPEC] The per-plugin directory that survives updates — caches, installed dependencies, generated state. Never a channel for consumer configuration (it is machine-local and untracked); configuration flows through seams 1–3.
The adopted rule for how a plugin settles a value at runtime, applied to every seam:
- Config present → use it.
- Absent → explore the repo and infer, then persist the inference into tracked project config (seam 2) so the next run is deterministic. For a personal scalar (seam 1), direct the user to Claude Code's native plugin configuration surface instead.
- Cannot infer → ask the user, and offer to persist the answer.
- Otherwise → a safe generic default.
No baked repo assumptions, ever. A plugin never hardcodes a consumer's layout; it reads a declared value, infers-and-records, or asks — never guesses silently.
This ladder is the runtime application of the durable convention posture owned by PLUGIN-PHILOSOPHY.md § Two-lane convention posture: a pre-prescribed convention is a hardcoded dependency, so a plugin ships a default only in lane 1 (a good-practice value that cannot conflict in any consuming repo) and otherwise takes lane 2 — its setup discovers the consumer's convention and externalizes it as an extensibility point the ladder then resolves.
Whether a plugin needs a setup skill, and the uniform contract it follows (setup name,
disable-model-invocation: true, check + apply actions, non-interactive completion), is owned
by PLUGIN-PHILOSOPHY.md § Setup is explicit and repeatable. Migration work
applies it as-is. Playbook-specific additions: the Thariq config.json first-run pattern is
rejected for plugins — it is not an official mechanism, and it writes into
${CLAUDE_PLUGIN_ROOT}, which is replaced on every update (the plugins-reference caching note), so
its state does not survive. Setup writes only the consumer configuration the plugin owns; Claude
Code's native configuration surface collects userConfig and owns pluginConfigs — a setup skill
never edits that key directly.
A plugin that vendors or distills an upstream source — a docs site, a third-party playbook, a tool's
own documentation — carries a drift-check/update path: either an inline maintainer update action on
its skill or a dedicated update skill. A self-authored pack has no upstream to drift from; its update
path states "no upstream" and names the regeneration trigger instead (e.g. a model-version change).
Evals are model-graded behavior fixtures at plugins/<plugin>/skills/<skill>/evals/evals.json,
schema plugins/skill-quality/reference/evals.schema.json. They are warranted, not mandatory —
a skill ships them only when they earn their keep.
Warrant rule. A skill warrants evals when it carries a judgment-bearing behavioral contract
that could silently regress — how it triggers, how it routes an ambiguous request, when it refuses,
or the shape of what it emits. A skill is an explicit skip when it is pure-reference (answers
from a knowledge corpus with no decision contract — playbooks:fable-5, tdd, …). A hook plugin
is a skip only in the case its rationale actually describes — deterministic, silent-always-on,
guarded by .test.sh, no skill carrying a judgment-bearing contract. The condition is the
absence of that contract, not the invocation mode. Stating it by invocation mode does not work: a
setup skill sets disable-model-invocation: true, so "no model-invoked skill" is satisfied by a
plugin that ships one — admitting as a skip the very plugin the rest of this rule excludes. A setup skill makes interview and write-config decisions that can
silently regress, which is precisely the contract the skip exists to excuse the absence of. The
plugin's shape does not exempt it: a setup skill is warrantable (the codebase-health/setup
eval is the model). Gray-zone skills (thin mechanical wrappers, reference-ish
routers) are author-confirm: re-check the warrant against the live SKILL.md at authoring time
and record an explicit skip verdict if it dissolves — a satisfied "looks covered" is not a warrant.
This section is the policy; current coverage is verified on demand — a live glob of
plugins/*/skills/*/evals/evals.json against the tree, read against the warrant rule above — never a
checked-in snapshot that decays the moment a skill lands.
The gate honors a recorded skip. scripts/check-changed-skills.sh passes --require-evals for
every skill whose SKILL.md is new or modified, and plugins/skill-quality/scripts/check-skill.sh
then hard-FAILs on a missing evals/evals.json — unless that skill is listed in
scripts/evals-warrant-exemptions.txt. A skip becomes a reviewed, diffable line rather than an
implicit absence; anything unlisted still fails closed. The file is stale-guarded: a row whose
skill is gone, or that now ships evals/evals.json, fails the gate so the list can only shrink.
This is the Exit A decision from #3135 — the warrant rule above and the CI gate now agree.
Rich form. Each case carries id, a kebab-case name, a prompt, an expected_output
description, optional files fixtures, and an expectations array of objectively-verifiable checks
(the field may equivalently be named assertions — skill-creator upstream uses that name). Aim to
cover trigger/routing, the happy path, at least one refusal/guardrail, and one anti-pattern the skill
must not do.
Method source. The methodology behind this policy is Anthropic's "Define success criteria and
build evaluations" (indexed in OFFICIAL-DOCS.md;
the evals plugin distills it). The rich form is that guidance's eval anatomy with the golden
answer in its rubric-instructions form (expected_output + expectations are what a grader is
told to look for), and every case must carry one — the schema rejects a case with no
expected_output, expectations, or assertions, because a case that cannot be graded is not an
eval. Two deliberate divergences from the guidance, both consequences of the deferred runner
(medley#1418): case volume stays low (the guidance's volume-over-polish principle assumes cheap
automated grading, which does not exist here yet), and grading is a human judgment pass (the
method the guidance ranks last). Both revisit when the runner lands.
Which eval format this is, and why it is not claude plugin eval's. Two Anthropic-owned eval
formats exist and they are not the same. The one shipped here is skill-creator's:
evals/evals.json inside the skill directory, cases carrying id / prompt / expected_output /
files / expectations, which is why the schema's own description notes that upstream names that
last field assertions. It is the ecosystem-wide shape — a public code search returns thousands of
evals.json files in that form against a handful in any other. claude plugin eval consumes a
different layout (<eval dir>/**/case.yaml, or prompt.md plus graders/*.md, with
experimental.evals naming the directory). This repo has none of it, deliberately: the command is
early access and refuses to run (plugin eval is currently in early access), so adopting its
format would trade a corpus CI checks on every PR for one nobody here can execute. Adoption stays
deferred behind the same melodic-software/medley#1418 tracker as the runner; revisit when the
command leaves early access. The consequence for authors: no command in this marketplace and
nothing in this CI executes a prompt — the gates lint and schema-check them — so a case must be
readable and followable by a human or an agent working by hand, and must not depend on a runner
having been invoked. That is not the same as no runner existing: a consumer with Anthropic's
skill-creator installed can run these suites, which is the format's own runner and which stages a
case's files[] for it. So use files[] to declare fixtures and reference them by their documented
path; do not hand-roll staging inside the prompt string. A prompt that builds its own workspace is
neither followable by hand nor compatible with the runner that would otherwise stage it.
Consumer-verify recipe — "verify this plugin in MY repo". There is no first-party command that
executes model-graded evals today — automated eval running is a deferred surface (owned by
melodic-software/medley#1418); skill-quality only checks presence and schema, and it resolves
skills under ${user_config.skills_root} → ${CLAUDE_PROJECT_DIR}/.claude/skills only — it does
not discover an installed marketplace plugin's skills by plugin name. So the static checks below run
against the plugin's source tree, not against a bare /plugin install; the exercise step is the
part that runs against the plugin as you actually enabled it.
Steps 1-2 are source-tree verification: run them against a checkout of this marketplace with
<root> = plugins/<plugin>/skills. This is the source, not necessarily the version you have
enabled — installed plugins are copied to a version-keyed cache under ~/.claude/plugins/cache
(cache isolation; see "Cache isolation" and "Local development loop" below and the official plugins
reference "plugin caching and file resolution"), so after a marketplace update the source evals.json
can differ from the enabled copy. Step 3 (exercise) is the definitive as-enabled check because it runs
against the plugin you actually invoked. Then:
- Presence — confirm the file
<root>/<skill>/evals/evals.jsonexists. The static gate is only a partial signal:/skill-quality:check <skill>(checkis both the skill's leaf name and its default action, run withskills_rootpointed at<root>via/skill-quality:setup) flags a missing eval file only for action-router-shaped skills — its check fires on a## Actionsheading — so a warranted non-router skill (e.g.debug) passescheckwithout flagging the gap. Rely on the direct file check or the coverage snapshot, not a greencheck, to confirm presence. - Schema + quality lint —
/skill-quality:check validate-evals <skill>(sameskills_root) validatesevals/evals.jsonagainst the bundled schema, then runs the deterministic eval-quality lint (check-evals-quality.sh: duplicate case ids/names, unresolvablefilesfixtures, empty or vague grading criteria, advisory set-coverage warnings). Still static — it does not run the cases, and it treats an absent file as "not a failure", so it is a schema-and-content gate, not a presence gate. - Exercise (manual) — the real consumer check — enable the plugin in your repo (
/plugin install <plugin>@<marketplace>), then read the eval cases from the copy you actually enabled, not from<root>: the enabled version lives in the version-keyed cache under~/.claude/plugins/cache, and reading cases from a source checkout that has drifted from it would exercise the installed plugin against a different version's prompts/fixtures. To use the source evals as the enabled plugin instead, load that source directory with--plugin-dir(the local copy then takes session precedence — see "Local development loop" below). For each case paste itspromptinto a fresh session and read the result against that case'sexpected_output/expectations; cases with afileslist need those fixtures present relative to the skill directory. This is a human judgment pass, not an automated pass/fail, until the deferred runner lands — at which point it becomes a single command and this recipe is revised.
Separate plugin-owned logic from consumer-owned extension points:
- Plugin-owned scripts ship inside the plugin and run via
${CLAUDE_PLUGIN_ROOT}/scripts/(orbin/) — bundled and cache-isolated, never reaching outside the plugin directory. - Consumer-owned extension points are declared paths, not assumed layout: expose them through a
userConfigdirectoryoption or a tracked-config key with a conventional default (e.g.tools/). A plugin reaches the consumer's own scripts only through a path the consumer declared or the convention the plugin documents — never a hardcoded repo structure.
- A
versionbump inplugin.jsonis the only delivery vehicle. A consumer receives a change only after the plugin's semverversionincreases — the version is the update cache key, so an unbumped plugin never delivers, even when its files changed (see "Shared code across plugins" below). - Consumers update deliberately with
/plugin marketplace update <marketplace>, which refetches the marketplace. There is no silent auto-push of plugin changes to a consumer. - Breaking-change / changelog note per plugin. A version bump that changes behavior a consumer
depends on — a renamed option, a moved config path, a removed action — records the change in the
plugin's own changelog (a
CHANGELOG.mdin the plugin), so a consumer updating deliberately sees what shifted. A bump that adds a new trust surface additionally re-triggers the plugin-acceptance security review below.
The marketplace renames map is frozen-historical. Its twelve entries stay: a consumer whose
enabledPlugins still names a pre-rename plugin id resolves only through the map, and removing an
entry strands them. But nothing new is added to it. A rename from here on is a clean breaking change
carried by a version bump and a changelog note — the standing posture in
shadowed-skill-renames — so the map records migrations
already shipped rather than serving as the go-forward mechanism.
For a marketplace registered with a directory source (a local clone or a repo-relative path in
checked-in settings), the installed plugin cache is keyed by the semver version in
plugin.json, not by the git commit SHA. Claude Code records the commit at install time in
installed_plugins.json, but the cache directory name is only <version> — so a later commit under
the same version does not replace the snapshot.
That bites the normal PR shape here: a branch lands several commits under one version bump (review fixes before merge, audit follow-ups, and the like). Whoever installed on the branch's first commit keeps that snapshot until the version changes. Every later commit under the same version is invisible to installed sessions — including corrections that would otherwise be live after merge.
claude plugin update <name>@<marketplace> compares version numbers only. When the marketplace
ref and the cache both read 0.7.0, update reports success ("already at the latest version") and
copies nothing — a false green that confirms the wrong state while the recorded SHA lags the source.
Workarounds (until upstream fixes this — melodic-software/claude-code-plugins#2061):
- Force a fresh snapshot:
claude plugin uninstall <name>@<marketplace> --keep-datatheninstallagain, thenenable—uninstalldrops enabled state, so skippingenableleaves the plugin silently absent rather than silently stale.--keep-datakeeps${CLAUDE_PLUGIN_DATA}only; uninstall still drops the storedpluginConfigsentry, so options return to manifest defaults on reinstall. Omitting the flag would also destroy the data directory. - Ship a version bump when the merged result must reach consumers — the only delivery vehicle for marketplace installs (see bullets above).
- Local iteration:
claude --plugin-dir ./plugins/<name>loads the working tree and takes session precedence over the cached install (see "Local development loop" below) — no reinstall needed for same-session edits after/reload-plugins.
Creation and update delivery are above; this is the third move. Retirement is one PR that removes
both halves at once — the plugin's .claude-plugin/marketplace.json catalog entry and its whole
plugins/<name>/ directory. The symmetry is not a convention to remember:
scripts/check-plugin-manifest-presence.sh runs FORWARD (a catalog entry whose directory has no
readable plugin.json) and INVERSE (a plugins/*/ directory no catalog entry names), so removing
either half alone fails the gate. Regenerate the derived surfaces in the same PR
(node scripts/generate-catalog.mjs, node scripts/generate-cheatsheet.mjs) and sweep the repo for
references to the dead plugin id.
The plugin's CHANGELOG.md goes with its directory — check-changelog-parity.sh --check-preserved
deliberately exempts a changelog whose directory is also gone, reading it as a removal rather than
an absorbed section — so the retirement is recorded in the PR body, not in a changelog nobody
can read afterward. A final version bump is pointless: there is no artifact left to deliver.
Consumer guidance to state in that PR body:
- Installed copies keep working. The install is a version-keyed local snapshot — the same property "Same-version commit drift" documents above — so a consumer who already installed the plugin keeps it until they uninstall; retirement removes future installs and updates, not the copy on disk.
- Consumers should drop the plugin's
enabledPluginsentry, which now names a plugin the marketplace no longer publishes. - No tombstone and no
renamesentry. The map is frozen-historical (see "Version pinning and update delivery" above), and a retirement has no successor id to point at anyway; if the capability moved into another plugin, say which one in the PR body and in the surviving plugin's changelog.
A skill is a markdown prompt (plus optional scripts), not a compiled runtime — so ports / adapters / CQS layering is a category error here. Expose variability the way real plugins and the extensibility model above already prescribe:
- Persistence. Write generated state and caches to
${CLAUDE_PLUGIN_DATA}(the per-plugin directory that survives updates — see the extensibility table). Choose JSON / JSONL / SQLite per need. - Configurable location or behavior. One
userConfigknob (${user_config.KEY}, per the extensibility table) with a sane default — never a consumer-bound interface. Add a knob only where a real repo-specific behavior surfaces (Rule of Three; no speculative knobs — see the design charter). - External systems (issue trackers and the like). Use backend-neutral "work item" vocabulary
plus either a direct CLI call (e.g.
gh) or dependence on an MCP server — swapping the backend means swapping the MCP server, not introducing a pluggable-tracker abstraction (every official integration is a bare MCP wrapper). - Cross-skill references. Hand off through the slash invocation when the target skill is present; degrade gracefully to prose when it is absent.
This is deliberately not ports / adapters: there is no runtime seam to invert in a prompt medium, so a declared config surface, not an abstraction layer, is the extension point.
A plugin can ship MCP servers via .mcp.json at the plugin root (or an mcpServers key in
plugin.json), across all transports — stdio, HTTP, SSE, WS
(plugins-reference, MCP servers). Those servers
auto-connect when the plugin is enabled (managed through plugin install, not a second /mcp
approval) and appear as standard tools. The connect cost differs by transport: a stdio server
costs a local process spawn on every session that enables the plugin, used or not; an HTTP/SSE/WS
server spawns no local process but still auto-connects (its trust prompt + tool-schema context cost).
Tool-search deferral hides the tool schema from context until first use but does not defer the
stdio spawn or the connect. That auto-start cost is why the default is not to ship MCP: exactly
one marketplace plugin ships one (miro, the dedicated Miro board capability — below), and the
discriminator below keeps it rare, reserved for a plugin genuinely useless without its server. A
credentialed SHIP additionally ships defaultEnabled: false, so its server does not auto-start for
consumers who never opt in.
Uniform discriminator — apply to every server, no exemptions:
-
CLI covers the skill's need → CLI-first. The plugin depends on the CLI (with documented install/setup — the binary is on PATH, not bundled: e.g.
npm install -g ctx7,npm install -g firecrawl-cli,playwright-cli) and drops the MCP dependency; a CLI-first migration MUST carry that install guidance or the plugin breaks on a machine without the CLI. Token-economics precedent (results pipe to disk instead of flooding context): context7 (ctx7), playwright (playwright-cli— Microsoft-recommended, ~4× fewer tokens), firecrawl (firecrawl-cli), ccusage (ccusage daily|monthly|session|blocks --json— same token/cost breakdown as the MCP, ccusage json-output). -
No CLI + plugin is useless without the server → SHIP. Bundle it and map each secret to
userConfigsensitive(below). "Useless" is a high bar met by a dedicated server-wrapper plugin (its entire capability is the server); a plugin that runs in a reduced mode without the server is degraded-but-functional (rule 3), not a SHIP. A stdio SHIP owns its spawn: annpxcommand needs acmd /cwrapper on Windows (#58510 below), so prefer invoking a bundlednode <server>— it sidesteps #58510 entirely. Two bundling mechanisms, ratified by themiroSHIP (the first instance, below):- Single self-contained bundle (preferred). An esbuild bundle
of the TypeScript source and every runtime dependency into one
dist/index.min.js— no shippednode_modules, so noNODE_PATH. The source is the source of truth; the.min.jsis committed generated output (plugin install runs no build step), and a CI lane rebuilds it from source with the pinned toolchain, fails on drift, and runs the artifact over stdio so a bundle that compiles but cannot serve MCP is caught in CI..min.keeps the generated bundle out of the text-quality lanes (typos, editorconfig). - Committed
node_modulesunder${CLAUDE_PLUGIN_DATA}(fallback). For a server that cannot be single-file bundled, ship itsnode_modulesand setenv.NODE_PATH: "${CLAUDE_PLUGIN_DATA}/node_modules"(the persist-deps example in plugins-reference) or it fails at startup withMODULE_NOT_FOUND.
A SHIP that connects to a credentialed external service ships
defaultEnabled: false— it installs disabled and the consumer opts in, so enabling the marketplace does not auto-start a credentialed server for users who never asked for it. - Single self-contained bundle (preferred). An esbuild bundle
of the TypeScript source and every runtime dependency into one
-
No CLI + plugin is degraded-but-functional without it → STAY repo-level. The skill NAMES the dependency and the consumer provides the server in their own
.mcp.json; the skill degrades gracefully or loads the tool viaToolSearchwhen present. This is the extensibility model's "swap the MCP server, not a pluggable abstraction" — declare the dependency, don't fork. -
Medley-/infra-bound server (no general-purpose plugin, or repo-coupled identity) → STAY repo-level. Not a plugin concern.
Secrets → userConfig sensitive seam. A SHIP declares each secret as a userConfig entry with
sensitive: true (masked input; macOS Keychain storage, or ~/.claude/.credentials.json where no
supported keychain exists) and substitutes it as ${user_config.KEY}
— but where it goes depends on transport: a stdio server takes it in .mcp.json env, while
a remote HTTP/SSE/WS server takes it in headers / headersHelper (env only reaches a spawned
stdio process, so an HTTP key placed in env never authenticates). medley's own config shows the
split: context7/ref are HTTP and pass their key via headers (x-api-key / x-ref-api-key),
whereas a stdio server like perplexity uses env. Keychain storage is shared with OAuth tokens
(~2 KB total) — keep values small. Mapping for the credentialed servers below: MIRO_API_TOKEN →
miro_api_token (stdio, env), PERPLEXITY_API_KEY → perplexity_api_key (stdio, env),
REF_API_KEY → ref_api_key (HTTP, headers), CONTEXT7_API_KEY → context7_api_key (HTTP,
headers). Infra/medley-bound secrets (AZURE_*, AZURE_DEVOPS_PAT, GITHUB_EVENTS_SECRET) do not
map — those servers stay repo-level.
The medley launcher stack — only its Node-pinning layer is medley-local. medley's
fnm exec + tools/mcp-launcher/launcher.js stack solves two problems that generalize differently:
- GUI-host Node PATH via
.nvmrcpinning — medley-local. A plugin does not need it; bundle assets via${CLAUDE_PLUGIN_ROOT}(+${CLAUDE_PLUGIN_DATA}for a built server'snode_modules). - Windows bare-
npxspawn ENOENT— a general plugin problem, still open. Plugin-shipped stdio MCPs that spawnnpxfail on native Windows until wrapped withcmd /c(anthropics/claude-code#58510 — OPEN; the LSP spawn fix #17312 never reached the MCP spawn path). Do not assume the plugin runtime wrapsnpxfor you: a SHIP that runsnpxmust ship its owncmd /cwrapper, while a SHIP that runs a bundlednode <server>sidesteps the bug entirely.
So the .nvmrc/fnm layer stays medley-bound, but the Windows-npx concern travels with any
npx-spawning SHIP.
Decision table — medley .mcp.json (14 servers, audited 2026-07-12). Verdict is plugin-carry,
not "is the server useful". enabled/disabled = medley .claude/settings.json
enabledMcpjsonServers/disabledMcpjsonServers at audit time.
| Server | Transport | Secret | Verdict | Basis |
|---|---|---|---|---|
| miro | stdio (bundled) | miro_api_token (sensitive) |
SHIP (cutover+bundle) | Owner-confirmed 2026-07-12: ships as a dedicated miro plugin whose whole capability is the Miro board server, so it is useless without the server (rule 2), not event-storming's optional dependency (event-storming stays degraded-but-functional and ships no server, consuming miro only when connected). The server's TypeScript relocates out of mcp-servers/miro/node into plugins/miro (single source of truth, no copy left behind), bundled to one dist/index.min.js invoked as node ${CLAUDE_PLUGIN_ROOT}/dist/index.min.js (sidesteps #58510); MIRO_API_TOKEN → userConfig miro_api_token (sensitive, Claude secure credential storage); defaultEnabled: false so it never auto-starts unasked. First instance of the SHIP convention |
| aspire | stdio (aspire native) |
— | STAY | medley .NET Aspire orchestration; no general-purpose plugin; infra-bound |
| azure | stdio | AZURE_CLIENT_SECRET… |
STAY (disabled) | Infra opt-in; disabled (auth-isolation issues); not a plugin concern |
| azure-devops | stdio | AZURE_DEVOPS_PAT |
STAY (disabled) | Infra opt-in PAT workflow; disabled; work-item tooling uses gh, not ADO |
| ccusage | stdio | — | STAY | Live consumer /claude-ops:claude-observability; CLI covers the need (rule 1) and claude-ops is multi-skill — shipping would spawn it for changelog/troubleshooting sessions. CLI-first is the preferred future direction |
| chrome-devtools | stdio | — | STAY | Ad-hoc browser/debug; stateful; no migrating plugin structurally requires it (degraded-but-functional) |
| context7 | http | CONTEXT7_API_KEY |
STAY (CLI-first) | context7 plugin ships ctx7; HTTP MCP kept repo-level as fallback |
| github-events | stdio (repo-built) | GITHUB_EVENTS_SECRET |
STAY | Repo-local broker; stateful activeFilter; repo identity via CLAUDE_PROJECT_DIR — not repo-agnostic |
| microsoft-learn | http | — | STAY | /discovery:research + .NET docs; no plugin structurally requires it; degrades to WebSearch/WebFetch |
| nuget | stdio (dotnet dnx) |
— | STAY | /packages + .NET; no dotnet/packages plugin in the locked slugs; .NET-scoped |
| openai-developer-docs | http | — | STAY | codex/OpenAI research; degraded-but-functional |
| perplexity | stdio | PERPLEXITY_API_KEY |
STAY | /discovery:research + ai-briefing; multi-consumer, degrades gracefully — shipping would auto-spawn for all discovery sessions |
| playwright | stdio | — | STAY (CLI-first, disabled) | playwright plugin ships @playwright/cli; MCP disabled in medley in its favor |
| ref | http | REF_API_KEY |
STAY | /discovery:research doc search; degraded-but-functional |
SHIP: 1. STAY: 13. DROP: 0 — only miro clears the SHIP bar, and only once reframed as its own
dedicated plugin (rule 2). The other 13 are CLI-first, degraded-but-functional (their consumer plugin
already runs without them), or infra-bound. Every STAY server has a live consumer; the three disabled
entries are deliberate documented opt-ins, not dead servers. firecrawl already migrated to
firecrawl-cli (absent from .mcp.json) — it confirms rule 1 rather than being a 15th row.
miro was the closest call and initially landed STAY when weighed as event-storming's optional
dependency. The owner's 2026-07-12 direction reframed it: the Miro board capability becomes a
dedicated miro plugin, and a dedicated server-wrapper plugin is useless without its server
(rule 2 → SHIP). The original STAY objection — that bundling would auto-start a credentialed MCP for
every event-storming session — is dissolved by defaultEnabled: false (the plugin installs disabled;
event-storming keeps its structured-markdown default and consumes miro only when a consumer opts in).
The mechanism is cutover + bundle: relocate the server's source into the plugin (the playbook's
reintegration end-state — the repo drops its in-repo copy), single-file esbuild bundle, node <server>
over stdio — no npm/registry publish, no consumer token wall, no npx (#58510).
§2 first-party trust accept (miro SHIP). Recorded here as the single SSOT per the security review:
- Vendor / provenance. First-party — a thin wrapper (authored in-house) over Miro's official REST
API client (
@mirohq/miro-api);plugin.jsonauthor= Melodic Software. Not a third-party remote MCP (Miro's ownmcp.miro.comwas rejected — no board-delete tool, and a third-party remote-egress acceptance the playbook denies by default). - Transport. Local
stdio— a per-sessionnode dist/index.min.jsprocess; no listening port, no auto-connect to any remote MCP host. - Data egress. Only the Miro REST calls the consumer's own tool invocations make, to
api.miro.com, authenticated by the consumer's own token. No telemetry, no other outbound network. - Token scope.
MIRO_API_TOKEN→userConfigmiro_api_token,sensitive(macOS Keychain, or~/.claude/.credentials.jsonwhere no supported keychain exists; neversettings.json); the consumer supplies and scopes it. The server exits at startup if unset. - Opt-in.
defaultEnabled: false— installs disabled; the consumer enables it deliberately.
Consuming a sibling plugin's MCP tools (first instance: event-storming → miro). When plugin A's
skill drives plugin B's bundled MCP server, three rules hold:
- Namespaced tool names. A plugin-bundled server's tools are callable as
mcp__plugin_<plugin>_<server>__<tool>— formiro,mcp__plugin_miro_miro__miro_create_board(mcp reference). A baremiro_*name, or a bare-server-keymcp__miro__…, does not resolve for a plugin-bundled server. Any declarative reference (a skill'sallowed-tools, a permission rule, a subagenttoolsfield, a hook matcher) MUST use the full prefixed form; a matcher against the bare server key never fires. In model-facing prose the runtime resolves the tool the model actually calls, so a skill may name tools by their bare conceptual<tool>provided it states once that those names denote the provider's tools under themcp__plugin_<plugin>_<server>__prefix (simulationdoes this in its availability gate). - Availability gate probes the prefixed form. The consumer detects the capability by checking a
prefixed tool (
mcp__plugin_miro_miro__miro_list_boards), not a bare name — otherwise the gate can never fire and the consumer silently stays in its degraded default forever. - Soft dependency, never bundle-or-fork. The consumer does not bundle the provider's server nor hard-depend on it: it keeps its no-server default (here structured-markdown), documents that the enhanced path requires the separately-enabled provider plugin, and degrades gracefully when the provider's tools are absent. This is the cross-plugin form of the "swap the MCP server, not a pluggable abstraction; declare the dependency, don't fork" rule above.
Catalog these per migration; they are the usual failures when an in-repo skill becomes a plugin.
- Cache isolation. Installed plugins are copied to
~/.claude/plugins/cache. Any reference to files outside the plugin directory (../../tools/...,.claude/rules/...) breaks. Fix: bundle dependencies inside the plugin and reference them via${CLAUDE_PLUGIN_ROOT}; persist state via${CLAUDE_PLUGIN_DATA}. - Namespacing. Components are namespaced by the plugin's own
name, not the marketplace name — an in-repo/foobecomes/<plugin-name>:foo. Internal cross-references to the bare name break — update them. - Agent shadowing. Project/user
.claude/agents/override same-named plugin agents. A leftover in-repo copy masks the plugin version until removed from the source repo. - Headless registration. Distinguish the marketplace source from the session shape:
- Remote or git-sourced catalogs (GitHub repo, URL, npm, …) in CI or other non-interactive
runs: no interactive trust dialog — run
claude plugin marketplace addexplicitly or pre-seed viaCLAUDE_CODE_PLUGIN_SEED_DIR(Plugin marketplaces — Pre-populate plugins for containers, fetched 2026-08-12). directory/filesource with a relative path in checked-in project.claude/settings.json: the path resolves against the repository checkout, including cloud sessions that install from the clone at session start — no separatemarketplace addstep. Local collaborators still see the interactive trust prompt once they trust the folder. Seedocs/CLOUD-SESSIONS.md"Plugins in sessions on this repo".
- Remote or git-sourced catalogs (GitHub repo, URL, npm, …) in CI or other non-interactive
runs: no interactive trust dialog — run
For each skill/hook/agent being migrated:
- Research fresh. WebFetch the official docs for every component involved (see
CLAUDE.md). - Scope one capability. One cohesive plugin; no grab-bags. If the source is oversized, run "Decompose an oversized skill before packaging it" and retain its trigger-migration evidence.
- De-couple from the source repo. Remove hardcoded paths/names; route project-specifics to the consumer's context.
- Bundle + isolate. Move required assets inside the plugin; reference via
${CLAUDE_PLUGIN_ROOT}. - Expose extensibility. Declare
userConfigfor consumer choices; document each option. Apply the userConfig full-potential criterion and the exec-form hook rule from PLUGIN-PHILOSOPHY.md § Configuration ownership and scope: no custom config channel where the native schema fits, and no${user_config.*}in shell-form hooks. - Strip PII / secrets. Hard gate — before the first commit.
- Check component stances. Every component the plugin ships conforms to the component stance
table in PLUGIN-PHILOSOPHY.md — no
commands/, no unjustifiedsettings.jsonagent, wait-listed components absent; setup criteria applied per its setup section; runtime prerequisites degrade per its failure-behavior rules. - Idempotent, modular, extensible. Re-running is safe; pieces compose; variability is declared.
- Validate.
claude plugin validate; test with--plugin-dirin a clean repo that is NOT the source repo (proves repo-agnosticism). - Version. Set an explicit semver
versioninplugin.json. A later bump that changes behavior a consumer depends on records the change in the plugin's changelog — see "Version pinning and update delivery" above. - Publish. Add the entry to
.claude-plugin/marketplace.json— the pluginsourceis the./-prefixed relative path (e.g../plugins/<name>). Bare names failclaude plugin validate --stricteven withmetadata.pluginRootset, despite the marketplaces-doc example to the contrary (verified 2026-06-23). Then runclaude plugin validate --strict <repo-root>to validate the catalog manifest itself — a bad entry surfaces only there, not in per-plugin validation. The catalog page (docs/CATALOG.md) regenerates from the manifests — runnode scripts/generate-catalog.mjs.
Seams first (Fowler's Branch by Abstraction). Establish the shared foundations — the conventions in
this playbook, the shared lib/ source of truth (see "Shared code across plugins"), and the
persistence/config pattern above — in one small sequential PR before fanning out. Everything downstream
builds on those seams.
Per-unit atomic PRs, authored in parallel. One PR per cohesive migratable unit: small changesets
review faster and more thoroughly, and the per-unit acceptance gate makes each one atomic and
rollback-safe. Parallelize with one worktree (or worker) per unit — conflict-free by design, because
each plugin is an isolated plugins/<name>/ directory, so N units become N concurrent PRs with no merge
conflicts. Group units into one PR only when they are hard-coupled, or when the change is a single
mechanical bulk edit.
Expect one shared-file conflict, resolved at merge. The two files parallel PRs all touch are the
catalog manifest (.claude-plugin/marketplace.json) and the generated catalog page (docs/CATALOG.md). Those conflicts are
expected — resolve them by serializing the final merges, not by serializing authorship.
Gate every unit before publish. Each unit clears its parity / acceptance gate — the per-plugin migration gate and the plugin-acceptance security review below — before it ships.
Sequence heuristic. Order lowest-coupling units first (clean, self-contained units with graceful degradation). Defer risky units (hard external dependencies, no graceful degradation) and any license-gated units to per-item triage rather than a blanket hold. The ordering is reversible.
Swim-lane execution (orchestrated fan-out). When an orchestrator drives several units to merge in
one effort, each unit is a swim lane: a dedicated worktree (created under the same
identity-scoped directory root as the primary checkout, so the repo's commit/push identity applies —
never a sibling path outside it), a feature branch named <type>/<issue>-<slug>, its own atomic PR
that closes exactly one issue, driven independently through CI to a clean merge, then post-merge
cleanup (delete the branch, remove the worktree). A seams-first unit whose contract binds
downstream lanes lands and merges before the dependent lanes open, so they build on the merged
contract rather than rediscovering it. Independent lanes run concurrently; lanes sharing a
contract-blocking dependency wait on its merge. The shared-file conflicts above are still resolved by
serializing the final merges, not authorship.
A plugin runs code on the consumer's machine and can wire Claude to external systems. Every plugin accepted
here — new, or a version bump that adds a trust surface — passes this review in addition to the migration
gate above (whose step 6 gates PII/secrets). Deny by default any surface below that can't be justified.
Facts verified against the plugins/MCP reference 2026-07-09 and re-verified against the plugins,
plugins-reference, and hooks pages 2026-07-17; re-verify per the CLAUDE.md fresh-docs mandate.
- Code execution — hooks & scripts. A hook command runs on the consumer's machine on matched events,
with
${CLAUDE_PLUGIN_ROOT},${CLAUDE_PROJECT_DIR},${CLAUDE_PLUGIN_DATA}, and any${ENV_VAR}interpolated in. An exec-form hook may also use${user_config.*}in its arguments. A shell-form hook rejects that substitution and must readCLAUDE_PLUGIN_OPTION_*from the hook process environment. Check: which binaries it spawns; whether it mutates files in place and is advisory (exits 0, never blocks) vs gating; noeval/curl … | sh/ outbound network; untrusted input (file contents, tool args, PR/issue text) never flows unquoted into a shell; a kill switch (a per-hookuserConfigboolean with adefaultoftrue) exists.- A skill's frontmatter
allowed-toolsis a prompt-free execution grant, and workspace trust does not gate it. Measured on Claude Code 2.1.225: a marketplace-installed skill'sallowed-toolsentry takes effect at user scope in a never-trusted workspace, under-pwhere no trust dialog can appear — the covered command ran without a prompt, the uncovered one blocked withThis command requires approval, and a no-grant baseline confirmed that shape blocks. Bounds on the measurement: a local-directory marketplace, and user scope only. Consequence: the install-time plugin trust prompt is the only gate in front of such a grant — there is no second, per-workspace one. Review everyallowed-toolsentry with the scrutiny a hook command gets, and deny by default anything broader than the specific command the skill's own scripts invoke. A wildcard interpreter grant (Bash(python*),Bash(*), bareBash) is a deny outright: it is arbitrary code execution in a workspace the consumer never trusted.claude-config:audit-permission-grantscheck P1 detects exactly these shapes and is the mechanical half of this criterion.
- A skill's frontmatter
- MCP servers —
.mcp.json/ inline inplugin.json.mirois the only plugin that ships a localstdio, bundled server (see its §2 trust accept above);dometrainis the only plugin that ships a remote server (see its review record below), which remains the higher-scrutiny case. A plugin's MCP server starts automatically when the plugin is enabled (subject to per-server approval), unless it shipsdefaultEnabled: false. Check: the server host/URL and who runs it (first-party vs a third party you're delegating trust to); transport (localstdiovs remotehttp/sse/ws); what data leaves the machine — a remote server receives whatever Claude sends and, if it returns external content, is a prompt-injection vector (official guidance: "Verify you trust each server before connecting it"); auth shape (header/Bearer/OAuth) with any token sourced fromuserConfigsensitiveor an env var, never hardcoded; a stated reason the capability can't be a localstdioserver. Do not accept a third-party remote MCP server without an explicit recorded trust decision naming the vendor, the data egress, and the token scope. - Consumer config —
userConfig. Any credential/token option MUST set"sensitive": true— that masks input and stores the value in the macOS Keychain or, on platforms without a supported keychain,~/.claude/.credentials.json— notsettings.json. Non-sensitive values land in usersettings.jsonunderpluginConfigs[<id>].optionsand are readable — never put a secret there. Claude Code reads this key from user settings,--settings, and managed settings, not project or local settings. Endpoints and toggles are fine as non-sensitive. Every option is documented. - Cache isolation — no reach-outs. References only files inside the plugin via
${CLAUDE_PLUGIN_ROOT}; persists state in${CLAUDE_PLUGIN_DATA}. No../reach-outs, no constructed absolute paths, no reading consumer repository files outside${CLAUDE_PROJECT_DIR}.- The operator's own
~/.claude/is not consumer repository data. Reading a documented user-global config file there is sanctioned rather than a reach-out: criterion 3 above already stores consumer credentials at~/.claude/.credentials.json, and seam 2 mandates an optional~/.claude/<plugin>.mduser-global layer — a criterion that forbade the read would contradict both. Read only the documented path for the plugin's own declared config; anything broader is a reach-out again. What this criterion targets is a plugin wandering out of the repository it was pointed at, not the operator's own Claude Code home.
- The operator's own
- Data egress — telemetry & network. Any telemetry (e.g.
HOOK_TELEMETRY_SINK) is opt-in (unset = exact no-op), never writes to the hook's stdout/additionalContextchannel, and emits only the declared envelope — no payload beyond the documented schema. Name any other outbound network call and justify it. - Provenance & third-party trust. Verify authorship (does
plugin.jsonauthormatch who actually submitted the PR?), license, and that the source is what it claims. A plugin that promotes or wires a third-party SaaS is a trust delegation — record accept/deny with rationale. Note the platform already blocks plugin-shipped agents from declaringhooks/mcpServers/permissionMode"for security reasons" — don't design around that.- An
archivemarketplace entry MUST carry itssha256pin. A catalog entry may set"source": "archive"with aurland an optionalsha256, installing the plugin from a zip downloaded over HTTPS with no git or npm on the consumer's machine (plugin-marketplaces, fetched 2026-08-10; requires Claude Code v2.1.224 or later, and on v2.1.120–v2.1.223 the install fails while on older versions "a marketplace containing anarchiveentry fails to load entirely"). The platform's own floor is transport-level only:urlis "Required. HTTPS URL of the zip archive. Claude Code rejectshttp://URLs, along with loopback, link-local, and cloud-metadata hosts. Every redirect hop must satisfy the same rules". Content identity is not in that floor — thesha256field is documented as "Optional", so an unpinned entry lets the same URL serve different bytes on every install with nothing to detect it. That is a mutable-remote-artifact surface, which criterion 6 denies by default, so this review requires the pin: anarchiveentry withoutsha256is a deny, whether this repository publishes it or accepts a plugin that depends on it. With the pin, "Claude Code verifies every download against it and refuses the install on a mismatch". Two follow-ons to record when accepting one: the digest doubles as the plugin's version when neitherplugin.jsonnor the entry declares one, so a repinned archive still needs itsversionbumped or "users keep the cached copy"; and organization distribution through claude.ai admin settings does not accept this source at all — "Plugin sources of typegithub,url, andgit-subdirare supported.npmandarchivesources are not." Enforced byscripts/validate-plugin-contracts.mjsover.claude-plugin/marketplace.json. This marketplace publishes every plugin as a relative path ("source": "./plugins/<name>"), so no entry usesarchivetoday; the rule governs the first that does.
- An
- Main-thread and PATH surfaces. A plugin
settings.jsonagententry takes over the consumer's main thread — prohibited by default per the component stance table in PLUGIN-PHILOSOPHY.md; an exception requires the documented justification the stance demands, reviewed here.bin/executables join the Bash tool'sPATHwhile the plugin is enabled: names must be collision-safe (plugin-prefixed), and each binary's provenance is reviewed like any hook script.
Record accept/deny + rationale for any plugin touching surfaces 2, 5, 6, or 7; a later version bump that introduces a new surface re-triggers this review.
Recorded here as the single SSOT (miro §2 precedent). Reviewed at 0.1.0; a version bump adding a
new trust surface re-triggers this review.
- Code execution (1). No hooks, no scripts wired to any event. The plugin ships prompt
artifacts only (skills + reference markdown) plus
github.test.sh, a repo-CI contract test referenced by nothing in the manifest — inert in a consumer install. - MCP servers (2). None.
- Consumer config (3). One
userConfigboolean (offer_browser_automation, non-sensitive, documented, defaulttrue). No credential options: authentication stays entirely in the consumer's ownghCLI login — the plugin never prompts for, stores, or transports a token. Consumer-side routing/conventions files live at the documented.claude/github/project layers and~/.claude/github/user-global layer (sanctioned read per criterion 4's operator-home carve-out). - Cache isolation (4). All intra-plugin references anchor at
${CLAUDE_PLUGIN_ROOT}(skills) or resolve inside the plugin directory (reference cross-links). No../reach-outs beyond the plugin root, no constructed absolute paths. - Data egress (5). Three channels, all consumer-initiated, no telemetry:
api.github.comvia the consumer's ownghauth. Bare invocations are read-only by written contract (write-capability guard: no field/input flags, no non-GET method, no GraphQLmutation); writes exist only behind--apply→ consumer-declared routing → per-step user confirm naming the exact command and its doc provenance.- Official GitHub docs (
docs.github.comet al.) runtime fetches for grounding — the D4 zero-vendored-knowledge posture; read-only, with a fetch-integrity rung and a refuse-recall-as-grounded branch. - Browser automation over the consumer's authenticated GitHub session — the heavy surface,
accepted with layered gates: presence-gated (claude-in-chrome tool probe / playwright
plugin-installed seam), never auto-fires, each action individually offered and confirmed
with the resolved settings URL, intended action, and mechanics provenance; post-write
read-back verification where an API read exists; guided-manual + deep-link fallback always
available.
offer_browser_automation: falsesuppresses the offer — recorded honestly as an advisory, model-honored gate layered under the per-action confirm, not a runtime-enforced kill switch; the hard gate is the per-action user confirm. Accept rationale: some org-admin surfaces are UI-only, the session and credentials remain the user's own, and every action is user-in-loop; denying the surface would only push users to unassisted manual clicking with no provenance trail.
- Prompt injection via ingested GitHub content (explicit item). Everything fetched from GitHub
(repo names, descriptions, issue/PR bodies, webhook URLs, custom property values) is declared
untrusted data, never instructions as a standing instruction in both ingesting skills
(
audit,advise); an injected instruction must not trigger a write, browser action, or routing change. Evidence: anti-pattern eval cases (audit id 5, advise id 9). Defense in depth: every write path is already user-in-loop, so a successful steer still lands on a human confirm. - Provenance & third-party trust (6). First-party authored (
author= Melodic Software), MIT. No third-party SaaS delegation: the only vendor wired is GitHub itself, reached through the consumer's pre-existingghrelationship. - Main-thread / PATH (7). No
settings.jsonagent, nobin/.
Verdict: ACCEPT — surfaces 1/2/7 absent; 3/4 conform; 5's browser-automation channel accepted with the layered gates above; 6 first-party.
Reviewed at 0.1.0; a version bump adding a new trust surface re-triggers this review.
- Code execution (1). None — no hooks;
sync/scripts/update.shis not wired to any event and is not model-reachable:sync/SKILL.mdcarriesdisable-model-invocation: true, so it runs only on a maintainer's explicit/dometrain:syncinvocation. - MCP servers (2). The remote server itself: third-party (Dometrain-hosted),
httptransport, Bearer auth viauserConfig.dometrain_api_key(never hardcoded),defaultEnabled: false. Data egress / prompt-injection: search queries and lesson IDs are sent tomcp.dometrain.com; responses are curated lesson text, which IS a genuine indirect-prompt-injection surface — the risk is that returned text could steer Claude's use of other tools already in the session (Bash, Write, other MCP servers), not whether Dometrain's own tools are mutating (they are all read-only). Mitigated the same way this repo'sgithubplugin already accepts this class of risk (§740–745):grounding/SKILL.mdcarries a standing instruction treating allsearch_dometrain/search_code/get_lessonresults as untrusted reference data, never instructions, backed by an anti-pattern eval case — an advisory, model-honored defense, not a runtime-enforced one, stated honestly as such rather than implied to be stronger than it is. Explicit trust decision: ACCEPT, third-party, rationale = read-only course-content grounding, no destructive tool surface, user's own paid-subscription-scoped token,defaultEnabled: false, standing untrusted-data instruction. - Consumer config (3). One sensitive required
userConfigstring (dometrain_api_key), documented. - Cache isolation (4). All skill/script paths resolve via
${CLAUDE_PLUGIN_ROOT}-relative or script-own-location-relative paths (matchingcontext7's pattern); theupdate.shupstream fetch reachesraw.githubusercontent.com, a documented, justified outbound call (criterion 5), not a../reach-out. - Data egress (5). Two channels: (a) the MCP server itself, covered under (2); (b)
sync/scripts/update.sh's fetch of Dometrain's public GitHub-raw skill content — read-only, and never model-reachable:sync/SKILL.md'sdisable-model-invocation: truemeans only a human explicitly running/dometrain:syncfires it, never the model on its own initiative and never from the installed plugin cache absent that explicit human action. No data leaves beyond the anonymous GET itself. No telemetry. - Provenance & third-party trust (6). First-party plugin manifest/config (Melodic Software
authored), but it wires TWO third-party trust surfaces: Dometrain's MCP server (the primary
trust delegation, covered under (2)) and Dometrain's own public skill content as a
vendored/reviewed text dependency (covered under (4)/(5)) — every sync is human-reviewed
before a baseline refresh, never auto-applied, so the trust surface is bounded by that review
gate, not blind ingestion. Note: this plugin's
grounding/syncskill split is a stronger enforcement of that boundary than this repo's existingcontext7:lookupprecedent, which bundles an equivalentupdateaction into a model-invocable skill — a pre-existing gap flagged during this review, not remediated here, tracked separately. - Main-thread / PATH (7). None; no
settings.jsonagent, nobin/.
Verdict: ACCEPT — surfaces 1/7 absent; 2 accepted with the stated third-party rationale; 3/4 conform; 5 bounded to two justified, non-telemetry channels; 6 dual third-party surfaces both gated (credential scope + human-reviewed sync).
Reviewed at 0.1.0; a version bump adding a new trust surface re-triggers this review.
- Code execution (1). No hooks. Two bash scripts, neither wired to any event:
statusline-tee.shruns only when the OPERATOR wires it into their ownsettings.jsonstatusline (the setup skill prints the edit, never applies it), andcontext-zone.shruns only on explicit invocation. Both reviewed: noeval, nocurl | sh, no outbound network; the one untrusted input that reaches the filesystem (session_idfrom statusline stdin) is sanitized to[A-Za-z0-9_-]before filename use;captured_atis format-gated to strict ISO-8601 UTC before being passed todate -d; no snapshot value is passed toeval,sh -c, or any code executor. Every failure path is transparent (wrapped statusline output and exit code unchanged). No kill-switchuserConfigneeded — nothing runs unless the operator wires it, and unwiring is the same one-line edit. - MCP servers (2). None.
- Consumer config (3). No
userConfig. The one machine file the plugin owns (~/.claude/context-guard/zones.json) is written only by the setup skill's explicitapply. - Cache isolation (4). Skills reference bundled files via
${CLAUDE_PLUGIN_ROOT}; no../reach-outs. Writes go only to~/.claude/context-guard/— the operator-home carve-out — deliberately outside${CLAUDE_PLUGIN_DATA}because the directory is a documented cross-plugin artifact seam (per-session snapshots + zones SSOT) that sibling-plugin sessions read by path;${CLAUDE_PLUGIN_DATA}resolves per-plugin-identity and would hide the seam. Same accepted pattern asrate-limit-guard. - Data egress (5). None. No network, no telemetry. Snapshot data (context-window token
counts + session id) never leaves the machine. Residual local-integrity limitation, stated
honestly: the contract dir's
chmod 700is best-effort — a no-op on filesystems without POSIX modes (Windows ACL volumes under Git Bash), where another local user could read or forge snapshots. The reader contract therefore forbids consumers from attaching security decisions to zone words (routing hints only), and the resolver format-gatescaptured_atand requires the embedded session id to match, so forgery cannot ride a lenient parser. - Provenance & third-party trust (6). First-party (Melodic Software authored), MIT, no third-party delegation.
- Main-thread / PATH (7). None; no
settings.jsonagent, nobin/.
Verdict: ACCEPT — surfaces 2/5/6/7 absent; 1 bounded to operator-wired transparent scripts with sanitized untrusted input; 3 empty; 4 conforms under the documented operator-home seam carve-out.
Triggered by the review record's own rule: both plugins' setup apply now writes an EXECUTABLE
(bin/statusline-shim.sh) into the plugin's operator-home directory, where apply previously
wrote only data (zones.json) or nothing at all. Reviewed as a delta; the base records stand.
- Code execution (1). The write is a byte-identical copy of a reviewed, tested, bundled script
— no generation, no templating, no operator-supplied content, so nothing enters it that was not
already in the plugin. Critically, the copy is inert until the operator wires it: it is not
on
PATH, not a hook, and not referenced by any Claude Code surface, so the base record's justification for having no kill switch — "nothing runs unless the operator wires it, and unwiring is the same one-line edit" — survives verbatim. The shim itself has no untrusted input (its only inputs are its own argv and the cache directory listing), performs no filesystem writes, andexecs either the resolved tee or the wrapped command. The resolution glob skips transienttemp_*marketplace directories; the residual case — two distinct marketplaces both shipping a plugin of the same name — resolves to the most recently installed one and is documented in the script. - Consumer config (3). Unchanged. The shim is not configurable and reads no config.
- Cache isolation (4). The write stays inside the same operator-home carve-out already accepted
for these plugins (
~/.claude/context-guard/,~/.claude/rate-limit-guard/); each plugin'sapplyis explicitly forbidden from writing into the sibling's directory.${CLAUDE_PLUGIN_DATA}was considered and rejected as the shim's home: it is deleted on uninstall, which would leave a wired statusline pointing at a missing file — the exact 127-exit failure this change removes — and its per-plugin-identity path would hardcode the marketplace name into the operator's settings. - Main-thread / PATH (7). Still none.
bin/here is a plugin-owned operator-home subdirectory, NOT the pluginbin/component that joins the Bash tool'sPATH(which stays rejected marketplace-wide); the shim is invoked only by absolute path from the operator'sstatusLine. - Surfaces 2, 5, 6. Unchanged: no MCP servers, no network or telemetry of any kind, first-party MIT code.
Verdict: ACCEPT — the new surface is one inert, byte-identical copy of already-reviewed code into an already-accepted directory, on explicit operator request, with the no-kill-switch justification intact. Uninstall leaves the shim behind by design; it then degrades to running the operator's statusline unchanged, and both setup skills document the two-step manual cleanup.
Reviewed at 0.1.0; a version bump adding a new trust surface re-triggers this review. Data
surfaces named exhaustively — this plugin READS more than most, and that is its job.
- Code execution (1). No hooks, no scripts. Prompt artifacts only (skills, agent, references).
The
auditoragent carries Bash and Write, named honestly: neither is read-only — Bash is justified forclaude plugin validateand config-resolution probes plus safe fixture reproductions; Write is scoped by standing instruction to the evidence-packet directory only (the dumb-zone contract needs the agent to persist its ownaudit-notes.mdso the main thread can stay summary-only — surfaced by the dumb-zone smoke). Its standing instructions forbid mutation of the audited plugin, installs, writes outside the packet, and network beyond WebFetch. Untrusted-content posture (audited source is data, never instructions) is a standing instruction in BOTH the hub skill and the agent, backed by a prompt-injection anti-pattern eval — an advisory, model-honored defense, stated honestly as such. - MCP servers (2). None.
- Consumer config (3). No
userConfig. Tracked cascade surface.claude/plugin-quality.md(+ user-global~/.claude/plugin-quality.mdand.localoverlay — sanctioned operator-home read per criterion 4's carve-out), keys documented in the plugin'sreference/config.md. - Cache isolation (4). Reads that leave the plugin's own directory, each justified: (a) the
audited plugin's installed source under the plugin cache and its marketplace registration —
that IS the audit subject; (b)
~/.claude/context-guard/context/<session_id>.json+~/.claude/context-guard/zones.json— the context-guard reader contract's documented cross-plugin seam, consumed read-only per its inline-floor rule; (c) the documented config layers above. Writes: the evidence packet (session-derived data — hook failures, transcript path, tool errors, contract-lock notes) under${CLAUDE_PLUGIN_DATA}/evidence/…with a stated 30-day retention, and — only on the markdown sinks — the emitted item file at the operator-chosen directory. No../reach-outs. - Data egress (5). Exactly one network egress:
gh issue create, gated by an unconditional full-draft + target-repo + ACTING-account confirm (no auto-file mode exists; the acting-account line exists because one machine can hold multiple GitHub identity domains). WebFetch in the auditor agent reaches official docs pages for claim grounding — read-only GETs. No telemetry. - Provenance & third-party trust (6). First-party (Melodic Software authored), MIT, no third-party delegation. The producer/consumer split (audit session never implements fixes in the audited repo) bounds the blast radius of a hostile audited plugin to the findings text itself, which the draft+confirm gate puts in front of the user before it leaves the machine.
- Main-thread / PATH (7). None; no
settings.jsonagent, nobin/.
Verdict: ACCEPT — surfaces 2/7 absent; 1 bounded to an honestly-named agent Bash grant under standing instructions; 3/4 conform with every cross-boundary read justified at its seam; 5 is a single confirm-gated egress plus docs-only WebFetch; 6 first-party with the split as containment.
Reviewed at 0.1.0; a version bump adding a new trust surface re-triggers this review. This is a
third-party trust delegation — the plugin's entire function is routing a URL through converters
operated by others — so surfaces 5 and 6 carry the weight here.
-
Code execution (1). Present — remediated, and the remediation is instruction-level. No hooks, no scripts, no
bin/; noeval, nocurl … | sh. But the skill does interpolate untrusted input into a shell command line: the X URL becomes part of acurlrequest body. A first review draft claimed this was not shell interpolation of untrusted input; that claim was false and is retracted here. An adversarial pass demonstrated the breakout against a realargvdump — a URL containing an apostrophe terminates the body's quoting and contributes newargvwords, yielding a second unconstrained URL and an-oarbitrary-write flag in the receiving process. Becausedisable-model-invocationisfalseand the description carries a research trigger, the URL can arrive from attacker-authored web content, closing an indirect-injection chain into a shell argument.Remediation: a mandatory gate ahead of step 1 anchors the input against post/article patterns, refuses on no match, and on match discards the input entirely and rebuilds the URL from captures restricted to
[A-Za-z0-9_]and[0-9]— classes that cannot express a quote, space, or metacharacter. Only handle and id are captured: scheme, host, and query string are all discarded and re-emitted canonically, so the accepted input set (http/https, either case,x.comortwitter.com, optionallywww.ormobile.) is wider than the emitted set, which is always onehttps://x.com/…URL. Widening what is accepted therefore does not widen what is sent. Rebuild-from-captures, not escaping, so the emitted command is quote-safe by construction. Every URL re-enters the gate, including ones offered at step 3 or surfaced by fetched content. Stated honestly: the gate is model-honored, not runtime-enforced. It is the primary defense, not a guarantee.No kill-switch
userConfigneeded: nothing runs unless the skill is invoked, and scope-levelenabledPluginsis the off switch. -
Tool pre-approval — no shell grant, and the prompt must stay legible. An earlier draft pre-approved
Bash(curl … https://xtomd.com/api/*)and a PowerShell mirror. Removed. A prefix rule cannot express "and no further flags": the trailing wildcard admits every appended argument, so the grant would have suppressed the prompt on exactly the injected command above. The permissions documentation warns against argument-constraining Bash patterns for this reason. The network call now prompts, showing the operator the exact command — the only runtime-enforced layer available without shipping a hook.Review then found that this backstop is only as good as what the prompt displays, and that an intermediate Windows design had quietly destroyed it. To dodge a PowerShell quoting-portability problem, the request had been moved into a curl config file; the prompt then showed
curl.exe -q -K <file>, hiding the destination URL, thedatareference, anyoutputdirective, and redirect behavior inside a model-authored file no operator approves. Should attacker-authored content push the model off the gate, the operator would see nothing dangerous — the control failing exactly when it is needed.Resolved by declaring a narrower platform boundary rather than keeping an uninspectable path: the skill ships one bash invocation, requiring Git Bash on Windows, with no PowerShell variant. Every PowerShell-portable form either breaks across
$PSNativeCommandArgumentPassingmodes or moves request detail out of the prompt. A stated prerequisite is the honest cost; an approval the operator cannot read is not. This is the cross-platform contract's declared-narrower-scope allowance, taken deliberately and recorded at the coupling site.allowed-toolsretains onlyWebFetch(domain:threadreaderapp.com), which involves no shell. A validatingPreToolUsehook is the stronger control and is deferred, with re-introducing a shell grant as its trigger. -
MCP servers (2). None. The user's stated growth path includes a future MCP surface; that would be a new trust surface and re-triggers this review at that version.
-
Consumer config (3). No
userConfig. No credential exists to store — both providers are unauthenticated. -
Cache isolation (4). One bounded write per invocation. A first draft claimed "no file reads or writes at all"; that was wrong — the skill instructs redirecting the response to a file and reading the slice needed, which is a write plus a read carrying third-party content. Retracted and corrected: the redirect target is constrained to
${CLAUDE_PLUGIN_DATA}, explicitly never an agent-chosen absolute path and never a path derived from fetched content. Review then found the redirect had been written as conditional on the response being a long article — unevaluable, since an X Article is routinely shared as an ordinary/status/link, which would have left the concrete documented command streaming an unbounded body to stdout. The redirect is now unconditional, so the write happens on every invocation rather than on an unknowable subset, and the file is deleted on every exit path. A later round found the spool path was double-quoted, which does not contain it: bash expands$name, runs a backtick or$(…)substitution, and consumes a backslash inside double quotes. Verified against a directory namedlit$name-`whoami`.txt— the variable expanded and the substitution executed. The path is now single-quoted at every shell site. Note the asymmetry with criterion 1: the URL is safe by construction because it is rebuilt from quote-free capture classes, but the plugin-data path comes from the environment, so it carries whatever the consumer's home directory contains and must be escaped rather than trusted. No${CLAUDE_PLUGIN_ROOT}references beyond the skill body, no consumer-repository reads, no../reach-outs. -
Data egress (5). Present and accepted — conditional on the criterion-1 gate. Per invocation the machine emits one datum: the gate's rebuilt URL
https://x.com/<handle>/status/<id>, toxtomd.com(step 1) and, only on a chain fragment,threadreaderapp.com(step 2). No credentials — neither endpoint takes auth. No repository content, no conversation text, no telemetry.A first draft asserted this unconditionally; that was false as built, because the pre-gate request body was attacker-steerable (criterion 1) and could carry
@filecontents or reach an attacker-chosen host. The claim is sound only downstream of the gate, and is recorded that way.Two residuals stated rather than glossed: rebuilding drops the query string, so
?s=/?t=share tracking tokens are not transmitted — but the URL itself still identifies both the post and the reader's interest in it, and neither vendor publishes a retention policy, so assume every submitted URL is logged indefinitely.--proto '=https',--max-time, and--max-filesizebound the transport; no-L, so no redirect-driven egress. The byte cap is best-effort rather than absolute — before curl 8.4.0--max-filesizedoes not stop an unknown-length response, so a chunked reply can exceed it and--max-timeis the bound that always holds. When either bound does fire it aborts rather than truncating cleanly, and review found the skill would have read the wreckage: verified against curl 8.19.0, an over-cap transfer prints200on stdout while exiting63, leaving a partial spool whose Markdown prefix passes every content check. The exit status is now the first gate, ahead of the HTTP code and the body, and a nonzero exit deletes the spool unread. Two further rounds closed the remaining paths by which third-party bytes reach the session: success requires exactly200, since a non-followed3xxcompletes with exit0and a plaintext body that no Markdown check can reject; and the spool is read to a fixed 256 KB cumulative ceiling rather than unconditionally to EOF, because bounded slices cap each tool result but never their sum, so a response near the 5 MB cap could exhaust the session before the result was reported. The ceiling is a constant rather than a per-invocation budget, since an instruction to "set a budget" is satisfied by choosing the response's own size. Both were reachable by a hostile or malfunctioning converter, which is the threat this criterion assumes.A P1 in the same round corrected an over-application of the escaping above: the shell quoting had been extended to the
Readtool, whose argument is a literal filesystem path that no shell parses. Quotes there become part of the filename, so every successful fetch would have failed to open its own spool. Escaped at the shell sites, raw atRead— one path, two renderings.Those bounds are only enforceable because
-qleads the invocation. Review surfaced that curl reads a default.curlrc"even when--configis used", skipping it only when--disable"is used as the first parameter on the command line" (curl's own manual, verified against the local binary). A consumer's ambient.curlrcsettinglocationwould otherwise re-enable redirect following and silently defeat the no-redirect egress claim — an environment-supplied bypass of a control this record asserts. The finding predates the removal of the Windows config-file path and applied there too. -
Provenance & third-party trust (6). Present and accepted. Two vendors, neither first-party:
xtomd.com— publishes aPOST /api/markdownendpoint under a documented public contract (/llms.txt,/llms-full.txt, and an OpenAPI 3.1.0 document at/.well-known/openapi.json), unauthenticated and free. The operating entity is not identified on the site, and no terms, jurisdiction, or retention policy is published — material for a trust delegation, and recorded as an unknown rather than passed over. Its docs instruct installing an@xtomd/mcp-servernpm package that does not exist (registry404). That is not merely a documentation-quality caveat: the name is unregistered and claimable by anyone, so the vendor's own docs steer users into a standing dependency-confusion hazard. The plugin does not wire, install, or reference it, and the skill body instructs against hunting for it.threadreaderapp.com— a long-running public thread-unroll service, fetched read-only overWebFetchwith no key. Operator likewise not identified on the fetched surfaces; retention unstated.
Both return attacker-authored content: X post bodies written by arbitrary third parties. This is the prompt-injection vector criterion 2 names, arriving through a different door. Containment lives in the skill body — returned bytes are data to report, never instructions, and fetched text may never introduce a URL, host, or file path — with dedicated eval coverage including a URL harvested from page content. Consistent with the
github,dometrain, andplugin-qualityrecords, this is an advisory, model-honored defense, not a runtime-enforced one; an earlier draft called it "mandatory" without that qualifier and is corrected here.Two residual risks stated rather than assumed away: a converter could return content that differs from the source post, and the plugin cannot detect that — consumers get attribution and the gate's rebuilt URL, never the converter-echoed one, so a claim can be checked against the original without trusting a value the converter chose. And step 2's escalation is a decision made on the shape of step-1 output, which is third-party text; it is constrained to reusing the gate-captured id and can therefore change whether a second fetch happens, never where it goes.
-
Main-thread / PATH (7). None; no
settings.jsonagent, nobin/.
Review history. A first draft of this record reached ACCEPT on claims that an adversarial
fresh-context pass then falsified: criterion 1's "no shell interpolation of untrusted input",
criterion 4's "no file reads or writes at all", and criterion 5's unconditional no-credential-egress
assertion. The argument-injection breakout was demonstrated at argv level in both bash and
PowerShell and independently reproduced before remediation. Each retraction is recorded inline above
rather than silently rewritten, because a review record whose failures are edited out of history
teaches nothing to the next reviewer.
Verdict: ACCEPT at the remediated state — surfaces 2/7 absent; 3 empty. Criterion 1 carries a
real shell-interpolation surface, remediated by a validate-and-rebuild gate whose model-honored
nature is stated rather than glossed, and backed by the deliberate absence of any Bash/PowerShell
pre-approval so the call prompts. Criterion 4 is one write bounded to ${CLAUDE_PLUGIN_DATA}.
Criteria 5 and 6 are the substance: egress is a single rebuilt, query-stripped, already-public URL
with no credential, and the trust delegation buys a capability with no unauthenticated first-party
alternative. Both vendors are unidentified operators with unstated retention — recorded as a known
unknown, not waved through — and the untrusted-content risk is contained by advisory instruction
that is labeled advisory.
Recheck trigger: a named trigger on an in-repo decision
(upstream-drift) — re-introducing a Bash or PowerShell
pre-approval, shipping the deferred validating PreToolUse hook, or adding an MCP surface each
re-opens this review.
Reviewed at 0.1.0; a version bump adding a new trust surface re-triggers this review. Ported
from mattpocock/skills v1.2.3 (main@84fdeff, MIT), hardened; provenance SSOT
docs/upstream/mattpocock-skills.md. The weight here is a surface no prior record carries: the
plugin's product is a model-generated executable. The statusline-shim delta review (above)
accepted an executable write precisely because it was "a byte-identical copy of a reviewed,
tested, bundled script — no generation, no templating". This plugin deliberately breaks that
precedent: the skill's whole capability is authoring per-procedure stages onto a bundled
library. That is accepted here as an explicit, recorded exception with the mitigations below —
not a quiet widening of the shim rationale.
- Code execution (1). The generated-executable surface, accepted with layered conditions.
No hooks, no
bin/, nothing event-wired; the plugin ships prompt artifacts plus one bundled bash template. The trust argument for generation, layered:- The agent authors; it never executes. The skill forbids running the wizard end-to-end
(verification is
bash -n/shellcheckplus a fresh-context static trace); the human runs the script in their own terminal. The template enforces the same doctrine mechanically — it aborts without a controlling TTY (exec 3</dev/tty, fail-closed), so neither an agent nor piped/pasted input can drive its gates. - A human reads before anything is runnable. The skill's verify step is stop-the-line:
the full
STAGESblock is printed to the user and explicitly approved BEFOREchmod +xand before any run instruction. The generated content a human is asked to trust is exactly the content they are made to read. - The generated region is bounded. Model-authored content goes only below the
STAGESmarker; the library above it is fixed, reviewed here, and never hand-edited. Stage authoring composes reviewed helpers whose dangerous edges are hardened in the library itself: https-onlyopen_urlwith the URL printed before dispatch (also closing a Windows UNC/NTLM leak via theexplorer.exebranch); fail-closedpause/confirm(fatal on read failure, never|| true); key-name validation in every helper; single-quoted escaped.envvalues,chmod 600after every write, a gitignore assert, trap-cleaned same-filesystem mktemp; gh writes that resolve/echo/confirm the target repo before the first write, pass explicit--repo, pipe values via stdin (set_secretpipe,set_var --body-file -), refuse empty values, and surface stderr into the closing summary.
- The agent authors; it never executes. The skill forbids running the wizard end-to-end
(verification is
- MCP servers (2). None.
- Consumer config (3). No
userConfig, no tracked config surface; setup-skill exemption recorded in the plugin README ((a)/(b)/(c) all absent for generation itself). - Cache isolation (4). The skill references only its own bundled
template.shrelatively; generated wizards are written into the consumer's project or scratch space at the user's direction, which is the deliverable, not a reach-out. No../, no constructed absolute paths. - Data egress (5). None by the plugin. The generated script's egress is operator-visible and
operator-driven: browser opens of https URLs printed before dispatch, and
ghwrites to a repo the human explicitly confirmed. Captured values flow terminal →.env/ghonly; the closing summary prints names, never values. The skill's scoping step reads key NAMES only from a live.env, and states honestly that a value pasted into chat is in context. - Provenance & third-party trust (6). Derived from mattpocock/skills (MIT) with the
hardening deltas recorded in the plugin CHANGELOG; authored/maintained first-party (Melodic
Software), MIT. No third-party service is wired:
ghis the consumer's own authenticated CLI, optional, degrading to warn + summary when absent. - Main-thread / PATH (7). None; no
settings.jsonagent, no pluginbin/.
Verdict: ACCEPT — surfaces 2/3/7 absent; 1 is the recorded model-generated-executable
exception, bounded to the below-the-marker region and gated by the mandatory human
read-and-approve before chmod +x, with the library's fail-closed hardening as defense in
depth; 4 conforms; 5 is operator-driven with names-only output; 6 first-party over an MIT
upstream. Conditions shipped, load-bearing for this ACCEPT: the human STAGES approval gate,
the agent-never-executes instruction, the TTY-only fail-closed library, https-only open_url,
and the hardened .env/gh write path. Removing or weakening any of them re-opens this review,
as does any move to have the agent execute a generated wizard.
For a plugin that already ships here, iterate against your local clone without re-publishing and
without changing any consumer's marketplace registration. --plugin-dir loads a plugin straight from
a directory; when its name matches an installed marketplace plugin, the local copy takes
precedence for that session, so you exercise working-tree edits against the installed copy without
uninstalling it (verified 2026-06-24).
# from this repo root — point at the plugin directory, not the marketplace root
claude --plugin-dir ./plugins/<name>- Edit, then
/reload-pluginsto pick up changes without restarting — it reloads skills, agents, hooks, and plugin MCP/LSP servers, reading the files on disk, so no commit or reinstall is needed. - Multiple plugins at once — repeat the flag:
claude --plugin-dir ./plugins/<a> --plugin-dir ./plugins/<b>.--plugin-diralso accepts a.ziparchive (Claude Code v2.1.128+). See Create plugins "Test your plugins locally". - Session-scoped and non-destructive. The override lasts only for that session and never edits a
consumer's
extraKnownMarketplaces; the published registration stays on its GitHub remote. The lone exception:--plugin-dircannot override a plugin that managed settings force-enable or force-disable. - Trust. A locally loaded plugin carries the same trust considerations as any source — only load directories you control.
- Then ship. Run
claude plugin validatebefore opening a PR; after merge, consumers pull the change with/plugin marketplace update melodic-software, gated by theversionbump inplugin.json.
Reintegration (below) covers a repo that already ran an in-repo copy and now switches to the plugin. A brand-new repo adopting the marketplace for the first time follows this checklist:
- Register the marketplace — checked in for clones. Interactive: the trust dialog on first
/pluginuse registers the marketplace and installs enabled plugins. For project-wide adoption, declare the marketplace in the project's checked-in.claude/settings.jsonextraKnownMarketplaces(headless:claude plugin marketplace add <repo> --scope project). A bareclaude plugin marketplace add <repo>writes to user settings, so a fresh clone or CI agent on another machine would carry the enabled plugin but have no registered marketplace to resolve it from — mirror the Reintegration cutover, which pairs both fields in the project settings. - Enable at project scope so every clone inherits it — declare
enabledPluginsin the same checked-in.claude/settings.json(choose user scope instead for machine-wide, not per-repo). - Install and seed config. Pass every option on the install command:
claude plugin install <plugin>@<marketplace> --scope project --config KEY=VALUE …(repeatable, schema-validated). Non-sensitive options land in the usersettings.jsonpluginConfigsregardless of the enable scope — documented behavior, not an observation: seam 1 above records that non-sensitive values store in user settings, and that a sensitive value routes to secure credential storage instead. Re-running that command later against an already-installed plugin printsalready installedand still writes the value (smoke-test C), so a headless reconfiguration is another--configinstall rather than an uninstall/reinstall — verified for a non-sensitive option atuserscope on Claude Code 2.1.240 and not at the--scope projectthis step uses, so read the stored value back — for a non-sensitive option, from the usersettings.jsonpluginConfigsper the storage rule above, not from the project settings this command names; asensitivevalue is absent from settings entirely (smoke-test A) and cannot be verified this way — rather than assuming the write landed. Interactively,/plugin configureowns personaluserConfig; an explicit setup skill owns any separate tracked project configuration declared by the plugin. - Headless prompting caveat. Install never prompts non-interactively — a required
userConfigoption left unset does not block the install; it stays advisory until set (smoke-test C). Seed every required option on the install command so the plugin does not run unconfigured.
The forward migration (above) ends at publish. The lifecycle closes when the source repo stops running its in-repo copy and instead consumes the published plugin — one source of truth, and the repo dogfoods the marketplace. Reintegration is a consumer-side change: adapt through the documented extension points, never by teaching the plugin a consumer's specifics.
The plugin is generic; the consumer's own seams restore its specifics. Map each behavior the in-repo hook had that the generalized plugin dropped to one of these, in order:
- Kill switch / toggles → the plugin's own
userConfigtoggles (/plugin configureinteractively,claude plugin install --configheadless) — user-scoped, replacing the in-repoHOOK_<OLD>_ENABLEDenv var. Per-repo control is the plugin'senabledPluginsentry; a genuinely project-scoped per-hook need is a plugin gap (below), not an env var. - Project conventions → for a hook plugin, the consumer's own tool config files that the hook already
reads (
biome.json,.shellcheckrc,.editorconfig, …) — that is where these plugins pick up project conventions, notCLAUDE.md. (CLAUDE.md/.claude/rulesreach only a plugin's skill/agent components, which run in Claude's model context; hook scripts see only env vars and file-based config.) - Telemetry / observability → the consumer's own telemetry sink. This is the key seam: the
plugin emits the generic telemetry envelope contract to
HOOK_TELEMETRY_SINK, and the consumer's sink script translates that envelope into the consumer's local observability shape. A consumer whose prior hook emitted a different status or hook-identity (e.g.status=erroron a surfaced violation, or a legacy hook name) restores that contract in its own sink, by remapping the plugin's native envelope (status=ok+ populatedfindings) — not by changing the plugin. Before remapping, verify how the consumer's observability actually keys events (e.g. onstatusvs a derivedexit_code/findings count), so the remap preserves the real contract rather than a guessed one.
If a genuine specific has no seam, that is a real plugin gap → add a declared extension
(userConfig, or a tracked consumer-project config key) — but only when it carries
real behavior, not cosmetic prose a consumer's CLAUDE.md already establishes. Resist adding config
surface to a published plugin for a single consumer's low-value nicety.
Cutover checklist:
- Register the marketplace in the consumer's
extraKnownMarketplacesand enable the plugin inenabledPlugins(projectsettings.json, so clones inherit it on trust — the interactive trust prompt both registers and installs the enabled plugin for local collaborators). Headless CI and other non-interactive runs with remote/git-sourced catalogs have no such prompt, and registering a marketplace does not install its plugins, so do both explicitly at project scope:claude plugin marketplace add <repo> --scope projectthenclaude plugin install <plugin>@<marketplace> --scope project --config KEY=VALUE …, seeding every non-defaultuserConfigtoggle on that install command — re-running it later against an already-installed plugin printsalready installedand still writes the value (smoke-test C), so a headless reconfiguration is another--configinstall, not an uninstall/reinstall. That was verified for a non-sensitive option atuserscope on Claude Code 2.1.240 and is untested at theprojectscope this step uses, so read the stored value back before reporting a project-scope reconfiguration as applied — for a non-sensitive option, from the usersettings.jsonpluginConfigs, where such options land regardless of enable scope (seam 1 above records that they store there), not from the project settings this command names; asensitivevalue is absent from settings entirely (smoke-test A) and cannot be verified this way. Exception: adirectory/filerelative-path entry in checked-in project settings resolves against the repo checkout (cloud sessions included) — seedocs/CLOUD-SESSIONS.md. Otherwise the marketplace is known but the plugin is absent, and step 3's verify edit would run with no plugin hook. - Interactively,
/plugin configureadjustsuserConfigtoggles at any time; keep theHOOK_TELEMETRY_SINKwiring and the sink script (the bridge), adapting the sink for any observability-contract divergence. - Verify before retiring the old hook (blue-green — keep it recoverable, but never run both on the
same edit). Matching
PostToolUsehooks run concurrently, so leaving both registered would race two formatters on the just-edited file (last-writer-wins clobbering, plus doubled telemetry and context) — idempotence only makes serial re-runs converge, not concurrent writes safe. So exactly one is active at a time: disable the in-repo hook by setting its kill-switch env var to"false"(or, if it has none, removing its registration entry —settings.jsonis JSON, so toggling the value or removing the entry is the edit, never a//comment). With the old hook off and the plugin enabled, edit a governed file and confirm: the plugin formats/lints and surfaces findings, the telemetry sink receives the expected envelope (so a broken remap is caught now, not when observability is next needed), and the consumer's hard gate (commit hooks, CI) is untouched — those are independent of the edit-time hook. If verification fails, disable the plugin first, then re-enable the old hook (always flip one off as you turn the other on) and debug before retrying — so the two never run together and there is never a no-hook gap. - Only once verified, remove the in-repo hook's
settings.jsonregistration and delete the hook script and its test.
Bootstrap-direction caveat. While a repo is still the harvest source (its hooks are mid-migration out), reintegrating one plugin makes it consume one plugin while still running the rest in-repo — a mixed state. Flip a repo from source to consumer deliberately, not incidentally, and ideally once the repo's ported plugins can move together.
Cross-surface caveat — a repo-built stdio MCP server declared on more than the Claude Code surface.
The checklist above assumes a hook plugin, whose only consumer is Claude Code. A marketplace plugin is
Claude-Code-only, so a marketplace-plugin cutover replaces the Claude Code surface only. A repo-built
stdio MCP server, however, is often declared on additional surfaces — Cursor (.cursor/mcp.json), Codex
(.codex/config.toml), Claude Desktop (a tools/desktop-mcp installer) — none of which can consume a
Claude Code marketplace plugin. When such a server also has no npx/registry publish (the playbook's
stdio (repo-built), "No CLI" class), those other surfaces have no path to the plugin at all, so deleting
the in-repo build strands the server on every non-CC surface. Compounding this, the MCP-parity CI gates
enforce exact equality across .mcp.json / .cursor/mcp.json / .codex/config.toml — removing the
server from only the CC surface breaks parity. So resolve cross-surface consumption before deleting the
in-repo server, picking one:
- Clean-delete — confirm (with the owner) the other surfaces do not need the server, then remove its entry from all surfaces at once. Parity stays trivially satisfied and no new machinery is needed.
- Parity exemption — keep the in-repo server for Cursor/Codex/Desktop while only the CC surface adopts
the plugin; this requires an exemption in the parity gates (an
.mcp.json-only removal otherwise fails them) plus a follow-up track for a genuine cross-surface distribution. - Defer — hold the cutover until the server has a cross-surface distribution path (e.g. repoint the other surfaces at the plugin's on-disk bundle, or a shared build), then re-scope.
- Don't pre-build cross-plugin
dependenciesgraphs until two plugins genuinely share a need. - Don't abstract a shared library before a second consumer exists (Rule of Three); at the threshold, ADR 0019 is the settled shape — extend it rather than re-deciding.
- Don't rely on any mechanism not confirmed from current docs this session — if a customization need has no proven native path yet, record it here as a gap and keep the workaround in the consumer's repo until the native mechanism is verified.
Decisions that shaped this playbook live in adr/, one file each, and are not restated
here. Read the record when you are about to reopen the decision it settled, not while following the
playbook:
- ADR 0019, sharing code across plugins (2026-07-04).
- ADR 0020, deferred surfaces (2026-07-12).
- ADR 0021, unused official plugin components (2026-07-12).
- ADR 0022, knowledge-corpus consuming repo and integration flow (2026-07-13).
- ADR 0023,
skill-qualityretrofit scope (2026-07-13). - ADR 0024, convention-seam ratification and the shared-identity limitation (2026-07-23).