#!/usr/bin/env python3 """Generate catalog.json — the reviewable list of every skill in this repo. Usage: python3 scripts/generate-catalog.py [--check] (default) write catalog.json --check fail if catalog.json is out of date (for CI) """ from __future__ import annotations import hashlib import json import re import sys from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parent.parent CATALOG = ROOT / "catalog.json" def frontmatter(text: str) -> dict: if not text.startswith("---"): return {} end = text.find("\n---", 3) if end == -1: return {} data: dict = {} metadata: dict = {} key = None for line in text[3:end].splitlines(): if not line.strip(): continue m = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", line) if m: key = m.group(1) data[key] = m.group(2).strip().strip('"').strip("'") continue m = re.match(r"^\s+([A-Za-z0-9_-]+):\s*(.*)$", line) if m and key == "metadata": metadata[m.group(1)] = m.group(2).strip().strip('"').strip("'") if metadata: data["metadata"] = metadata return data def build() -> dict: skills = [] for plugin_json in sorted(ROOT.glob("skills-*/*-plugin/.claude-plugin/plugin.json")): plugin_dir = plugin_json.parent.parent meta = json.loads(plugin_json.read_text(encoding="utf-8")) skill_files = sorted((plugin_dir / "skills").rglob("SKILL.md")) if not skill_files: continue skill_md = skill_files[0] fm = frontmatter(skill_md.read_text(encoding="utf-8", errors="replace")) desc = fm.get("description", "") body = skill_md.read_text(encoding="utf-8", errors="replace") install_name = meta.get("install_name") or fm.get("name") has_evals = any((plugin_dir / "skills").rglob("evals/evals.json")) has_docs = (ROOT / "docs" / "skills" / f"{install_name}.md").is_file() skills.append( { "name": install_name, "category": meta.get("dir_category"), "version": meta.get("version"), "status": meta.get("status", "promoted"), "invocation": (fm.get("metadata") or {}).get("invocation", "model-invoked"), "description": desc, "description_len": len(desc), "path": str(skill_md.relative_to(ROOT)), "digest": hashlib.sha256(body.encode("utf-8")).hexdigest()[:16], "has_evals": has_evals, "has_docs": has_docs, } ) counts: dict = {} for skill in skills: key = f"{skill['status']}/{skill['invocation']}" counts[key] = counts.get(key, 0) + 1 required = [ x for x in skills if x["status"] == "promoted" and x["invocation"] == "model-invoked" ] coverage = { "required": len(required), "with_evals": sum(1 for x in required if x["has_evals"]), "with_docs": sum(1 for x in required if x["has_docs"]), "missing_evals": [x["name"] for x in required if not x["has_evals"]], "missing_docs": [x["name"] for x in required if not x["has_docs"]], } return { "repo": "ai-proj-helper", "coverage": coverage, "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "total": len(skills), "counts": counts, "skills": skills, } def main() -> int: catalog = build() text = json.dumps(catalog, ensure_ascii=False, indent=2) + "\n" if "--check" in sys.argv: if not CATALOG.is_file(): print("catalog.json 不存在,请运行 python3 scripts/generate-catalog.py") return 1 current = json.loads(CATALOG.read_text(encoding="utf-8")) current.pop("generated_at", None) fresh = dict(catalog) fresh.pop("generated_at", None) if current != fresh: print("catalog.json 已过期,请重新生成") return 1 print("catalog.json 是最新的") return 0 CATALOG.write_text(text, encoding="utf-8") print(f"写入 {CATALOG.relative_to(ROOT)}:{catalog['total']} 个技能 {catalog['counts']}") return 0 if __name__ == "__main__": raise SystemExit(main())