mirror of
https://github.com/alirezarezvani/claude-skills.git
synced 2026-08-28 04:24:58 +00:00
feat(registry): add community skills registry (schema, index, validate, site, CI)
Adds a gitagent-style registry under registry/ that catalogs this repo's own plugins (seeded from .claude-plugin/marketplace.json) alongside community-submitted skills hosted in external repos. GitHub is the source of truth — no backend. - schema/metadata.schema.json: draft-07 submission schema - scripts/build_index.py: stdlib index builder (deterministic; --github, --check) - scripts/validate.py: stdlib schema + structural validator with optional repo clone - skills/: community submissions; includes a working template example - site/: vanilla HTML/CSS/JS browse + search (no build step) - .github/workflows/registry.yml: validate PRs + keep index.json in sync (no Pages deploy — that stays owned by the MkDocs docs site) https://claude.ai/code/session_01CYgAb1quXh3XQhYScBZRca
This commit is contained in:
parent
eace618ba3
commit
44e1a5b2d3
9 changed files with 2576 additions and 0 deletions
56
.github/workflows/registry.yml
vendored
Normal file
56
.github/workflows/registry.yml
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
name: Registry
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'registry/**'
|
||||
- '.claude-plugin/marketplace.json'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate-submissions:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Detect changed submission folders
|
||||
id: changed
|
||||
run: |
|
||||
FOLDERS=$(git diff --name-only "${{ github.event.pull_request.base.sha }}" HEAD -- registry/skills/ \
|
||||
| grep '/' \
|
||||
| cut -d'/' -f1-3 \
|
||||
| sort -u \
|
||||
| tr '\n' ' ')
|
||||
echo "folders=$FOLDERS" >> "$GITHUB_OUTPUT"
|
||||
echo "Changed submission folders: $FOLDERS"
|
||||
|
||||
- name: Validate changed submissions
|
||||
if: steps.changed.outputs.folders != ''
|
||||
run: |
|
||||
RESULT=0
|
||||
for folder in ${{ steps.changed.outputs.folders }}; do
|
||||
echo "::group::Validating $folder"
|
||||
python registry/scripts/validate.py --clone "$folder" || RESULT=1
|
||||
echo "::endgroup::"
|
||||
done
|
||||
exit $RESULT
|
||||
|
||||
check-index:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Verify index.json is in sync
|
||||
run: python registry/scripts/build_index.py --check
|
||||
103
registry/CONTRIBUTING.md
Normal file
103
registry/CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# Contributing to the Claude Skills Registry
|
||||
|
||||
Share your skill with the community. This guide walks through submitting a skill
|
||||
hosted in your own public GitHub repository.
|
||||
|
||||
## Requirements
|
||||
|
||||
Your skill must:
|
||||
|
||||
1. Be hosted in a **public GitHub repository**
|
||||
2. Have a `SKILL.md` at the repo root (or at the path you declare)
|
||||
3. Be usable with at least one supported tool (Claude Code, Codex, Gemini CLI, …)
|
||||
|
||||
## Submission steps
|
||||
|
||||
### 1. Fork this repository
|
||||
|
||||
### 2. Create your submission folder
|
||||
|
||||
```
|
||||
registry/skills/<your-github-username>__<skill-name>/
|
||||
```
|
||||
|
||||
The folder name uses a **double underscore** (`__`) to separate your GitHub
|
||||
username from the skill name. Both halves must match `author` and `name` in your
|
||||
`metadata.json`.
|
||||
|
||||
### 3. Add the required files
|
||||
|
||||
#### `metadata.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-skill",
|
||||
"author": "your-github-username",
|
||||
"description": "A short description of what your skill does",
|
||||
"repository": "https://github.com/your-username/your-repo",
|
||||
"path": "",
|
||||
"version": "1.0.0",
|
||||
"category": "productivity",
|
||||
"tags": ["tag1", "tag2"],
|
||||
"license": "MIT",
|
||||
"adapters": ["claude-code"],
|
||||
"icon": false,
|
||||
"banner": false
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `name` | Yes | Skill name (lowercase, hyphens) |
|
||||
| `author` | Yes | Your GitHub username |
|
||||
| `description` | Yes | What the skill does (10–300 chars) |
|
||||
| `repository` | Yes | Public GitHub repo URL (`https://github.com/...`) |
|
||||
| `path` | No | Subdirectory in the repo where `SKILL.md` lives (default: root) |
|
||||
| `version` | Yes | Semver version |
|
||||
| `category` | Yes | One of the allowed categories (below) |
|
||||
| `tags` | Yes | 1–10 lowercase-hyphen tags |
|
||||
| `license` | Yes | SPDX license identifier |
|
||||
| `model` | No | Preferred model identifier |
|
||||
| `adapters` | No | Supported tools (e.g. `claude-code`, `codex`, `gemini-cli`) |
|
||||
| `icon` | No | `true` if `icon.png` (256×256) is included |
|
||||
| `banner` | No | `true` if `banner.png` (1200×630) is included |
|
||||
|
||||
**Categories:** `development`, `data-engineering`, `devops`, `security`,
|
||||
`compliance`, `documentation`, `testing`, `research`, `productivity`, `finance`,
|
||||
`leadership`, `product`, `marketing`, `project-management`, `business-growth`,
|
||||
`commercial`, `operations`, `design`, `knowledge`, `customer-support`,
|
||||
`creative`, `education`, `other`.
|
||||
|
||||
#### `README.md`
|
||||
|
||||
A markdown description of your skill — what it does, key capabilities, example
|
||||
usage. Shown on the registry.
|
||||
|
||||
#### `icon.png` / `banner.png` (optional)
|
||||
|
||||
A 256×256 icon and/or 1200×630 banner. Set the matching boolean in
|
||||
`metadata.json` to `true` when included.
|
||||
|
||||
### 4. Validate locally (optional but recommended)
|
||||
|
||||
```bash
|
||||
python registry/scripts/validate.py --clone registry/skills/<author>__<name>/
|
||||
```
|
||||
|
||||
### 5. Open a pull request
|
||||
|
||||
CI will automatically:
|
||||
|
||||
- Validate `metadata.json` against the schema
|
||||
- Check the folder name matches `<author>__<name>`
|
||||
- Verify `README.md` exists and is non-empty
|
||||
- Clone your repository and verify `SKILL.md` exists at the declared path
|
||||
- Confirm the committed `index.json` is in sync
|
||||
|
||||
## Updating your skill
|
||||
|
||||
Open a new PR modifying your folder and bump `version` in `metadata.json`.
|
||||
|
||||
## Questions?
|
||||
|
||||
Open an issue or discussion in this repository.
|
||||
70
registry/README.md
Normal file
70
registry/README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Claude Skills Registry
|
||||
|
||||
A browsable, searchable registry of skills — the skill packages that ship in
|
||||
this repo **plus** community-submitted skills hosted in their own repos.
|
||||
|
||||
Modeled on the [gitagent registry](https://registry.gitagent.sh) pattern:
|
||||
GitHub is the source of truth, there is no database and no backend.
|
||||
|
||||
```
|
||||
PR → CI validates → merge → index.json regenerated → static site reads index.json
|
||||
```
|
||||
|
||||
## What's in here
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `schema/metadata.schema.json` | JSON Schema for a community submission's `metadata.json` |
|
||||
| `scripts/validate.py` | Validate a submission folder (stdlib only) |
|
||||
| `scripts/build_index.py` | Generate `index.json` from internal + community skills (stdlib only) |
|
||||
| `skills/<author>__<name>/` | Community submission folders |
|
||||
| `index.json` | Generated catalog the site reads |
|
||||
| `site/` | Static browse/search UI (vanilla HTML/CSS/JS, no build step) |
|
||||
|
||||
## Two sources, one index
|
||||
|
||||
- **Internal** entries are derived automatically from
|
||||
[`.claude-plugin/marketplace.json`](../.claude-plugin/marketplace.json) — every
|
||||
plugin in this repo appears in the registry.
|
||||
- **Community** entries are folders under `skills/`, each pointing at an external
|
||||
public GitHub repo that contains a `SKILL.md`.
|
||||
|
||||
## Develop
|
||||
|
||||
No npm, no build system — Python standard library only.
|
||||
|
||||
```bash
|
||||
# Regenerate index.json (deterministic, no network)
|
||||
python registry/scripts/build_index.py
|
||||
|
||||
# Enrich entries with live GitHub stars/forks (network)
|
||||
python registry/scripts/build_index.py --github
|
||||
|
||||
# Fail if the committed index.json is stale (used in CI)
|
||||
python registry/scripts/build_index.py --check
|
||||
|
||||
# Validate every community submission
|
||||
python registry/scripts/validate.py --all
|
||||
|
||||
# Validate + clone each repo to confirm SKILL.md exists (network)
|
||||
python registry/scripts/validate.py --clone registry/skills/<author>__<name>/
|
||||
```
|
||||
|
||||
## Run the site locally
|
||||
|
||||
```bash
|
||||
cd registry
|
||||
python -m http.server 8000
|
||||
# open http://localhost:8000/site/
|
||||
```
|
||||
|
||||
The site fetches `index.json`; serve from the `registry/` directory so the
|
||||
relative path resolves.
|
||||
|
||||
## Submit a skill
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
1704
registry/index.json
Normal file
1704
registry/index.json
Normal file
File diff suppressed because it is too large
Load diff
121
registry/schema/metadata.schema.json
Normal file
121
registry/schema/metadata.schema.json
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://github.com/alirezarezvani/claude-skills/registry/schema/metadata.schema.json",
|
||||
"title": "Claude Skills Registry Metadata",
|
||||
"description": "Schema for a community skill submission's metadata.json in the claude-skills registry",
|
||||
"type": "object",
|
||||
"required": ["name", "author", "description", "repository", "version", "category", "tags", "license"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
|
||||
"minLength": 2,
|
||||
"maxLength": 64,
|
||||
"description": "Skill name (lowercase, hyphens only)"
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9-]*$",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"description": "GitHub username of the author"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 10,
|
||||
"maxLength": 300,
|
||||
"description": "Short description of what the skill does"
|
||||
},
|
||||
"repository": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"pattern": "^https://github\\.com/",
|
||||
"description": "Public GitHub repository URL hosting the skill"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"description": "Subdirectory within repo where SKILL.md lives (default: root)"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+",
|
||||
"description": "Semver version"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"development",
|
||||
"data-engineering",
|
||||
"devops",
|
||||
"security",
|
||||
"compliance",
|
||||
"documentation",
|
||||
"testing",
|
||||
"research",
|
||||
"productivity",
|
||||
"finance",
|
||||
"leadership",
|
||||
"product",
|
||||
"marketing",
|
||||
"project-management",
|
||||
"business-growth",
|
||||
"commercial",
|
||||
"operations",
|
||||
"design",
|
||||
"knowledge",
|
||||
"customer-support",
|
||||
"creative",
|
||||
"education",
|
||||
"other"
|
||||
],
|
||||
"description": "Skill category"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+$",
|
||||
"maxLength": 32
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 10,
|
||||
"uniqueItems": true,
|
||||
"description": "Tags for discoverability"
|
||||
},
|
||||
"license": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"description": "SPDX license identifier"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"description": "Preferred model identifier (optional)"
|
||||
},
|
||||
"adapters": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"description": "Supported tools/adapters (e.g. claude-code, codex, gemini-cli)"
|
||||
},
|
||||
"icon": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Whether icon.png is included (256x256 PNG)"
|
||||
},
|
||||
"banner": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Whether banner.png is included (1200x630 PNG, used for social sharing / OG image)"
|
||||
}
|
||||
}
|
||||
}
|
||||
233
registry/scripts/build_index.py
Executable file
233
registry/scripts/build_index.py
Executable file
|
|
@ -0,0 +1,233 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generate registry/index.json from two sources.
|
||||
|
||||
1. Internal skills — the plugins declared in .claude-plugin/marketplace.json
|
||||
(this repo's own skill packages).
|
||||
2. Community skills — submission folders under registry/skills/<author>__<name>/
|
||||
each containing a metadata.json (validated by validate.py).
|
||||
|
||||
Usage:
|
||||
python registry/scripts/build_index.py # deterministic, no network
|
||||
python registry/scripts/build_index.py --github # enrich entries with live
|
||||
# GitHub stars/forks (network)
|
||||
python registry/scripts/build_index.py --check # fail if index.json is stale
|
||||
|
||||
The default run performs no network calls so the committed index.json is
|
||||
reproducible in CI. Stdlib only.
|
||||
|
||||
Exit code: 0 on success; with --check, 1 if the on-disk index.json differs from
|
||||
a freshly built one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
REGISTRY_ROOT = Path(__file__).resolve().parent.parent
|
||||
REPO_ROOT = REGISTRY_ROOT.parent
|
||||
MARKETPLACE_PATH = REPO_ROOT / ".claude-plugin" / "marketplace.json"
|
||||
SKILLS_DIR = REGISTRY_ROOT / "skills"
|
||||
INDEX_PATH = REGISTRY_ROOT / "index.json"
|
||||
|
||||
REPO_URL = "https://github.com/alirezarezvani/claude-skills"
|
||||
RAW_BASE = "https://raw.githubusercontent.com/alirezarezvani/claude-skills/main"
|
||||
TREE_BASE = f"{REPO_URL}/tree/main"
|
||||
|
||||
|
||||
def _git_first_commit_date(rel_path: str) -> str:
|
||||
"""Date of the first commit that touched rel_path (YYYY-MM-DD)."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "log", "--diff-filter=A", "--format=%aI", "--", rel_path],
|
||||
cwd=REPO_ROOT,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=20,
|
||||
).stdout.strip().splitlines()
|
||||
if out:
|
||||
return out[-1].split("T")[0]
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
return date.today().isoformat()
|
||||
|
||||
|
||||
def _fetch_github_stats(repository: str) -> dict | None:
|
||||
"""Best-effort GitHub repo stats via the public API (only with --github)."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
repo_path = repository.replace("https://github.com/", "").rstrip("/")
|
||||
api = f"https://api.github.com/repos/{repo_path}"
|
||||
try:
|
||||
req = urllib.request.Request(api, headers={"User-Agent": "claude-skills-registry"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp: # noqa: S310 (https only)
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return {
|
||||
"stars": data.get("stargazers_count", 0),
|
||||
"forks": data.get("forks_count", 0),
|
||||
"issues": data.get("open_issues_count", 0),
|
||||
"language": data.get("language"),
|
||||
"avatar": (data.get("owner") or {}).get("avatar_url", ""),
|
||||
"description": data.get("description"),
|
||||
}
|
||||
except (urllib.error.URLError, ValueError, KeyError, TimeoutError):
|
||||
return None
|
||||
|
||||
|
||||
def _entry_base(meta: dict) -> dict:
|
||||
"""Common fields shared by internal and community entries."""
|
||||
return {
|
||||
"name": meta["name"],
|
||||
"author": meta["author"],
|
||||
"description": meta["description"],
|
||||
"category": meta.get("category", "other"),
|
||||
"tags": meta.get("tags", []),
|
||||
"version": meta.get("version", "0.0.0"),
|
||||
"license": meta.get("license", "MIT"),
|
||||
}
|
||||
|
||||
|
||||
def build_internal_entries(with_github: bool) -> list[dict]:
|
||||
if not MARKETPLACE_PATH.exists():
|
||||
return []
|
||||
market = json.loads(MARKETPLACE_PATH.read_text(encoding="utf-8"))
|
||||
owner = (market.get("owner") or {}).get("name", "alirezarezvani")
|
||||
entries: list[dict] = []
|
||||
for plugin in market.get("plugins", []):
|
||||
source = (plugin.get("source") or "").lstrip("./")
|
||||
author = (plugin.get("author") or {}).get("name") or owner
|
||||
skill_md = REPO_ROOT / source / "SKILL.md"
|
||||
readme = (
|
||||
f"{RAW_BASE}/{source}/SKILL.md"
|
||||
if skill_md.exists()
|
||||
else f"{TREE_BASE}/{source}"
|
||||
)
|
||||
entry = {
|
||||
"name": plugin["name"],
|
||||
"author": author,
|
||||
"description": plugin.get("description", ""),
|
||||
"category": plugin.get("category", "other"),
|
||||
"tags": plugin.get("keywords", []),
|
||||
"version": plugin.get("version", "0.0.0"),
|
||||
"license": "MIT",
|
||||
"origin": "internal",
|
||||
"repository": REPO_URL,
|
||||
"path": source,
|
||||
"readme": readme,
|
||||
"icon": None,
|
||||
"banner": None,
|
||||
"github": None,
|
||||
"added_at": _git_first_commit_date(source) if source else date.today().isoformat(),
|
||||
}
|
||||
if with_github:
|
||||
entry["github"] = _fetch_github_stats(REPO_URL)
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def build_community_entries(with_github: bool) -> list[dict]:
|
||||
if not SKILLS_DIR.exists():
|
||||
return []
|
||||
entries: list[dict] = []
|
||||
for folder in sorted(SKILLS_DIR.iterdir()):
|
||||
if not folder.is_dir() or "__" not in folder.name:
|
||||
continue
|
||||
meta_path = folder / "metadata.json"
|
||||
if not meta_path.exists():
|
||||
print(f" skipping {folder.name}: no metadata.json", file=sys.stderr)
|
||||
continue
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f" skipping {folder.name}: invalid metadata.json — {exc}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
has_icon = meta.get("icon") is True and (folder / "icon.png").exists()
|
||||
has_banner = meta.get("banner") is True and (folder / "banner.png").exists()
|
||||
rel = f"registry/skills/{folder.name}"
|
||||
|
||||
entry = _entry_base(meta)
|
||||
entry.update(
|
||||
{
|
||||
"origin": "community",
|
||||
"repository": meta["repository"],
|
||||
"path": meta.get("path", ""),
|
||||
"readme": f"{RAW_BASE}/{rel}/README.md",
|
||||
"icon": f"{RAW_BASE}/{rel}/icon.png" if has_icon else None,
|
||||
"banner": f"{RAW_BASE}/{rel}/banner.png" if has_banner else None,
|
||||
"github": _fetch_github_stats(meta["repository"]) if with_github else None,
|
||||
"added_at": _git_first_commit_date(rel),
|
||||
}
|
||||
)
|
||||
if "adapters" in meta:
|
||||
entry["adapters"] = meta["adapters"]
|
||||
if "model" in meta:
|
||||
entry["model"] = meta["model"]
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def build_index(with_github: bool = False) -> dict:
|
||||
internal = build_internal_entries(with_github)
|
||||
community = build_community_entries(with_github)
|
||||
agents = internal + community
|
||||
agents.sort(key=lambda e: (e["origin"] != "community", e["name"].lower()))
|
||||
return {
|
||||
"skills": agents,
|
||||
"total": len(agents),
|
||||
"counts": {"internal": len(internal), "community": len(community)},
|
||||
"generated_at": date.today().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _serialize(index: dict) -> str:
|
||||
return json.dumps(index, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Build registry/index.json.")
|
||||
parser.add_argument(
|
||||
"--github", action="store_true", help="enrich entries with live GitHub stats (network)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="do not write; exit 1 if index.json is out of date",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
index = build_index(with_github=args.github)
|
||||
serialized = _serialize(index)
|
||||
|
||||
if args.check:
|
||||
# generated_at changes daily; compare everything except that field.
|
||||
current = json.loads(INDEX_PATH.read_text(encoding="utf-8")) if INDEX_PATH.exists() else {}
|
||||
fresh = json.loads(serialized)
|
||||
current.pop("generated_at", None)
|
||||
fresh.pop("generated_at", None)
|
||||
if current != fresh:
|
||||
print(
|
||||
"index.json is stale. Run: python registry/scripts/build_index.py",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print("index.json is up to date.")
|
||||
return 0
|
||||
|
||||
INDEX_PATH.write_text(serialized, encoding="utf-8")
|
||||
print(
|
||||
f"Wrote {INDEX_PATH.relative_to(REPO_ROOT)}: "
|
||||
f"{index['total']} skills "
|
||||
f"({index['counts']['internal']} internal, {index['counts']['community']} community)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
256
registry/scripts/validate.py
Executable file
256
registry/scripts/validate.py
Executable file
|
|
@ -0,0 +1,256 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate a claude-skills registry submission folder.
|
||||
|
||||
Usage:
|
||||
python registry/scripts/validate.py registry/skills/<author>__<skill-name>/ [more...]
|
||||
python registry/scripts/validate.py --all # validate every submission
|
||||
python registry/scripts/validate.py --clone <dir> # also clone repo + verify SKILL.md
|
||||
|
||||
Checks:
|
||||
1. metadata.json exists and validates against schema/metadata.schema.json
|
||||
2. Folder name matches <author>__<name> from metadata
|
||||
3. README.md exists and is non-empty
|
||||
4. If icon: true, icon.png exists; if banner: true, banner.png exists
|
||||
5. With --clone: clones the repository (shallow) and verifies SKILL.md exists
|
||||
at the declared path
|
||||
|
||||
Stdlib only. The JSON-Schema validation supports the draft-07 subset used by
|
||||
schema/metadata.schema.json (type/required/properties/additionalProperties/
|
||||
enum/pattern/min-maxLength/min-maxItems/uniqueItems/items/format:uri).
|
||||
|
||||
Exit code: 0 if all submissions pass, 1 otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
REGISTRY_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCHEMA_PATH = REGISTRY_ROOT / "schema" / "metadata.schema.json"
|
||||
SKILLS_DIR = REGISTRY_ROOT / "skills"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Minimal JSON-Schema (draft-07 subset) validator
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _type_ok(value, expected: str) -> bool:
|
||||
if expected == "string":
|
||||
return isinstance(value, str)
|
||||
if expected == "array":
|
||||
return isinstance(value, list)
|
||||
if expected == "object":
|
||||
return isinstance(value, dict)
|
||||
if expected == "boolean":
|
||||
return isinstance(value, bool)
|
||||
if expected == "integer":
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
if expected == "number":
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
return True
|
||||
|
||||
|
||||
def validate_schema(instance, schema, path: str = "") -> list[str]:
|
||||
"""Return a list of human-readable schema violation strings (empty == valid)."""
|
||||
errors: list[str] = []
|
||||
here = path or "(root)"
|
||||
|
||||
expected_type = schema.get("type")
|
||||
if expected_type and not _type_ok(instance, expected_type):
|
||||
errors.append(f"{here}: expected type {expected_type}")
|
||||
return errors # further checks assume the type matched
|
||||
|
||||
if "enum" in schema and instance not in schema["enum"]:
|
||||
errors.append(f"{here}: '{instance}' is not one of {schema['enum']}")
|
||||
|
||||
if isinstance(instance, str):
|
||||
if "minLength" in schema and len(instance) < schema["minLength"]:
|
||||
errors.append(f"{here}: shorter than minLength {schema['minLength']}")
|
||||
if "maxLength" in schema and len(instance) > schema["maxLength"]:
|
||||
errors.append(f"{here}: longer than maxLength {schema['maxLength']}")
|
||||
if "pattern" in schema and not re.search(schema["pattern"], instance):
|
||||
errors.append(f"{here}: does not match pattern {schema['pattern']}")
|
||||
if schema.get("format") == "uri" and not re.match(r"^[a-z][a-z0-9+.\-]*://", instance):
|
||||
errors.append(f"{here}: not a valid URI")
|
||||
|
||||
if isinstance(instance, list):
|
||||
if "minItems" in schema and len(instance) < schema["minItems"]:
|
||||
errors.append(f"{here}: fewer than minItems {schema['minItems']}")
|
||||
if "maxItems" in schema and len(instance) > schema["maxItems"]:
|
||||
errors.append(f"{here}: more than maxItems {schema['maxItems']}")
|
||||
if schema.get("uniqueItems") and len(instance) != len(
|
||||
{json.dumps(i, sort_keys=True) for i in instance}
|
||||
):
|
||||
errors.append(f"{here}: items are not unique")
|
||||
item_schema = schema.get("items")
|
||||
if item_schema:
|
||||
for idx, item in enumerate(instance):
|
||||
errors.extend(validate_schema(item, item_schema, f"{here}[{idx}]"))
|
||||
|
||||
if isinstance(instance, dict):
|
||||
for req in schema.get("required", []):
|
||||
if req not in instance:
|
||||
errors.append(f"{here}: missing required property '{req}'")
|
||||
props = schema.get("properties", {})
|
||||
if schema.get("additionalProperties") is False:
|
||||
for key in instance:
|
||||
if key not in props:
|
||||
errors.append(f"{here}: additional property '{key}' not allowed")
|
||||
for key, subschema in props.items():
|
||||
if key in instance:
|
||||
errors.extend(validate_schema(instance[key], subschema, f"{here}.{key}"))
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Submission validation
|
||||
# --------------------------------------------------------------------------- #
|
||||
class Result:
|
||||
def __init__(self) -> None:
|
||||
self.errors: list[str] = []
|
||||
self.warnings: list[str] = []
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
return not self.errors
|
||||
|
||||
|
||||
def validate_submission(folder: Path, schema: dict, clone: bool = False) -> Result:
|
||||
result = Result()
|
||||
folder = folder.resolve()
|
||||
|
||||
if not folder.is_dir():
|
||||
result.errors.append(f"folder does not exist: {folder}")
|
||||
return result
|
||||
|
||||
folder_name = folder.name
|
||||
if "__" not in folder_name:
|
||||
result.errors.append(
|
||||
f"folder name must use <author>__<name> format, got: {folder_name}"
|
||||
)
|
||||
return result
|
||||
|
||||
metadata_path = folder / "metadata.json"
|
||||
if not metadata_path.exists():
|
||||
result.errors.append("metadata.json not found")
|
||||
return result
|
||||
|
||||
try:
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
result.errors.append(f"metadata.json is not valid JSON: {exc}")
|
||||
return result
|
||||
|
||||
for err in validate_schema(metadata, schema):
|
||||
result.errors.append(f"schema: {err}")
|
||||
|
||||
author = metadata.get("author")
|
||||
name = metadata.get("name")
|
||||
if author and name:
|
||||
expected = f"{author}__{name}"
|
||||
if folder_name != expected:
|
||||
result.errors.append(
|
||||
f"folder name mismatch: expected '{expected}' from metadata, got '{folder_name}'"
|
||||
)
|
||||
|
||||
readme_path = folder / "README.md"
|
||||
if not readme_path.exists():
|
||||
result.errors.append("README.md not found")
|
||||
else:
|
||||
content = readme_path.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
result.errors.append("README.md is empty")
|
||||
elif len(content) < 50:
|
||||
result.warnings.append("README.md is very short — consider adding more detail")
|
||||
|
||||
if metadata.get("icon") is True and not (folder / "icon.png").exists():
|
||||
result.errors.append('icon.png not found but metadata has "icon": true')
|
||||
if metadata.get("banner") is True and not (folder / "banner.png").exists():
|
||||
result.errors.append('banner.png not found but metadata has "banner": true')
|
||||
|
||||
if clone and isinstance(metadata.get("repository"), str) and not result.errors:
|
||||
_clone_and_verify(metadata, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _clone_and_verify(metadata: dict, result: Result) -> None:
|
||||
repo = metadata["repository"]
|
||||
tmp = Path(tempfile.mkdtemp(prefix="registry-validate-"))
|
||||
try:
|
||||
print(f" cloning {repo} ...")
|
||||
proc = subprocess.run(
|
||||
["git", "clone", "--depth", "1", repo, str(tmp)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=60,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
result.errors.append(f"failed to clone repository: {proc.stdout.strip()[:200]}")
|
||||
return
|
||||
sub = metadata.get("path", "") or ""
|
||||
skill_root = (tmp / sub).resolve()
|
||||
if not str(skill_root).startswith(str(tmp.resolve())):
|
||||
result.errors.append(f"path escapes repository root: {sub!r}")
|
||||
return
|
||||
if not (skill_root / "SKILL.md").exists():
|
||||
suffix = f' at path "{sub}"' if sub else ""
|
||||
result.errors.append(f"SKILL.md not found in repository{suffix}")
|
||||
except subprocess.TimeoutExpired:
|
||||
result.errors.append("timed out cloning repository")
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------- #
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate registry skill submissions.")
|
||||
parser.add_argument("folders", nargs="*", help="submission folder(s) to validate")
|
||||
parser.add_argument("--all", action="store_true", help="validate every folder under skills/")
|
||||
parser.add_argument(
|
||||
"--clone",
|
||||
action="store_true",
|
||||
help="clone each repository and verify SKILL.md exists (network required)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
targets: list[Path] = [Path(f) for f in args.folders]
|
||||
if args.all:
|
||||
targets = sorted(
|
||||
p for p in SKILLS_DIR.iterdir() if p.is_dir() and "__" in p.name
|
||||
)
|
||||
|
||||
if not targets:
|
||||
parser.error("provide one or more folders, or use --all")
|
||||
|
||||
all_passed = True
|
||||
for folder in targets:
|
||||
print(f"\nValidating: {folder}")
|
||||
result = validate_submission(folder, schema, clone=args.clone)
|
||||
for err in result.errors:
|
||||
print(f" x {err}")
|
||||
for warn in result.warnings:
|
||||
print(f" ! {warn}")
|
||||
if result.passed:
|
||||
print(" ok valid")
|
||||
else:
|
||||
all_passed = False
|
||||
|
||||
print()
|
||||
return 0 if all_passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
19
registry/skills/alirezarezvani__example-skill/README.md
Normal file
19
registry/skills/alirezarezvani__example-skill/README.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# example-skill
|
||||
|
||||
This is a **template submission** that shows the shape of a community entry in
|
||||
the claude-skills registry. Use it as a starting point for your own skill.
|
||||
|
||||
## How to submit your own
|
||||
|
||||
1. Copy this folder to `registry/skills/<your-github-username>__<your-skill-name>/`
|
||||
2. Edit `metadata.json`:
|
||||
- `name` / `author` must match the folder name (`<author>__<name>`)
|
||||
- `repository` must be a public GitHub repo that contains a `SKILL.md`
|
||||
- `path` is the subdirectory within that repo where `SKILL.md` lives (omit or `""` for the repo root)
|
||||
3. Replace this `README.md` with a description of your skill
|
||||
4. Open a pull request
|
||||
|
||||
CI validates your `metadata.json` against the schema, checks the folder name,
|
||||
and (in the validate workflow) clones your repo to confirm `SKILL.md` exists.
|
||||
|
||||
See [../../CONTRIBUTING.md](../../CONTRIBUTING.md) for the full guide.
|
||||
14
registry/skills/alirezarezvani__example-skill/metadata.json
Normal file
14
registry/skills/alirezarezvani__example-skill/metadata.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "example-skill",
|
||||
"author": "alirezarezvani",
|
||||
"description": "Example community submission. Copy this folder, point it at your own public repo that contains a SKILL.md, and open a PR. See registry/CONTRIBUTING.md.",
|
||||
"repository": "https://github.com/alirezarezvani/claude-skills",
|
||||
"path": "engineering/caveman/skills/caveman",
|
||||
"version": "1.0.0",
|
||||
"category": "productivity",
|
||||
"tags": ["example", "template", "getting-started"],
|
||||
"license": "MIT",
|
||||
"adapters": ["claude-code", "codex", "gemini-cli"],
|
||||
"icon": false,
|
||||
"banner": false
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue