mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
Every PR to this repo is currently verified by hand. This adds the checks
a machine can do:
- latex-smoke: compiles cv/main_example.tex (lualatex) and the new
cover_letters/cover_example.tex (xelatex) in the texlive/texlive
container, failing on any LaTeX error. Exact page-count assertions
(CV=2, cover letter=1) run on the upstream repo only
- lint (tools/lint_skills.py, also runnable locally): every SKILL.md has
parseable YAML frontmatter with name+description (frontmatter breakage
happened before - 37a0eed), allowed-tools 'bun run <path>' targets
exist, command files start with a '# /<name>' title, settings.json is
valid JSON with a permissions.allow list
- cli-typecheck: bun install + tsc --noEmit for all five portal CLIs
(matrix, fail-fast off)
- placeholder-integrity (upstream only): tracked template files still
carry their placeholder tokens, catching accidental personal-data
commits before they land
Fork-friendly by design: /setup personalizes CLAUDE.md, the skill files,
and main_example.tex in forks, so placeholder checks and exact page
counts are guarded with github.repository == upstream; compile success
and lint run everywhere. Live CLI smoke tests are deliberately excluded:
network-flaky, and linkedin-search is personal-use-only per its own ToS
warning - CI-automated requests would violate it. CLIs are typechecked
instead.
The cover letter previously had no tracked example (cover_*.tex is
gitignored), so cover_example.tex is new: a placeholder letter following
the documented 06 structure, demonstrating the correct itemize-outside-
lettercontent pattern. It doubles as the structural reference /apply
Step 2 looks for on fresh clones, which until now matched nothing. The
gitignore exception is ordered after Cover_*.tex because case-insensitive
filesystems match that pattern against cover_example.tex too.
Writing it surfaced a latent bug in the documented template itself:
06-cover-letter-templates.md's structure ends with \closing{Kind
regards,\} - but cover.cls appends its own \, and the doubled break
produces '! LaTeX Error: There's no line here to end.' on every compile
(nonstopmode swallows it, so it went unnoticed). Fixed in 06 and noted
in the example.
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())
|