forked from phuryn/pm-skills
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvalidate_plugins.py
More file actions
502 lines (397 loc) · 17.3 KB
/
validate_plugins.py
File metadata and controls
502 lines (397 loc) · 17.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
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()