claude-skills/scripts/check_skill_names.py
Claude a80eec2267
fix: rename all remaining built-in-shadowing skill names and harden the last cp1252-fatal scripts (#885, #969 follow-through)
Round-2 sweep after re-auditing all 15 reported issues against the merged dev:

- #885 generalized: the original fix only renamed self-improving-agent's
  status/review, but three more plugins shipped skills whose bare names
  shadow Claude Code built-ins. Renamed with the same convention:
  playwright-pro init/review -> pw-init/pw-review, agenthub init/status ->
  hub-init/hub-status, autoresearch-agent status/resume -> ar-status/
  ar-resume. All command references (/pw: /hub: /ar:), docs, audit records,
  harness manifests, and mirror trees/indexes updated; the flat mirror
  namespace no longer collides on 'status'. New scripts/check_skill_names.py
  gate (wired into ci-quality-gate.yml as blocking) fails CI on any future
  bare reserved name; rule added to SKILL-AUTHORING-STANDARD.md.
- #969 follow-through: five more scripts print box-drawing characters that
  cannot exist in cp1252 (api_scorecard, api_linter,
  breaking_change_detector, humanizer_scorer, content_scorer) — same
  guarded UTF-8 reconfigure applied; all smoke-tested under a forced
  legacy encoding.

Verified: check_skill_names (incl. negative test), check_plugin_json,
check_paths, derive_counters, check_dual_publish, smoke_scripts (634/634),
0 broken mirror symlinks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qgc6RYXWJPr5oW9DHU7zR4
2026-08-21 08:38:50 +00:00

87 lines
3 KiB
Python

#!/usr/bin/env python3
"""Guard against plugin skill names that shadow Claude Code built-in commands.
Issue #885: a plugin skill whose frontmatter `name:` equals a built-in slash
command word (e.g. `status`, `review`) shadows the built-in for every user who
installs the plugin — Claude Code's slash resolver matches the bare leaf name.
The fix convention is a namespaced leaf name (`memory-status`, `hub-status`,
`pw-review`, ...). This gate fails CI when a new bare reserved name appears.
Scans every SKILL.md outside mirror trees / docs / eval output and checks the
frontmatter `name:` against the reserved-word list below (Claude Code built-in
commands as of CC 2.1.x — extend the set when new built-ins land).
Exit codes: 0 = clean, 1 = at least one shadowing name found.
"""
import argparse
import os
import re
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Claude Code built-in slash commands a plugin skill name must never equal.
RESERVED = {
"add-dir", "agents", "bug", "clear", "compact", "commit", "config",
"context", "cost", "doctor", "export", "help", "hooks", "init", "login",
"logout", "mcp", "memory", "model", "permissions", "plugin", "pr",
"resume", "review", "rewind", "settings", "status", "terminal", "todos",
"usage", "vim",
}
SKIP_DIRS = {".git", ".gemini", ".codex", ".vibe", ".hermes", "docs",
"eval-workspace", "node_modules"}
NAME_RE = re.compile(r'^name:\s*["\']?([A-Za-z0-9_-]+)["\']?\s*$', re.M)
def find_skill_files():
out = []
for root, dirs, files in os.walk(REPO):
rel = os.path.relpath(root, REPO)
top = rel.split(os.sep)[0]
if top in SKIP_DIRS:
dirs[:] = []
continue
if "SKILL.md" in files:
out.append(os.path.join(root, "SKILL.md"))
return sorted(out)
def check(path):
try:
with open(path, encoding="utf-8", errors="replace") as f:
head = f.read(2048)
except OSError as e:
return f"unreadable: {e}"
m = NAME_RE.search(head)
if m and m.group(1).lower() in RESERVED:
return (f"skill name {m.group(1)!r} shadows the built-in "
f"/{m.group(1).lower()} command (issue #885) — use a "
f"namespaced leaf name like '<plugin>-{m.group(1).lower()}'")
return None
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--all", action="store_true",
help="Check every SKILL.md in the repo (default behavior)")
ap.parse_args()
failed = 0
for f in find_skill_files():
msg = check(f)
rel = os.path.relpath(f, REPO)
if msg:
failed += 1
print(f"FAIL {rel}\n - {msg}")
if failed:
print(f"\n{failed} skill(s) shadow built-in commands", file=sys.stderr)
return 1
print("OK: no plugin skill name shadows a Claude Code built-in command")
return 0
if __name__ == "__main__":
sys.exit(main())