basic BMAD setup
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
#!/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/<other>/..., .claude/skills/<other>/..., 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 <path>`,
|
||||
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 <skill-folder> [--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]:[\\/]|(?<![\w.])~/)\S*")
|
||||
OLD_FORMAT_RE = re.compile(r"\bmodule\.yaml\b|\bmodule-help\.csv\b")
|
||||
PYTHON_CALL_RE = re.compile(r"(?<![\w/.-])(?:python3?|pip3?)\s+(?:-m\s+\S+|\S+\.py\b|install\b)")
|
||||
BACKTICK_REF_RE = re.compile(r"`([^`\s]+/[^`\s]+\.(?:md|yaml|yml|toml|json|csv|txt|xml|py|html))`")
|
||||
SKILL_DIR_RE = re.compile(r"(?:^|/)(?:skills|\.claude/skills|\.agents/skills|_bmad)/([a-z0-9][a-z0-9-]*)/")
|
||||
# Runtime folders under _bmad that are not skills.
|
||||
RULES = {
|
||||
"bare-script-call",
|
||||
"installed-path",
|
||||
"absolute-path",
|
||||
"cross-skill-ref",
|
||||
"missing-file",
|
||||
"old-module-format",
|
||||
"python-call",
|
||||
}
|
||||
BMAD_RUNTIME_DIRS = {"scripts", "config", "custom", "memory", "render", "_config", "knowledge"}
|
||||
|
||||
|
||||
def finding(path: str, line: int, rule: str, text: str, fix: str) -> 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/<name>.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 <path>`; 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())
|
||||
Reference in New Issue
Block a user