feat: refresh shared skills catalog
This commit is contained in:
1 parent
98bb35e6fb
commit
418792d1ec
49 files changed
+2273
-2390
No files matched your search
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user