Files
ai-job-search/tools/lint_skills.py
T
Mads LorentzenandClaude Fable 5 a5ffcc39ff chore: untrack tracker CSV, scope scraper Bash permission, fix portal SKILL.md paths (#71)
- Untrack job_search_tracker.csv: it was both tracked and listed in
  .gitignore (same inconsistency class as the settings.local.json fix
  in #27). Users' personal rows risked merge conflicts on every pull;
  commands already create the file with the standard header when it
  is missing.
- Scope job-scraper's allowed-tools Bash entry (from #52) to
  'bun --version' and the portal-CLI invocation pattern, adopting the
  tighter form proposed in #65.
- Fix all five portal SKILL.mds documenting 'bun run skills/...'
  paths that do not resolve from the repo root ('.agents/skills/...'
  is correct) - now load-bearing since #52 wired /scrape to read
  these docs for CLI invocations. Surfaced in #66.
- Teach tools/lint_skills.py to glob-expand allowed-tools bun run
  targets so scoped wildcard permissions lint correctly.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:14:39 +02:00

116 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""Lint the repo's skill, command, and settings files.
Run from anywhere: python tools/lint_skills.py
Checks:
- Every SKILL.md (.claude/skills/*, .agents/skills/*) has YAML frontmatter that
parses, with non-empty `name` and `description` keys
- `allowed-tools` entries of the form `Bash(bun run <path> *)` point at files
that exist (skill paths resolve relative to the repo root and to .agents/)
- Every .claude/commands/*.md starts with a `# /<name>` title
- .claude/settings.json is valid JSON with a permissions.allow list
Exit code 0 on success, 1 with a failure list otherwise.
"""
import json
import re
import sys
from pathlib import Path
try:
import yaml
except ImportError:
sys.exit("lint_skills.py requires PyYAML: pip install pyyaml")
ROOT = Path(__file__).resolve().parent.parent
errors: list[str] = []
def rel(path: Path) -> str:
return str(path.relative_to(ROOT))
def check_skill(path: Path) -> None:
text = path.read_text(encoding="utf-8")
if not text.startswith("---\n"):
errors.append(f"{rel(path)}: missing YAML frontmatter (file must start with ---)")
return
end = text.find("\n---", 4)
if end == -1:
errors.append(f"{rel(path)}: unterminated YAML frontmatter")
return
try:
data = yaml.safe_load(text[4:end])
except yaml.YAMLError as exc:
errors.append(f"{rel(path)}: frontmatter is not valid YAML: {exc}")
return
if not isinstance(data, dict):
errors.append(f"{rel(path)}: frontmatter did not parse to a mapping")
return
for key in ("name", "description"):
if not data.get(key):
errors.append(f"{rel(path)}: frontmatter missing required key '{key}'")
allowed = data.get("allowed-tools", "")
if isinstance(allowed, str):
for match in re.finditer(r"bun run ([^\s)]+)", allowed):
target = match.group(1).rstrip("*")
if not target or target.endswith("/"):
continue
# Targets may contain globs (e.g. .agents/skills/*/cli/src/cli.ts);
# require at least one existing file to match.
if "*" in target:
if not list(ROOT.glob(target)) and not list((ROOT / ".agents").glob(target)):
errors.append(f"{rel(path)}: allowed-tools glob matches no files: {target}")
else:
candidates = [ROOT / target, ROOT / ".agents" / target]
if not any(c.is_file() for c in candidates):
errors.append(f"{rel(path)}: allowed-tools references a missing file: {target}")
def check_command(path: Path) -> None:
lines = path.read_text(encoding="utf-8").lstrip().splitlines()
first = lines[0] if lines else ""
if not first.startswith("# /"):
errors.append(f"{rel(path)}: command file must start with a '# /<name>' title (found: {first[:50]!r})")
def check_settings() -> None:
path = ROOT / ".claude" / "settings.json"
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
errors.append(f".claude/settings.json: {exc}")
return
if not isinstance(data.get("permissions", {}).get("allow"), list):
errors.append(".claude/settings.json: expected permissions.allow to be a list")
def main() -> int:
skills = sorted(ROOT.glob(".claude/skills/*/SKILL.md")) + sorted(ROOT.glob(".agents/skills/*/SKILL.md"))
commands = sorted((ROOT / ".claude" / "commands").glob("*.md"))
if not skills:
errors.append("no SKILL.md files found - glob roots are wrong or the tree moved")
if not commands:
errors.append("no command files found under .claude/commands/")
for skill in skills:
check_skill(skill)
for command in commands:
check_command(command)
check_settings()
if errors:
print(f"lint_skills: {len(errors)} failure(s)")
for err in errors:
print(f" - {err}")
return 1
print(f"lint_skills: OK ({len(skills)} skills, {len(commands)} commands, settings.json)")
return 0
if __name__ == "__main__":
sys.exit(main())