#!/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())