Plugin Marketplace Publisher skill for Claude Code
Ship your skills, agents and hooks as one install. Packages a scattered set of skills, subagents, hooks and MCP servers into one versioned plugin and publishes it through a git-hosted marketplace. Claude fixes the layout mistakes that stop components loading, rewrites paths for the plugin cache, validates manifests with a bundled checker, proves the install works in a clean session, and commits the settings that prompt teammates to install it.
- name
plugin-marketplace-publisher - size215 lines · ≈2,050 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Packages skills, subagents, slash commands, hooks, and MCP servers into a Claude Code plugin with .claude-plugin/plugin.json, publishes it through a marketplace.json in a git repo, and rolls it out to a team via extraKnownMarketplaces and enabledPlugins. Use when the user wants to share skills or agents across repos or teammates, create a plugin or marketplace, version and update a plugin, or debug a plugin that installs but whose skills, hooks, or MCP servers do not load. Try asking
- Package our .claude skills and agents as a plugin the whole org can install
- Set up a private marketplace repo for our team's Claude Code plugins
- My plugin installs fine but none of its hooks or skills show up
Use it when
- The same skills or agents should be shared across many repos
- Creating or updating a marketplace.json for a team or the public
- Releasing a new plugin version that users need to pick up
- An installed plugin's components fail to load
Skip it when
- The skill is only needed in one repository — commit it under .claude/skills with skill-authoring
- You are still designing the MCP server itself — use mcp-server-builder first
How to check the result
The checker reports no errors, and a clean-session install from the marketplace shows every component in /skills, /agents, /hooks and /mcp working from the cached copy.
The common mistake it prevents
Placing skills/ or hooks/ inside .claude-plugin/ or pointing hooks at relative paths, so components vanish once the plugin is copied to the cache.
A plugin is ready when it installs from its marketplace into a clean environment, every component (skills, agents, commands, hooks, MCP servers) loads and works from the cache location, it references nothing outside its own folder, and its version was bumped for the change. Testing only from your working copy proves none of that.
When a plugin is the right vehicle
| Situation | Use |
|---|---|
| Skills/agents for one repo only | Commit them in .claude/ of that repo |
| Same skills, agents, hooks across many repos or a whole team | Plugin in a marketplace |
| Personal toolkit on several machines | Plugin (private git marketplace) or dotfiles |
| A single MCP server | .mcp.json or claude mcp add may suffice; a plugin if bundled with skills |
Layout
acme-dev/ # plugin root
├── .claude-plugin/
│ └── plugin.json # manifest - the ONLY thing inside .claude-plugin/
├── skills/
│ └── release-notes/SKILL.md # invoked as /acme-dev:release-notes
├── agents/
│ └── code-reviewer.md
├── commands/ # legacy command files still work; prefer skills
├── hooks/
│ └── hooks.json
├── scripts/ # helpers used by hooks or skills
├── .mcp.json # MCP servers bundled with the plugin
└── README.md
Component directories sit at the plugin root, not inside .claude-plugin/. Putting
skills/ inside .claude-plugin/ is the most common reason nothing loads.
Workflow
- [ ] 1. Inventory components and remove anything repo-specific or secret
- [ ] 2. Create the layout and plugin.json
- [ ] 3. Rewrite paths to ${CLAUDE_PLUGIN_ROOT}
- [ ] 4. Validate structure (script) and test locally
- [ ] 5. Create or update the marketplace.json
- [ ] 6. Publish (git push + tag) after confirming visibility with the user
- [ ] 7. Install from the marketplace in a clean session and smoke-test
- [ ] 8. Roll out to the team via project settings
2. plugin.json
{
"name": "acme-dev",
"version": "1.3.0",
"description": "Acme engineering workflows: release notes, review agent, format hooks, issue tracker MCP.",
"author": { "name": "Acme DevEx", "email": "[email protected]" },
"homepage": "https://github.com/acme/claude-plugins",
"repository": "https://github.com/acme/claude-plugins",
"license": "MIT",
"keywords": ["release", "review", "hooks"]
}
nameis required: kebab-case, stable forever (it becomes the namespace in/acme-dev:skilland inenabledPluginskeys).- Use semver for
versionand bump it on every published change; clients compare it to decide on updates. - Default component folders need no manifest entries. Only add path fields if you must use non-standard locations.
3. Paths inside the plugin
Installed plugins are copied into a cache directory, so anything outside the plugin root
(../shared/, repo-relative paths) is missing at runtime. Inside hooks and MCP configs
use ${CLAUDE_PLUGIN_ROOT}:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [{ "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}\"/scripts/format.py", "timeout": 30 }]
}
]
}
}
{ "mcpServers": { "issues": { "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/mcp/issues/build/index.js"],
"env": { "ISSUES_TOKEN": "${ISSUES_TOKEN}" } } } }
Ship built artifacts (compiled MCP server JS) or a command that needs no build step;
users will not run npm install inside your plugin cache. Skills reference their own
files with ${CLAUDE_SKILL_DIR} as usual.
4. Validate and test locally
python3 ${CLAUDE_SKILL_DIR}/scripts/check_plugin.py path/to/repo-or-plugin
The script detects plugins and marketplaces under the path and checks: manifest JSON
and required fields, kebab-case names, semver, components misplaced inside
.claude-plugin/, skill frontmatter names matching folders, agent frontmatter, hooks.json
shape and event names, hook/MCP commands that do not use ${CLAUDE_PLUGIN_ROOT}, paths
escaping the plugin root, secret-looking strings, and marketplace entries whose sources
are missing or whose names disagree with the plugin manifest. If the installed CLI has
claude plugin validate, run it as well.
Local trial without publishing:
claude --plugin-dir ./acme-dev # load one plugin for this session
# or exercise the real install path:
/plugin marketplace add ./ # repo containing .claude-plugin/marketplace.json
/plugin install acme-dev@acme-tools
Then check: /skills shows acme-dev:* skills, /agents shows the agents, /hooks
shows the hooks, /mcp shows the server connected.
5. marketplace.json
At the marketplace repo root, .claude-plugin/marketplace.json:
{
"name": "acme-tools",
"owner": { "name": "Acme DevEx", "email": "[email protected]" },
"metadata": { "description": "Internal Claude Code plugins for Acme engineering" },
"plugins": [
{ "name": "acme-dev", "source": "./plugins/acme-dev", "description": "Release notes, review agent, hooks", "version": "1.3.0" },
{ "name": "db-tools", "source": { "source": "github", "repo": "acme/claude-db-tools" }, "description": "Migration and query skills" }
]
}
- Plugin entry
namemust equal that plugin’splugin.jsonname. sourceis a path relative to the marketplace root, agithubrepo object, or a git URL object ({ "source": "url", "url": "https://git.example.com/x.git" }).- One marketplace can mix in-repo plugins and external repos.
6-7. Publish and verify the real install
- Confirm with the user whether the repo is public or private before pushing; a public push of internal tooling cannot be taken back.
- Commit, push, and tag:
git tag acme-dev-v1.3.0 && git push --tags. - In a clean session (or another machine):
/plugin marketplace add acme/claude-plugins # GitHub owner/repo, git URL, local path, or marketplace.json URL
/plugin install acme-dev@acme-tools
claude plugin install acme-dev@acme-tools --scope project # CLI form, records it for the repo
Users pick up new versions with /plugin marketplace update acme-tools.
8. Team rollout
Commit to the consuming repo’s .claude/settings.json so teammates are prompted to
install on trust:
{
"extraKnownMarketplaces": {
"acme-tools": { "source": { "source": "github", "repo": "acme/claude-plugins" } }
},
"enabledPlugins": { "acme-dev@acme-tools": true }
}
Private repos work as long as each user’s git credentials can clone them.
Gotchas
- Components inside
.claude-plugin/instead of the plugin root. - Hook commands with relative paths or
$CLAUDE_PROJECT_DIRpointing at plugin files. The project dir is the user’s repo, not your plugin. - Forgetting the version bump: users keep the old cached copy.
- Skill name collisions: plugin skills are namespaced (
/acme-dev:release-notes), but descriptions still compete with the user’s own skills for automatic triggering. Keep descriptions specific. - Bundling hooks that run on every tool call in a broadly installed plugin. Every user pays the latency; make them fast or opt-in.
- Secrets in
.mcp.jsonor hook scripts. Use${ENV_VAR}expansion and document the variables in the README.
Verification
check_plugin.pyreports zero errors.- Clean-environment install from the marketplace succeeds, and every component shows up
in
/skills,/agents,/hooks,/mcp. - One real task per component works from the installed copy (not the working tree).
- After a version bump and
/plugin marketplace update, the new version is active.
Output
**Marketplace:** acme/claude-plugins (private) -> acme-tools
**Plugin:** acme-dev v1.3.0 - 2 skills, 1 agent, 1 PostToolUse hook, 1 MCP server
**Checks:** check_plugin.py 0 errors / 1 warning; clean install verified on a fresh machine
**Team rollout:** extraKnownMarketplaces + enabledPlugins committed to web-app/.claude/settings.json
**Env vars users must set:** ISSUES_TOKEN (documented in README) Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/check_plugin.py
#!/usr/bin/env python3
"""Validate Claude Code plugins and marketplaces before publishing.
Usage:
python3 check_plugin.py <path>
<path> may be a plugin root (contains .claude-plugin/plugin.json), a marketplace repo
(contains .claude-plugin/marketplace.json), or any directory containing either; all
plugins found below it are checked.
Exit code 1 if any error is found, else 0. Standard library only.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
KEBAB = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$")
KNOWN_PLUGIN_KEYS = {"name", "version", "description", "author", "homepage", "repository", "license",
"keywords", "commands", "agents", "skills", "hooks", "mcpServers", "outputStyles", "lspServers"}
HOOK_EVENTS = {"PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop", "SubagentStop", "SessionStart",
"SessionEnd", "Notification", "PreCompact", "PermissionRequest"}
COMPONENT_DIRS = ("skills", "agents", "commands", "hooks")
SECRET_RES = [re.compile(p) for p in (r"sk-[A-Za-z0-9_-]{20,}", r"AKIA[0-9A-Z]{16}",
r"gh[pousr]_[A-Za-z0-9]{30,}", r"-----BEGIN [A-Z ]*PRIVATE KEY-----")]
SKIP = {".git", "node_modules", ".venv", "__pycache__"}
errors: list[str] = []
warnings: list[str] = []
def load_json(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
errors.append(f"{path}: invalid JSON ({e})")
except OSError as e:
errors.append(f"{path}: cannot read ({e})")
return None
def frontmatter(path: Path) -> dict[str, str]:
text = path.read_text(encoding="utf-8", errors="replace")
m = re.match(r"^---\n(.*?)\n---", text, re.S)
if not m:
return {}
return {k: v.strip().strip('"\'') for k, v in re.findall(r"^([\w-]+):\s*(.*)$", m.group(1), re.M)}
def check_commands_in(obj, where: str, root: Path) -> None:
"""Walk hook/MCP config, flag commands and args that escape the plugin root."""
if isinstance(obj, dict):
for k, v in obj.items():
if k in ("command", "args") or isinstance(v, (dict, list)):
check_commands_in(v, where, root)
elif isinstance(obj, list):
for v in obj:
check_commands_in(v, where, root)
elif isinstance(obj, str):
if "CLAUDE_PROJECT_DIR" in obj:
warnings.append(f"{where}: uses $CLAUDE_PROJECT_DIR (the user's repo); plugin files need ${{CLAUDE_PLUGIN_ROOT}}")
if re.search(r"(^|[\s\"'])\.\.?/", obj) and "CLAUDE_PLUGIN_ROOT" not in obj:
warnings.append(f"{where}: relative path in {obj!r}; plugins run from a cache dir, use ${{CLAUDE_PLUGIN_ROOT}}")
if "../" in obj:
errors.append(f"{where}: path escapes the plugin root: {obj!r}")
def check_plugin(root: Path) -> str | None:
manifest = root / ".claude-plugin" / "plugin.json"
data = load_json(manifest)
if not isinstance(data, dict):
return None
name = data.get("name", "")
tag = f"[plugin {name or root.name}]"
if not name:
errors.append(f"{tag} plugin.json missing required 'name'")
elif not KEBAB.match(name):
errors.append(f"{tag} name {name!r} must be kebab-case")
ver = data.get("version")
if ver is None:
warnings.append(f"{tag} no 'version'; updates cannot be tracked")
elif not SEMVER.match(str(ver)):
errors.append(f"{tag} version {ver!r} is not semver")
if not data.get("description"):
warnings.append(f"{tag} no 'description'")
for k in data:
if k not in KNOWN_PLUGIN_KEYS:
warnings.append(f"{tag} unknown plugin.json key {k!r}")
for d in COMPONENT_DIRS + (".mcp.json",):
if (root / ".claude-plugin" / d).exists():
errors.append(f"{tag} {d} is inside .claude-plugin/; move it to the plugin root")
found = [d for d in COMPONENT_DIRS if (root / d).exists()] + ([".mcp.json"] if (root / ".mcp.json").exists() else [])
if not found and not any(k in data for k in ("commands", "agents", "skills", "hooks", "mcpServers")):
errors.append(f"{tag} no components found (skills/, agents/, commands/, hooks/, .mcp.json)")
skills = root / "skills"
if skills.is_dir():
for sk in sorted(p for p in skills.iterdir() if p.is_dir()):
f = sk / "SKILL.md"
if not f.is_file():
errors.append(f"{tag} skills/{sk.name}/ has no SKILL.md")
continue
fm = frontmatter(f)
if fm.get("name") != sk.name:
errors.append(f"{tag} skills/{sk.name}/SKILL.md name {fm.get('name')!r} != folder")
if not fm.get("description"):
errors.append(f"{tag} skills/{sk.name}/SKILL.md missing description")
agents = root / "agents"
if agents.is_dir():
for a in sorted(agents.glob("*.md")):
fm = frontmatter(a)
for key in ("name", "description"):
if not fm.get(key):
errors.append(f"{tag} agents/{a.name} missing frontmatter {key!r}")
hooks_file = root / "hooks" / "hooks.json"
if hooks_file.exists():
hd = load_json(hooks_file)
if isinstance(hd, dict):
events = hd.get("hooks")
if not isinstance(events, dict):
errors.append(f"{tag} hooks/hooks.json must have a top-level 'hooks' object")
else:
for ev in events:
if ev not in HOOK_EVENTS:
warnings.append(f"{tag} hooks.json: unrecognized event {ev!r}")
check_commands_in(events, f"{tag} hooks.json", root)
mcp = root / ".mcp.json"
if mcp.exists():
md = load_json(mcp)
if isinstance(md, dict):
if "mcpServers" not in md:
errors.append(f"{tag} .mcp.json missing 'mcpServers'")
else:
check_commands_in(md["mcpServers"], f"{tag} .mcp.json", root)
for p in root.rglob("*"):
if p.is_file() and not any(s in p.parts for s in SKIP) and p.stat().st_size < 1_000_000:
try:
text = p.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
for rx in SECRET_RES:
if rx.search(text):
errors.append(f"{tag} secret-like string in {p.relative_to(root)}")
break
if p.is_symlink():
target = p.resolve()
if root.resolve() not in target.parents:
warnings.append(f"{tag} symlink {p.relative_to(root)} points outside the plugin")
if not (root / "README.md").exists():
warnings.append(f"{tag} no README.md (document env vars and usage)")
print(f"checked plugin {name or root.name} ({root}) components: {', '.join(found) or 'manifest paths'}")
return name
def check_marketplace(repo: Path) -> None:
path = repo / ".claude-plugin" / "marketplace.json"
data = load_json(path)
if not isinstance(data, dict):
return
mname = data.get("name", "")
tag = f"[marketplace {mname or repo.name}]"
if not mname or not KEBAB.match(mname):
errors.append(f"{tag} 'name' missing or not kebab-case")
if not isinstance(data.get("owner"), dict) or not data["owner"].get("name"):
errors.append(f"{tag} 'owner.name' is required")
plugins = data.get("plugins")
if not isinstance(plugins, list) or not plugins:
errors.append(f"{tag} 'plugins' must be a non-empty list")
return
root_prefix = (data.get("metadata") or {}).get("pluginRoot", "")
seen: set[str] = set()
for i, entry in enumerate(plugins):
if not isinstance(entry, dict):
errors.append(f"{tag} plugins[{i}] is not an object")
continue
pname = entry.get("name", "")
if not pname:
errors.append(f"{tag} plugins[{i}] missing 'name'")
if pname in seen:
errors.append(f"{tag} duplicate plugin name {pname!r}")
seen.add(pname)
src = entry.get("source")
if src is None:
errors.append(f"{tag} {pname}: missing 'source'")
elif isinstance(src, str):
rel = src if src.startswith(("./", "../")) or not root_prefix else f"{root_prefix.rstrip('/')}/{src}"
pdir = (repo / rel).resolve()
if not pdir.is_dir():
errors.append(f"{tag} {pname}: source {src!r} does not exist")
elif not (pdir / ".claude-plugin" / "plugin.json").is_file():
if entry.get("strict", True):
errors.append(f"{tag} {pname}: {src} has no .claude-plugin/plugin.json")
else:
pdata = load_json(pdir / ".claude-plugin" / "plugin.json") or {}
if pdata.get("name") != pname:
errors.append(f"{tag} entry {pname!r} != plugin.json name {pdata.get('name')!r}")
if entry.get("version") and pdata.get("version") and entry["version"] != pdata["version"]:
warnings.append(f"{tag} {pname}: version {entry['version']} in marketplace vs {pdata['version']} in plugin.json")
elif isinstance(src, dict):
kind = src.get("source")
if kind == "github" and not re.match(r"^[\w.-]+/[\w.-]+$", str(src.get("repo", ""))):
errors.append(f"{tag} {pname}: github source needs repo 'owner/name'")
elif kind == "url" and not str(src.get("url", "")).startswith(("https://", "git@", "ssh://")):
errors.append(f"{tag} {pname}: url source needs a git URL")
elif kind not in ("github", "url", "git", "npm", "pip", "git-subdir"):
warnings.append(f"{tag} {pname}: unrecognized source type {kind!r}")
if not entry.get("description"):
warnings.append(f"{tag} {pname}: no description (shown in /plugin browse)")
print(f"checked marketplace {mname} ({len(plugins)} plugins)")
def main() -> int:
if len(sys.argv) != 2:
print(__doc__.strip())
return 2
base = Path(sys.argv[1]).resolve()
manifests = [p for p in base.rglob("plugin.json")
if p.parent.name == ".claude-plugin" and not any(s in p.parts for s in SKIP)]
markets = [p for p in base.rglob("marketplace.json")
if p.parent.name == ".claude-plugin" and not any(s in p.parts for s in SKIP)]
if not manifests and not markets:
print(f"No .claude-plugin/plugin.json or marketplace.json found under {base}")
return 1
for m in sorted(markets):
check_marketplace(m.parent.parent)
for m in sorted(manifests):
check_plugin(m.parent.parent)
for e in errors:
print(f"ERROR {e}")
for w in warnings:
print(f"WARNING {w}")
print(f"Result: {'FAIL' if errors else 'PASS'} ({len(errors)} errors, {len(warnings)} warnings)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.