mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
108 lines
3.6 KiB
Python
108 lines
3.6 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)
|
||
|
|
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())
|