Chapter 16. Claude Code Plugins and Marketplaces
A Claude Code plugin is a directory with an optional manifest at
.claude-plugin/plugin.json, and a marketplace is a repo
with a JSON catalog at .claude-plugin/marketplace.json that
points at plugin sources. Registration, installation, and enablement are
three separate acts backed by three separate files, and most plugin
confusion comes from treating them as one. My skills travel as a plugin.
The pack I maintain in JasonColapietro/suede-creator-skills
installs on this machine as suede-skills@suede: plugin
name, an at sign, then the marketplace that served it. That composite id
shows up in the settings file, the install ledger, and the cache path,
and once you can read it the whole system becomes legible.
plugin.json
fields and where components must sit
The manifest lives at .claude-plugin/plugin.json. It is
optional, and when present, name is its only required
field. The name is load-bearing: plugin skills are namespaced as
/plugin-name:skill-name, and the prefix comes straight from
that field. My pack’s skills surface as
suede-skills:suede-code and its siblings, while skills in
~/.claude/skills/ answer to bare names.
displayName (v2.1.143 and later) changes what the UI shows
without touching namespacing or lookup, which is how you rename a plugin
without breaking installs.
The one placement rule that bites: only plugin.json
belongs inside .claude-plugin/. Component directories sit
at the plugin root, not inside the manifest directory. Drop
skills/ inside .claude-plugin/ and nothing
loads. The full set that belongs at the root: skills/,
commands/, agents/, hooks/,
workflows/, output-styles/,
themes/, monitors/, bin/, plus
the files .mcp.json, .lsp.json, and
settings.json.
| Component | Default location |
|---|---|
| Skills | skills/<name>/SKILL.md |
| Commands (flat) | commands/*.md |
| Agents | agents/ |
| Workflows | workflows/ |
| Output styles | output-styles/ |
| Themes | themes/ |
| Hooks | hooks/hooks.json |
| MCP servers | .mcp.json |
| LSP servers | .lsp.json |
| Monitors | monitors/monitors.json |
| Executables | bin/ |
| Default settings | settings.json |
Two of those rows deserve a second look. Files in bin/
join the Bash tool’s PATH while the plugin is enabled, so a
plugin can ship a CLI invokable as a bare command. And the root
settings.json supports two keys, agent and
subagentStatusLine; unknown keys are dropped with no
warning, and this file wins over a settings block declared
inside plugin.json.
The manifest can override component paths with the fields
skills, commands, agents,
workflows, hooks, mcpServers,
outputStyles, lspServers,
experimental.themes, experimental.monitors,
userConfig, channels, and
dependencies. Paths must be relative to the plugin root and
start with ./. The asymmetry matters: skills
adds to the default skills/ scan, while
commands, agents, workflows,
outputStyles, experimental.themes, and
experimental.monitors replace their default directory when
declared. Themes and monitors are experimental components; the top-level
spelling still works, but claude plugin validate warns on
it and a future release will require the experimental.*
form.
Plugin paths: install cache, data directory, and version ids
Three path variables cover a plugin’s on-disk life:
${CLAUDE_PLUGIN_ROOT} is the install directory,
${CLAUDE_PLUGIN_DATA} persists across updates, and
${CLAUDE_PROJECT_DIR} is the project root. All three also
export as environment variables to hook processes and MCP/LSP
subprocesses. The data path resolves to
~/.claude/plugins/data/{id}, where the id sanitizes
anything outside a-z A-Z 0-9 _ - to a dash:
formatter@my-marketplace becomes
formatter-my-marketplace. My data directory holds
suede-skills-suede and
vercel-claude-plugins-official, each with an
-inline sibling (suede-skills-inline,
vercel-inline).
Marketplace installs copy into the cache at
~/.claude/plugins/cache, one directory per installed
version, shaped
cache/<marketplace>/<plugin>/<version>.
The version segment is a semver string when the plugin declares one, or
a 12-character git SHA prefix when it does not
(e8f1b3954fac sits in my ledger). A superseded version
directory is marked orphaned and deleted 14 days later. My
suede-skills cache holds 0.6.0, 0.6.1, and 0.6.2 side by
side, each shipping its own .mcp.json.
Version itself resolves in a fixed order: version in
plugin.json, then version in the marketplace
entry, then the git commit SHA of the source, then unknown
for npm sources or local directories outside a git repository.
What a plugin can ship: hooks, MCP servers, agents, LSP, and user config
Beyond skills, a plugin ships hooks, MCP servers, agents, LSP servers, and its own operator-facing config, which is why a plugin beats a loose skills directory.
Hooks ship in hooks/hooks.json and fire on 30 events,
from SessionStart through SessionEnd, with
PreToolUse, PostToolUse,
WorktreeCreate, and PreCompact among them.
Five hook types exist: command, http (a POST
of the event JSON), mcp_tool, prompt (which
takes $ARGUMENTS), and agent.
MCP servers register through the .mcp.json at the plugin
root. The stdio shape, taken from my own pack:
{
"mcpServers": {
"suede_creator_mcp": {
"cwd": "${CLAUDE_PLUGIN_ROOT}",
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/mcp/suede-skills-mcp.mjs", "--profile", "creator"]
}
}
}${CLAUDE_PLUGIN_ROOT} in both cwd and
args keeps the server path-independent across cache
versions. The same file defines a second server,
suede_workflow_mcp, running the same script with
--profile workflow: one script, two servers, one flag
apart. The remote shape swaps command and args
for type: "http" plus url; the vercel plugin
ships {"type": "http", "url": "https://mcp.vercel.com"}
with a free-form note field riding along. Bundled servers
get scoped names: a hook matcher targeting the plugin’s own tools uses
mcp__plugin_<plugin-name>_<server-name>__<tool>,
and an mcp_tool hook’s server field takes
plugin:<plugin-name>:<server-name>. That
three-part id also keys
~/.claude/mcp-needs-auth-cache.json, where servers waiting
on OAuth queue up; plugin:vercel:vercel sits there on my
machine now.
Plugin-shipped agents support name,
description, model, effort,
maxTurns, tools, disallowedTools,
skills, memory, background, and
isolation in frontmatter. Three fields are refused for
security reasons: hooks, mcpServers, and
permissionMode. The one valid isolation value
is "worktree".
LSP servers configure through .lsp.json with
command and extensionToLanguage required. One
version trap worth carrying: restartOnCrash and
shutdownTimeout need Claude Code v2.1.205 or later, and on
older builds setting either made the harness skip the LSP server,
visible only in claude --debug.
A plugin that needs operator input declares userConfig:
options typed string, number,
boolean, directory, or file, each
requiring type, title, and
description, with sensitive,
required, default, multiple,
min, and max optional. Values substitute as
${user_config.KEY} and export to hooks as
CLAUDE_PLUGIN_OPTION_<KEY>. Storage splits on
sensitivity: plain values sit under
pluginConfigs[<plugin-id>].options in user settings,
while sensitive values go to the macOS Keychain or
~/.claude/.credentials.json, a store with a total limit of
about 2 KB shared with OAuth tokens. Shell-executed fields refuse the
substitution outright: shell-form hook commands, monitor commands, and
MCP headersHelper fail with an error rather than
interpolating a config value into a shell string, a hardening change in
v2.1.207 (before that, they substituted).
marketplace.json:
required fields and the five source types
A marketplace is a repo with a catalog at
.claude-plugin/marketplace.json. Three top-level fields are
required: name, owner, and
plugins, and inside owner only
name is required. Optional fields include
$schema, description, version,
metadata.pluginRoot,
allowCrossMarketplaceDependenciesOn, and
renames, which maps a former plugin name to its current
name or to null and needs v2.1.193 or later.
Each plugin entry declares a source, which takes five
forms: a relative path string starting with ./;
github with repo plus optional
ref and sha; url with the same
pinning options; git-subdir with url and
path (a sparse partial clone); and npm with
package, optional version, and optional
registry. When both ref and sha
are set, the 40-character sha is the effective pin. Entries
also take category, tags, strict,
and relevance. strict defaults to
true, meaning plugin.json is the authority and
the entry merges into it; strict: false makes the
marketplace entry the entire definition and fails to load if
plugin.json also declares components.
Anthropic reserves 16 marketplace names for itself
(claude-code-marketplace, anthropic-plugins,
agent-skills, healthcare, and the rest),
rejects them for third parties, and runs the check on every load;
first-party-plugins and healthcare joined the
list in v2.1.205.
Registration is one command:
claude plugin marketplace add <source>, with
--scope <user|project|local> defaulting to
user and --sparse <paths...> for partial
checkouts. Branch or tag pinning appends @ref to GitHub
shorthand or #ref to a git URL, and as of v2.1.196 a URL
without a scheme is rejected. The state lands once per user in
~/.claude/plugins/known_marketplaces.json, not per project.
One worktree gotcha rides with that: local directory and
file sources with relative paths resolve against the
repository’s main checkout even when you add them from a worktree.
That file on my machine is a map of marketplace name to
{source, installLocation, lastUpdated} with an optional
autoUpdate: true, each installLocation
following ~/.claude/plugins/marketplaces/<name>. Five
marketplaces are registered: claude-plugins-official
(anthropics/claude-plugins-official, which registers itself on first
interactive launch), superpowers-dev (obra/superpowers),
google-labs-code-stitch-skills
(google-labs-code/stitch-skills), addy-agent-skills
(addyosmani/agent-skills), and suede
(JasonColapietro/suede-creator-skills). A non-interactive script running
before that first launch has to add the official marketplace itself; the
community catalog adds as
anthropics/claude-plugins-community and installs as
@claude-community.
Install,
enable, update, and remove: the claude plugin commands
The CLI surface is claude plugin with
install, uninstall (aliases
remove, rm), enable,
disable, update, list
(ls), details, validate,
prune (autoremove), tag,
init (new), and marketplace.
Installation scopes map onto the settings files: user
writes ~/.claude/settings.json and is the default,
project writes .claude/settings.json,
local writes .claude/settings.local.json, and
managed is read-only and update-only.
Two ledgers track the result, and they answer different questions.
~/.claude/plugins/installed_plugins.json (a
version: 2 file) answers what is on disk: it maps each
<plugin>@<marketplace> id to an array of
install records carrying scope, installPath,
version, installedAt,
lastUpdated, and gitCommitSha, with
project-scoped records adding a projectPath. One plugin can
hold a user record and a project record at the same time;
superpowers@superpowers-dev does on my machine.
enabledPlugins in settings answers what is on. It maps
the composite id to a boolean, and disabled means installed-but-off, not
gone. My user settings hold 12 entries: 4 true, 8 set to false by hand,
with the cache directories for all 12 intact. The 4 enabled are
superpowers@claude-plugins-official,
frontend-design@claude-plugins-official,
vercel@claude-plugins-official, and
suede-skills@suede. A plugin can also pick its own starting
state: defaultEnabled: false in the manifest installs it
disabled (v2.1.154 or later), and the same field in the marketplace
entry takes precedence over the manifest value.
Removal has one flag worth knowing:
claude plugin uninstall --keep-data preserves the
${CLAUDE_PLUGIN_DATA} directory, which the default path
deletes when you remove the plugin from its last remaining scope.
claude plugin validate belongs in CI: unrecognized
top-level manifest fields come back as warnings, --strict
promotes them to errors, and wrong-typed fields (a keywords
string instead of an array) fail either way.
The development loop never needs an install. Scaffolding starts with
claude plugin init <name> (alias new),
which creates ~/.claude/skills/<name>/ and takes
--description, --author (defaulting to
git config user.name), --author-email,
-f/--force, and --with for any of
skills, agents, hooks,
mcp, lsp, output-style, and
channel. The result runs without a marketplace at all: any
folder under a skills directory containing
.claude-plugin/plugin.json loads as a plugin named
<name>@skills-dir, discovered in place with no
install step and no copy into the cache. Project-scope
@skills-dir plugins carry trust restrictions worth knowing
before you lean on them: they load only after the workspace trust dialog
is accepted, their MCP servers go through per-server approval, their LSP
servers start only after trust, and their background monitors do not
load at all.
For iteration on an existing plugin,
claude --plugin-dir <dir> loads it for one session,
repeats for multiple plugins, accepts a .zip from v2.1.128,
and takes precedence over an installed plugin of the same name unless
managed settings force the decision.
claude --plugin-url <url> pulls a zip at startup.
/reload-plugins hot-reloads plugins, skills, agents, hooks,
and plugin MCP and LSP servers without a restart, though when a plugin’s
MCP tools are not deferred by tool search it warns and skips unless you
pass --force (v2.1.163).
Registering a marketplace in a repo with a committed settings block
Each new public repo I create or scaffold gets a committed
.claude/settings.json registering the Suede marketplace on
first commit. The documentation describes what the system can do; that
standing rule describes what it must do across the estate.
{
"extraKnownMarketplaces": {
"suede": {
"source": { "source": "github", "repo": "JasonColapietro/suede-creator-skills" }
}
},
"enabledPlugins": { "suede-skills@suede": true }
}The block works because enabledPlugins honors project
and local settings. Clone the repo, trust the workspace, and the skill
pack is registered and on, with no per-machine setup. Contrast that with
how the same plugin resolves at user level on this machine: my
~/.claude/settings.json enables
suede-skills@suede but carries no
extraKnownMarketplaces key at all, because the marketplace
half lives in known_marketplaces.json. At user level the
two halves sit in different files. The committed project file carries
both halves in one place, which is what makes it portable;
podcast-sync/.claude/settings.json holds these two keys and
nothing else. One boundary to respect while extending it:
pluginConfigs stopped reading from project and local
settings as of v2.1.207, so a committed block can register and enable,
but per-plugin option values have to come from user or managed
scope.
The rule has operational teeth beyond the JSON. If
.claude/settings.json already exists with other keys, merge
with jq rather than overwriting. Push the file direct to
the default branch under my standing merge authorization; open-and-merge
a PR only when branch protection blocks the direct push. Two categories
are skipped: suede-creator-skills itself, because
registering the marketplace source into its own catalog is circular, and
repos with no real dev surface, like a .github org meta
repo or a GitHub-profile-README repo. Private repos are out of scope for
now.
The
.gitignore line that swallows
.claude/settings.json
Check .gitignore before committing, because skipping
that step kills this standard. A blanket .claude/ or
.claude/** line swallows the settings file, and the
standard dies untracked while looking applied on disk. The fix narrows
the ignore instead of removing it:
.claude/*
!.claude/settings.json
Worktree clutter and settings.local.json stay ignored;
the one shared file tracks. My own ~/.claude dotfiles repo
runs the same allowlist trick at larger scale, a bare *
followed by negations, so the pattern is proven in both directions.
When the record says applied and the checkout says otherwise
My records say the existing public repos got this treatment in a
sweep on 2026-07-21. An audit of this machine on 2026-07-27 disagrees.
Of the repos under ~/code/*/, one
(podcast-sync) carries the committed file.
strumly’s local HEAD is dated 2026-07-26 and
suede-agent-studio’s is dated 2026-07-24, both after the
claimed sweep, and git ls-files .claude/settings.json comes
back empty in both. fretpulse’s HEAD is 2026-07-19, before
the sweep, so it proves nothing either way.
This machine runs behind GitHub as a matter of policy, and a stale
mirror explains a missing file in a repo whose checkout predates the
change. It does not explain two checkouts at post-sweep commits that
lack the file. Either the sweep missed those repos, or the file landed
somewhere that never reached their default branches. The check that
settles it runs against GitHub, not this mirror, and this audit did not
run it. Until it does, the honest status: the standard stands, the
sweep’s coverage is unverified, and strumly and
suede-agent-studio are flagged for re-audit.
A standard encoded in an instruction file is a promise, not a state. The machinery holds up its end: one committed block, and registration plus enablement follow the repo wherever it goes. Holding up my end means re-deriving the state from the authoritative layer whenever the record and the evidence disagree, and the evidence here says two repos need another pass.