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.