← Files AMDARCHIVED FILE

.github/scripts/validate_marketplace.py

7.76 KB · Nov 12, 2026 · 18:37 UTC

↓ Download file

See the change to this file →

#!/usr/bin/env -S uv run --quiet
# /// script
# requires-python = ">=3.10"
# ///
"""Validate this catalog's published plugin bundle against the skills on disk.

Whether a skill is *well formed* -- frontmatter, `name` rules, description
length, body length, the governance card, the eval dataset, the references its
markdown makes -- is checked by skillscope, which grades every repo that keeps
skills in a tree the same way. See .github/workflows/evals.yml for how this
repo configures it, and docs/skill-requirements.md for the rules.

What skillscope deliberately does not read is a repo-wide manifest: which
skills a catalog publishes, and where it lists them, is that catalog's
business, and a harness with an opinion about it would be a second place to
update every time a skill ships. So that check stays here.

It validates the single-bundle plugin model: `.claude-plugin/marketplace.json`
lists exactly one plugin whose `source` is the repo root (`./`) with
`strict: false`, and whose `skills` array names the published skill folders as
`./skills/<name>` paths. Each listed path must resolve to a real skill under
`skills/`. Skills that are not listed are allowed -- they are simply
unpublished (the "canonical catalog, curated publish" model), so a skill can
live under `skills/` without shipping. No files are duplicated: the bundle
ships the skill folders in place, so there is no generated `plugins/` tree to
keep in sync.

Run from the repo root:

    ./.github/scripts/check.sh                              # thin wrapper; what CI runs
    uv run .github/scripts/validate_marketplace.py
    uv run .github/scripts/validate_marketplace.py --skills-dir skills

Exits non-zero if the manifest and `skills/` disagree.

One thing this cannot see: the routing room in .github/workflows/evals.yml is
listed by hand, so publishing a skill means adding it there as well. That is
deliberate -- who a skill competes against is what its routing score means, so
it is a visible diff rather than something derived from this file.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent.parent
DEFAULT_SKILLS_DIR = REPO_ROOT / "skills"
CLAUDE_MARKETPLACE = REPO_ROOT / ".claude-plugin" / "marketplace.json"

# Every published skill is referenced from the bundle's `skills` array as a
# path of this form, relative to the plugin `source` (the repo root).
SKILLS_PATH_PREFIX = "./skills/"


def discover_skills(root: Path) -> list[Path]:
    """List skill directories under `root`, ignoring dotfiles."""
    if not root.exists():
        return []
    return sorted(
        p for p in root.iterdir() if p.is_dir() and not p.name.startswith(".")
    )


def validate_claude_marketplace(skill_dirs: list[Path]) -> list[str]:
    """Return error strings if the bundle plugin doesn't match skills/ on disk.

    AMD ships a single curated plugin whose `source` is the repo root (`./`)
    with `strict: false` (so no `plugin.json` is needed). Its `skills` array
    lists the published skills as `./skills/<name>` paths; each must resolve to
    a real skill under `skills/`. Skills that are not listed are allowed -- they
    are simply unpublished. The plugin's human-readable `description` is
    intentionally allowed to differ from the SKILL.md descriptions (per
    docs/skill-requirements.md), so this only enforces that names and paths line up.
    """
    if not CLAUDE_MARKETPLACE.exists():
        return [
            f"Missing {CLAUDE_MARKETPLACE.relative_to(REPO_ROOT)}; expected the "
            "AMD bundle plugin entry."
        ]

    try:
        data = json.loads(CLAUDE_MARKETPLACE.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        return [f"{CLAUDE_MARKETPLACE.relative_to(REPO_ROOT)}: invalid JSON: {exc}"]

    plugins = data.get("plugins") if isinstance(data, dict) else None
    if not isinstance(plugins, list):
        return [
            f"{CLAUDE_MARKETPLACE.relative_to(REPO_ROOT)}: top-level `plugins` "
            "array is missing."
        ]
    if len(plugins) != 1:
        return [
            f"{CLAUDE_MARKETPLACE.relative_to(REPO_ROOT)}: expected exactly one "
            f"plugin (the AMD bundle), found {len(plugins)}."
        ]

    entry = plugins[0]
    if not isinstance(entry, dict):
        return ["plugins[0] must be an object."]

    errors: list[str] = []
    name = entry.get("name")
    source = entry.get("source")
    description = entry.get("description")

    if not isinstance(name, str) or not name:
        errors.append("plugins[0] is missing a non-empty `name`.")
        return errors

    if source != "./":
        errors.append(
            f"plugins[0] (`{name}`): `source` must be `./` (the repo root is the "
            f"bundle), got `{source}`."
        )
    # With a repo-root source the plugin ships no `plugin.json`, so the entry
    # must declare `strict: false` or Claude Code will look for one and fail.
    if entry.get("strict") is not False:
        errors.append(
            f"plugins[0] (`{name}`): must set `strict` to `false` (no plugin.json "
            "ships with a repo-root source)."
        )
    if not isinstance(description, str) or not description.strip():
        errors.append(f"plugins[0] (`{name}`) is missing a non-empty `description`.")

    errors.extend(_validate_bundle_skills(entry, {p.name for p in skill_dirs}))
    return errors


def _validate_bundle_skills(entry: dict, skill_names: set[str]) -> list[str]:
    """Check the bundle's `skills` paths resolve to real skills under skills/."""
    skills = entry.get("skills")
    if not isinstance(skills, list) or not skills:
        return [
            "plugins[0] `skills` must be a non-empty list of "
            f"`{SKILLS_PATH_PREFIX}<name>` paths naming the published skills."
        ]

    errors: list[str] = []
    seen: set[str] = set()
    for path in skills:
        if not isinstance(path, str) or not path.startswith(SKILLS_PATH_PREFIX):
            errors.append(
                f"`skills` entry {path!r} must be a `{SKILLS_PATH_PREFIX}<name>` path."
            )
            continue
        skill = path[len(SKILLS_PATH_PREFIX) :].strip("/")
        if not skill or "/" in skill:
            errors.append(f"`skills` entry {path!r} must point at a single skill folder.")
            continue
        if skill in seen:
            errors.append(f"`skills` lists `{path}` more than once.")
            continue
        seen.add(skill)
        if skill not in skill_names:
            errors.append(
                f"`skills` names `{path}`, which has no directory under skills/."
            )

    # Skills present under skills/ but absent from `skills` are intentionally
    # unpublished, so there is no error for that difference here.
    return errors


def run(skills_dir: Path) -> int:
    skills = discover_skills(skills_dir)
    if not skills:
        print(f"No skills found under {skills_dir}", file=sys.stderr)
        return 1

    errors = validate_claude_marketplace(skills)
    status = "OK  " if not errors else "FAIL"
    print(f"[{status}] .claude-plugin/marketplace.json")
    for err in errors:
        print(f"        {err}")
    print(
        f"\nSummary: {len(errors)} error(s) in the manifest, "
        f"checked against {len(skills)} skill(s) in {skills_dir}"
    )
    return 0 if not errors else 1


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument(
        "--skills-dir",
        type=Path,
        default=DEFAULT_SKILLS_DIR,
        help=f"Directory containing skill folders (default: {DEFAULT_SKILLS_DIR}).",
    )
    args = parser.parse_args(argv)
    return run(args.skills_dir.resolve())


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 1e3461cfc7871193230df887c43bb7ad19cfd425dcf45b9ed50dd22bdbf31933