basic BMAD setup

This commit is contained in:
Ebbe Baß
2026-10-15 08:57:23 +02:00
parent 44029127dc
commit 5e747654b9
438 changed files with 41818 additions and 0 deletions
@@ -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())