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.

  • nameplugin-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.

~/.claude/skills/plugin-marketplace-publisher/SKILL.md

--- name: plugin-marketplace-publisher description: Packages skills, subagents, slash commands, hooks, and MCP servers into a Claude Code plugin with .claude-plugin/plugin.json, publishes it… license: MIT ---

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

SituationUse
Skills/agents for one repo onlyCommit them in .claude/ of that repo
Same skills, agents, hooks across many repos or a whole teamPlugin in a marketplace
Personal toolkit on several machinesPlugin (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"]
}
  • name is required: kebab-case, stable forever (it becomes the namespace in /acme-dev:skill and in enabledPlugins keys).
  • Use semver for version and 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 name must equal that plugin’s plugin.json name.
  • source is a path relative to the marketplace root, a github repo 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

  1. Confirm with the user whether the repo is public or private before pushing; a public push of internal tooling cannot be taken back.
  2. Commit, push, and tag: git tag acme-dev-v1.3.0 && git push --tags.
  3. 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_DIR pointing 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.json or hook scripts. Use ${ENV_VAR} expansion and document the variables in the README.

Verification

  • check_plugin.py reports 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 242 lines · 10.9 KB
#!/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.

esc