mirror of
https://github.com/alirezarezvani/claude-skills.git
synced 2026-08-28 04:24:58 +00:00
Applies every pre-merge required change from audit/pr-stream-2026-08/ that could not land on contributor forks, plus the stream's cross-cutting fixes: - stock-analysis (#944): description trimmed 1463 -> 1001 chars; Anti-Patterns + Cross-References sections added; security-auditor false positive at holdco-assetmgr.md:58 suppressed inline (auditor:ignore-line) -> strict PASS - deepread (#965): renamed research/dsh-deepread -> research/deepread; H1 and name updated; research-summarizer cross-ref path-qualified; 12 cited sources added across both references; plugin.json + marketplace entry; routed in the research orchestrator (SKILL.md registry + SIGNALS + classifier.py in lockstep, verified: 'deeply read this pdf' -> deepread, 3 signals) - business-name-fit (#926): 'Use whenever' -> 'Use when' (validator trigger regex); +2 cited sources (USPTO TMEP §1209, Usunier & Shaner 2002) -> 5 - embedded-iot-mentor (#942): references/hardware-selection.md (7 sources, datasheet-anchored) + worked mini-example; validator length gate now passes - swedish-mentor (#943): references/swedish-resources.md (6 sources, stable official URLs only); session recipes, milestones, learner situations, worked example; mandated opener softened to guidance; plugin.json + marketplace entry; validator length gate now passes - Related Projects (#895): LinkedIn Skills row trued up (10 -> 11 skills, hardcoded star count dropped) - check_plugin_json.py: marketplace description <= 1024 guard added to --all (the #964 regression guard; commercial-skills sits at 1021/1024) - #954 doc drift: quality_gates_for_skills.md, cs-skill-author.md, security-guidance SKILL.md now point attribution at authoring-notes.json - Counter true-up after the 6-skill merge batch: 370 skills / 672 tools / 809 refs / 92 plugins across README.md badge+table, CLAUDE.md, marketplace metadata (derive_counters.py --check passes) All gates green locally: frontmatter 0 errors, model freshness 0 findings, dual-publish 0 drifted, paths 0, smoke 0 failed, plugin-json 0 FAIL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Bzm6Pafyxja6g4jUDPcei
251 lines
8.9 KiB
Python
Executable file
251 lines
8.9 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Validate plugin.json files against the ClawHub schema.
|
|
|
|
Required fields (exactly these 8):
|
|
name, description, version, author{name,url}, homepage, repository, license, skills
|
|
|
|
Non-spec keys are hard failures. Claude Code's manifest validator rejects the
|
|
whole plugin.json on ANY unrecognized key (issue #954 — 37+ plugins were
|
|
uninstallable because of `source` / `attribution` keys). Authoring metadata
|
|
(Path-B provenance, upstream vendoring credit) now lives in a sibling
|
|
`.claude-plugin/authoring-notes.json` file, which Claude Code never reads.
|
|
This script also sanity-checks that file when present.
|
|
|
|
skills layouts — per the live Claude Code plugin spec
|
|
(https://code.claude.com/docs/en/plugins-reference), "All paths must be
|
|
relative to the plugin root and start with ./". CC 2.1.145 returns
|
|
`Validation errors: skills: Invalid input` on a bare string without "./".
|
|
Legacy bare-string form is still accepted by this validator during the
|
|
migration window, but emits a WARN line.
|
|
|
|
CANONICAL (post-CC 2.1.144):
|
|
- Single-skill plugin (SKILL.md at root): "skills": ["./"]
|
|
- Plugin with skills/ subdir: "skills": "./skills" (or ["./skills"])
|
|
- Multi-skill domain plugin (subfolders): "skills": ["./sub1", "./sub2", ...]
|
|
|
|
LEGACY (pre-migration, still passes with WARN):
|
|
- "skills": "skills" (bare subdir name, no "./" prefix)
|
|
|
|
REJECTED:
|
|
- Empty string / empty array
|
|
- Non-string array entries
|
|
- Strings that are neither "skills"-style legacy nor "./"-prefixed
|
|
"""
|
|
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"}
|
|
# Formerly tolerated in-manifest; Claude Code rejects any unrecognized key
|
|
# (issue #954), so these now belong in .claude-plugin/authoring-notes.json.
|
|
RELOCATED_TO_NOTES = {"source", "attribution"}
|
|
NOTES_ALLOWED = {"source", "attribution"}
|
|
STRING_FIELDS = ("name", "description", "homepage", "repository", "license")
|
|
SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:-[\w.]+)?$")
|
|
|
|
|
|
def _check_keys(data):
|
|
keys = set(data.keys())
|
|
errors = []
|
|
relocated = keys & RELOCATED_TO_NOTES
|
|
extra = keys - ALLOWED - RELOCATED_TO_NOTES
|
|
missing = ALLOWED - keys
|
|
if relocated:
|
|
errors.append(
|
|
f"non-spec fields {sorted(relocated)}: Claude Code rejects the whole manifest "
|
|
f"on any unrecognized key (issue #954) — move them to "
|
|
f".claude-plugin/authoring-notes.json"
|
|
)
|
|
if extra:
|
|
errors.append(f"extra fields: {sorted(extra)}")
|
|
if missing:
|
|
errors.append(f"missing fields: {sorted(missing)}")
|
|
return errors
|
|
|
|
|
|
def _check_authoring_notes(path):
|
|
"""Sanity-check the sibling authoring-notes.json, if one exists."""
|
|
notes_path = os.path.join(os.path.dirname(path), "authoring-notes.json")
|
|
if not os.path.exists(notes_path):
|
|
return []
|
|
try:
|
|
with open(notes_path) as f:
|
|
notes = json.load(f)
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
return [f"authoring-notes.json: unreadable JSON: {e}"]
|
|
if not isinstance(notes, dict):
|
|
return ["authoring-notes.json: must be a JSON object"]
|
|
extra = set(notes.keys()) - NOTES_ALLOWED
|
|
if extra:
|
|
return [f"authoring-notes.json: unexpected keys {sorted(extra)} "
|
|
f"(allowed: {sorted(NOTES_ALLOWED)})"]
|
|
return []
|
|
|
|
|
|
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
|
|
|
|
|
|
_LEGACY_SKILLS_VALUES = {"skills"}
|
|
|
|
|
|
def _check_skills_string(s):
|
|
if s == "":
|
|
return ["skills: empty string"]
|
|
if s == "./":
|
|
return ['skills: bare "./" must be wrapped in an array — use ["./"] for single-skill plugins']
|
|
if s.startswith("./"):
|
|
return []
|
|
if s in _LEGACY_SKILLS_VALUES:
|
|
return [f'WARN skills: legacy bare {s!r} — Claude Code 2.1.144+ requires the "./" prefix '
|
|
f'per the plugin spec. Migrate to "./{s}" or ["./{s}"].']
|
|
return [f'skills: {s!r} must start with "./" (Claude Code plugin spec: "All paths must be '
|
|
f'relative to the plugin root and start with ./")']
|
|
|
|
|
|
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}")
|
|
continue
|
|
if entry == "":
|
|
errors.append("skills: array contains empty string")
|
|
continue
|
|
if not entry.startswith("./"):
|
|
errors.append(f'skills: array entry {entry!r} must start with "./" '
|
|
f'(Claude Code plugin spec)')
|
|
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) + _check_authoring_notes(path))
|
|
|
|
|
|
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 check_marketplace_descriptions():
|
|
"""GitHub Copilot CLI rejects the whole marketplace if any plugin
|
|
description exceeds 1024 chars (see PR #964). Guard the cap here so a
|
|
routine description tweak can't silently re-break external loaders."""
|
|
path = os.path.join(REPO, ".claude-plugin", "marketplace.json")
|
|
errors = []
|
|
try:
|
|
with open(path) as f:
|
|
data = json.load(f)
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
return [f"marketplace.json unreadable: {e}"]
|
|
for p in data.get("plugins", []):
|
|
n = len(p.get("description", ""))
|
|
if n > 1024:
|
|
errors.append(
|
|
f"marketplace.json: '{p.get('name', '?')}' description is "
|
|
f"{n} chars (max 1024 — breaks Copilot CLI marketplace load)")
|
|
return errors
|
|
|
|
|
|
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
|
|
warned = 0
|
|
for t in targets:
|
|
msgs = validate(t)
|
|
rel = os.path.relpath(t, REPO)
|
|
hard = [m for m in msgs if not m.startswith("WARN ")]
|
|
soft = [m for m in msgs if m.startswith("WARN ")]
|
|
if hard:
|
|
failed += 1
|
|
print(f"FAIL {rel}")
|
|
for e in hard:
|
|
print(f" - {e}")
|
|
for w in soft:
|
|
print(f" - {w[5:]}")
|
|
elif soft:
|
|
warned += 1
|
|
print(f"WARN {rel}")
|
|
for w in soft:
|
|
print(f" - {w[5:]}")
|
|
else:
|
|
print(f"OK {rel}")
|
|
if args.all:
|
|
mp_errors = check_marketplace_descriptions()
|
|
if mp_errors:
|
|
failed += 1
|
|
print("FAIL .claude-plugin/marketplace.json")
|
|
for e in mp_errors:
|
|
print(f" - {e}")
|
|
else:
|
|
print("OK .claude-plugin/marketplace.json (all plugin descriptions <= 1024 chars)")
|
|
if warned:
|
|
print(f"\n{warned} file(s) passed with warnings (legacy schema)", file=sys.stderr)
|
|
if failed:
|
|
print(f"\n{failed} file(s) failed validation", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|