126 lines
5.0 KiB
Python
Executable File
126 lines
5.0 KiB
Python
Executable File
#!/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(encoding='utf-8').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())
|