Consolidate the repository into clearer apps, tools, and layered docs areas so contributors can navigate and maintain it more reliably. Align validation, metadata sync, and CI around the same canonical workflow to reduce drift across local checks and GitHub Actions.
301 lines
11 KiB
Python
301 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Manage skill date_added metadata.
|
|
|
|
Usage:
|
|
python manage_skill_dates.py list # List all skills with their dates
|
|
python manage_skill_dates.py add-missing [--date YYYY-MM-DD] # Add dates to skills without them
|
|
python manage_skill_dates.py add-all [--date YYYY-MM-DD] # Add/update dates for all skills
|
|
python manage_skill_dates.py update <skill-id> YYYY-MM-DD # Update a specific skill's date
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
import argparse
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
import yaml
|
|
from _project_paths import find_repo_root
|
|
|
|
# Ensure UTF-8 output for Windows compatibility
|
|
if sys.platform == 'win32':
|
|
import io
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
|
|
|
def get_project_root():
|
|
"""Get the project root directory."""
|
|
return str(find_repo_root(__file__))
|
|
|
|
def parse_frontmatter(content):
|
|
"""Parse frontmatter from SKILL.md content using PyYAML."""
|
|
fm_match = re.search(r'^---\s*\n(.*?)\n---', content, re.DOTALL)
|
|
if not fm_match:
|
|
return None, content
|
|
|
|
fm_text = fm_match.group(1)
|
|
try:
|
|
metadata = yaml.safe_load(fm_text) or {}
|
|
return metadata, content
|
|
except yaml.YAMLError as e:
|
|
print(f"⚠️ YAML parsing error: {e}")
|
|
return None, content
|
|
|
|
def reconstruct_frontmatter(metadata):
|
|
"""Reconstruct frontmatter from metadata dict using PyYAML."""
|
|
# Ensure important keys are at the top if they exist
|
|
ordered = {}
|
|
priority_keys = ['id', 'name', 'description', 'category', 'risk', 'source', 'tags', 'date_added']
|
|
|
|
for key in priority_keys:
|
|
if key in metadata:
|
|
ordered[key] = metadata[key]
|
|
|
|
# Add any remaining keys
|
|
for key, value in metadata.items():
|
|
if key not in ordered:
|
|
ordered[key] = value
|
|
|
|
fm_text = yaml.dump(ordered, sort_keys=False, allow_unicode=True, width=1000).strip()
|
|
return f"---\n{fm_text}\n---"
|
|
|
|
def update_skill_frontmatter(skill_path, metadata):
|
|
"""Update a skill's frontmatter with new metadata."""
|
|
try:
|
|
with open(skill_path, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
old_metadata, body_content = parse_frontmatter(content)
|
|
if old_metadata is None:
|
|
print(f"❌ {skill_path}: Could not parse frontmatter")
|
|
return False
|
|
|
|
# Merge metadata
|
|
old_metadata.update(metadata)
|
|
|
|
# Reconstruct content
|
|
new_frontmatter = reconstruct_frontmatter(old_metadata)
|
|
|
|
# Find where the frontmatter ends in the original content
|
|
fm_end = content.find('---', 3) # Skip first ---
|
|
if fm_end == -1:
|
|
print(f"❌ {skill_path}: Could not locate frontmatter boundary")
|
|
return False
|
|
|
|
body_start = fm_end + 3
|
|
body = content[body_start:]
|
|
|
|
new_content = new_frontmatter + body
|
|
|
|
with open(skill_path, 'w', encoding='utf-8') as f:
|
|
f.write(new_content)
|
|
|
|
return True
|
|
except Exception as e:
|
|
print(f"❌ Error updating {skill_path}: {str(e)}")
|
|
return False
|
|
|
|
def list_skills():
|
|
"""List all skills with their date_added values."""
|
|
skills_dir = os.path.join(get_project_root(), 'skills')
|
|
skills_with_dates = []
|
|
skills_without_dates = []
|
|
|
|
for root, dirs, files in os.walk(skills_dir):
|
|
# Skip hidden/disabled directories
|
|
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
|
|
|
if "SKILL.md" in files:
|
|
skill_name = os.path.basename(root)
|
|
skill_path = os.path.join(root, "SKILL.md")
|
|
|
|
try:
|
|
with open(skill_path, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
metadata, _ = parse_frontmatter(content)
|
|
if metadata is None:
|
|
continue
|
|
|
|
date_added = metadata.get('date_added', 'N/A')
|
|
|
|
if date_added == 'N/A':
|
|
skills_without_dates.append(skill_name)
|
|
else:
|
|
skills_with_dates.append((skill_name, date_added))
|
|
except Exception as e:
|
|
print(f"⚠️ Error reading {skill_path}: {str(e)}", file=sys.stderr)
|
|
|
|
# Sort by date
|
|
skills_with_dates.sort(key=lambda x: x[1], reverse=True)
|
|
|
|
print(f"\n📅 Skills with Date Added ({len(skills_with_dates)}):")
|
|
print("=" * 60)
|
|
|
|
if skills_with_dates:
|
|
for skill_name, date in skills_with_dates:
|
|
print(f" {date} │ {skill_name}")
|
|
else:
|
|
print(" (none)")
|
|
|
|
print(f"\n⏳ Skills without Date Added ({len(skills_without_dates)}):")
|
|
print("=" * 60)
|
|
|
|
if skills_without_dates:
|
|
for skill_name in sorted(skills_without_dates):
|
|
print(f" {skill_name}")
|
|
else:
|
|
print(" (none)")
|
|
|
|
total = len(skills_with_dates) + len(skills_without_dates)
|
|
percentage = (len(skills_with_dates) / total * 100) if total > 0 else 0
|
|
print(f"\n📊 Coverage: {len(skills_with_dates)}/{total} ({percentage:.1f}%)")
|
|
|
|
def add_missing_dates(date_str=None):
|
|
"""Add date_added to skills that don't have it."""
|
|
if date_str is None:
|
|
date_str = datetime.now().strftime('%Y-%m-%d')
|
|
|
|
# Validate date format
|
|
if not re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
|
|
print(f"❌ Invalid date format: {date_str}. Use YYYY-MM-DD.")
|
|
return False
|
|
|
|
skills_dir = os.path.join(get_project_root(), 'skills')
|
|
updated_count = 0
|
|
skipped_count = 0
|
|
|
|
for root, dirs, files in os.walk(skills_dir):
|
|
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
|
|
|
if "SKILL.md" in files:
|
|
skill_name = os.path.basename(root)
|
|
skill_path = os.path.join(root, "SKILL.md")
|
|
|
|
try:
|
|
with open(skill_path, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
metadata, _ = parse_frontmatter(content)
|
|
if metadata is None:
|
|
print(f"⚠️ {skill_name}: Could not parse frontmatter, skipping")
|
|
continue
|
|
|
|
if 'date_added' not in metadata:
|
|
if update_skill_frontmatter(skill_path, {'date_added': date_str}):
|
|
print(f"✅ {skill_name}: Added date_added: {date_str}")
|
|
updated_count += 1
|
|
else:
|
|
print(f"❌ {skill_name}: Failed to update")
|
|
else:
|
|
skipped_count += 1
|
|
except Exception as e:
|
|
print(f"❌ Error processing {skill_name}: {str(e)}")
|
|
|
|
print(f"\n✨ Updated {updated_count} skills, skipped {skipped_count} that already had dates")
|
|
return True
|
|
|
|
def add_all_dates(date_str=None):
|
|
"""Add/update date_added for all skills."""
|
|
if date_str is None:
|
|
date_str = datetime.now().strftime('%Y-%m-%d')
|
|
|
|
# Validate date format
|
|
if not re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
|
|
print(f"❌ Invalid date format: {date_str}. Use YYYY-MM-DD.")
|
|
return False
|
|
|
|
skills_dir = os.path.join(get_project_root(), 'skills')
|
|
updated_count = 0
|
|
|
|
for root, dirs, files in os.walk(skills_dir):
|
|
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
|
|
|
if "SKILL.md" in files:
|
|
skill_name = os.path.basename(root)
|
|
skill_path = os.path.join(root, "SKILL.md")
|
|
|
|
try:
|
|
if update_skill_frontmatter(skill_path, {'date_added': date_str}):
|
|
print(f"✅ {skill_name}: Set date_added: {date_str}")
|
|
updated_count += 1
|
|
else:
|
|
print(f"❌ {skill_name}: Failed to update")
|
|
except Exception as e:
|
|
print(f"❌ Error processing {skill_name}: {str(e)}")
|
|
|
|
print(f"\n✨ Updated {updated_count} skills")
|
|
return True
|
|
|
|
def update_skill_date(skill_name, date_str):
|
|
"""Update a specific skill's date_added."""
|
|
# Validate date format
|
|
if not re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
|
|
print(f"❌ Invalid date format: {date_str}. Use YYYY-MM-DD.")
|
|
return False
|
|
|
|
skills_dir = os.path.join(get_project_root(), 'skills')
|
|
skill_path = os.path.join(skills_dir, skill_name, 'SKILL.md')
|
|
|
|
if not os.path.exists(skill_path):
|
|
print(f"❌ Skill not found: {skill_name}")
|
|
return False
|
|
|
|
if update_skill_frontmatter(skill_path, {'date_added': date_str}):
|
|
print(f"✅ {skill_name}: Updated date_added to {date_str}")
|
|
return True
|
|
else:
|
|
print(f"❌ {skill_name}: Failed to update")
|
|
return False
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Manage skill date_added metadata",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
python manage_skill_dates.py list
|
|
python manage_skill_dates.py add-missing
|
|
python manage_skill_dates.py add-missing --date 2024-01-15
|
|
python manage_skill_dates.py add-all --date 2025-01-01
|
|
python manage_skill_dates.py update my-skill-name 2024-06-01
|
|
"""
|
|
)
|
|
|
|
subparsers = parser.add_subparsers(dest='command', help='Command to execute')
|
|
|
|
# list command
|
|
subparsers.add_parser('list', help='List all skills with their date_added values')
|
|
|
|
# add-missing command
|
|
add_missing_parser = subparsers.add_parser('add-missing', help='Add date_added to skills without it')
|
|
add_missing_parser.add_argument('--date', help='Date to use (YYYY-MM-DD), defaults to today')
|
|
|
|
# add-all command
|
|
add_all_parser = subparsers.add_parser('add-all', help='Add/update date_added for all skills')
|
|
add_all_parser.add_argument('--date', help='Date to use (YYYY-MM-DD), defaults to today')
|
|
|
|
# update command
|
|
update_parser = subparsers.add_parser('update', help='Update a specific skill date')
|
|
update_parser.add_argument('skill_name', help='Name of the skill')
|
|
update_parser.add_argument('date', help='Date to set (YYYY-MM-DD)')
|
|
|
|
args = parser.parse_args()
|
|
|
|
if not args.command:
|
|
parser.print_help()
|
|
return
|
|
|
|
if args.command == 'list':
|
|
list_skills()
|
|
elif args.command == 'add-missing':
|
|
add_missing_dates(args.date)
|
|
elif args.command == 'add-all':
|
|
add_all_dates(args.date)
|
|
elif args.command == 'update':
|
|
update_skill_date(args.skill_name, args.date)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|