This commit is contained in:
Pawel Huryn
2026-03-02 00:36:23 +01:00
parent 61004d0c4e
commit 77dbdfa1b9
118 changed files with 11087 additions and 0 deletions
+502
View File
@@ -0,0 +1,502 @@
#!/usr/bin/env python3
"""
Plugin Collection Validator
===========================
Validates all plugins in the collection against the Claude Code plugin spec:
- plugin.json manifest: required fields, author attribution, keywords
- Skills: YAML frontmatter (name must match directory, description required)
- Commands: YAML frontmatter (description and argument-hint required)
- Cross-references: commands referencing skills that exist in the same plugin
- README: exists and has expected sections
Based on:
- Anthropic plugin-dev README (https://github.com/anthropics/claude-code/tree/main/plugins/plugin-dev)
- agentskills.io specification
- Claude Code plugins reference (https://code.claude.com/docs/en/plugins-reference)
Author: Paweł Huryn — The Product Compass Newsletter (https://www.productcompass.pm)
"""
import json
import os
import re
import sys
from pathlib import Path
from typing import Optional
# ─── Configuration ───────────────────────────────────────────────────────────
# Required plugin.json fields per spec
REQUIRED_MANIFEST_FIELDS = ["name", "version", "description"]
RECOMMENDED_MANIFEST_FIELDS = ["author", "keywords", "homepage", "license"]
REQUIRED_AUTHOR_FIELDS = ["name", "email"]
RECOMMENDED_AUTHOR_FIELDS = ["url"]
# Required skill frontmatter fields
REQUIRED_SKILL_FIELDS = ["name", "description"]
# Required command frontmatter fields
REQUIRED_COMMAND_FIELDS = ["description"]
RECOMMENDED_COMMAND_FIELDS = ["argument-hint"]
# Expected README sections (case-insensitive substring match)
EXPECTED_README_SECTIONS = ["overview", "install", "skill", "command"]
# ─── ANSI Colors ─────────────────────────────────────────────────────────────
class C:
RED = "\033[91m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
BLUE = "\033[94m"
CYAN = "\033[96m"
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
# ─── Helpers ─────────────────────────────────────────────────────────────────
def parse_yaml_frontmatter(content: str) -> Optional[dict]:
"""Extract YAML frontmatter from a markdown file (between --- markers)."""
if not content.startswith("---"):
return None
end = content.find("---", 3)
if end == -1:
return None
fm_text = content[3:end].strip()
# Simple YAML parser for flat key-value pairs
result = {}
for line in fm_text.split("\n"):
line = line.strip()
if not line or line.startswith("#"):
continue
match = re.match(r'^(\S+):\s*(.+)$', line)
if match:
key = match.group(1)
value = match.group(2).strip().strip('"').strip("'")
result[key] = value
return result
def count_words(content: str) -> int:
"""Count words in markdown content (excluding frontmatter)."""
# Strip frontmatter
if content.startswith("---"):
end = content.find("---", 3)
if end != -1:
content = content[end + 3:]
return len(content.split())
# ─── Validators ──────────────────────────────────────────────────────────────
class ValidationResult:
def __init__(self):
self.errors: list[str] = [] # Must fix
self.warnings: list[str] = [] # Should fix
self.info: list[str] = [] # FYI
def error(self, msg: str):
self.errors.append(msg)
def warn(self, msg: str):
self.warnings.append(msg)
def note(self, msg: str):
self.info.append(msg)
@property
def ok(self) -> bool:
return len(self.errors) == 0
def validate_manifest(plugin_dir: str) -> ValidationResult:
"""Validate plugin.json manifest."""
result = ValidationResult()
pj_path = os.path.join(plugin_dir, ".claude-plugin", "plugin.json")
if not os.path.isfile(pj_path):
result.error("Missing .claude-plugin/plugin.json")
return result
try:
with open(pj_path, "r") as f:
data = json.load(f)
except json.JSONDecodeError as e:
result.error(f"Invalid JSON in plugin.json: {e}")
return result
# Required fields
for field in REQUIRED_MANIFEST_FIELDS:
if field not in data or not data[field]:
result.error(f"Missing required field: {field}")
# Name must match directory name
dir_name = os.path.basename(plugin_dir)
if data.get("name") and data["name"] != dir_name:
result.error(f"Name mismatch: plugin.json says '{data['name']}' but directory is '{dir_name}'")
# Version format
version = data.get("version", "")
if version and not re.match(r'^\d+\.\d+\.\d+$', version):
result.warn(f"Version '{version}' doesn't follow semver (x.y.z)")
# Recommended fields
for field in RECOMMENDED_MANIFEST_FIELDS:
if field not in data:
result.warn(f"Missing recommended field: {field}")
# Author validation
author = data.get("author")
if isinstance(author, dict):
for field in REQUIRED_AUTHOR_FIELDS:
if field not in author or not author[field]:
result.warn(f"Missing author.{field}")
for field in RECOMMENDED_AUTHOR_FIELDS:
if field not in author:
result.note(f"Missing author.{field}")
elif author is not None:
result.warn("Author should be an object with name, email, url fields")
# Keywords validation
keywords = data.get("keywords", [])
if not keywords:
result.warn("No keywords defined")
elif not isinstance(keywords, list):
result.error("Keywords must be an array")
# Description length check
desc = data.get("description", "")
if desc and len(desc) < 20:
result.warn(f"Description is very short ({len(desc)} chars)")
result.note(f"Version: {version}")
result.note(f"Keywords: {len(keywords) if isinstance(keywords, list) else 0}")
return result
def validate_skill(skill_dir: str) -> ValidationResult:
"""Validate a single skill directory."""
result = ValidationResult()
skill_name = os.path.basename(skill_dir)
skill_md = os.path.join(skill_dir, "SKILL.md")
if not os.path.isfile(skill_md):
result.error("Missing SKILL.md")
return result
with open(skill_md, "r", encoding="utf-8") as f:
content = f.read()
# Frontmatter check
fm = parse_yaml_frontmatter(content)
if fm is None:
result.error("Missing YAML frontmatter (must start with ---)")
return result
# Required fields
for field in REQUIRED_SKILL_FIELDS:
if field not in fm or not fm[field]:
result.error(f"Missing required frontmatter field: {field}")
# Name must match directory name (agentskills.io spec)
if fm.get("name") and fm["name"] != skill_name:
result.error(f"Name mismatch: frontmatter says '{fm['name']}' but directory is '{skill_name}'")
# Description quality
desc = fm.get("description", "")
if desc:
if len(desc) < 30:
result.warn(f"Description is very short ({len(desc)} chars)")
# Check for trigger phrases (recommended)
trigger_keywords = ["trigger", "use when", "use for"]
has_triggers = any(kw in desc.lower() for kw in trigger_keywords)
if not has_triggers:
result.note("Description lacks explicit trigger phrases (e.g., 'Triggers: ...')")
# Word count
word_count = count_words(content)
result.note(f"Word count: {word_count}")
if word_count > 3000:
result.warn(f"Skill is quite long ({word_count} words). Consider progressive disclosure with references/")
elif word_count < 50:
result.warn(f"Skill is very short ({word_count} words). May need more content.")
return result
def validate_command(cmd_path: str) -> ValidationResult:
"""Validate a single command file."""
result = ValidationResult()
with open(cmd_path, "r", encoding="utf-8") as f:
content = f.read()
# Frontmatter check
fm = parse_yaml_frontmatter(content)
if fm is None:
result.error("Missing YAML frontmatter (must start with ---)")
return result
# Required fields
for field in REQUIRED_COMMAND_FIELDS:
if field not in fm or not fm[field]:
result.error(f"Missing required frontmatter field: {field}")
# Recommended fields
for field in RECOMMENDED_COMMAND_FIELDS:
if field not in fm or not fm[field]:
result.warn(f"Missing recommended frontmatter field: {field}")
# Description quality
desc = fm.get("description", "")
if desc and len(desc) < 10:
result.warn(f"Description is very short ({len(desc)} chars)")
# Check if command references skills (informational)
skill_refs = re.findall(r'\*\*(\w[\w-]+)\*\*\s+skill', content)
if skill_refs:
result.note(f"References skills: {', '.join(skill_refs)}")
return result
def validate_readme(plugin_dir: str) -> ValidationResult:
"""Validate plugin README.md."""
result = ValidationResult()
readme_path = os.path.join(plugin_dir, "README.md")
if not os.path.isfile(readme_path):
result.warn("Missing README.md")
return result
with open(readme_path, "r", encoding="utf-8") as f:
content = f.read().lower()
# Check for expected sections
for section in EXPECTED_README_SECTIONS:
if section not in content:
result.note(f"README may be missing '{section}' section")
result.note(f"README: {count_words(content)} words")
return result
def validate_cross_references(plugin_dir: str, skill_names: list[str]) -> ValidationResult:
"""Check that commands reference skills that actually exist in this plugin."""
result = ValidationResult()
cmds_dir = os.path.join(plugin_dir, "commands")
if not os.path.isdir(cmds_dir):
return result
for cmd_file in sorted(os.listdir(cmds_dir)):
if not cmd_file.endswith(".md"):
continue
cmd_path = os.path.join(cmds_dir, cmd_file)
with open(cmd_path, "r", encoding="utf-8") as f:
content = f.read()
# Find skill references like **skill-name** skill
refs = re.findall(r'\*\*(\w[\w-]+)\*\*\s+skill', content)
for ref in refs:
if ref not in skill_names:
result.warn(f"Command {cmd_file} references skill '{ref}' not found in this plugin")
return result
# ─── Main Validator ──────────────────────────────────────────────────────────
def validate_plugin(plugin_dir: str) -> dict:
"""Run all validations on a single plugin."""
plugin_name = os.path.basename(plugin_dir)
results = {"name": plugin_name, "sections": {}}
# 1. Manifest
results["sections"]["manifest"] = validate_manifest(plugin_dir)
# 2. Skills
skills_dir = os.path.join(plugin_dir, "skills")
skill_names = []
skill_results = {}
if os.path.isdir(skills_dir):
for skill_name in sorted(os.listdir(skills_dir)):
skill_path = os.path.join(skills_dir, skill_name)
if os.path.isdir(skill_path):
skill_names.append(skill_name)
skill_results[skill_name] = validate_skill(skill_path)
results["sections"]["skills"] = skill_results
results["skill_count"] = len(skill_names)
# 3. Commands
cmds_dir = os.path.join(plugin_dir, "commands")
cmd_results = {}
if os.path.isdir(cmds_dir):
for cmd_file in sorted(os.listdir(cmds_dir)):
if cmd_file.endswith(".md"):
cmd_path = os.path.join(cmds_dir, cmd_file)
cmd_results[cmd_file] = validate_command(cmd_path)
results["sections"]["commands"] = cmd_results
results["command_count"] = len(cmd_results)
# 4. README
results["sections"]["readme"] = validate_readme(plugin_dir)
# 5. Cross-references
results["sections"]["cross-refs"] = validate_cross_references(plugin_dir, skill_names)
return results
def print_validation_result(label: str, vr: ValidationResult, indent: int = 4):
"""Print a single validation result."""
prefix = " " * indent
if vr.errors:
for e in vr.errors:
print(f"{prefix}{C.RED}✗ ERROR:{C.RESET} {e}")
if vr.warnings:
for w in vr.warnings:
print(f"{prefix}{C.YELLOW}⚠ WARN:{C.RESET} {w}")
if vr.info:
for i in vr.info:
print(f"{prefix}{C.DIM} {i}{C.RESET}")
def print_report(all_results: list[dict]):
"""Print the full validation report."""
total_errors = 0
total_warnings = 0
total_skills = 0
total_commands = 0
print(f"\n{C.BOLD}{'='*70}")
print(f" Plugin Collection Validator — Report")
print(f"{'='*70}{C.RESET}\n")
for plugin in all_results:
name = plugin["name"]
sc = plugin["skill_count"]
cc = plugin["command_count"]
total_skills += sc
total_commands += cc
# Count errors/warnings for this plugin
p_errors = 0
p_warnings = 0
for key, section in plugin["sections"].items():
if isinstance(section, ValidationResult):
p_errors += len(section.errors)
p_warnings += len(section.warnings)
elif isinstance(section, dict):
for vr in section.values():
p_errors += len(vr.errors)
p_warnings += len(vr.warnings)
total_errors += p_errors
total_warnings += p_warnings
# Plugin header
status = f"{C.GREEN}✓ PASS{C.RESET}" if p_errors == 0 else f"{C.RED}✗ FAIL{C.RESET}"
warn_str = f" {C.YELLOW}({p_warnings} warnings){C.RESET}" if p_warnings > 0 else ""
print(f"{C.BOLD}{C.CYAN}┌─ {name}{C.RESET} [{sc} skills, {cc} commands] {status}{warn_str}")
# Manifest
manifest = plugin["sections"]["manifest"]
if manifest.errors or manifest.warnings:
print(f" {C.BOLD}Manifest:{C.RESET}")
print_validation_result("manifest", manifest)
# Skills with issues
skill_results = plugin["sections"]["skills"]
skills_with_issues = {k: v for k, v in skill_results.items() if v.errors or v.warnings}
if skills_with_issues:
print(f" {C.BOLD}Skills with issues:{C.RESET}")
for sname, vr in skills_with_issues.items():
print(f" {sname}:")
print_validation_result(sname, vr, indent=6)
# Commands with issues
cmd_results = plugin["sections"]["commands"]
cmds_with_issues = {k: v for k, v in cmd_results.items() if v.errors or v.warnings}
if cmds_with_issues:
print(f" {C.BOLD}Commands with issues:{C.RESET}")
for cname, vr in cmds_with_issues.items():
print(f" {cname}:")
print_validation_result(cname, vr, indent=6)
# README
readme = plugin["sections"]["readme"]
if readme.errors or readme.warnings:
print(f" {C.BOLD}README:{C.RESET}")
print_validation_result("readme", readme)
# Cross-references
xrefs = plugin["sections"]["cross-refs"]
if xrefs.errors or xrefs.warnings:
print(f" {C.BOLD}Cross-references:{C.RESET}")
print_validation_result("cross-refs", xrefs)
print(f"{C.CYAN}{''*69}{C.RESET}\n")
# Summary
print(f"{C.BOLD}{'='*70}")
print(f" Summary")
print(f"{'='*70}{C.RESET}")
print(f" Plugins: {len(all_results)}")
print(f" Skills: {total_skills}")
print(f" Commands: {total_commands}")
print(f" Total: {total_skills + total_commands} components")
print()
if total_errors == 0:
print(f" {C.GREEN}{C.BOLD}✓ ALL CHECKS PASSED{C.RESET} ({total_warnings} warnings)")
else:
print(f" {C.RED}{C.BOLD}{total_errors} ERRORS{C.RESET}, {total_warnings} warnings")
print()
return total_errors
def main():
"""Find and validate all plugins in the collection."""
# Determine base path
if len(sys.argv) > 1:
base_path = sys.argv[1]
else:
base_path = os.path.dirname(os.path.abspath(__file__))
if not os.path.isdir(base_path):
print(f"Error: {base_path} is not a directory")
sys.exit(1)
# Find all plugin directories (those containing .claude-plugin/)
plugin_dirs = []
for entry in sorted(os.listdir(base_path)):
full_path = os.path.join(base_path, entry)
if os.path.isdir(full_path) and os.path.isdir(os.path.join(full_path, ".claude-plugin")):
plugin_dirs.append(full_path)
if not plugin_dirs:
print(f"No plugins found in {base_path}")
print("(Looking for directories containing .claude-plugin/)")
sys.exit(1)
print(f"Found {len(plugin_dirs)} plugins in {base_path}\n")
# Validate each plugin
all_results = []
for pd in plugin_dirs:
all_results.append(validate_plugin(pd))
# Print report
errors = print_report(all_results)
sys.exit(1 if errors > 0 else 0)
if __name__ == "__main__":
main()