Files
our-claude-skills/scripts/migrate_skill_root.py
Andrew Yim 6ac547e78f
Some checks failed
Verify Skills / verify-skills (push) Has been cancelled
refactor(skills): clean skill names (strip NN- prefix from name:) — convention change
Adopt: directory keeps its NN- ordering prefix; skill `name:` is the clean form
without it (dir 16-seo-schema-validator → name: seo-schema-validator). Nicer to
invoke, matches the original desktop/SKILL.md names, still globally unique.

- 71 root SKILL.md: name: NN-foo → name: foo (flat skills + reference-curator suite).
  Plugins (mac-optimizer/multi-agent-guide/dintel-bootstrap) already clean; 95 already clean.
- scripts/migrate_skill_root.py: derive name = dirname minus NN- prefix (skill_name()).
- CLAUDE.md + SKILL-MIGRATION-GUIDE.md: document the dir-prefix / clean-name convention.

verify_skills.py: 0 name collisions across all renamed skills. (The ~/.claude/skills
symlinks were re-pointed to the clean names separately — filesystem only.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:11:01 +09:00

148 lines
5.3 KiB
Python

#!/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/<name>/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())