#!/usr/bin/env python3 # /// script # requires-python = ">=3.11" # /// """scan_paths: lint a skill's Markdown files for path conventions. Rules, each a `rule` value in the findings: bare-script-call `uv run scripts/x.py` or `uv run ./scripts/x.py`; a skill's own scripts run as `uv run {skill-root}/scripts/x.py` because the agent's working directory is the project installed-path `{installed_path}` or an `installed_path` definition, a pre-skill leftover absolute-path /Users/, /home/, C:\\ or ~/ paths, which do not travel between machines cross-skill-ref a path into another skill's folder (skills//..., .claude/skills//..., or a ../ path that leaves this skill); to use another skill, invoke it by name missing-file a backticked skill-relative path (`references/x.md`) that does not exist from the file's folder or the skill root, when its first folder does exist (otherwise it is prose); files under assets/ and files named sample-* are not checked, they describe emitted output old-module-format a `module.yaml` or `module-help.csv` mention; modules are described by bmod.toml now python-call `python x.py`, `python3 -m x` or `pip install`; every script runs as `uv run `, which reads the script's PEP 723 header for its dependencies Fenced code blocks are checked for bare-script-call, installed-path, absolute-path, old-module-format and python-call (a wrong example teaches the wrong call) and skipped for the two reference-resolution rules. Usage: scan_paths.py [--allow RULE ...] --allow suppresses a rule by name, for a skill that documents the old format on purpose (`--allow old-module-format`). Output, one JSON object on stdout: {"skill": str, "files_scanned": int, "findings": [{"path", "line", "rule", "text", "fix"}, ...]} Exit 0 when clean, 1 when there are findings, 2 on a usage error. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv"} EXAMPLE_DIR = "assets" EXAMPLE_PREFIX = "sample-" BARE_SCRIPT_RE = re.compile(r"\buv\s+run\b[^`\n]*?\s(?:\./)?scripts/\S+") INSTALLED_PATH_RE = re.compile(r"\{installed_path\}|\binstalled_path\s*[:=]") ABS_PATH_RE = re.compile(r"(?:/Users/|/home/|\b[A-Za-z]:[\\/]|(? dict: return {"path": path, "line": line, "rule": rule, "text": text.strip()[:200], "fix": fix} def blank_fences(content: str) -> str: """Blank fenced code blocks but keep their newlines so line numbers stay aligned.""" return re.sub(r"```.*?```", lambda m: re.sub(r"[^\n]", "", m.group(0)), content, flags=re.DOTALL) def line_of(content: str, offset: int) -> int: return content.count("\n", 0, offset) + 1 def iter_md_files(skill_root: Path): for path in sorted(skill_root.rglob("*.md")): parts = path.relative_to(skill_root).parts if any(part in SKIP_DIRS or part.startswith(".") for part in parts): continue yield path def is_example(rel: Path) -> bool: return EXAMPLE_DIR in rel.parts[:-1] or rel.name.startswith(EXAMPLE_PREFIX) def scan_regex_rules(content: str, rel: str) -> list[dict]: findings = [] for regex, rule, fix in ( (BARE_SCRIPT_RE, "bare-script-call", "write `uv run {skill-root}/scripts/.py`"), (INSTALLED_PATH_RE, "installed-path", "remove installed_path; use a path relative to this file"), (ABS_PATH_RE, "absolute-path", "use {project-root}, {skill-root} or a config value"), (OLD_FORMAT_RE, "old-module-format", "describe the module in bmod.toml; see the migrate mode"), ( PYTHON_CALL_RE, "python-call", "run it as `uv run `; dependencies come from the script's PEP 723 header", ), ): for match in regex.finditer(content): findings.append(finding(rel, line_of(content, match.start()), rule, match.group(0), fix)) return findings def scan_references(content: str, rel: Path, skill_root: Path) -> list[dict]: findings = [] stripped = blank_fences(content) skill_name = skill_root.name file_dir = (skill_root / rel).parent for match in BACKTICK_REF_RE.finditer(stripped): raw = match.group(1) line = line_of(stripped, match.start()) other = SKILL_DIR_RE.search(raw) if other and other.group(1) != skill_name and other.group(1) not in BMAD_RUNTIME_DIRS: findings.append( finding( rel.as_posix(), line, "cross-skill-ref", raw, f'do not reach into `{other.group(1)}`; write "invoke the `{other.group(1)}` skill"', ) ) continue if any(ch in raw for ch in "*<{"): continue if raw.startswith("../"): target = (file_dir / raw).resolve() if skill_root.resolve() not in target.parents and target != skill_root.resolve(): findings.append(finding(rel.as_posix(), line, "cross-skill-ref", raw, "a skill's files stay inside it")) continue if raw.startswith(("/", "./", "_bmad/", "@")) or is_example(rel): continue roots = [file_dir, skill_root] if any((root / raw).exists() for root in roots): continue first_dir = raw.split("/")[0] if any((root / first_dir).is_dir() for root in roots): findings.append( finding(rel.as_posix(), line, "missing-file", raw, "fix the path or remove the dead reference") ) return findings def scan_skill(skill_root: Path, allow: tuple[str, ...] = ()) -> dict: findings: list[dict] = [] count = 0 for path in iter_md_files(skill_root): count += 1 rel = path.relative_to(skill_root) content = path.read_text(encoding="utf-8", errors="replace") findings.extend(scan_regex_rules(content, rel.as_posix())) findings.extend(scan_references(content, rel, skill_root)) findings = [f for f in findings if f["rule"] not in allow] findings.sort(key=lambda f: (f["path"], f["line"], f["rule"])) return {"skill": skill_root.name, "files_scanned": count, "findings": findings} def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("skill", type=Path, help="skill folder to scan") p.add_argument("--allow", action="append", default=[], metavar="RULE", help="suppress a rule by name (repeatable)") args = p.parse_args(argv) if not args.skill.is_dir(): p.error(f"not a directory: {args.skill}") unknown = sorted(set(args.allow) - RULES) if unknown: p.error(f"unknown rule(s): {', '.join(unknown)}; rules: {', '.join(sorted(RULES))}") result = scan_skill(args.skill, tuple(args.allow)) print(json.dumps(result, indent=2)) return 1 if result["findings"] else 0 if __name__ == "__main__": sys.exit(main())