mirror of
https://github.com/phuryn/pm-skills.git
synced 2026-06-12 14:34:56 +03:00
v1.0
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user