#!/usr/bin/env python3 """ migrate_skill_root.py — additive bulk migration: give each skill a root SKILL.md. Non-destructive. For every skill directory under custom-skills/ that lacks a root SKILL.md, it generates one by copying the skill's existing directive and: - setting `name:` to the directory name MINUS its NN- ordering prefix (dir `16-seo-schema-validator` -> name `seo-schema-validator`; clean + unique), - preserving `description` and the markdown body verbatim, - validating the result (frontmatter <= 1024 chars, kebab name, description present). Source priority per skill: desktop/SKILL.md -> code/SKILL.md. It NEVER touches desktop/, code/, or an existing root SKILL.md. The implementation of the SKILL-MIGRATION-GUIDE recipe, for the whole repo at once. Usage: python scripts/migrate_skill_root.py # dry-run (default): report only python scripts/migrate_skill_root.py --apply # write the new root SKILL.md files """ import argparse import re import sys from pathlib import Path REPO = Path(__file__).resolve().parent.parent SKILLS = REPO / "custom-skills" FM_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.S) MAX_FM = 1024 def skill_name(dirname): """Skill `name:` = directory name without its NN- ordering prefix.""" return re.sub(r"^\d+-", "", dirname) def is_skill_dir(d): """A skill dir is a real skill, not shared infra.""" if not d.is_dir(): return False n = d.name return not (n.startswith("_") or n.endswith("-shared")) def find_source(d): """Existing directive to copy from, by priority.""" for cand in (d / "desktop" / "SKILL.md", d / "code" / "SKILL.md"): if cand.exists(): return cand return None def discover_skill_dirs(): """Top-level skill dirs PLUS any nested dir that has a desktop/ or code/ SKILL.md (e.g. the sub-skills of a suite like 90-reference-curator). Plugins keep their skill at skills//SKILL.md with no desktop/ or code/, so they are correctly left alone.""" found = {} for d in SKILLS.iterdir(): # top-level: report SKIP/MANUAL/CREATE if is_skill_dir(d): found[d] = True for src in list(SKILLS.rglob("desktop/SKILL.md")) + list(SKILLS.rglob("code/SKILL.md")): d = src.parent.parent # nested sub-skills (suite members) if is_skill_dir(d): found[d] = True return sorted(found) def set_name(frontmatter, name): """Replace the single-line `name:` value (or prepend one) with `name`.""" if re.search(r"^name:.*$", frontmatter, re.M): return re.sub(r"^name:.*$", f"name: {name}", frontmatter, count=1, flags=re.M) return f"name: {name}\n{frontmatter}" def build_root_skill(src_text, dir_name): """Return (new_skill_text, issues[]) or (None, issues) if unusable.""" m = FM_RE.match(src_text) if not m: return None, ["source has no YAML frontmatter"] fm, body = m.group(1), m.group(2).lstrip("\n") fm = set_name(fm, dir_name) issues = [] if len(fm) > MAX_FM: issues.append(f"frontmatter {len(fm)}>{MAX_FM} chars") if "description:" not in fm: issues.append("no description") if not re.search(r"^name:\s*[a-z0-9-]+\s*$", fm, re.M): issues.append("name not kebab-case") text = f"---\n{fm}\n---\n\n{body}" return text, issues def main(argv=None): ap = argparse.ArgumentParser(description="Additively add a root SKILL.md to each skill.") ap.add_argument("--apply", action="store_true", help="write files (default: dry-run)") args = ap.parse_args(argv) rows = [] # (dir, status, detail) created = skipped = manual = warned = 0 for d in discover_skill_dirs(): name = str(d.relative_to(SKILLS)) if (d / "SKILL.md").exists(): rows.append((name, "SKIP", "already has root SKILL.md")) skipped += 1 continue src = find_source(d) if not src: rows.append((name, "MANUAL", "no desktop/ or code/ SKILL.md source (commands/README only)")) manual += 1 continue text, issues = build_root_skill(src.read_text(encoding="utf-8"), skill_name(d.name)) rel = src.relative_to(d) if text is None: rows.append((name, "MANUAL", f"{rel}: {issues[0]}")) manual += 1 continue if issues: rows.append((name, "CREATE*", f"from {rel} | FIX: {'; '.join(issues)}")) warned += 1 else: rows.append((name, "CREATE", f"from {rel}")) created += 1 if args.apply: (d / "SKILL.md").write_text(text, encoding="utf-8") width = max(len(r[0]) for r in rows) print(f"{'SKILL':<{width}} STATUS DETAIL") print(f"{'-'*width} ------- ------") for name, status, detail in rows: print(f"{name:<{width}} {status:<7} {detail}") mode = "APPLIED" if args.apply else "DRY-RUN (no files written)" print(f"\n[{mode}] create={created} create-with-fix={warned} " f"skip={skipped} manual={manual} total_skills={len(rows)}") if warned: print(" * CREATE* rows were written but need a frontmatter fix (see FIX note).") if not args.apply: print(" Re-run with --apply to write the files.") return 0 if __name__ == "__main__": sys.exit(main())