Files
global-skills/bin/validate-skills
T

125 lines
5.0 KiB
Python
Raw Normal View History

2026-10-30 12:40:16 +08:00
#!/usr/bin/env python3
"""Check catalog metadata, local links, and duplicate names without mutating files."""
import argparse
import json
from pathlib import Path
import re
import sys
def metadata(text):
"""Read the catalog's scalar and block-string YAML frontmatter subset."""
match = re.match(r'\A---\r?\n(.*?)\r?\n---(?:\r?\n|$)', text, re.S)
if not match:
raise ValueError('missing frontmatter')
result = {}
field = None
for line in match[1].splitlines():
if not line.strip() or line.lstrip().startswith('#'):
continue
if line[0].isspace():
if field is None:
raise ValueError('unexpected indentation in frontmatter')
result[field] += ' ' + line.strip()
continue
item = re.fullmatch(r'([a-z][a-z0-9_-]*):(?:\s+(.*))?', line)
if not item:
raise ValueError('invalid frontmatter field')
key, value = item.groups()
if key in result:
raise ValueError(f'duplicate field: {key}')
value = (value or '').strip()
field = key if value in ('>', '|', '>-', '|-') else None
if field:
result[key] = ''
else:
if value.startswith('"'):
try:
result[key] = json.loads(value)
except json.JSONDecodeError as exc:
raise ValueError(f'invalid quoted field: {key}') from exc
elif value.startswith("'"):
if len(value) < 2 or not value.endswith("'"):
raise ValueError(f'invalid quoted field: {key}')
result[key] = value[1:-1].replace("''", "'")
else:
if re.search(r':\s', value):
raise ValueError(f'invalid plain scalar: {key}; use a block string')
result[key] = value.split(' #', 1)[0]
return {k: v.strip() for k, v in result.items()}
def local_links(path):
fenced = False
for number, line in enumerate(path.read_text().splitlines(), 1):
if line.lstrip().startswith(('```', '~~~')):
fenced = not fenced
continue
if fenced:
continue
for raw in re.findall(r'\]\(([^)]+)\)', line):
target = raw.strip().split('#', 1)[0]
if not target or re.match(r'[a-zA-Z][a-zA-Z0-9+.-]*:', target):
continue
target = target.strip('<>')
resolved = path.parent / target
if not resolved.exists():
yield f'{path}:{number}: missing link target {target}'
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--global-dir', type=Path, default=Path(__file__).resolve().parents[1] / 'skills')
parser.add_argument('--jev-dir', type=Path, default=Path.home() / 'Projects/work/JEV/.agents/skills')
args = parser.parse_args(argv)
errors = []
names = {}
count = 0
if not args.global_dir.is_dir():
parser.error(f'global catalog unavailable: {args.global_dir}')
roots = [args.global_dir]
if args.jev_dir.is_dir():
roots.append(args.jev_dir)
else:
print(f'WARN: JEV catalog unavailable: {args.jev_dir}', file=sys.stderr)
for root in roots:
for directory in sorted(root.iterdir()):
if not directory.is_dir():
continue
path = directory / 'SKILL.md'
if not path.is_file():
errors.append(f'{directory}: missing SKILL.md')
continue
count += 1
try:
fields = metadata(path.read_text())
name = fields.get('name', '')
if name != directory.name or not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', name):
errors.append(f'{path}: invalid or mismatched name')
if not fields.get('description'):
errors.append(f'{path}: empty description')
if fields.get('scope') not in ('global', 'project', 'alias'):
errors.append(f'{path}: scope must be global, project, or alias')
if name in names:
errors.append(f'{path}: name collides with {names[name]}')
names[name] = path
except ValueError as exc:
errors.append(f'{path}: {exc}')
for path in sorted(root.rglob('*.md')):
errors.extend(local_links(path))
# Global protocols are part of this catalog; JEV protocols belong to its source.
for folder in [args.global_dir.parent / 'protocols', args.jev_dir.parent / 'protocols']:
if folder.is_dir():
for path in sorted(folder.glob('*.md')):
errors.extend(local_links(path))
readme = args.global_dir.parent / 'README.md'
if readme.is_file():
errors.extend(local_links(readme))
for error in errors:
print(error, file=sys.stderr)
print(f'catalog: {count} skills, {len(errors)} errors')
return bool(errors)
if __name__ == '__main__':
sys.exit(main())