Some checks failed
Verify Skills / verify-skills (push) Has been cancelled
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>
148 lines
5.3 KiB
Python
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())
|