feat: Implement date tracking for skills

- Added `date_added` field to all skills in frontmatter.
- Updated Home page to display skill addition date alongside risk level.
- Enhanced Skill Detail page to show date added in a badge format.
- Created scripts for managing skill dates, including adding missing dates and generating reports.
- Updated validators to enforce date format compliance.
- Added comprehensive documentation on date tracking implementation and usage.
- Introduced a new skill template including the `date_added` field.
This commit is contained in:
Zied
2026-02-26 12:39:28 +01:00
parent 689a825411
commit f8123cb5a9
14 changed files with 5168 additions and 2361 deletions

View File

@@ -1,9 +1,16 @@
import os
import json
import re
import sys
import yaml
# 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 parse_frontmatter(content):
"""
Parses YAML frontmatter, sanitizing unquoted values containing @.
@@ -59,7 +66,8 @@ def generate_index(skills_dir, output_file):
"name": dir_name.replace("-", " ").title(),
"description": "",
"risk": "unknown",
"source": "unknown"
"source": "unknown",
"date_added": None
}
try:
@@ -77,6 +85,7 @@ def generate_index(skills_dir, output_file):
if "description" in metadata: skill_info["description"] = metadata["description"]
if "risk" in metadata: skill_info["risk"] = metadata["risk"]
if "source" in metadata: skill_info["source"] = metadata["source"]
if "date_added" in metadata: skill_info["date_added"] = metadata["date_added"]
# Fallback for description if missing in frontmatter (legacy support)
if not skill_info["description"]:

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Generate a report of skills with their date_added metadata in JSON format.
Usage:
python generate_skills_report.py [--output report.json] [--sort date|name]
"""
import os
import re
import json
import sys
import argparse
from datetime import datetime
from pathlib import Path
def get_project_root():
"""Get the project root directory."""
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def parse_frontmatter(content):
"""Parse frontmatter from SKILL.md content."""
fm_match = re.search(r'^---\s*\n(.*?)\n---', content, re.DOTALL)
if not fm_match:
return None
fm_text = fm_match.group(1)
metadata = {}
for line in fm_text.split('\n'):
if ':' in line and not line.strip().startswith('#'):
key, val = line.split(':', 1)
metadata[key.strip()] = val.strip().strip('"').strip("'")
return metadata
def generate_skills_report(output_file=None, sort_by='date'):
"""Generate a report of all skills with their metadata."""
skills_dir = os.path.join(get_project_root(), 'skills')
skills_data = []
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
skill_info = {
'id': metadata.get('id', skill_name),
'name': metadata.get('name', skill_name),
'description': metadata.get('description', ''),
'date_added': metadata.get('date_added', None),
'source': metadata.get('source', 'unknown'),
'risk': metadata.get('risk', 'unknown'),
'category': metadata.get('category', metadata.get('id', '').split('-')[0] if '-' in metadata.get('id', '') else 'other'),
}
skills_data.append(skill_info)
except Exception as e:
print(f"⚠️ Error reading {skill_path}: {str(e)}", file=sys.stderr)
# Sort data
if sort_by == 'date':
# Sort by date_added (newest first), then by name
skills_data.sort(key=lambda x: (x['date_added'] or '0000-00-00', x['name']), reverse=True)
elif sort_by == 'name':
skills_data.sort(key=lambda x: x['name'])
# Prepare report
report = {
'generated_at': datetime.now().isoformat(),
'total_skills': len(skills_data),
'skills_with_dates': sum(1 for s in skills_data if s['date_added']),
'skills_without_dates': sum(1 for s in skills_data if not s['date_added']),
'coverage_percentage': round(
sum(1 for s in skills_data if s['date_added']) / len(skills_data) * 100 if skills_data else 0,
1
),
'sorted_by': sort_by,
'skills': skills_data
}
# Output
if output_file:
try:
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(report, f, indent=2, ensure_ascii=False)
print(f"✅ Report saved to: {output_file}")
except Exception as e:
print(f"❌ Error saving report: {str(e)}")
return None
else:
# Print to stdout
print(json.dumps(report, indent=2, ensure_ascii=False))
return report
def main():
parser = argparse.ArgumentParser(
description="Generate a skills report with date_added metadata",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python generate_skills_report.py
python generate_skills_report.py --output skills_report.json
python generate_skills_report.py --sort name --output sorted_skills.json
"""
)
parser.add_argument('--output', '-o', help='Output file (JSON). If not specified, prints to stdout')
parser.add_argument('--sort', choices=['date', 'name'], default='date', help='Sort order (default: date)')
args = parser.parse_args()
generate_skills_report(output_file=args.output, sort_by=args.sort)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,306 @@
#!/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
# 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 os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def parse_frontmatter(content):
"""Parse frontmatter from SKILL.md content."""
fm_match = re.search(r'^---\s*\n(.*?)\n---', content, re.DOTALL)
if not fm_match:
return None, content
fm_text = fm_match.group(1)
metadata = {}
for line in fm_text.split('\n'):
if ':' in line and not line.strip().startswith('#'):
key, val = line.split(':', 1)
metadata[key.strip()] = val.strip().strip('"').strip("'")
return metadata, content
def reconstruct_frontmatter(metadata):
"""Reconstruct frontmatter from metadata dict."""
lines = ["---"]
# Order: id, name, description, category, risk, source, tags, date_added
priority_keys = ['id', 'name', 'description', 'category', 'risk', 'source', 'tags']
for key in priority_keys:
if key in metadata:
val = metadata[key]
if isinstance(val, list):
# Handle list fields like tags
lines.append(f'{key}: {val}')
elif ' ' in str(val) or any(c in str(val) for c in ':#"'):
lines.append(f'{key}: "{val}"')
else:
lines.append(f'{key}: {val}')
# Add date_added at the end
if 'date_added' in metadata:
lines.append(f'date_added: "{metadata["date_added"]}"')
lines.append("---")
return '\n'.join(lines)
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()

View File

@@ -41,6 +41,7 @@ const ALLOWED_FIELDS = new Set([
"metadata",
"allowed-tools",
"package",
"date_added",
]);
const USE_SECTION_PATTERNS = [

View File

@@ -41,6 +41,7 @@ def validate_skills(skills_dir, strict_mode=False):
security_disclaimer_pattern = re.compile(r"AUTHORIZED USE ONLY", re.IGNORECASE)
valid_risk_levels = ["none", "safe", "critical", "offensive", "unknown"]
date_pattern = re.compile(r'^\d{4}-\d{2}-\d{2}$') # YYYY-MM-DD format
for root, dirs, files in os.walk(skills_dir):
# Skip .disabled or hidden directories
@@ -91,6 +92,15 @@ def validate_skills(skills_dir, strict_mode=False):
if strict_mode: errors.append(msg.replace("⚠️", ""))
else: warnings.append(msg)
# Date Added Validation (optional field)
if "date_added" in metadata:
if not date_pattern.match(metadata["date_added"]):
errors.append(f"{rel_path}: Invalid 'date_added' format. Must be YYYY-MM-DD (e.g., '2024-01-15'), got '{metadata['date_added']}'")
else:
msg = f" {rel_path}: Missing 'date_added' field (optional, but recommended)"
if strict_mode: warnings.append(msg)
# In normal mode, we just silently skip this
# 3. Content Checks (Triggers)
if not has_when_to_use_section(content):
msg = f"⚠️ {rel_path}: Missing '## When to Use' section"