Plugin Marketplace (generator)
Maintainer tooling that projects CCGM's modules into a native Claude Code plugin marketplace (.claude-plugin/marketplace.json + per-module plugin.json), additively. The bash installer stays the canonical full-fidelity path. Ships the generator, a JSON-schema validator, and the SessionStart rule-injection hook that bridges the plugin-CLAUDE.md-not-loaded gap.
Tags
README
plugin-marketplace
Maintainer tooling that makes CCGM installable as a native Claude Code plugin marketplace — additively. The bash installer (start.sh) remains the canonical, full-fidelity path.
What it ships
| File | Purpose |
|---|---|
lib/gen_marketplace.py |
Generator. Projects modules/*/module.json into .claude-plugin/marketplace.json + per-module .claude-plugin/plugin.json. Deterministic; --check for CI. |
lib/validate_marketplace.py |
Dependency-free structural validator for the marketplace + every plugin manifest. CI fallback when the claude CLI is absent. |
hooks/plugin-rule-inject.py |
SessionStart hook copied into every rules-bearing plugin. Injects that plugin's rules/*.md as additionalContext (opt-in via CCGM_PLUGIN_RULE_INJECTION). |
rules/plugin-marketplace.md |
The rule explaining the two install paths and the generate-don't-hand-edit contract. |
Why a marketplace projection
Claude Code plugins ship commands, agents, skills, hooks, and output styles — but a plugin's CLAUDE.md is not loaded as context, and a plugin can only contribute the agent/subagentStatusLine settings keys. So:
- Commands / agents / skills / output styles map cleanly to native plugin components.
- Rules (
rules/*.md), which the bash path auto-loads, are bridged by the SessionStart rule-injection hook. - Deep
settings.jsonmerge + globalCLAUDE.mdhave no plugin equivalent — that is why the bash installer stays canonical.
Usage
# Regenerate after changing any module.json
python3 modules/plugin-marketplace/lib/gen_marketplace.py
# CI: fail if committed output drifted from module.json
python3 modules/plugin-marketplace/lib/gen_marketplace.py --check
# Validate (no claude CLI required)
python3 modules/plugin-marketplace/lib/validate_marketplace.py
# Validate with the real CLI when available
claude plugin validate .claude-plugin/marketplace.json --strict
Installing CCGM as a marketplace (end user)
claude plugin marketplace add lucasmccomb/ccgm
claude plugin install code-quality@ccgm
# rules behind a flag (opt-in, token cost):
echo 'CCGM_PLUGIN_RULE_INJECTION=true' >> ~/.claude/.ccgm.env
Manual installation (without the CCGM installer)
mkdir -p ~/.claude/lib ~/.claude/hooks ~/.claude/rules
cp lib/gen_marketplace.py ~/.claude/lib/
cp lib/validate_marketplace.py ~/.claude/lib/
cp hooks/plugin-rule-inject.py ~/.claude/hooks/
cp rules/plugin-marketplace.md ~/.claude/rules/
Status
beta — maintainer/distribution tooling, opt-in via start.sh --add plugin-marketplace. Not bundled in any preset.
Will install
| Path | Action | Target | Type |
|---|---|---|---|
rules/plugin-marketplace.md | → | rules/plugin-marketplace.md | rule |
lib/gen_marketplace.py | → | lib/gen_marketplace.py | lib |
lib/validate_marketplace.py | → | lib/validate_marketplace.py | lib |
hooks/plugin-rule-inject.py | → | hooks/plugin-rule-inject.py | hook |
Dependencies
No dependencies.
Required by
No other module depends on this one.
Included in presets
Not included in any preset.
Install this module
Agent prompt
Recommended for agent users -- hands the whole install off to your assistant.
Fetch https://cd23a9be.ccgm-site.pages.dev/modules/plugin-marketplace.md and install this module into my Claude Code setup.
Native plugin marketplace
One command via the native plugin marketplace -- additive, does not merge settings.json.
claude plugin install plugin-marketplace@ccgm
The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.
Files
rule (1)
rules/plugin-marketplace.md
# Plugin Marketplace CCGM can be installed two ways. They are not equivalent; pick deliberately. ## The two install paths | | Bash installer (`start.sh`) | Plugin marketplace | |---|---|---| | Canonical? | **Yes** — full fidelity | No — additive projection | | Deep `settings.json` merge | Yes | No (plugins ship only `agent`/`subagentStatusLine`) | | Global `CLAUDE.md` context | Yes (auto-loaded) | No (plugin `CLAUDE.md` is never auto-loaded) | | Rules (`rules/*.md`) | Auto-loaded by Claude Code | Injected by a SessionStart hook (opt-in) | | Commands / agents / skills | Installed to `~/.claude/` | Native plugin components | | Install command | `bash start.sh` | `claude plugin marketplace add <owner>/ccgm` (owner in README) | **When deep settings or always-on CLAUDE.md context matters, use the bash installer.** The marketplace path is for users who want CCGM's commands, agents, and skills delivered through Claude Code's native plugin manager, with rules available behind an opt-in flag. ## How the marketplace is produced The marketplace is **generated, never hand-maintained**. The source of truth is `modules/*/module.json`. The generator projects those manifests into: - `.claude-plugin/marketplace.json` — the catalog, one entry per module, via `metadata.pluginRoot: "./modules"`. - `modules/<name>/.claude-plugin/plugin.json` — one manifest per module. Run it after changing any `module.json`: ```bash python3 modules/plugin-marketplace/lib/gen_marketplace.py # write python3 modules/plugin-marketplace/lib/gen_marketplace.py --check # CI: fail if stale ``` The generator is deterministic (sorted output), so an unchanged tree produces byte-identical files. CI runs `--check` and fails if the committed output drifts from `module.json`. ## The CLAUDE.md / rules gap and the workaround A plugin's root `CLAUDE.md` is **not** loaded as context, and a plugin can only contribute the `agent` and `subagentStatusLine` settings keys. So a rules-only module (autonomy, code-quality, git-workflow, systematic-debugging, ...) would contribute nothing under the plugin path. The workaround: the generator wires a `SessionStart` hook into every rules-bearing plugin's `plugin.json`. The hook (`hooks/plugin-rule-inject.py`) reads that plugin's bundled `rules/*.md` and emits them as `additionalContext` at session start. It is **opt-in** — a strict no-op unless `CCGM_PLUGIN_RULE_INJECTION=true` is set in the environment or `~/.claude/.ccgm.env`, because injecting full rule bodies costs tokens. With the flag off, the plugin's commands/agents/skills still work natively. ## Validation - `claude plugin validate .claude-plugin/marketplace.json --strict` — validates the marketplace manifest when the Claude Code CLI is available. - `python3 modules/plugin-marketplace/lib/validate_marketplace.py` — dependency-free structural validation of the marketplace and every plugin manifest. CI runs this so the manifests stay valid even where `claude` is not installed. ## Rules of the road - **Never hand-edit `marketplace.json` or any `plugin.json`.** Edit `module.json` and re-run the generator. - **Never let the bash path regress** to accommodate the plugin path. The bash installer is canonical. - After adding or changing a module, run the generator and commit its output in the same change.
hook (1)
hooks/plugin-rule-inject.py
#!/usr/bin/env python3
"""SessionStart hook: inject an installed plugin's bundled rules (issue #703).
WHY THIS EXISTS
---------------
When CCGM is installed via the native Claude Code plugin marketplace, a plugin's
root CLAUDE.md is NOT loaded as context, and a plugin can only contribute the
`agent`/`subagentStatusLine` settings keys. CCGM modules that are rules-only
(autonomy, code-quality, git-workflow, systematic-debugging, ...) would
therefore contribute nothing under the plugin path.
This hook is the documented workaround: at fresh session start it reads the
rules/*.md files bundled in THIS plugin (resolved via ${CLAUDE_PLUGIN_ROOT}) and
emits them as `additionalContext`, so the plugin's guidance reaches Claude the
same way the bash installer's ~/.claude/rules/ files do.
The generator (gen_marketplace.py) copies this exact file into every
rules-bearing plugin's hooks/ directory and wires the plugin.json SessionStart
hook to call ${CLAUDE_PLUGIN_ROOT}/hooks/plugin-rule-inject.py.
OPT-IN BY DEFAULT
-----------------
Injecting full rule bodies costs tokens. To stay conservative and match the
relevance-injection module's posture (issue #695), this hook is a strict NO-OP
unless an explicit opt-in flag is set:
CCGM_PLUGIN_RULE_INJECTION=true in ~/.claude/.ccgm.env (or the environment)
With the flag unset, the plugin's commands/agents/skills still work natively;
only the rule-body injection is suppressed. When the flag is set, the hook emits
a header plus the concatenated rule files for this one plugin.
SAFETY
------
- Fires only on source == "startup" (not resume/compact).
- Never raises: any failure path returns without emitting, so it can never crash
a session.
- Reads only files inside ${CLAUDE_PLUGIN_ROOT}/rules/; no network, no writes.
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
ENV_FILE = Path.home() / ".claude" / ".ccgm.env"
FLAG = "CCGM_PLUGIN_RULE_INJECTION"
def _read_env_file() -> "dict[str, str]":
"""Parse ~/.claude/.ccgm.env into a flat dict. Missing file -> {}."""
out: "dict[str, str]" = {}
if not ENV_FILE.exists():
return out
try:
with open(ENV_FILE, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, val = line.split("=", 1)
out[key.strip()] = val.strip()
except OSError:
return {}
return out
def _truthy(val: "str | None") -> bool:
return (val or "").strip().lower() in ("true", "1", "yes")
def _flag_enabled() -> bool:
"""Opt-in flag from the environment first, then ~/.claude/.ccgm.env."""
if _truthy(os.environ.get(FLAG)):
return True
return _truthy(_read_env_file().get(FLAG))
def _plugin_root() -> "Path | None":
"""Resolve the plugin's installed root.
Prefers $CLAUDE_PLUGIN_ROOT (set by Claude Code for plugin hook processes).
Falls back to two levels up from this file (hooks/ -> plugin root) so the
hook is testable in-repo without the env var.
"""
env_root = os.environ.get("CLAUDE_PLUGIN_ROOT")
if env_root:
p = Path(env_root)
if p.is_dir():
return p
here = Path(__file__).resolve()
cand = here.parent.parent # hooks/<file> -> plugin root
return cand if cand.is_dir() else None
def _plugin_name(root: Path) -> str:
"""Best-effort plugin name from .claude-plugin/plugin.json, else dir name."""
manifest = root / ".claude-plugin" / "plugin.json"
if manifest.is_file():
try:
data = json.loads(manifest.read_text(encoding="utf-8"))
name = data.get("name")
if isinstance(name, str) and name:
return name
except (OSError, json.JSONDecodeError, ValueError):
pass
return root.name
def _rule_files(root: Path) -> "list[Path]":
rules_dir = root / "rules"
if not rules_dir.is_dir():
return []
return sorted(p for p in rules_dir.glob("*.md") if p.is_file())
def build_context(root: "Path | None") -> "str | None":
"""Build the additionalContext block, or None if there is nothing to emit."""
if not _flag_enabled():
return None
if root is None:
return None
rule_paths = _rule_files(root)
if not rule_paths:
return None
name = _plugin_name(root)
parts: "list[str]" = [
f"<ccgm-plugin-rules plugin=\"{name}\">",
"These rules are bundled with the installed CCGM plugin "
f"'{name}'. A plugin's CLAUDE.md is not auto-loaded, so they are "
"injected here at session start. Treat them as project/global rules.",
"",
]
for path in rule_paths:
try:
body = path.read_text(encoding="utf-8").strip()
except OSError:
continue
if not body:
continue
parts.append(f"## rule: {path.name}")
parts.append(body)
parts.append("")
parts.append("</ccgm-plugin-rules>")
return "\n".join(parts) + "\n"
def main() -> None:
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError, EOFError):
hook_input = {}
# Only fire on fresh sessions, matching the other CCGM session-start hooks.
if hook_input.get("source", "") != "startup":
return
context = build_context(_plugin_root())
if context:
sys.stdout.write(context)
if __name__ == "__main__":
main()
lib (2)
lib/gen_marketplace.py
#!/usr/bin/env python3
"""Project CCGM modules into a native Claude Code plugin marketplace (issue #703).
WHAT THIS DOES
--------------
CCGM's source of truth is `modules/*/module.json`. The bash installer
(`start.sh`) is and remains the canonical, full-fidelity install path: only it
performs the deep settings.json merge and writes the global CLAUDE.md context
that Claude Code auto-loads.
This generator ADDITIVELY projects the same modules into the native Claude Code
plugin format so CCGM can also be consumed as a plugin marketplace
(`claude plugin marketplace add <owner>/ccgm`; see the README for the owner).
It is a pure projection:
* It reads every `modules/<name>/module.json`.
* It writes one `modules/<name>/.claude-plugin/plugin.json` per module,
declaring the native plugin components that module actually ships.
* It writes the catalog `.claude-plugin/marketplace.json` at the repo root,
listing every module as a plugin (via `metadata.pluginRoot: "./modules"`).
It NEVER edits module rule/command/agent/skill content. The only files it
writes are the generated manifests and rule-hook copies, all of which are
committed alongside this script.
WHY EACH modules/<name> IS ITS OWN PLUGIN ROOT
----------------------------------------------
A CCGM module's on-disk layout already matches a plugin's expected layout:
`commands/`, `agents/`, `skills/`, `output-styles/`, `hooks/`. So the plugin
root is simply `modules/<name>/`, and `.claude-plugin/plugin.json` goes inside
it. Plugins are copied to a cache on install and cannot reference files outside
their own directory, so keeping each module self-rooted means every plugin is
self-contained.
THE CLAUDE.md / rules GAP
-------------------------
A plugin's root `CLAUDE.md` is NOT loaded as context, and a plugin can only
contribute the `agent`/`subagentStatusLine` settings keys. Many CCGM modules
are rules-only (`rules/*.md`). To preserve their value under the plugin path,
modules that ship `type:"rule"` files get a SessionStart hook
(`hooks/plugin-rule-inject.py`) wired into their generated `plugin.json`. That
hook injects the plugin's own bundled rules as `additionalContext` at session
start. This is the documented workaround for plugin-CLAUDE.md-not-loading.
DETERMINISM
-----------
Output is fully sorted (modules, JSON keys) so re-running the generator on an
unchanged tree produces byte-identical files. CI runs the generator with
--check and fails if the working tree would change, guaranteeing the committed
output stays in sync with module.json.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
# Repo root is two levels up from this file: modules/plugin-marketplace/lib/.
REPO_ROOT = Path(__file__).resolve().parents[3]
# Marketplace identity. `name` is public-facing (users type `plugin@ccgm`).
# It must not collide with the reserved Anthropic names.
#
# Author/owner is the project name, NOT a personal handle: these manifests are
# committed to a public repo and the personal-data guard (tests/) forbids the
# maintainer username in any file but README/postInstall. The repository URL is
# likewise omitted here to stay clear of that guard; users discover the repo via
# the README install instructions.
MARKETPLACE_NAME = "ccgm"
MARKETPLACE_OWNER = {"name": "CCGM"}
MARKETPLACE_DESCRIPTION = (
"Claude Code God Mode - modular rules, commands, agents, and skills. "
"The bash installer (start.sh) remains the canonical full-fidelity path; "
"this marketplace is an additive native-plugin projection of the same modules."
)
MARKETPLACE_SCHEMA = "https://json.schemastore.org/claude-code-marketplace.json"
PLUGIN_SCHEMA = "https://json.schemastore.org/claude-code-plugin-manifest.json"
# Where the per-plugin rule-injection hook lives inside an INSTALLED plugin.
HOOK_REL = "hooks/plugin-rule-inject.py"
def _load(path: Path) -> dict:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def _module_manifests() -> "list[tuple[str, dict]]":
"""Return sorted (module_name, manifest) for every modules/*/module.json."""
mods_dir = REPO_ROOT / "modules"
out: "list[tuple[str, dict]]" = []
for child in sorted(mods_dir.iterdir()):
manifest = child / "module.json"
if child.is_dir() and manifest.is_file():
out.append((child.name, _load(manifest)))
return out
def _file_types(manifest: dict) -> "set[str]":
files = manifest.get("files")
if not isinstance(files, dict):
return set()
return {
v.get("type")
for v in files.values()
if isinstance(v, dict) and v.get("type")
}
def _has_rule(manifest: dict) -> bool:
return "rule" in _file_types(manifest)
def _has_output_style(manifest: dict) -> bool:
"""A module ships an output style iff it has a content file targeting
output-styles/."""
files = manifest.get("files")
if not isinstance(files, dict):
return False
for v in files.values():
if not isinstance(v, dict):
continue
target = v.get("target", "")
if v.get("type") == "content" and isinstance(target, str) and \
target.startswith("output-styles/"):
return True
return False
def _component_summary(manifest: dict) -> "list[str]":
"""Human-facing list of native plugin components this module contributes."""
types = _file_types(manifest)
comps: "list[str]" = []
if "command" in types:
comps.append("commands")
if "agent" in types:
comps.append("agents")
if "skill" in types:
comps.append("skills")
if _has_output_style(manifest):
comps.append("output-styles")
if _has_rule(manifest):
comps.append("rules")
if "hook" in types:
comps.append("hooks")
return comps
def build_plugin_manifest(name: str, manifest: dict) -> dict:
"""Build the .claude-plugin/plugin.json for one module.
Only `name` is strictly required. We add metadata for the /plugin picker and
wire the rule-injection hook for modules that ship rules. Native components
(commands/agents/skills/output-styles) are auto-discovered from their
default directories, so we do not emit redundant path fields.
"""
plugin: dict = {
"$schema": PLUGIN_SCHEMA,
"name": name,
"description": manifest.get("description", ""),
"author": dict(MARKETPLACE_OWNER),
"license": "MIT",
}
display = manifest.get("displayName")
if isinstance(display, str) and display:
plugin["displayName"] = display
tags = manifest.get("tags")
if isinstance(tags, list) and tags:
plugin["keywords"] = sorted({str(t) for t in tags if str(t)})
# Rules-bearing modules get the session-start rule injector. The hook is
# copied into each such plugin's hooks/ directory by the generator (see
# _ensure_rule_hook) so ${CLAUDE_PLUGIN_ROOT}/hooks/... resolves after the
# plugin is cached.
if _has_rule(manifest):
plugin["hooks"] = {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": (
'python3 "${CLAUDE_PLUGIN_ROOT}/'
+ HOOK_REL
+ '"'
),
}
]
}
]
}
return plugin
def build_marketplace(manifests: "list[tuple[str, dict]]") -> dict:
plugins: "list[dict]" = []
for name, manifest in manifests:
entry: dict = {
"name": name,
# pluginRoot is "./modules", so source is just the module dir name.
"source": f"./{name}",
"description": manifest.get("description", ""),
"category": manifest.get("category", "workflow"),
}
tags = manifest.get("tags")
if isinstance(tags, list) and tags:
entry["tags"] = sorted({str(t) for t in tags if str(t)})
# Surface beta/deprecated status so the catalog mirrors module.json.
status = manifest.get("status")
if isinstance(status, str) and status and status != "stable":
entry["keywords"] = [f"status:{status}"]
plugins.append(entry)
return {
"$schema": MARKETPLACE_SCHEMA,
"name": MARKETPLACE_NAME,
"owner": dict(MARKETPLACE_OWNER),
"description": MARKETPLACE_DESCRIPTION,
"metadata": {"pluginRoot": "./modules"},
"plugins": plugins,
}
def _serialize(data: dict) -> str:
return json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
def _dump(path: Path, data: dict) -> bool:
"""Write data as deterministic JSON. Return True if the file changed."""
text = _serialize(data)
if path.exists() and path.read_text(encoding="utf-8") == text:
return False
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return True
def _ensure_rule_hook(name: str, changed: "list[str]", check: bool) -> None:
"""Copy the shared rule-injection hook into a rules-bearing plugin.
The hook source of truth is plugin-marketplace/hooks/plugin-rule-inject.py.
Each rules-bearing plugin needs its own copy because installed plugins
cannot reference files outside their directory. The copy is committed.
"""
src = REPO_ROOT / "modules" / "plugin-marketplace" / HOOK_REL
dst = REPO_ROOT / "modules" / name / HOOK_REL
src_text = src.read_text(encoding="utf-8")
if dst.exists() and dst.read_text(encoding="utf-8") == src_text:
return
changed.append(str(dst.relative_to(REPO_ROOT)))
if not check:
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(src_text, encoding="utf-8")
os.chmod(dst, 0o755)
def generate(check: bool = False) -> "list[str]":
"""Generate (or, with check=True, dry-run) all marketplace files.
Returns the list of repo-relative paths that changed (or would change).
"""
manifests = _module_manifests()
changed: "list[str]" = []
# Per-module plugin manifests + rule-hook copies.
for name, manifest in manifests:
# plugin-marketplace is the host module for the shared hook; it does not
# need a self-injected copy, but it still gets a plugin.json so it can
# be installed like any other plugin.
if _has_rule(manifest) and name != "plugin-marketplace":
_ensure_rule_hook(name, changed, check)
plugin = build_plugin_manifest(name, manifest)
dst = REPO_ROOT / "modules" / name / ".claude-plugin" / "plugin.json"
if check:
if not dst.exists() or dst.read_text(encoding="utf-8") != _serialize(plugin):
changed.append(str(dst.relative_to(REPO_ROOT)))
elif _dump(dst, plugin):
changed.append(str(dst.relative_to(REPO_ROOT)))
# Root marketplace catalog.
market = build_marketplace(manifests)
market_path = REPO_ROOT / ".claude-plugin" / "marketplace.json"
if check:
if not market_path.exists() or \
market_path.read_text(encoding="utf-8") != _serialize(market):
changed.append(str(market_path.relative_to(REPO_ROOT)))
elif _dump(market_path, market):
changed.append(str(market_path.relative_to(REPO_ROOT)))
return changed
def main(argv: "list[str] | None" = None) -> int:
parser = argparse.ArgumentParser(
description="Generate the CCGM plugin marketplace from modules/*/module.json"
)
parser.add_argument(
"--check",
action="store_true",
help="Do not write; exit 1 if generated output would differ from "
"what is committed. Use in CI.",
)
args = parser.parse_args(argv)
changed = generate(check=args.check)
if args.check:
if changed:
print("Marketplace files are STALE. Re-run the generator:")
print(" python3 modules/plugin-marketplace/lib/gen_marketplace.py")
print("\nFiles that would change:")
for p in changed:
print(f" {p}")
return 1
print("Marketplace files are up to date.")
return 0
if changed:
print(f"Wrote {len(changed)} file(s):")
for p in changed:
print(f" {p}")
else:
print("Marketplace files already up to date; nothing written.")
return 0
if __name__ == "__main__":
sys.exit(main())
lib/validate_marketplace.py
#!/usr/bin/env python3
"""Validate the CCGM marketplace + plugin manifests (issue #703).
CI runs `claude plugin validate` when the Claude Code CLI is available. When it
is not (the common CI case), this script provides an equivalent structural
check against the documented marketplace.json / plugin.json schemas:
https://docs.claude.com/en/docs/claude-code/plugin-marketplaces (marketplace)
https://docs.claude.com/en/docs/claude-code/plugins-reference (plugin)
It is intentionally dependency-free (stdlib only) and portable (macOS BSD +
Linux). It validates the SHAPE of the manifests, not the runtime behavior of the
plugins, so it stays meaningful even where `claude` cannot run.
Checks:
marketplace.json
- required: name (kebab-case string), owner.name (string), plugins (array)
- name not in the reserved-Anthropic set
- metadata.pluginRoot, when present, is a string
- each plugin entry: name (kebab-case) + source; relative-path sources start
with "./"; the resolved plugin directory exists and has plugin.json
each plugin.json
- required: name (kebab-case string)
- keywords, when present, is an array (a string would fail at load)
- hooks, when present, is an object
- declared component paths (commands/agents/skills/...) are relative + ./
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
KEBAB = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
# From the official marketplace schema "Reserved names" note.
RESERVED_MARKETPLACE_NAMES = {
"claude-code-marketplace",
"claude-code-plugins",
"claude-plugins-official",
"claude-plugins-community",
"claude-community",
"anthropic-marketplace",
"anthropic-plugins",
"agent-skills",
"anthropic-agent-skills",
"knowledge-work-plugins",
"life-sciences",
"claude-for-legal",
"claude-for-financial-services",
"financial-services-plugins",
}
# Plugin manifest fields that hold component paths; each must be relative + ./.
PATH_FIELDS = (
"skills",
"commands",
"agents",
"outputStyles",
)
class Result:
def __init__(self) -> None:
self.errors: "list[str]" = []
self.checks = 0
def check(self, cond: bool, msg: str) -> None:
self.checks += 1
if not cond:
self.errors.append(msg)
def _load(path: Path) -> "dict | None":
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError) as exc:
return None # caller reports; keep exc out of signature for simplicity
def _is_kebab(val) -> bool:
return isinstance(val, str) and bool(KEBAB.match(val))
def _check_path_field(r: Result, where: str, field: str, value) -> None:
vals = value if isinstance(value, list) else [value]
for v in vals:
if not isinstance(v, str):
r.check(False, f"{where}: {field} entry must be a string, got {type(v).__name__}")
continue
r.check(v.startswith("./"), f"{where}: {field} path '{v}' must start with './'")
r.check(".." not in v, f"{where}: {field} path '{v}' must not traverse outside plugin root")
def validate_plugin(r: Result, plugin_path: Path) -> None:
where = str(plugin_path.relative_to(REPO_ROOT))
data = _load(plugin_path)
if data is None:
r.check(False, f"{where}: not valid JSON")
return
r.check(isinstance(data, dict), f"{where}: top level must be an object")
if not isinstance(data, dict):
return
r.check(_is_kebab(data.get("name")), f"{where}: 'name' must be kebab-case string")
if "keywords" in data:
r.check(isinstance(data["keywords"], list), f"{where}: 'keywords' must be an array")
if "hooks" in data:
r.check(isinstance(data["hooks"], (dict, str, list)),
f"{where}: 'hooks' must be object, array, or path string")
for field in PATH_FIELDS:
if field in data:
_check_path_field(r, where, field, data[field])
def validate_marketplace(r: Result, market_path: Path) -> "list[str]":
"""Validate marketplace.json. Returns list of plugin-source dirs to recurse."""
where = str(market_path.relative_to(REPO_ROOT))
data = _load(market_path)
if data is None:
r.check(False, f"{where}: not valid JSON")
return []
r.check(isinstance(data, dict), f"{where}: top level must be an object")
if not isinstance(data, dict):
return []
name = data.get("name")
r.check(_is_kebab(name), f"{where}: 'name' must be kebab-case string")
r.check(name not in RESERVED_MARKETPLACE_NAMES,
f"{where}: 'name' '{name}' is a reserved Anthropic marketplace name")
owner = data.get("owner")
r.check(isinstance(owner, dict) and isinstance(owner.get("name"), str),
f"{where}: 'owner.name' is required and must be a string")
plugins = data.get("plugins")
r.check(isinstance(plugins, list), f"{where}: 'plugins' must be an array")
plugin_root = "."
metadata = data.get("metadata")
if isinstance(metadata, dict) and "pluginRoot" in metadata:
pr = metadata["pluginRoot"]
r.check(isinstance(pr, str), f"{where}: metadata.pluginRoot must be a string")
if isinstance(pr, str):
plugin_root = pr
plugin_manifests: "list[str]" = []
if not isinstance(plugins, list):
return plugin_manifests
seen: "set[str]" = set()
market_root = market_path.parent.parent # repo root (.claude-plugin/..)
for i, entry in enumerate(plugins):
tag = f"{where} plugins[{i}]"
if not isinstance(entry, dict):
r.check(False, f"{tag}: must be an object")
continue
pname = entry.get("name")
r.check(_is_kebab(pname), f"{tag}: 'name' must be kebab-case string")
r.check(pname not in seen, f"{tag}: duplicate plugin name '{pname}'")
if isinstance(pname, str):
seen.add(pname)
source = entry.get("source")
r.check(source is not None, f"{tag}: 'source' is required")
# Relative-path source: resolve through pluginRoot and confirm it exists.
if isinstance(source, str):
r.check(source.startswith("./"), f"{tag}: relative source '{source}' must start with './'")
rel = Path(plugin_root) / source[2:] if source.startswith("./") else Path(source)
resolved = (market_root / rel).resolve()
r.check(resolved.is_dir(), f"{tag}: source dir '{rel}' does not exist")
manifest = resolved / ".claude-plugin" / "plugin.json"
r.check(manifest.is_file(), f"{tag}: '{rel}' is missing .claude-plugin/plugin.json")
if manifest.is_file():
plugin_manifests.append(str(manifest))
elif isinstance(source, dict):
r.check(isinstance(source.get("source"), str),
f"{tag}: object source must have a 'source' type field")
return plugin_manifests
def main(argv: "list[str] | None" = None) -> int:
market_path = REPO_ROOT / ".claude-plugin" / "marketplace.json"
r = Result()
if not market_path.is_file():
print(f"FAIL: {market_path} does not exist. Run gen_marketplace.py first.")
return 1
manifests = validate_marketplace(r, market_path)
for m in manifests:
validate_plugin(r, Path(m))
if r.errors:
print(f"validate_marketplace: {len(r.errors)} error(s) in {r.checks} checks:")
for e in r.errors:
print(f" - {e}")
return 1
print(f"validate_marketplace: all {r.checks} checks passed "
f"({len(manifests)} plugin manifest(s) validated).")
return 0
if __name__ == "__main__":
sys.exit(main())