CCLearn

Distributing skills, agents and hooks: plugins and managed settings

verified against Claude Code 2.1.261 · 2026-09-0920 min

By the end you can

  • Decide between a committed .claude/ directory and a versioned plugin for a given piece of team config
  • Declare a team marketplace and enabled plugins so a new clone picks them up
  • Version a plugin so that a fix actually reaches people who already installed it
  • Convert an existing .claude/ setup into a plugin without leaving shadowing copies behind
  • Place a hook where the people who should get it will get it, and say who can decline it
  • Validate a plugin before shipping it, and find why a component failed to load

Your .claude/ directory is good now. A review agent, two skills, a formatting hook, and a CLAUDE.md that has stopped being edited every week. Three people have asked for it, and you sent them a zip.

That is the moment this lesson is about. Copying is a distribution mechanism with no update path: the moment you fix the hook, there are four versions of it in the company and you cannot name which one anybody is running. Lesson 12.1 routed settings to the file whose audience matches. This lesson does the same for the things settings only point at — skills, agents, hooks, MCP servers — and the routing question is different, because those are code you are asking other people to run.

Committed .claude/ versus a plugin

The docs put the two side by side and answer with use cases rather than features:

ApproachSkill namesBest for
Standalone (.claude/ directory)/helloPersonal workflows, project-specific customizations, quick experiments
Plugins (self-contained directories with skills, agents, hooks, or a .claude-plugin/plugin.json manifest)/plugin-name:helloSharing with teammates, distributing to community, versioned releases, reusable across projects

— Create plugins

Read the right-hand column as four separate reasons; your case is probably one of them and not all four. A committed .claude/ directory already shares with teammates in this repository — that was 12.1. What it cannot do is cross a repository boundary or carry a version. The recommended path is sequential rather than a choice made up front: "Start with standalone configuration in .claude/ for quick iteration, then convert to a plugin when you're ready to share" (Create plugins).

Your situationWhere it belongs
One repository, evolving weeklyCommitted .claude/
Your own habits, every project~/.claude/
Same config wanted in four repositoriesPlugin
You need to ship a fix and know who has itPlugin, with a version
An admin must be able to force it onPlugin, force-enabled in managed settings

That last row is Lesson 12.5, and it belongs on this table because it changes the packaging decision. Managed settings force-enable a plugin by its full plugin@marketplace id in enabledPlugins. Under allowManagedHooksOnly — the lockdown that blocks user, project, local and plugin hooks — hooks from those force-enabled plugins keep running, alongside managed and Agent SDK ones, and the match is on the full id, so a same-named plugin from another marketplace stays blocked (Settings reference). Packaging as a plugin is what makes that route available at all.

Declaring a team marketplace in the repo

A marketplace is a catalog: .claude-plugin/marketplace.json in a repository root, listing a name, an owner, and a plugins array whose entries each need at minimum a name and a source.

json
{
  "name": "acme-tools",
  "owner": { "name": "DevTools Team" },
  "plugins": [
    { "name": "acme-review", "source": "./plugins/acme-review" }
  ]
}

Adding a marketplace and installing a plugin are two steps. Registering the catalog only buys you a browsable list — "No plugins are installed yet" (Discover and install plugins). What a repository can do is register it for whoever opens the folder:

Team admins can set up automatic marketplace installation for projects by adding marketplace configuration to .claude/settings.json. Once a team member trusts the repository folder, Claude Code adds these marketplaces without a further prompt. — Discover and install plugins

json
{
  "extraKnownMarketplaces": {
    "acme-tools": {
      "source": { "source": "github", "repo": "acme/claude-plugins" }
    }
  }
}

Three things carry over from 12.1 and from the reference pages, all load-bearing. extraKnownMarketplaces waits for workspace trust — in a folder the teammate has not trusted, including a -p run there, Claude Code ignores the entries without a message (Settings reference). To keep the plugin internal, host the marketplace in a private repository: the commands a developer runs — /plugin marketplace add, /plugin install, /plugin update — use their existing git credential helpers. The background refresh does not, and disables them for its git pull by default, so a private HTTPS marketplace falls back to a full re-clone and its auto-updates can fail intermittently. SSH remotes are unaffected. And pick the marketplace name carefully: each user can register only one marketplace per name, and a second one with the same name replaces the first (Plugin marketplaces).

Enabling plugins for everyone, and what still needs an install

enabledPlugins is keyed by plugin-name@marketplace-name, and each scope means something different: user settings are your personal preferences, project settings are the plugins shared with everyone in the repository, local settings are per-machine overrides, and managed settings are policy. Project settings take precedence over user settings, so setting a plugin to false in ~/.claude/settings.json does not disable one the project enables — the opt-out goes in .claude/settings.local.json (Settings reference).

Now the sentence that decides whether your rollout works on day one:

As of Claude Code v2.1.195, adding the marketplace doesn't install plugins that come from an external source, on any path that loads plugins. A plugin that only the project's .claude/settings.json enables, and that comes from an external source such as a GitHub repository or npm package, doesn't load until the team member installs it. Until then, Claude Code reports the plugin as not installed and shows the claude plugin install command to run. — Discover and install plugins

Keep the qualifier attached to that: the rule is stated as of v2.1.195, and the docs do not describe what earlier versions did on that path. So it holds for you, and it says nothing at all about the teammate still running a build from before that — a gap you can name rather than guess at. Lesson 12.6 makes the general case for carrying the version number along with the sentence.

Installing is per-scope too. /plugin install plugin-name@marketplace-name asks for a scope: user (you, every project), project (all collaborators on this repository, which writes the plugin into .claude/settings.json), or local (you, this repository only). The shell form, claude plugin install, installs to user scope unless you pass --scope, and is the documented way to install without an interactive step — so that is the form for a setup script (Discover and install plugins). One case still stops it: a plugin whose marketplace entry uses a command source prints that command for review first, and --yes is what accepts it in a shell with no TTY (Plugins reference). That source type is recent, and its version note doubles as a distribution rule:

Requires Claude Code v2.1.229 or later. On v2.1.120 through v2.1.228, installing the plugin fails with This plugin uses a source type your Claude Code version does not support. Update Claude Code and try again., and on older versions the whole marketplace fails to load. — Plugin marketplaces, on command sources

Note that it names two different failures. Inside that band the marketplace still loads and only the install stops, with a message that names the real cause. Below it, one entry takes the whole marketplace down — every plugin in it, not just the one that used the new source. The catalogue you publish is read by whatever version each teammate happens to be running, so adopting a source type in the week it ships means you do not get to choose which of the two failures anybody gets: a teammate inside the band loses that one install, a teammate below it loses your whole catalogue.

A fourth scope is not yours: managed plugins are installed by administrators and cannot be modified, and a plugin set to false in managed settings is blocked at every scope and hidden from the marketplace. Lesson 12.5 owns that end.

Versioning: the update signal is a string you have to bump

This is the part that turns a working plugin into a shipping one, and it is one field.

Setting version pins the plugin for every source type except command… If you declare "version": "1.0.0" in plugin.json and push new commits without changing that string, existing users of those sources keep the cached copy, because Claude Code sees the same version. — Plugin marketplaces

The version is the cache key. Claude Code computes the current version on /plugin update or auto-update and skips the plugin if it matches what is installed. For every source type but command, that gives three deliberate strategies (Plugins reference):

ApproachHowUpdate behaviourBest for
Explicit versionSet "version": "2.1.0" in plugin.jsonUsers get updates only when you bump this fieldPublished plugins with stable release cycles
Commit-SHA versionOmit version from both plugin.json and the marketplace entryUsers get updates whenever the source's resolved commit changesInternal or team plugins under active development
Digest versionAn archive source with no version in either placeUpdates when the sha256 pin, or the hosted zip's bytes, changePlugins published as zip files

Row three carries a floor the other two do not. The archive source "Requires Claude Code v2.1.224 or later", and on versions v2.1.120 through v2.1.223 "installing the plugin fails with This plugin uses a source type your Claude Code version does not support. Update Claude Code and try again."; on older versions than that, a marketplace containing an archive entry fails to load entirely (Plugin marketplaces). That floor is not a question about your machine — you have the version. It is a question about the slowest-updating teammate on the list, so weigh row three against where the fleet actually is, which is Lesson 12.6's subject rather than this one's.

For a first internal plugin, row two is usually the honest choice: omit version and every push is a release. Check what the fallback actually resolves to first — the commit SHA covers github, url, git-subdir and relative-path sources in a git-hosted marketplace, while an npm source or a local directory outside a git repository resolves to unknown, which is no update signal at all (Plugins reference).

Row one is a promise to bump on every fix, and a promise you will break once. When you do, nothing errors — with the version string unchanged, pushing new commits "has no effect, and /plugin update reports 'already at the latest version'" (Plugins reference). A reassuring message that names the wrong version as the latest one.

Two more edges on the same field:

  • Do not declare version in both places. "Claude Code always uses the plugin.json value without warning, so a stale manifest version can mask a version you set in marketplace.json" (Plugin marketplaces).
  • A plugin's name is its stable identifier. Users reference it in enabledPlugins, pluginConfigs and /plugin install, so changing it breaks every existing install. Change displayName for a nicer label, or add a top-level renames entry in marketplace.json mapping the old name to the new one, or to null if the plugin is gone. Automatic migration needs v2.1.193 or later; older versions ignore the field and report plugin-not-found. The map is append-only history: Claude Code follows chains, so a second rename gets a second entry, never an edit to the first (Plugin marketplaces).

Even a correctly bumped version does not arrive instantly:

Claude Code checks for marketplace and plugin updates after your session starts, with a random delay of up to ten minutes… If any plugins were updated, you'll see a notification prompting you to run /reload-plugins, or the new versions load on your next launch. — Discover and install plugins

That background check runs only where auto-update is on for the marketplace. claude-plugins-official and most other official Anthropic marketplaces have it enabled by default; third-party and local development marketplaces have auto-update disabled by default — which includes yours. Without it nobody is stranded, but the update stops being something that happens and becomes something a person does: /plugin update <plugin>@<marketplace>, preceded by /plugin marketplace update <marketplace> when the catalog itself moved. Each user can toggle auto-update under /plugin → Marketplaces, or an administrator can set "autoUpdate": true on the extraKnownMarketplaces entry in managed settings and stop asking.

Migrating an existing .claude/ setup

The mechanical part is copying. Create the plugin directory beside .claude/, write the manifest, and move each component to the plugin root:

bash
mkdir -p my-plugin/.claude-plugin

cp -r .claude/agents my-plugin/
cp -r .claude/skills my-plugin/

The manifest is my-plugin/.claude-plugin/plugin.json, and its name is the namespace every skill in the plugin is prefixed with:

json
{ "name": "acme-review", "description": "Migrated from standalone configuration", "version": "1.0.0" }

Hooks move out of settings and into a file: create my-plugin/hooks/hooks.json and copy the hooks object from your .claude/settings.json — the format is the same. The docs flag one common mistake: do not put commands/, agents/, skills/ or hooks/ inside the .claude-plugin/ directory. Only plugin.json goes in there; everything else sits at the plugin root (Create plugins).

Then the step everyone skips, and the reason a migration produces a plugin that appears not to work:

After migrating, remove the original files from .claude/ to avoid duplicates. Project and user .claude/agents/ definitions override same-named plugin agents, so the plugin version only takes effect once the originals are removed. Plugin skills are namespaced as /plugin-name:skill-name, so the original /skill-name and the plugin copy both remain available rather than one overriding the other. — Create plugins

Two components, two different failure shapes:

ComponentLeftover copy in .claude/What you observe
AgentOverrides the plugin's same-named agentYour fix is in the plugin and the old behaviour persists, with no documented warning for the collision
SkillBoth stay callable, under different names/review is the old one, /acme-review:review is the new one; people invoke whichever they learned first

The agent case is the dangerous one. A plugin's agents/ sits at the bottom of the subagent precedence order — managed settings, then --agents, then .claude/agents/, then ~/.claude/agents/, then the plugin (Subagents) — so the plugin loaded, the agent exists, and the definition in use is the stale file on disk. No documented warning covers that shape: /doctor's duplicate-subagent check is for names colliding inside one directory. Your one handle while the original is still there is the plugin agent's scoped name, acme-review:reviewer. Lesson 7.6 taught this precedence for skills; here the same precedence resolves in favour of the copy you meant to delete.

Hooks that travel with the plugin

A hook's location decides who gets it. The docs' own scope table reads as a distribution table — all seven rows of it, because the last two are the ones people forget a hook can live in:

LocationShareable
~/.claude/settings.jsonNo, local to your machine
.claude/settings.jsonYes, can be committed to the repo
.claude/settings.local.jsonNo, gitignored when Claude Code saves a setting to it
Managed policy settingsYes, admin-controlled
Plugin hooks/hooks.jsonYes, bundled with the plugin
Skill frontmatterYes, defined in the skill file
Subagent frontmatterYes, defined in the subagent file

— Hooks

A plugin hook references its own script through ${CLAUDE_PLUGIN_ROOT}, so it does not care where the plugin was installed:

json
{
  "description": "Automatic code formatting",
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/scripts/format.sh", "timeout": 30 }
        ]
      }
    ]
  }
}

Distributing a hook adds; it does not replace:

Hook entries merge across settings levels rather than replacing each other: user, project, and local settings add their own hooks without removing managed ones. — Hooks

The same sentence continues past that: "the disableAllHooks setting can't disable managed hooks from outside managed settings" (Hooks). So a plugin hook is shared and declinable — a developer disables the plugin, or sets disableAllHooks in their own settings, which reaches user, project, local and plugin hooks. A managed hook is shared and not declinable from anywhere but managed settings. A plugin hook that managed settings force-enable sits on the managed side of that line (Settings reference).

Merging is good news for adoption, and a warning about cost. Everyone who enables the plugin now runs your script on every Write and Edit, on their machine, with their privileges:

Plugins and marketplaces are highly trusted components that can execute arbitrary code on your machine with your user privileges. Only install plugins and add marketplaces from sources you trust. — Discover and install plugins

Reversed, that sentence is about you. Publishing a marketplace asks colleagues to extend that trust to whatever lands in the repository next, including a commit somebody else pushes. Review it like production code, and expect an administrator to restrict which marketplaces are addable at all (Lesson 12.5).

Validating before you ship

Three checks, none of which needs a volunteer.

bash
claude plugin validate ./my-plugin
claude --plugin-dir ./my-plugin

The first is the same check the submission pipeline runs (Create plugins). Pointed at a plugin directory it reads plugin.json, hooks/hooks.json, and the skills, agents and commands directories at the plugin root. It prints ✔ Validation passed, or ✔ Validation passed with warnings; warnings do not fail it unless you add --strict. Run it from the marketplace directory as claude plugin validate . as well — that form checks marketplace.json instead, and rejects a renames map whose chain cycles or does not terminate at null or a name listed in plugins (Plugin marketplaces).

The second loads the plugin for one session with no install, and a --plugin-dir plugin takes precedence over an installed one of the same name — except one that managed settings force-enable or force-disable — so you can test a change without uninstalling anything. Trigger each hook's event and confirm the effect; check that agents appear in /context under Custom Agents; run each skill as /plugin-name:skill-name; /reload-plugins after edits.

The third is the Errors tab in /plugin, where a component that failed to load turns up: a language server whose binary is not on $PATH, an archive --plugin-url could not fetch, a plugin whose install summary reported a failure. The summary reports the failure once; the tab still has it tomorrow. It is not exhaustive either — an entry with an invalid configuration is skipped rather than listed, and claude --debug is what says why (Create plugins).

What a new clone actually needs

The whole chain, with the observation that proves each link:

  1. The teammate trusts the folder — otherwise extraKnownMarketplaces is ignored silently (12.1).
  2. The marketplace registers — /plugin → Marketplaces lists it.
  3. The plugin installs — a plugin from an external source waits for each teammate to install it; until then Claude Code reports it as not installed and shows the claude plugin install command to run.
  4. The plugin activates — installed from the /plugin interface, the summary reports Plugin is now active., Run /reload-plugins to activate., or a load failure whose detail sits in the Errors tab. claude plugin install does not run in a session at all: what it installs loads on the next launch, or when someone runs /reload-plugins.
  5. The version is the one you shipped — true if you bumped the string, or omitted it and let a git source's resolved commit SHA stand in for it.

Link four's three-way summary is recent: "Before v2.1.221, no install took effect in the current session until you ran /reload-plugins or restarted" (Discover and install plugins). So the summary is now the artefact to read, and the outcome worth learning is the middle one. Run /reload-plugins to activate. means the plugin is not active yet, either because activating it would invalidate the prompt cache or because the activation attempt failed, and running that command is the answer in both cases. It is not a reason to reinstall. A teammate who has learned to watch for Plugin is now active. and reads anything else as broken will install a working plugin twice. Keep /reload-plugins in the instructions you write as the last step of installing rather than as a remedy for something going wrong.

Two links fail without saying so, in opposite ways. Link one fails silently. Link five fails while reporting success, with /plugin update answering "already at the latest version". Links two, three and four each leave something to look at — the Marketplaces tab, the not-installed line with its command, the install summary — so check the artefact rather than infer the state. Uninstalling has a quiet edge of its own: removing a marketplace uninstalls every plugin you installed from it (Discover and install plugins). "I put it in the repo" is a claim about link one.

Checkpoint

  1. You fix a bug in acme-review, push to the plugin repository, and confirm on a fresh clone of your own that the fix is there. A teammate runs /plugin update and is told they are already at the latest version. What is in plugin.json, and what are your two options for fixing it?

    Show answer

    An explicit version string you did not bump. The version is the cache key: Claude Code computes the current version on /plugin update or auto-update and skips the plugin when it matches what is installed, so pushing new commits with the string unchanged "has no effect, and /plugin update reports 'already at the latest version'". Your fresh clone proved the fix is in the repository, which is a different claim from the fix being on their machine.

    Two fixes. Bump version in plugin.json and push — the explicit-version strategy, and a promise to bump on every fix. Or omit version from both plugin.json and the marketplace entry, so the source's resolved commit SHA becomes the version and every push is a release; that is usually the honest choice for an internal plugin under active development. If you take the second route, check what the fallback actually resolves to: the commit SHA covers github, url, git-subdir and relative-path sources in a git-hosted marketplace, while an npm source or a local directory outside a git repository resolves to unknown, which is no update signal at all. Do not set version in both places either — plugin.json wins without warning, so a stale manifest can mask the version you bumped in marketplace.json.

  2. You migrate a reviewer agent and two skills into a plugin, install it, and the skills work under their new namespaced names — but the agent still behaves the way it did last month. What did the migration leave behind, and where does the subagent precedence order put the plugin's copy?

    Show answer

    The original agent file in .claude/agents/. The migration copied everything into the plugin and skipped the step after it: remove the originals from .claude/. Project and user .claude/agents/ definitions override same-named plugin agents, so the plugin loaded, the agent exists, and the definition in use is the stale file on disk.

    The plugin's copy sits at the bottom of the order — managed settings, --agents, .claude/agents/, ~/.claude/agents/, then the plugin. The skills behaved differently because plugin skills are namespaced: /review and /acme-review:review both stay callable rather than one shadowing the other, which is why the migration looked half-successful. No documented warning covers the agent collision — /doctor's duplicate-subagent check is for names colliding inside one directory — so your one handle while the original is still there is the plugin agent's scoped name, acme-review:reviewer.

  3. Your formatting hook must run for everyone, every time, including for a developer who would rather it did not. You ship it in the plugin's hooks/hooks.json. What can that developer do about it, and which hook location would have removed that option?

    Show answer

    They can decline it two ways: disable the plugin, or set disableAllHooks in their own settings, which reaches user, project, local and plugin hooks. A plugin hook is shared and declinable — that is the trade you made by shipping it in the plugin.

    Managed policy settings is the location that removes the option. Hook entries merge across settings levels rather than replacing each other, so a developer adding their own hooks cannot displace a managed one, and disableAllHooks set outside managed settings cannot disable managed hooks. The middle route is to keep the plugin and have managed settings force-enable it by its full plugin@marketplace id in enabledPlugins: those hooks sit on the managed side of the line, and they keep running even under allowManagedHooksOnly. Lesson 12.5 owns that end.

  4. A skill you shipped never appears for a colleague, and the install summary they saw yesterday is long gone. Name the flag that lets you test a fix without uninstalling your own copy, the surface that still holds the failure today, and the case that surface will not list at all.

    Show answer

    --plugin-dir. claude --plugin-dir ./my-plugin loads the plugin for one session with no install, and a --plugin-dir plugin takes precedence over an installed one of the same name — except one that managed settings force-enable or force-disable — so you can test the fix without touching your installation.

    The surface that still holds the failure is the Errors tab in /plugin. The install summary reports a load failure once; that tab still has it tomorrow, which is why it, and not their memory of yesterday's summary, is what you ask for. It is not exhaustive: an entry with an invalid configuration is skipped rather than listed, and claude --debug is what says why.

Sources