You wrote .claude/commands/deploy.md, two hooks, and an MCP server config — and now you’re paste-bombing it to teammates. Wrap it in a plugin.json and a marketplace.json, push to GitHub, and the loop becomes one /plugin install.
1. Lay out the plugin
Two paths, depending on whether you’re starting fresh or converting an existing .claude/:
# Fresh:
mkdir -p my-team-tools/.claude-plugin
# Convert existing .claude/ in the same project:
mkdir -p my-team-tools/.claude-plugin
cp -r .claude/skills my-team-tools/skills/ # if you have any
cp -r .claude/commands my-team-tools/commands/ # the legacy flat-file shape
cp -r .claude/agents my-team-tools/agents/
mkdir my-team-tools/hooks
# Then move the "hooks" object out of .claude/settings.json
# into my-team-tools/hooks/hooks.json (same shape, different file).
The structure of the resulting plugin directory, using markdown shorthand for the tree:
- `my-team-tools/`
- `.claude-plugin/plugin.json` — manifest
- `skills/deploy/SKILL.md` — one folder per skill, namespaced by plugin name
- `agents/reviewer.md` — subagent definitions
- `hooks/hooks.json` — same shape as the `hooks` block in `settings.json`
- `.mcp.json` (optional) — bundled MCP servers
- `.lsp.json` (optional) — language servers
- `settings.json` (optional) — only `agent` and `subagentStatusLine` keys honored
The single biggest mistake: putting commands/, skills/, agents/, or hooks/ inside .claude-plugin/. Only plugin.json (and marketplace.json, if you use one) lives in .claude-plugin/. Everything else is at the plugin’s root. The doc calls this out as the #1 failure mode.
2. Write plugin.json
This is the manifest. The bare minimum is name:
{
"name": "my-team-tools"
}
Realistic minimum, with the fields users will actually see:
{
"name": "my-team-tools",
"description": "Deploy hooks, code-review skill, and the staging MCP server",
"author": {
"name": "Your Team"
}
}
Field by field:
name— required. Becomes the namespace prefix on every skill: askills/deploy/folder turns into/my-team-tools:deploy. Kebab-case, no spaces.description— optional but expected. Shown in the plugin manager when users browse.author— optional. Helpful for attribution.version— optional, and read footgun #2 before deciding whether to set it. Omitting it lets Claude Code use the git commit SHA, so updates flow on every push.
Other optional fields: homepage, repository, license, keywords. Full schema lives in the plugins reference (cited in sources).
3. Test locally before you publish
Don’t push first and iterate later. Use --plugin-dir to load the plugin directly from your filesystem:
claude --plugin-dir ./my-team-tools
Once Claude Code is running:
/my-team-tools:deploy
/reload-plugins
The first call invokes your skill. The second reloads after you edit any file in the plugin (no need to restart). Verify each piece:
/agents
That should list your bundled agents. /help shows skills under the plugin namespace. Hooks fire on the events declared in hooks.json. If anything’s missing, run with --debug to see why.
4. Add the marketplace, push, install
A marketplace is what makes the plugin installable via /plugin install. It’s a separate JSON file at .claude-plugin/marketplace.json in your repo, alongside plugin.json:
{
"name": "my-team-tools-mp",
"owner": {
"name": "Your Team",
"email": "[email protected]"
},
"plugins": [
{
"name": "my-team-tools",
"source": "./",
"description": "Deploy hooks, code-review skill, and the staging MCP server"
}
]
}
The "source": "./" means “the plugin’s root is this same repository’s root.” Relative sources must start with ./. If you’re shipping multiple plugins from one repo, put each in ./plugins/<name>/ and either write "source": "./plugins/<name>" or set metadata.pluginRoot: "./plugins" so you can write "source": "<name>" instead.
name and owner are required at the marketplace level. Watch out for reserved names: claude-code-marketplace, claude-code-plugins, claude-plugins-official, anthropic-marketplace, anthropic-plugins, agent-skills, knowledge-work-plugins, life-sciences, plus anything that pretends to be official (anthropic-tools-v2, official-claude-plugins). All blocked.
Push the whole plugin directory, then have teammates install:
cd my-team-tools
git init
git add .
git commit -m "feat(plugin): initial team-tools plugin"
git remote add origin [email protected]:your-org/my-team-tools.git
git push -u origin main
In their Claude Code session:
/plugin marketplace add your-org/my-team-tools
/plugin install my-team-tools@my-team-tools-mp
/reload-plugins
Done. Subsequent updates flow through /plugin marketplace update my-team-tools-mp (subject to the version-management caveat below). For private or internal plugins, host the repo privately — users with read access add it the same way, since Claude Code uses their git auth.
Footguns
Skills are namespaced. Always. If your standalone .claude/commands/deploy.md was /deploy, the plugin version becomes /my-team-tools:deploy. There’s no opt-out — it’s deliberate, to prevent collisions when two plugins both define a deploy command. People who liked the short form push back the first time they install. Two ways to soften it: pick a short plugin name (one syllable), and document the exact namespaced commands in the plugin’s README. /help is also where they’ll discover them.
Pinning version quietly stops updates. If you set "version": "0.1.0" in plugin.json, Claude Code uses that string as the cache key. New commits to your plugin repo do not trigger an update for installed users until you bump the version. The doc warns about this directly: “Pushing new commits alone is not enough, because Claude Code sees the same version string and keeps the cached copy.” Two safe paths:
- Iterating fast / internal team plugin: omit
versionentirely from bothplugin.jsonand the marketplace entry. Claude Code falls back to the git commit SHA, so every commit is treated as a new version. - Published plugin with a release cycle: set
versionand bump it on every change you want users to see. Use semantic versioning. Add aCHANGELOG.md.
The trap is the middle ground — setting 0.1.0, pushing fixes, and wondering why nobody’s getting them.
Plugins can’t reach files outside their own directory. Claude Code copies the plugin directory to a cache when users install. A path like ../shared-utils/helper.sh from inside your plugin works on your machine but breaks for everyone else, because ../shared-utils/ was never copied. If you need to share files across plugins, use symlinks (which the cache resolves), or move the shared code into one of the plugins itself and have the others depend on it via dependencies in plugin.json.
Settings files in plugins are restricted. A plugin’s settings.json only respects the agent and subagentStatusLine keys today. Trying to inject hooks via the plugin-root settings.json won’t work — hooks belong in hooks/hooks.json. Same trap with environment variables and other ambient settings: those have to be configured on the user’s side, or surfaced via userConfig in plugin.json so Claude Code prompts the user at enable time.
${CLAUDE_PLUGIN_ROOT} is the cached install — don’t write persistent state there. Files that ship with the plugin live under ${CLAUDE_PLUGIN_ROOT} after install. That directory is versioned and ephemeral: the next plugin update can replace it wholesale. Persistent state goes in ${CLAUDE_PLUGIN_DATA}, which resolves to ~/.claude/plugins/data/<plugin-id>/. The data directory outlives the plugin version itself, so if your plugin runs npm install on first start (a common pattern for MCP-server-bundled plugins), you have to detect when the bundled package.json has changed and reinstall — directory-existence alone isn’t enough. The plugins reference has the canonical diff-based SessionStart hook for this.
When NOT to use this
- It’s a one-project customization. Keep
.claude/commands/<name>.mdin the project’s own repo. Plugins exist for cross-project reuse and for distribution; if your skill only makes sense in one repo, the namespace overhead and marketplace plumbing is friction without payoff. - You’re still iterating on the design. Use
--plugin-diragainst a local checkout while you change names, frontmatter, and structure. Publishing too early forces you into either repeatedversionbumps or commit-SHA churn that pushes uninvited updates onto every teammate. Stabilize first, then ship. - The setup carries secrets. API keys in
.mcp.json, paths to private credential files, anything you wouldn’t paste into a Slack channel — none of that should live inside a plugin you’re distributing, even privately. UseuserConfiginplugin.jsonto declare values Claude Code prompts each user for at enable time, or have the plugin read from$ENV_VARat runtime. The repo’s git history is forever. - You’d be reinventing an official plugin. Before authoring a TypeScript LSP plugin, an MCP wrapper for GitHub, or a code-review runner, browse the official marketplace catalog (
/plugin→ Discover) for the pre-built version. Those are maintained by Anthropic, get auto-updates, and avoid you owning a small piece of infrastructure forever.