mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
Valid JSON such as [] or {"permissions": []} caused lint_skills.py to raise AttributeError because it assumed both values were objects.
Validate the top-level settings value and permissions object before reading nested keys. Malformed settings now produce clear lint errors and exit 1 without a traceback.
Add subprocess regression tests covering invalid JSON, malformed root values, invalid permissions values, and non-list permissions.allow values.
123 lines
4.4 KiB
Python
123 lines
4.4 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, dict):
|
|
errors.append(".claude/settings.json: expected top-level JSON value to be an object")
|
|
return
|
|
permissions = data.get("permissions", {})
|
|
if not isinstance(permissions, dict):
|
|
errors.append(".claude/settings.json: expected permissions to be an object")
|
|
return
|
|
if not isinstance(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())
|