mirror of
https://github.com/alirezarezvani/claude-skills.git
synced 2026-09-09 22:31:29 +00:00
Phase 0 of the multi-skill build: ship the two stdlib-only tools that
the rest of the work depends on.
- scripts/sync_skill_bundles.py: mirror a standalone plugin's
SKILL.md + scripts/ + references/ + assets/ into its domain-bundled
location. --check exits 1 on drift; --sync rewrites the mirror.
- scripts/check_plugin_json.py: validate plugin.json against the
strict ClawHub schema (exactly the 8 allowed fields, semver version,
author{name,url}, skills as string or array — bare "./" rejected per
Claude Code v2.1.107+).
Verified: --all run reports OK on all 30 existing plugin.json files;
sync --check correctly detects missing mirrors. Karpathy-coder gate:
both files score 85/100 under strict (single nesting-depth WARN, no
FAIL) — better than the canonical karpathy-coder tools themselves.
https://claude.ai/code/session_01Dq12xJakFRxwaoU8Pqejdm
140 lines
4.2 KiB
Python
Executable file
140 lines
4.2 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Validate plugin.json files against the strict ClawHub schema.
|
|
|
|
Required fields (exactly these 8, no others):
|
|
name, description, version, author{name,url}, homepage, repository, license, skills
|
|
|
|
skills: must be either a string ("./skills") or an array of relative paths.
|
|
The bare "./" form is REJECTED (Claude Code v2.1.107+ rejects it).
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
ALLOWED = {"name", "description", "version", "author", "homepage", "repository", "license", "skills"}
|
|
STRING_FIELDS = ("name", "description", "homepage", "repository", "license")
|
|
SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:-[\w.]+)?$")
|
|
|
|
|
|
def _check_keys(data):
|
|
keys = set(data.keys())
|
|
errors = []
|
|
extra = keys - ALLOWED
|
|
missing = ALLOWED - keys
|
|
if extra:
|
|
errors.append(f"extra fields: {sorted(extra)}")
|
|
if missing:
|
|
errors.append(f"missing fields: {sorted(missing)}")
|
|
return errors
|
|
|
|
|
|
def _check_strings(data):
|
|
return [f"{k}: must be string" for k in STRING_FIELDS if k in data and not isinstance(data[k], str)]
|
|
|
|
|
|
def _check_version(data):
|
|
if "version" not in data:
|
|
return []
|
|
v = data["version"]
|
|
if not isinstance(v, str) or not SEMVER.match(v):
|
|
return [f"version: must match semver, got {v!r}"]
|
|
return []
|
|
|
|
|
|
def _check_author(data):
|
|
if "author" not in data:
|
|
return []
|
|
a = data["author"]
|
|
if not isinstance(a, dict):
|
|
return ["author: must be object {name, url}"]
|
|
errors = []
|
|
if not isinstance(a.get("name"), str):
|
|
errors.append("author.name: must be string")
|
|
if not isinstance(a.get("url"), str):
|
|
errors.append("author.url: must be string")
|
|
extra = set(a.keys()) - {"name", "url"}
|
|
if extra:
|
|
errors.append(f"author: extra fields {sorted(extra)}")
|
|
return errors
|
|
|
|
|
|
def _check_skills_string(s):
|
|
if s in ("./", ""):
|
|
return ['skills: "./" is rejected by Claude Code v2.1.107+; use "./skills" or an array']
|
|
return []
|
|
|
|
|
|
def _check_skills_array(s):
|
|
if not s:
|
|
return ["skills: array is empty"]
|
|
errors = []
|
|
for entry in s:
|
|
if not isinstance(entry, str):
|
|
errors.append(f"skills: entries must be strings, got {entry!r}")
|
|
elif entry == "./":
|
|
errors.append('skills: "./" is rejected by Claude Code v2.1.107+; list explicit subfolders')
|
|
return errors
|
|
|
|
|
|
def _check_skills(data):
|
|
if "skills" not in data:
|
|
return []
|
|
s = data["skills"]
|
|
if isinstance(s, str):
|
|
return _check_skills_string(s)
|
|
if isinstance(s, list):
|
|
return _check_skills_array(s)
|
|
return ["skills: must be string or array of strings"]
|
|
|
|
|
|
def validate(path):
|
|
try:
|
|
with open(path) as f:
|
|
data = json.load(f)
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
return [f"unreadable JSON: {e}"]
|
|
return (_check_keys(data) + _check_strings(data) + _check_version(data)
|
|
+ _check_author(data) + _check_skills(data))
|
|
|
|
|
|
def find_all():
|
|
out = []
|
|
for root, dirs, files in os.walk(REPO):
|
|
if any(skip in root for skip in (".git", "node_modules", "eval-workspace", ".gemini")):
|
|
dirs[:] = []
|
|
continue
|
|
if "plugin.json" in files and root.endswith(".claude-plugin"):
|
|
out.append(os.path.join(root, "plugin.json"))
|
|
return sorted(out)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
g = ap.add_mutually_exclusive_group(required=True)
|
|
g.add_argument("path", nargs="?", help="Path to a plugin.json file")
|
|
g.add_argument("--all", action="store_true", help="Validate every plugin.json in the repo")
|
|
args = ap.parse_args()
|
|
|
|
targets = find_all() if args.all else [args.path]
|
|
failed = 0
|
|
for t in targets:
|
|
errs = validate(t)
|
|
rel = os.path.relpath(t, REPO)
|
|
if errs:
|
|
failed += 1
|
|
print(f"FAIL {rel}")
|
|
for e in errs:
|
|
print(f" - {e}")
|
|
else:
|
|
print(f"OK {rel}")
|
|
if failed:
|
|
print(f"\n{failed} file(s) failed validation", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|