#!/usr/bin/env python3 # -*- coding: utf-8 -*- """check_skills.py — assert every Felhom SKILL.md is well formed and live-linked. Run from anywhere: python3 scripts/check_skills.py Exit 0 clean · 1 convicted (at least one skill is malformed). WHY THIS EXISTS. `install_skills.py` discovers skills by globbing `skills/*/SKILL.md` and creates a symlink for each. It does not read the file. A SKILL.md whose frontmatter is missing a `description` therefore installs perfectly and then never loads, and NOTHING SAYS SO — the failure is silent at exactly the point a skill is supposed to fire. "The file exists" is a hollow check; this script asserts the properties that decide whether the material is ever reached. It reports EVERY offending file and field, never stopping at the first — a checker that stops early turns one fix into several runs. NO THIRD-PARTY DEPENDENCY, deliberately. The frontmatter here is two simple `key: value` lines; a YAML library would be a new install requirement on every machine that runs the gates, bought for nothing. If the frontmatter ever needs real YAML, that is the moment to reconsider — not before. WHAT THIS CANNOT SEE, stated so it is not mistaken for coverage it does not give: whether the model actually REACHES a skill when it should. That is behavioural, it is decided by the wording of the `description`, and no mechanical check can settle it. A green run here means the skill is loadable, never that it fires. """ import io import os import sys REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SRC = os.path.join(REPO, "skills") INSTALLED = os.path.join(os.path.expanduser("~"), ".claude", "skills") MAX_LINES = 150 # ── GRANDFATHERED, and named rather than silently exempted ─────────────────────────────────────── # `felhom-build-deploy` was 179 lines on the day this checker was written (2026-08-25), which is how # the over-length was discovered at all. The task that added this script forbids editing the four # pre-existing skills, so trimming it here would have been out of scope AND would have hidden the # finding. It is therefore an EXPLICIT, single-entry exception carrying its register row, printed as # a WARN on every run so it cannot fade into the background. # # THE SET CANNOT GROW SILENTLY: a NEW skill over the limit is not in this dict and is convicted # normally. Adding an entry is an edit to this file, in a commit, with a row to name. GRANDFATHERED = { "felhom-build-deploy": "R-394 — 179 lines when the limit was introduced; trim is a scoped session", } def parse_frontmatter(lines): """Return (fields, body_lines, error). Frontmatter is the block between the first two '---'.""" if not lines or lines[0].strip() != "---": return None, None, "does not start with a '---' frontmatter block" close = None for i in range(1, len(lines)): if lines[i].strip() == "---": close = i break if close is None: return None, None, "frontmatter block is never closed by a second '---'" fields = {} for raw in lines[1:close]: if not raw.strip(): continue if raw.startswith((" ", "\t")): continue # continuation of the previous value — the key is what we check if ":" not in raw: return None, None, "frontmatter line is not 'key: value': %r" % raw.strip()[:60] k, v = raw.split(":", 1) fields[k.strip()] = v.strip() return fields, lines[close + 1:], None def check(name, problems, warnings): path = os.path.join(SRC, name, "SKILL.md") with io.open(path, "r", encoding="utf-8") as fh: text = fh.read() lines = text.splitlines() fields, body, err = parse_frontmatter(lines) if err: problems.append("%s: %s" % (path, err)) return if not fields.get("name"): problems.append("%s: frontmatter field 'name' is missing or empty" % path) elif fields["name"] != name: problems.append("%s: frontmatter 'name' is %r but the directory is %r — they must match" % (path, fields["name"], name)) if not fields.get("description"): problems.append("%s: frontmatter field 'description' is missing or empty" % path) if body is not None and not "".join(body).strip(): problems.append("%s: the body below the frontmatter is empty" % path) n = len(lines) if n >= MAX_LINES: if name in GRANDFATHERED: warnings.append("%s: %d lines, OVER the %d-line limit — grandfathered (%s)" % (name, n, MAX_LINES, GRANDFATHERED[name])) else: problems.append("%s: %d lines — a SKILL.md must be UNDER %d" % (path, n, MAX_LINES)) # installed copy, if the installer has been run: it must resolve back into the repo inst = os.path.join(INSTALLED, name, "SKILL.md") link = "not installed" if os.path.exists(inst): try: if os.path.samefile(inst, path): link = "live-linked" else: problems.append("%s: installed copy %s does NOT resolve to the repo file — a stale " "copy will be read instead of your edits" % (path, inst)) link = "STALE COPY" except OSError as e: problems.append("%s: cannot compare with installed copy %s (%s)" % (path, inst, e)) link = "UNREADABLE" else: warnings.append("%s: not installed under %s — run scripts/install_skills.py" % (name, INSTALLED)) print("OK %-24s %3d lines %s" % (name, n, link)) def main(): if not os.path.isdir(SRC): print("FAIL: no skills/ dir at %s" % SRC) return 1 names = sorted(d for d in os.listdir(SRC) if os.path.isfile(os.path.join(SRC, d, "SKILL.md"))) if not names: print("FAIL: no skills found under %s" % SRC) return 1 problems, warnings = [], [] for n in names: try: check(n, problems, warnings) except OSError as e: problems.append("%s: unreadable (%s)" % (n, e)) print("FAIL %-24s unreadable" % n) for w in warnings: print("WARN %s" % w) if problems: print("\nFAIL: %d problem(s) across %d skill(s):" % (len(problems), len(names))) for p in problems: print(" - %s" % p) return 1 print("\nPASS: %d skill(s) well formed." % len(names)) return 0 if __name__ == "__main__": sys.exit(main())