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,103 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["tiktoken"]
# ///
"""count_tokens: the one length metric for skill authoring.
Counts tokens with tiktoken's cl100k_base encoding. When tiktoken is not
installed it falls back to len(text) // 4 and says so, so the script runs
under a bare interpreter too.
Usage:
count_tokens.py <path> [<path> ...] files, or directories recursed for text files
count_tokens.py --stdin count the text read from stdin
Output, one JSON object on stdout:
{"files": [{"path": str, "tokens": int}, ...], "total": int, "method": "tiktoken" | "fallback"}
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
ENCODING = "cl100k_base"
TEXT_SUFFIXES = {".md", ".py", ".toml", ".yaml", ".yml", ".json", ".txt", ".csv", ".html", ".sh", ".cfg", ".ini"}
SKIP_DIRS = {".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv"}
def count_tokens(text: str) -> tuple[int, str]:
"""Return (token_count, method); method is "fallback" when tiktoken is unavailable."""
try:
import tiktoken
except Exception:
return len(text) // 4, "fallback"
try:
enc = tiktoken.get_encoding(ENCODING)
except Exception:
return len(text) // 4, "fallback"
return len(enc.encode(text)), "tiktoken"
def iter_text_files(root: Path):
"""Yield text files under root, skipping noise directories and hidden files."""
# Sorted by the posix string so the order is the same on every OS; Path sorts case-insensitively on Windows.
for path in sorted(root.rglob("*"), key=lambda p: p.relative_to(root).as_posix()):
if not path.is_file():
continue
parts = path.relative_to(root).parts
if any(part in SKIP_DIRS or part.startswith(".") for part in parts):
continue
if path.suffix.lower() in TEXT_SUFFIXES:
yield path
def read_text(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
except UnicodeDecodeError:
return path.read_text(encoding="utf-8", errors="replace")
def count_paths(paths: list[Path]) -> dict:
files = []
method = "tiktoken"
for given in paths:
targets = list(iter_text_files(given)) if given.is_dir() else [given]
for target in targets:
tokens, used = count_tokens(read_text(target))
if used == "fallback":
method = "fallback"
files.append({"path": str(target), "tokens": tokens})
return {"files": files, "total": sum(f["tokens"] for f in files), "method": method}
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("paths", nargs="*", type=Path, help="files or directories to count")
p.add_argument("--stdin", action="store_true", help="read text from stdin instead of paths")
args = p.parse_args(argv)
if args.stdin and args.paths:
p.error("provide paths or --stdin, not both")
if not args.stdin and not args.paths:
p.error("provide at least one path or --stdin")
if args.stdin:
tokens, method = count_tokens(sys.stdin.read())
result = {"files": [{"path": "<stdin>", "tokens": tokens}], "total": tokens, "method": method}
else:
missing = [str(path) for path in args.paths if not path.exists()]
if missing:
p.error(f"not found: {', '.join(missing)}")
result = count_paths(args.paths)
print(json.dumps(result))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""init_skill: scaffold a new skill folder, or check an existing one.
Create:
init_skill.py --name <kebab> --dest <folder> --shape <shape> [--dirs references,scripts,assets]
[--bmod <record-folder-name>] [--source <update_source>] [--description "..."]
Shapes: plain-skill, script-utility, rendered-skill, agent, memory-agent, single-skill-module,
multi-skill-module. Writes <dest>/<name>/SKILL.md (frontmatter `name` and `description`, the given
description or a [TODO: ...] placeholder, and an empty body heading), the requested dirs, and bmod.toml
when --bmod is given or the shape is a module:
- with --bmod: a [skill] table carrying `bmod` and `source`
- single-skill-module: [bmod] with code (the name without its bmad- prefix), version = "0.1.0" and
update_source, plus an empty [skill]
- multi-skill-module: the module record, a bmod- folder with [bmod] only (code, version, update_source,
skills = []) and the fixed record description
Never writes module.yaml or a setup skill. Refuses a target folder that already exists.
Check:
init_skill.py --check <skill-folder> [--any-name]
Verifies SKILL.md exists, the frontmatter has only `name` and `description`, the name matches the folder
and the bmad regex (bmod- for a module record; any kebab name with --any-name), the description is at
most 1024 characters and contains "Use when" or "Use if" (a record carries the fixed text instead), the
body is not empty, no `[TODO:` is left in any text file, and bmod.toml, when present, parses and carries
[skill] or [bmod]. "bmod" in the output is absent, ok or invalid.
Output: one JSON object on stdout. Create: {"ok", "skill", "dir", "shape", "created": [...]}.
Check: {"ok", "skill", "bmod", "findings": [{"path", "line", "rule", "text", "fix"}, ...]}.
Exit 0 on success or a clean check, 1 on a failed check or a refused create, 2 on a usage error.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import tomllib
from pathlib import Path
SHAPES = (
"plain-skill",
"script-utility",
"rendered-skill",
"agent",
"memory-agent",
"single-skill-module",
"multi-skill-module",
)
KNOWN_DIRS = ("references", "scripts", "assets", "help", "evals")
KEBAB_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
BMAD_NAME_RE = re.compile(r"^(?:bmad|bmad-[a-z0-9]+(?:-[a-z0-9]+)*)$")
RECORD_NAME_RE = re.compile(r"^bmod-[a-z0-9]+(?:-[a-z0-9]+)*$")
RECORD_DESCRIPTION = "Required bmod metadata. Never invoke this skill."
USE_WHEN_RE = re.compile(r"\buse\s+(?:when|if)\b", re.IGNORECASE)
TODO_RE = re.compile(r"\[TODO:")
TEXT_SUFFIXES = {".md", ".toml", ".py", ".json", ".yaml", ".yml", ".txt", ".html", ".csv"}
DESCRIPTION_PLACEHOLDER = "[TODO: five-to-eight-word summary. Use when the user says '<phrase>' or '<phrase>'.]"
def yaml_single_quoted(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def toml_string(value: str) -> str:
return json.dumps(value)
# --- frontmatter ---------------------------------------------------------------
def frontmatter_block(content: str) -> tuple[str | None, str]:
"""Return (frontmatter text or None, body)."""
text = content.lstrip()
if not text.startswith("---"):
return None, content
end = text.find("\n---", 3)
if end == -1:
return None, content
after = text[end + 4 :]
if after and not after.startswith(("\n", "\r")):
return None, content
return text[3:end].strip("\r\n"), after
def parse_frontmatter(content: str) -> tuple[dict[str, str] | None, str]:
"""Parse `key: value` frontmatter, folding indented continuation lines; return (dict or None, body)."""
block, body = frontmatter_block(content)
if block is None:
return None, body
result: dict[str, str] = {}
key: str | None = None
value = ""
for line in block.split("\n"):
colon = line.find(":")
if colon > 0 and line[:1] not in (" ", "\t"):
if key is not None:
result[key] = strip_quotes(value.strip())
key = line[:colon].strip()
value = line[colon + 1 :]
elif key is not None and not line.lstrip().startswith("#"):
value += "\n" + line
if key is not None:
result[key] = strip_quotes(value.strip())
return result, body
def strip_quotes(value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
inner = value[1:-1]
return inner.replace("''", "'") if value[0] == "'" else inner
return value
# --- create --------------------------------------------------------------------
def skill_md(name: str, description: str) -> str:
return f"---\nname: {name}\ndescription: {yaml_single_quoted(description)}\n---\n\n# {name}\n"
def bmod_toml(shape: str, name: str, bmod: str | None, source: str | None) -> str:
source_value = toml_string(source) if source else toml_string("[TODO: update_source, e.g. github:org/repo/skills]")
if shape == "multi-skill-module":
code = name.removeprefix("bmod-")
return f'[bmod]\ncode = {toml_string(code)}\nversion = "0.1.0"\nupdate_source = {source_value}\nskills = []\n'
if shape == "single-skill-module":
code = name.removeprefix("bmad-")
return (
f'[bmod]\ncode = {toml_string(code)}\nversion = "0.1.0"\nupdate_source = {source_value}\n\n'
"# The record is in this same file, so the skill table needs no bmod or source.\n[skill]\n"
)
lines = ["[skill]"]
if bmod:
lines.append(f"bmod = {toml_string(bmod)}")
if source:
lines.append(f"source = {toml_string(source)}")
return "\n".join(lines) + "\n"
def create(args) -> dict:
name = args.name
if not KEBAB_RE.match(name) or len(name) > 64:
raise ValueError(f"name {name!r} must be kebab-case (lowercase, digits, single hyphens), at most 64 characters")
if args.shape == "multi-skill-module" and not RECORD_NAME_RE.match(name):
raise ValueError("a multi-skill-module record is named bmod-<code>")
dirs = [d.strip() for d in (args.dirs or "").split(",") if d.strip()]
unknown = [d for d in dirs if d not in KNOWN_DIRS]
if unknown:
raise ValueError(f"unknown dirs {', '.join(unknown)}; known: {', '.join(KNOWN_DIRS)}")
skill_dir = Path(args.dest) / name
if skill_dir.exists():
raise FileExistsError(f"{skill_dir} already exists")
description = args.description or DESCRIPTION_PLACEHOLDER
if args.shape == "multi-skill-module":
description = RECORD_DESCRIPTION
skill_dir.mkdir(parents=True)
created = []
emitted = [("SKILL.md", skill_md(name, description))]
if args.bmod or args.shape in ("single-skill-module", "multi-skill-module"):
emitted.append(("bmod.toml", bmod_toml(args.shape, name, args.bmod, args.source)))
for rel, text in emitted:
(skill_dir / rel).write_text(text, encoding="utf-8")
created.append(rel)
for d in dirs:
(skill_dir / d).mkdir()
created.append(d + "/")
return {"ok": True, "skill": name, "dir": str(skill_dir), "shape": args.shape, "created": created}
# --- check ---------------------------------------------------------------------
def finding(path: str, line: int, rule: str, text: str, fix: str) -> dict:
return {"path": path, "line": line, "rule": rule, "text": text[:200], "fix": fix}
def read_bmod(skill_dir: Path, findings: list[dict]) -> tuple[dict | None, str]:
"""Return (parsed bmod.toml or None, status): status is absent, ok or invalid."""
path = skill_dir / "bmod.toml"
if not path.is_file():
return None, "absent"
try:
data = tomllib.loads(path.read_text(encoding="utf-8"))
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as err:
findings.append(finding("bmod.toml", 1, "bmod-invalid", str(err), "fix the TOML"))
return None, "invalid"
if "skill" not in data and "bmod" not in data:
findings.append(finding("bmod.toml", 1, "bmod-tables", "neither [skill] nor [bmod]", "add a [skill] table"))
return data, "invalid"
return data, "ok"
def check(skill_dir: Path, any_name: bool) -> dict:
findings: list[dict] = []
bmod, bmod_status = read_bmod(skill_dir, findings)
is_record = bool(bmod) and "bmod" in bmod and "skill" not in bmod
skill_path = skill_dir / "SKILL.md"
if not skill_path.is_file():
findings.append(finding("SKILL.md", 1, "skill-md-missing", "no SKILL.md", "create SKILL.md"))
else:
content = skill_path.read_text(encoding="utf-8", errors="replace")
meta, body = parse_frontmatter(content)
if meta is None:
findings.append(
finding("SKILL.md", 1, "frontmatter-missing", content[:80], "open with --- name/description ---")
)
else:
extra = sorted(set(meta) - {"name", "description"})
if extra:
findings.append(
finding("SKILL.md", 1, "frontmatter-keys", ", ".join(extra), "keep only name and description")
)
name = meta.get("name", "")
if not name:
findings.append(finding("SKILL.md", 1, "name-missing", "", "add name: <folder name>"))
else:
if name != skill_dir.name:
findings.append(
finding("SKILL.md", 2, "name-folder-mismatch", name, f"name must equal {skill_dir.name}")
)
regex = KEBAB_RE if any_name else (RECORD_NAME_RE if is_record else BMAD_NAME_RE)
if not regex.match(name):
findings.append(finding("SKILL.md", 2, "name-format", name, f"name must match {regex.pattern}"))
desc = meta.get("description", "")
if not desc:
findings.append(
finding("SKILL.md", 1, "description-missing", "", "add a description with a Use when clause")
)
else:
if len(desc) > 1024:
findings.append(
finding("SKILL.md", 3, "description-length", f"{len(desc)} chars", "cut to 1024 or fewer")
)
if is_record:
if desc != RECORD_DESCRIPTION:
findings.append(
finding(
"SKILL.md",
3,
"description-trigger",
desc,
f"a record's description is {RECORD_DESCRIPTION!r}",
)
)
elif not USE_WHEN_RE.search(desc):
findings.append(finding("SKILL.md", 3, "description-trigger", desc, 'add a "Use when ..." clause'))
if not body.strip():
findings.append(finding("SKILL.md", 1, "body-empty", "", "write the skill body after the frontmatter"))
for path in sorted(skill_dir.rglob("*")):
rel = path.relative_to(skill_dir)
if not path.is_file() or path.suffix not in TEXT_SUFFIXES or any(p.startswith(".") for p in rel.parts):
continue
for number, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
if TODO_RE.search(line):
findings.append(
finding(rel.as_posix(), number, "todo-left", line.strip(), "fill in or remove the placeholder")
)
return {"ok": not findings, "skill": skill_dir.name, "bmod": bmod_status, "findings": findings}
# --- cli -----------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--check", type=Path, metavar="SKILL", help="check this skill folder instead of creating one")
p.add_argument("--any-name", action="store_true", help="with --check: accept any kebab-case name")
p.add_argument("--name", help="skill name, kebab-case")
p.add_argument("--dest", type=Path, help="parent folder the skill folder is created under")
p.add_argument("--shape", choices=SHAPES, help="skill shape")
p.add_argument("--dirs", default="", help=f"comma-separated folders to create: {', '.join(KNOWN_DIRS)}")
p.add_argument("--bmod", help="record folder name for the [skill] table, e.g. bmod-method")
p.add_argument("--source", help="update_source for the [skill] or [bmod] table")
p.add_argument("--description", help="frontmatter description; a [TODO: ...] placeholder when omitted")
args = p.parse_args(argv)
if args.check:
if not args.check.is_dir():
p.error(f"not a directory: {args.check}")
result = check(args.check, args.any_name)
print(json.dumps(result, indent=2))
return 0 if result["ok"] else 1
if not (args.name and args.dest and args.shape):
p.error("--name, --dest and --shape are required to create a skill (or use --check)")
try:
result = create(args)
except (ValueError, FileExistsError) as err:
print(json.dumps({"ok": False, "error": str(err)}))
return 1
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["tiktoken"]
# ///
"""prepass: the deterministic pre-pass the review lenses read before opening any file.
One skill folder in, one JSON object out. Token counts come from count_tokens, path findings from
scan_paths and script findings from scan_scripts, all imported from this folder, so the numbers match
what each script prints on its own.
shape_hint is inferred, first match wins:
memory-agent `_bmad/memory` in SKILL.md, or scripts/wake.py or scripts/init-sanctum.py present
agent customize.toml has an [agent] table
rendered-skill SKILL.md calls render_skill.py
single-skill-module bmod.toml has both [bmod] and [skill]
multi-skill-module bmod.toml has [bmod] only (a module record)
script-utility scripts/ exists and SKILL.md is under 60 lines
plain-skill everything else; "unknown" when there is no SKILL.md
Usage:
prepass.py <skill-folder>
Output, one JSON object on stdout:
{"skill", "shape_hint", "files": [{"path", "tokens", "kind"}], "skill_md_tokens", "total_tokens",
"has_customize", "has_scripts", "scripts": [{"path", "has_pep723", "has_test"}],
"description_chars", "has_use_when", "frontmatter_ok", "bmod_kind", "path_findings": [...], "script_findings": [...]}
kind is one of entry, prompt, script, test, asset, config, other.
bmod_kind is none (no bmod.toml), skill (a module member), record (a record folder), record+skill (a
single-skill module) or invalid (a file with neither table, or one that does not parse).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import tomllib
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from count_tokens import count_tokens, iter_text_files, read_text # noqa: E402
from init_skill import USE_WHEN_RE, parse_frontmatter # noqa: E402
from scan_paths import scan_skill # noqa: E402
from scan_scripts import scan_scripts # noqa: E402
MEMORY_RE = re.compile(r"_bmad/memory")
SCRIPT_UTILITY_MAX_LINES = 60
def kind_of(rel: Path) -> str:
parts = rel.parts
if "assets" in parts[:-1]:
return "asset"
if parts[0] == "scripts":
return "test" if "tests" in parts[1:-1] else "script"
if rel.suffix == ".md":
return "entry" if rel.name == "SKILL.md" and len(parts) == 1 else "prompt"
if rel.suffix == ".toml":
return "config"
return "other"
def load_toml(path: Path) -> dict:
try:
return tomllib.loads(path.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError):
return {}
def shape_hint(root: Path, skill_text: str | None) -> str:
if skill_text is None:
return "unknown"
scripts = root / "scripts"
if MEMORY_RE.search(skill_text) or (scripts / "wake.py").is_file() or (scripts / "init-sanctum.py").is_file():
return "memory-agent"
if "agent" in load_toml(root / "customize.toml"):
return "agent"
if "render_skill.py" in skill_text:
return "rendered-skill"
bmod = load_toml(root / "bmod.toml")
if "bmod" in bmod:
return "single-skill-module" if "skill" in bmod else "multi-skill-module"
if scripts.is_dir() and len(skill_text.splitlines()) < SCRIPT_UTILITY_MAX_LINES:
return "script-utility"
return "plain-skill"
def bmod_kind(root: Path) -> str:
manifest = root / "bmod.toml"
if not manifest.is_file():
return "none"
data = load_toml(manifest)
has_record = isinstance(data.get("bmod"), dict)
has_skill = isinstance(data.get("skill"), dict)
if has_record and has_skill:
return "record+skill"
if has_record:
return "record"
if has_skill:
return "skill"
return "invalid"
def build(root: Path) -> dict:
skill_path = root / "SKILL.md"
skill_text = read_text(skill_path) if skill_path.is_file() else None
files = []
skill_md_tokens = 0
for path in iter_text_files(root):
rel = path.relative_to(root)
tokens, _ = count_tokens(read_text(path))
files.append({"path": rel.as_posix(), "tokens": tokens, "kind": kind_of(rel)})
if path == skill_path:
skill_md_tokens = tokens
meta = parse_frontmatter(skill_text)[0] if skill_text is not None else None
description = (meta or {}).get("description", "")
frontmatter_ok = bool(
meta and meta.get("name") == root.name and description and set(meta) <= {"name", "description"}
)
scripts_result = scan_scripts(root)
return {
"skill": root.name,
"shape_hint": shape_hint(root, skill_text),
"files": files,
"skill_md_tokens": skill_md_tokens,
"total_tokens": sum(f["tokens"] for f in files),
"has_customize": (root / "customize.toml").is_file(),
"has_scripts": (root / "scripts").is_dir(),
"scripts": [
{"path": s["path"], "has_pep723": s["has_pep723"], "has_test": s["has_test"]}
for s in scripts_result["scripts"]
],
"description_chars": len(description),
"has_use_when": bool(USE_WHEN_RE.search(description)),
"frontmatter_ok": frontmatter_ok,
"bmod_kind": bmod_kind(root),
"path_findings": scan_skill(root)["findings"],
"script_findings": scripts_result["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 analyze")
args = p.parse_args(argv)
root = args.skill.expanduser().resolve()
if not root.is_dir():
p.error(f"not a directory: {args.skill}")
print(json.dumps(build(root), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""process_template: fill a template's variables and conditional blocks.
Template syntax:
{name} a variable; replaced when --var name=value is given, left alone otherwise
(unmatched tokens may be runtime tokens such as {project-root} or {agent.name})
{if-X} ... {/if-X} a conditional block; kept when X is in --true, removed whole otherwise.
Blocks nest; the innermost is resolved first.
Conditionals are resolved before variables, so a variable inside a removed block is never
substituted. A marker left after processing means a malformed or mismatched block that the
emitted file would carry verbatim: the script exits 3 and names the markers.
Usage:
process_template.py <template> [-o <out>] [--var key=value ...] [--true COND ...] [--json]
Output: the processed text on stdout, or in the file named by -o. With -o, one JSON object
describing the run is printed on stdout; with --json and no -o it goes to stderr:
{"output_file", "vars_substituted": [...], "conditions_true": [...], "conditions_false": [...],
"tokens_remaining": [...]}
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
BLOCK_RE = re.compile(r"\{if-([a-zA-Z0-9_-]+)\}(.*?)\{/if-\1\}", re.DOTALL)
MARKER_RE = re.compile(r"\{/?if-[a-zA-Z0-9_-]+\}")
TOKEN_RE = re.compile(r"\{[a-zA-Z][a-zA-Z0-9_.-]*\}")
def process_conditionals(text: str, true_conditions: set[str]) -> tuple[str, list[str], list[str]]:
"""Resolve {if-X}...{/if-X} blocks innermost first; return (text, kept, removed)."""
kept: list[str] = []
removed: list[str] = []
while match := BLOCK_RE.search(text):
condition = match.group(1)
if condition in true_conditions:
replacement = match.group(2)
if condition not in kept:
kept.append(condition)
else:
replacement = ""
if condition not in removed:
removed.append(condition)
text = text[: match.start()] + replacement + text[match.end() :]
# Removed blocks leave runs of blank lines behind; keep at most one.
return re.sub(r"\n{3,}", "\n\n", text), kept, removed
def process_variables(text: str, variables: dict[str, str]) -> tuple[str, list[str]]:
"""Replace {name} for every name in variables; return (text, names substituted)."""
substituted: list[str] = []
for name, value in variables.items():
placeholder = "{" + name + "}"
if placeholder in text:
text = text.replace(placeholder, value)
substituted.append(name)
return text, substituted
def parse_var(raw: str) -> tuple[str, str]:
key, sep, value = raw.partition("=")
if not sep or not key:
raise argparse.ArgumentTypeError(f"expected key=value, got {raw!r}")
return key, value
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("template", type=Path, help="template file to process")
p.add_argument("-o", "--output", type=Path, help="write the result here instead of stdout")
p.add_argument(
"--var", action="append", default=[], type=parse_var, metavar="key=value", help="variable (repeatable)"
)
p.add_argument(
"--true", action="append", default=[], dest="true_conditions", metavar="COND", help="condition to keep"
)
p.add_argument("--json", action="store_true", help="print run metadata on stderr when writing to stdout")
args = p.parse_args(argv)
try:
content = args.template.read_text(encoding="utf-8")
except OSError as err:
print(f"process_template: cannot read {args.template}: {err}", file=sys.stderr)
return 2
content, kept, removed = process_conditionals(content, set(args.true_conditions))
content, substituted = process_variables(content, dict(args.var))
leftover = sorted(set(MARKER_RE.findall(content)))
if leftover:
print(f"process_template: leftover conditional markers: {', '.join(leftover)}", file=sys.stderr)
return 3
metadata = {
"output_file": str(args.output) if args.output else "<stdout>",
"vars_substituted": substituted,
"conditions_true": kept,
"conditions_false": removed,
"tokens_remaining": sorted(set(TOKEN_RE.findall(content))),
}
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(content, encoding="utf-8")
print(json.dumps(metadata))
else:
sys.stdout.write(content)
if args.json:
print(json.dumps(metadata), file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""read_session_log: digest Claude Code transcripts and memlogs into a compact JSON for the from-logs approach.
Sources:
claude-code a session transcript, one JSON object per line, as Claude Code keeps them under
~/.claude/projects/<encoded-cwd>/*.jsonl (the cwd with every "/" and "." turned into "-";
$CLAUDE_CONFIG_DIR replaces ~/.claude when set). Read: user prompts, tool calls per turn,
`Skill` tool calls and /command invocations, files named in tool inputs. Meta and sidechain
(subagent) records are skipped. Tool results and assistant prose are never copied out.
memlog a .memlog.md: entries typed direction or decision, or marked `by user`, count as requests;
entries typed gap or correction, or reading like a correction, count as corrections.
A correction is a user message that opens with a word of refusal or redirection (no, don't, stop, wrong,
wait, actually, instead, undo, revert, ...) or a request interrupted by the user. It is a heuristic; read the
digest as leads, not verdicts.
Usage:
read_session_log.py [<path> ...] [--project <cwd>] [--format auto|claude-code|memlog] [--max-items N]
A path may be a file or a folder (every *.jsonl and *.memlog.md inside). --project resolves the
transcript folder for that working directory. --format auto picks by file name.
Output, one JSON object on stdout; every text is cut to 300 characters and every list to --max-items:
{"sources": [{"path", "format", "entries"}], "user_requests": [{"text", "count"}],
"tool_sequences": [{"tools": [...], "count"}], "corrections": [text, ...],
"files_touched": [path, ...], "skills_invoked": [{"name", "count"}]}
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from collections import Counter
from pathlib import Path
TEXT_LIMIT = 300
CORRECTION_RE = re.compile(
r"^(?:no|nope|not|don'?t|do not|stop|wrong|wait|actually|instead|undo|revert|never|hold on|"
r"that'?s (?:not|wrong)|that is (?:not|wrong)|why did you|i said|i asked|not what)\b",
re.IGNORECASE,
)
INTERRUPTED_RE = re.compile(r"^\[Request interrupted by user")
COMMAND_RE = re.compile(r"<command-name>\s*/?([^<\s]+)\s*</command-name>")
MEMLOG_ENTRY_RE = re.compile(r"^- (?:\((?P<tag>[^)]*)\) )?(?P<text>.*)$")
FILE_INPUT_KEYS = ("file_path", "notebook_path", "path")
def truncate(text: str) -> str:
text = " ".join(text.split())
return text if len(text) <= TEXT_LIMIT else text[: TEXT_LIMIT - 3] + "..."
def encode_cwd(cwd: str) -> str:
return re.sub(r"[/.]", "-", cwd)
def projects_dir() -> Path:
base = os.environ.get("CLAUDE_CONFIG_DIR")
return (Path(base) if base else Path.home() / ".claude") / "projects"
class Digest:
def __init__(self) -> None:
self.sources: list[dict] = []
self.requests: Counter[str] = Counter()
self.request_text: dict[str, str] = {}
self.sequences: Counter[tuple[str, ...]] = Counter()
self.corrections: list[str] = []
self.files: set[str] = set()
self.skills: Counter[str] = Counter()
def add_request(self, text: str) -> None:
key = " ".join(text.lower().split())
if not key:
return
self.requests[key] += 1
self.request_text.setdefault(key, truncate(text))
if CORRECTION_RE.match(text.strip()) or INTERRUPTED_RE.match(text.strip()):
self.add_correction(text)
def add_correction(self, text: str) -> None:
short = truncate(text)
if short not in self.corrections:
self.corrections.append(short)
def add_sequence(self, tools: list[str]) -> None:
collapsed: list[str] = []
for tool in tools:
if not collapsed or collapsed[-1] != tool:
collapsed.append(tool)
if collapsed:
self.sequences[tuple(collapsed)] += 1
def render(self, max_items: int) -> dict:
return {
"sources": self.sources,
"user_requests": [
{"text": self.request_text[key], "count": count} for key, count in self.requests.most_common(max_items)
],
"tool_sequences": [
{"tools": list(seq), "count": count} for seq, count in self.sequences.most_common(max_items)
],
"corrections": self.corrections[:max_items],
"files_touched": sorted(self.files)[:max_items],
"skills_invoked": [{"name": name, "count": count} for name, count in self.skills.most_common(max_items)],
}
def user_text(content) -> str | None:
"""Return the prompt text of a user record, or None when it is only tool results."""
if isinstance(content, str):
return content
if isinstance(content, list):
texts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
return "\n".join(t for t in texts if t) if texts else None
return None
def read_claude_code(path: Path, digest: Digest) -> int:
entries = 0
turn_tools: list[str] = []
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(record, dict) or record.get("isMeta") or record.get("isSidechain"):
continue
message = record.get("message") if isinstance(record.get("message"), dict) else None
if message is None:
continue
content = message.get("content")
kind = record.get("type")
if kind == "user":
text = user_text(content)
if text is None:
continue
entries += 1
digest.add_sequence(turn_tools)
turn_tools = []
for command in COMMAND_RE.findall(text):
digest.skills[command] += 1
stripped = text.strip()
if INTERRUPTED_RE.match(stripped):
digest.add_correction(stripped)
elif stripped and not stripped.startswith("<"):
digest.add_request(stripped)
elif kind == "assistant" and isinstance(content, list):
for block in content:
if not isinstance(block, dict) or block.get("type") != "tool_use":
continue
entries += 1
name = str(block.get("name", ""))
turn_tools.append(name)
inputs = block.get("input") if isinstance(block.get("input"), dict) else {}
if name == "Skill" and inputs.get("skill"):
digest.skills[str(inputs["skill"])] += 1
for key in FILE_INPUT_KEYS:
if isinstance(inputs.get(key), str) and inputs[key]:
digest.files.add(inputs[key])
digest.add_sequence(turn_tools)
return entries
def read_memlog(path: Path, digest: Digest) -> int:
entries = 0
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
match = MEMLOG_ENTRY_RE.match(line)
if not match:
continue
entries += 1
tag = (match.group("tag") or "").strip().lower()
kind = tag.split(" by ")[0].strip()
by_user = " by user" in f" {tag}" or tag.startswith("by user")
text = match.group("text").strip()
if kind in ("gap", "correction"):
digest.add_correction(text)
elif kind in ("direction", "decision") or by_user:
digest.add_request(text)
elif CORRECTION_RE.match(text):
digest.add_correction(text)
return entries
def detect_format(path: Path, requested: str) -> str | None:
if requested != "auto":
return requested
if path.suffix == ".jsonl":
return "claude-code"
if path.name.endswith(".memlog.md") or path.name == ".memlog.md":
return "memlog"
return None
def expand(paths: list[Path]) -> list[Path]:
files: list[Path] = []
for path in paths:
if path.is_dir():
files.extend(sorted(path.glob("*.jsonl"), key=lambda p: p.stat().st_mtime))
files.extend(sorted(path.glob("*.memlog.md")))
else:
files.append(path)
return files
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("paths", nargs="*", type=Path, help="transcript files, memlogs, or folders of them")
p.add_argument("--project", help="working directory whose Claude Code transcripts to read")
p.add_argument("--format", choices=("auto", "claude-code", "memlog"), default="auto")
p.add_argument("--max-items", type=int, default=20, help="cap for every list (default 20)")
args = p.parse_args(argv)
paths = list(args.paths)
if args.project:
folder = projects_dir() / encode_cwd(str(Path(args.project).expanduser().resolve()))
if not folder.is_dir():
p.error(f"no transcripts for {args.project} at {folder}")
paths.append(folder)
if not paths:
p.error("give at least one path or --project")
missing = [str(path) for path in paths if not path.exists()]
if missing:
p.error(f"not found: {', '.join(missing)}")
digest = Digest()
for path in expand(paths):
fmt = detect_format(path, args.format)
if fmt is None:
print(f"read_session_log: skipping {path}, unknown format", file=sys.stderr)
continue
reader = read_claude_code if fmt == "claude-code" else read_memlog
entries = reader(path, digest)
digest.sources.append({"path": str(path), "format": fmt, "entries": entries})
print(json.dumps(digest.render(args.max_items), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""registry: what BMad knows about in a project, so a new skill's registration can be guessed.
Scans the skills roots for bmod.toml files and reports the installed module records, their
members, and the skills that are wired to BMad (a customize.toml or an _bmad/scripts call) but
registered with no module. Also says whether _bmad/ is set up and whether the project is a skills
repository with a record of its own.
Usage:
registry.py --project-root <folder> [--root <skills folder> ...]
Without --root the roots are <project>/.agents/skills, <project>/.claude/skills, <project>/skills,
~/.agents/skills and ~/.claude/skills; folders reached twice through symlinks are read once.
Output, one JSON object on stdout:
{"project_root": str,
"bmad": {"present": bool, "version": str|null},
"skills_repo": bool,
"records": [{"code", "folder", "path", "scope", "version", "update_source", "prefix",
"skills": [...], "has_help", "has_roster", "single_skill"}, ...],
"unregistered": [{"skill", "path"}, ...]}
Exit 0; 2 on a usage error.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import tomllib
from pathlib import Path
RUNTIME_RE = re.compile(r"_bmad/scripts/")
def default_roots(project_root: Path) -> list[Path]:
home = Path.home()
return [
project_root / ".agents" / "skills",
project_root / ".claude" / "skills",
project_root / "skills",
home / ".agents" / "skills",
home / ".claude" / "skills",
]
def load_toml(path: Path) -> dict | None:
try:
return tomllib.loads(path.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError):
return None
def common_prefix(names: list[str]) -> str:
"""The shared leading `word-` run of the member names, or an empty string."""
if not names:
return ""
parts = [n.split("-") for n in names]
shared = []
for group in zip(*parts, strict=False):
if len(set(group)) != 1:
break
shared.append(group[0])
if len(parts) == 1:
# One member: its name minus the last word, or the whole name when it is one word (`bmad-`).
shared = parts[0][:-1] or parts[0]
return "-".join(shared) + "-" if shared else ""
def scan(project_root: Path, roots: list[Path]) -> dict:
seen: set[Path] = set()
records: dict[str, dict] = {}
members: dict[str, list[str]] = {}
unregistered: list[dict] = []
home = Path.home().resolve()
for root in roots:
if not root.is_dir():
continue
for folder in sorted(root.iterdir()):
if not folder.is_dir() or not (folder / "SKILL.md").is_file():
continue
real = folder.resolve()
if real in seen:
continue
seen.add(real)
scope = (
"user" if real.is_relative_to(home) and not real.is_relative_to(project_root.resolve()) else "project"
)
manifest = folder / "bmod.toml"
if not manifest.is_file():
skill_text = (folder / "SKILL.md").read_text(encoding="utf-8", errors="replace")
if (folder / "customize.toml").is_file() or RUNTIME_RE.search(skill_text):
unregistered.append({"skill": folder.name, "path": str(folder)})
continue
data = load_toml(manifest) or {}
bmod = data.get("bmod")
if isinstance(bmod, dict) and bmod.get("code"):
skills = bmod.get("skills") if isinstance(bmod.get("skills"), list) else []
records[folder.name] = {
"code": str(bmod["code"]),
"folder": folder.name,
"path": str(folder),
"scope": scope,
"version": bmod.get("version"),
"update_source": bmod.get("update_source"),
"skills": [str(s) for s in skills],
"has_help": (folder / "help" / "help.md").is_file(),
"has_roster": (folder / "roster.toml").is_file(),
"single_skill": "skill" in data,
}
skill = data.get("skill")
if isinstance(skill, dict) and skill.get("bmod"):
members.setdefault(str(skill["bmod"]), []).append(folder.name)
out = []
for folder, rec in sorted(records.items()):
names = sorted(set(rec["skills"]) | set(members.get(folder, [])))
rec["skills"] = names
rec["prefix"] = common_prefix(names) if names else f"{rec['code']}-"
out.append(rec)
bmad_dir = project_root / "_bmad"
core = next((r for r in out if r["code"] == "core-tools"), None)
skills_dir = project_root / "skills"
skills_repo = skills_dir.is_dir() and any(
(p / "SKILL.md").is_file() and (p / "bmod.toml").is_file() for p in skills_dir.iterdir() if p.is_dir()
)
return {
"project_root": str(project_root),
"bmad": {"present": bmad_dir.is_dir(), "version": core["version"] if core else None},
"skills_repo": skills_repo,
"records": out,
"unregistered": unregistered,
}
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--project-root", type=Path, required=True, help="the folder holding _bmad/ (or that would)")
p.add_argument("--root", type=Path, action="append", help="a skills folder to scan; repeatable")
args = p.parse_args(argv)
project_root = args.project_root.resolve()
if not project_root.is_dir():
p.error(f"not a directory: {project_root}")
roots = args.root or default_roots(project_root)
print(json.dumps(scan(project_root, roots), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml>=6.0.2,<7"]
# ///
"""scan_legacy_module: parse an old-format BMad module so the migrate mode can convert it.
The old format described a module with a `module.yaml` (code, module_version, module_greeting, an optional
`agents` roster, and one block per config key with `prompt`, `default`, `result`, `user_setting`,
`single-select` or `multi-select`), a `module-help.csv` registry, and a `<code>-setup` skill (or an
`assets/module-setup.md` in a single-skill module) that merged both into `_bmad/config.yaml`,
`config.user.yaml` and `module-help.csv` with merge scripts. bmod.toml replaces all of it.
Usage:
scan_legacy_module.py <module-folder>
Output, one JSON object on stdout; paths are relative to the module folder:
{"module": {"code", "name", "version", "greeting", "agents": [...]},
"config_keys": [{"key", "prompt", "default", "user_setting", "kind", "unconvertible": [...]}],
"help_rows": [{"skill", ...the other CSV columns}],
"skills": [folder names that hold a SKILL.md],
"legacy_reads": [{"skill", "path", "line", "text"}],
"setup_skill": name or null,
"files_to_delete": [...]}
kind is text, single-select, multi-select or confirm. unconvertible lists what a bmod [[bmod.config_questions]]
entry (key, prompt, default, scope) cannot carry: select options, a result template, regex, required, example,
a boolean default. legacy_reads lists every line in a skill that reads _bmad/config.yaml, config.user.yaml,
_bmad/<code>/config.yaml or names a bmad-*-setup skill, outside the files slated for deletion.
Exit 0 on success, 1 when no module.yaml is found, 2 on a usage error.
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import sys
from pathlib import Path
import yaml
SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", ".pytest_cache"}
MODULE_META_KEYS = {
"code",
"name",
"header",
"subheader",
"description",
"module_version",
"default_selected",
"module_greeting",
"agents",
"directories",
"post-install-notes",
}
LEGACY_FILE_NAMES = {"module.yaml", "module-help.csv", "merge-config.py", "merge-help-csv.py", "cleanup-legacy.py"}
LEGACY_READ_RE = re.compile(r"_bmad/config\.yaml|config\.user\.yaml|\bbmad-[a-z0-9-]+-setup\b|module-setup\.md")
def walk(root: Path):
for path in sorted(root.rglob("*")):
if path.is_file() and not any(part in SKIP_DIRS for part in path.relative_to(root).parts):
yield path
def find_one(root: Path, name: str) -> Path | None:
"""The shallowest file with this name, or None."""
matches = sorted((p for p in walk(root) if p.name == name), key=lambda p: (len(p.parts), str(p)))
return matches[0] if matches else None
def rel(root: Path, path: Path) -> str:
return path.relative_to(root).as_posix()
def load_module_yaml(path: Path) -> dict:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
def config_key(key: str, spec: dict) -> dict:
reasons = []
kind = "text"
if "single-select" in spec:
kind = "single-select"
reasons.append("single-select options; ask as free text and name the choices in the prompt")
elif "multi-select" in spec:
kind = "multi-select"
reasons.append("multi-select options; a bmod answer is one scalar")
elif isinstance(spec.get("default"), bool):
kind = "confirm"
reasons.append("boolean default; store the answer as a string or drop the question")
result = spec.get("result")
if isinstance(result, str) and result not in ("{value}", ""):
reasons.append(f"result template {result!r}; fold it into the default and the skill that reads it")
for extra in ("regex", "required", "example"):
if extra in spec:
reasons.append(f"{extra} has no bmod equivalent")
return {
"key": key,
"prompt": spec.get("prompt", ""),
"default": spec.get("default"),
"user_setting": spec.get("user_setting") is True,
"kind": kind,
"unconvertible": reasons,
}
def read_help_rows(path: Path | None) -> list[dict]:
if path is None:
return []
with path.open(encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle))
return [
{"skill": row.get("skill", ""), **{k: v for k, v in row.items() if k != "skill" and k is not None}}
for row in rows
]
def skill_dirs(root: Path) -> list[Path]:
return sorted({p.parent for p in walk(root) if p.name == "SKILL.md"})
def skill_of(root: Path, path: Path, skills: list[Path]) -> str:
for skill in sorted(skills, key=lambda p: -len(p.parts)):
if skill == path.parent or skill in path.parents:
return skill.name
return root.name
def scan(root: Path) -> dict | None:
module_yaml = find_one(root, "module.yaml")
if module_yaml is None:
return None
meta = load_module_yaml(module_yaml)
help_csv = find_one(root, "module-help.csv")
skills = skill_dirs(root)
setup_skill = next((s.name for s in skills if s.name.endswith("-setup")), None)
module_setup = find_one(root, "module-setup.md")
to_delete: set[str] = set()
for path in walk(root):
if path.name in LEGACY_FILE_NAMES:
to_delete.add(rel(root, path))
if setup_skill:
setup_dir = next(s for s in skills if s.name == setup_skill)
to_delete = {p for p in to_delete if not p.startswith(rel(root, setup_dir) + "/")}
to_delete.add(rel(root, setup_dir) + "/")
if module_setup is not None:
to_delete.add(rel(root, module_setup))
reads = []
for path in walk(root):
if path.suffix != ".md":
continue
relative = rel(root, path)
if relative in to_delete or any(relative.startswith(d) for d in to_delete if d.endswith("/")):
continue
for number, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
if LEGACY_READ_RE.search(line):
reads.append(
{
"skill": skill_of(root, path, skills),
"path": relative,
"line": number,
"text": line.strip()[:200],
}
)
greeting = meta.get("module_greeting", "")
return {
"module": {
"code": meta.get("code", ""),
"name": meta.get("name", ""),
"version": str(meta.get("module_version", "")),
"greeting": greeting.strip() if isinstance(greeting, str) else "",
"agents": meta.get("agents") if isinstance(meta.get("agents"), list) else [],
},
"config_keys": [
config_key(k, v)
for k, v in meta.items()
if k not in MODULE_META_KEYS and isinstance(v, dict) and "prompt" in v
],
"help_rows": read_help_rows(help_csv),
"skills": [s.name for s in skills if s.name != setup_skill],
"legacy_reads": reads,
"setup_skill": setup_skill,
"files_to_delete": sorted(to_delete),
}
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("module", type=Path, help="old-format module folder")
args = p.parse_args(argv)
if not args.module.is_dir():
p.error(f"not a directory: {args.module}")
result = scan(args.module)
if result is None:
print(f"scan_legacy_module: no module.yaml under {args.module}", file=sys.stderr)
return 1
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -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())
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""scan_scripts: lint a skill's scripts/*.py for the repository's script conventions.
Rules, each a `rule` value in the findings:
pep723-missing no `# /// script` inline metadata block; `uv run` needs it to pick dependencies
pep723-floor the block has no requires-python, or its floor is below 3.11
test-missing no scripts/tests/test_<name>.py beside the script
network-call imports a network module (urllib.request, requests, httpx, socket, http.client, aiohttp);
a skill script should work offline unless its docstring says why it cannot
model-id a string literal naming a model id (claude-*, gpt-*, gemini-*); users run any model,
so a script never hardcodes or validates against one
custom-io a string literal naming `_bmad/custom` or a `.user.toml` file; resolve_customization.py reads
overrides and the bmad-customize skill writes them, so a skill's own script touches neither
syntax-error the file does not parse
A shebang is optional. Files under scripts/tests/ are not linted.
Usage:
scan_scripts.py <skill-folder>
Output, one JSON object on stdout:
{"skill": str, "scripts": [{"path", "has_pep723", "floor", "has_test"}, ...],
"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 ast
import json
import re
import sys
from pathlib import Path
FLOOR = (3, 11)
PEP723_RE = re.compile(r"^# /// script\n(?P<body>(?:^#(?: .*)?\n)+?)^# ///$", re.MULTILINE)
REQUIRES_RE = re.compile(r'^#\s*requires-python\s*=\s*"([^"]*)"', re.MULTILINE)
VERSION_RE = re.compile(r">=\s*(\d+)\.(\d+)")
# Pieces are joined so this file's own literals do not match the rule.
CUSTOM_IO_RE = re.compile("|".join([r"_bmad/" + "custom", r"\.user\." + "toml"]))
NETWORK_MODULES = {"urllib.request", "requests", "httpx", "socket", "http.client", "aiohttp"}
# A model id carries a version digit; "claude-code" is a product name, not a model.
MODEL_ID_RE = re.compile(r"\b(?:claude|gpt|gemini)-(?:[a-z]+-)*\d[a-z0-9.-]*\b", re.IGNORECASE)
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 pep723_floor(content: str) -> tuple[bool, str | None]:
"""Return (has_block, requires-python value or None)."""
match = PEP723_RE.search(content)
if not match:
return False, None
requires = REQUIRES_RE.search(match.group("body"))
return True, requires.group(1) if requires else None
def floor_ok(requires: str) -> bool:
match = VERSION_RE.search(requires)
return bool(match) and (int(match.group(1)), int(match.group(2))) >= FLOOR
def scan_ast(tree: ast.AST, rel: str) -> list[dict]:
findings = []
docstring = tree.body[0].value if tree.body and isinstance(tree.body[0], ast.Expr) else None
for node in ast.walk(tree):
if node is docstring:
continue
names: list[str] = []
if isinstance(node, ast.Import):
names = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom) and node.module:
names = [node.module] + [f"{node.module}.{alias.name}" for alias in node.names]
for name in names:
if name in NETWORK_MODULES or name.split(".")[0] in {"requests", "httpx", "socket", "aiohttp"}:
findings.append(
finding(rel, node.lineno, "network-call", name, "work offline, or say in the docstring why not")
)
break
if isinstance(node, ast.Constant) and isinstance(node.value, str):
if CUSTOM_IO_RE.search(node.value):
findings.append(
finding(
rel,
node.lineno,
"custom-io",
node.value,
"drop it: resolve_customization.py reads overrides and the bmad-customize skill writes them",
)
)
match = MODEL_ID_RE.search(node.value)
if match:
findings.append(
finding(
rel, node.lineno, "model-id", match.group(0), "take the model from the caller, never a list"
)
)
return findings
def scan_script(path: Path, scripts_dir: Path) -> tuple[dict, list[dict]]:
rel = f"scripts/{path.name}"
content = path.read_text(encoding="utf-8", errors="replace")
has_block, requires = pep723_floor(content)
test_file = scripts_dir / "tests" / f"test_{path.stem}.py"
info = {"path": rel, "has_pep723": has_block, "floor": requires, "has_test": test_file.is_file()}
findings = []
if not has_block:
findings.append(
finding(rel, 1, "pep723-missing", path.name, 'add `# /// script` with requires-python = ">=3.11"')
)
elif requires is None or not floor_ok(requires):
findings.append(finding(rel, 1, "pep723-floor", requires or "(none)", 'set requires-python = ">=3.11"'))
if not info["has_test"]:
findings.append(finding(rel, 1, "test-missing", path.name, f"add scripts/tests/test_{path.stem}.py (unittest)"))
try:
tree = ast.parse(content)
except SyntaxError as err:
findings.append(finding(rel, err.lineno or 1, "syntax-error", str(err.msg), "fix the syntax"))
return info, findings
findings.extend(scan_ast(tree, rel))
return info, findings
def scan_scripts(skill_root: Path) -> dict:
scripts_dir = skill_root / "scripts"
scripts: list[dict] = []
findings: list[dict] = []
if scripts_dir.is_dir():
for path in sorted(scripts_dir.glob("*.py")):
info, found = scan_script(path, scripts_dir)
scripts.append(info)
findings.extend(found)
findings.sort(key=lambda f: (f["path"], f["line"], f["rule"]))
return {"skill": skill_root.name, "scripts": scripts, "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")
args = p.parse_args(argv)
if not args.skill.is_dir():
p.error(f"not a directory: {args.skill}")
result = scan_scripts(args.skill)
print(json.dumps(result, indent=2))
return 1 if result["findings"] else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,106 @@
"""Tests for count_tokens.py: output schema, the forced fallback, files, directories and stdin."""
import builtins
import importlib.util
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "count_tokens.py"
SAMPLE = (
"The builder is platform-agnostic. Nothing assumes a single runtime, and no "
"model list is ever hardcoded. Token counts replace line counts as the one "
"length metric, with a chars-over-four fallback when tiktoken is absent.\n"
) * 8
def load_module():
spec = importlib.util.spec_from_file_location("count_tokens", SCRIPT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def run(*args, stdin=None):
return subprocess.run([sys.executable, str(SCRIPT), *args], input=stdin, capture_output=True, text=True)
class CountTokensTest(unittest.TestCase):
def test_count_returns_int_and_known_method(self):
tokens, method = load_module().count_tokens(SAMPLE)
self.assertIn(method, ("tiktoken", "fallback"))
self.assertIsInstance(tokens, int)
self.assertGreater(tokens, 0)
if method == "fallback":
self.assertEqual(tokens, len(SAMPLE) // 4)
def test_fallback_when_tiktoken_import_blocked(self):
mod = load_module()
real_import = builtins.__import__
def blocked(name, *args, **kwargs):
if name == "tiktoken" or name.startswith("tiktoken."):
raise ImportError("blocked for test")
return real_import(name, *args, **kwargs)
builtins.__import__ = blocked
try:
tokens, method = mod.count_tokens(SAMPLE)
finally:
builtins.__import__ = real_import
self.assertEqual(method, "fallback")
self.assertEqual(tokens, len(SAMPLE) // 4)
def test_fallback_within_tolerance_of_tiktoken(self):
mod = load_module()
real_tokens, method = mod.count_tokens(SAMPLE)
if method != "tiktoken":
self.skipTest("tiktoken not installed")
fallback = len(SAMPLE) // 4
self.assertTrue(real_tokens * 0.5 <= fallback <= real_tokens * 1.5)
def test_cli_file_schema(self):
with tempfile.TemporaryDirectory() as tmp:
f = Path(tmp) / "sample.md"
f.write_text(SAMPLE, encoding="utf-8")
res = run(str(f))
self.assertEqual(res.returncode, 0, res.stderr)
data = json.loads(res.stdout)
self.assertEqual(set(data), {"files", "total", "method"})
self.assertEqual(data["files"][0]["path"], str(f))
self.assertEqual(data["total"], data["files"][0]["tokens"])
self.assertGreater(data["total"], 0)
def test_cli_directory_recurses_and_skips_hidden(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "SKILL.md").write_text(SAMPLE, encoding="utf-8")
(root / "references").mkdir()
(root / "references" / "a.md").write_text(SAMPLE, encoding="utf-8")
(root / ".memlog.md").write_text(SAMPLE, encoding="utf-8")
(root / "logo.png").write_bytes(b"\x89PNG")
data = json.loads(run(str(root)).stdout)
paths = [Path(f["path"]).name for f in data["files"]]
self.assertEqual(paths, ["SKILL.md", "a.md"])
self.assertEqual(data["total"], sum(f["tokens"] for f in data["files"]))
def test_cli_stdin_matches_file(self):
with tempfile.TemporaryDirectory() as tmp:
f = Path(tmp) / "sample.md"
f.write_text(SAMPLE, encoding="utf-8")
from_file = json.loads(run(str(f)).stdout)
from_stdin = json.loads(run("--stdin", stdin=SAMPLE).stdout)
self.assertEqual(from_stdin["files"][0]["path"], "<stdin>")
self.assertEqual(from_stdin["total"], from_file["total"])
def test_cli_requires_input(self):
self.assertNotEqual(run().returncode, 0)
self.assertNotEqual(run("/nonexistent/path/x.md").returncode, 0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,233 @@
"""Tests for init_skill.py: scaffolding each shape and checking skills for the frontmatter and bmod rules."""
import json
import subprocess
import sys
import tempfile
import tomllib
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "init_skill.py"
GOOD_DESCRIPTION = "Counts things in a folder. Use when the user asks how big a skill is."
def run(*args):
res = subprocess.run([sys.executable, str(SCRIPT), *[str(a) for a in args]], capture_output=True, text=True)
data = json.loads(res.stdout) if res.stdout.strip().startswith("{") else None
return res.returncode, data, res.stderr
def rules(data):
return sorted(f["rule"] for f in data["findings"])
class CreateTest(unittest.TestCase):
def test_plain_skill_with_dirs_and_skill_table(self):
with tempfile.TemporaryDirectory() as tmp:
code, data, err = run(
"--name",
"bmad-demo",
"--dest",
tmp,
"--shape",
"plain-skill",
"--dirs",
"references,scripts",
"--bmod",
"bmod-example",
"--source",
"github:acme/example/skills",
"--description",
GOOD_DESCRIPTION,
)
self.assertEqual(code, 0, err)
skill = Path(tmp) / "bmad-demo"
self.assertEqual(data["created"], ["SKILL.md", "bmod.toml", "references/", "scripts/"])
self.assertEqual(data["shape"], "plain-skill")
text = (skill / "SKILL.md").read_text(encoding="utf-8")
self.assertTrue(text.startswith("---\nname: bmad-demo\ndescription: '"))
self.assertIn("# bmad-demo", text)
bmod = tomllib.loads((skill / "bmod.toml").read_text(encoding="utf-8"))
self.assertEqual(bmod, {"skill": {"bmod": "bmod-example", "source": "github:acme/example/skills"}})
self.assertTrue((skill / "references").is_dir())
# The new skill passes its own check.
code, data, _ = run("--check", skill)
self.assertEqual(code, 0, data)
self.assertEqual(data["bmod"], "ok")
def test_no_bmod_without_flag(self):
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "bmad-demo"
code, data, err = run(
"--name", "bmad-demo", "--dest", tmp, "--shape", "plain-skill", "--description", GOOD_DESCRIPTION
)
self.assertEqual(code, 0, err)
self.assertEqual(data["created"], ["SKILL.md"])
self.assertFalse((target / "bmod.toml").exists())
code, data, _ = run("--check", target)
self.assertEqual(code, 0, data)
self.assertEqual(data["bmod"], "absent")
def test_placeholder_description_fails_check(self):
with tempfile.TemporaryDirectory() as tmp:
code, _, _ = run("--name", "bmad-demo", "--dest", tmp, "--shape", "script-utility")
self.assertEqual(code, 0)
code, data, _ = run("--check", Path(tmp) / "bmad-demo")
self.assertEqual(code, 1)
# The placeholder carries "Use when", so only the TODO marker fails the check.
self.assertEqual(rules(data), ["todo-left"])
def test_single_skill_module(self):
with tempfile.TemporaryDirectory() as tmp:
code, _, _ = run(
"--name",
"bmad-changelog",
"--dest",
tmp,
"--shape",
"single-skill-module",
"--source",
"github:acme/changelog",
"--description",
GOOD_DESCRIPTION,
)
self.assertEqual(code, 0)
bmod = tomllib.loads((Path(tmp) / "bmad-changelog" / "bmod.toml").read_text(encoding="utf-8"))
self.assertEqual(
bmod["bmod"], {"code": "changelog", "version": "0.1.0", "update_source": "github:acme/changelog"}
)
self.assertEqual(bmod["skill"], {})
code, data, _ = run("--check", Path(tmp) / "bmad-changelog")
self.assertEqual(code, 0, data)
def test_multi_skill_module_record(self):
with tempfile.TemporaryDirectory() as tmp:
code, _, _ = run(
"--name",
"bmod-acme",
"--dest",
tmp,
"--shape",
"multi-skill-module",
"--source",
"github:acme/x/skills",
)
self.assertEqual(code, 0)
skill = Path(tmp) / "bmod-acme"
bmod = tomllib.loads((skill / "bmod.toml").read_text(encoding="utf-8"))
self.assertEqual(
bmod,
{"bmod": {"code": "acme", "version": "0.1.0", "update_source": "github:acme/x/skills", "skills": []}},
)
self.assertIn(
"Required bmod metadata. Never invoke this skill.", (skill / "SKILL.md").read_text(encoding="utf-8")
)
code, data, _ = run("--check", skill)
self.assertEqual(code, 0, data)
code, _, _ = run("--name", "bmad-acme", "--dest", tmp, "--shape", "multi-skill-module")
self.assertEqual(code, 1)
def test_refuses_bad_name_existing_folder_and_unknown_dir(self):
with tempfile.TemporaryDirectory() as tmp:
code, data, _ = run("--name", "Bad_Name", "--dest", tmp, "--shape", "plain-skill")
self.assertEqual(code, 1)
self.assertFalse(data["ok"])
(Path(tmp) / "bmad-taken").mkdir()
code, _, _ = run("--name", "bmad-taken", "--dest", tmp, "--shape", "plain-skill")
self.assertEqual(code, 1)
code, _, _ = run("--name", "bmad-new", "--dest", tmp, "--shape", "plain-skill", "--dirs", "steps")
self.assertEqual(code, 1)
code, _, _ = run("--name", "bmad-new", "--dest", tmp)
self.assertEqual(code, 2)
class CheckTest(unittest.TestCase):
def write_skill(self, tmp, name, skill_md, bmod="[skill]\n", extra=None):
skill = Path(tmp) / name
skill.mkdir()
(skill / "SKILL.md").write_text(skill_md, encoding="utf-8")
if bmod is not None:
(skill / "bmod.toml").write_text(bmod, encoding="utf-8")
for rel, text in (extra or {}).items():
path = skill / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return skill
def test_good_skill_with_multiline_description(self):
with tempfile.TemporaryDirectory() as tmp:
skill = self.write_skill(
tmp,
"bmad-good",
"---\nname: bmad-good\ndescription: >-\n Does a thing.\n Use when asked.\n---\n\nBody.\n",
extra={".memlog.md": "- (note) [TODO: hidden files are not scanned]\n"},
)
code, data, _ = run("--check", skill)
self.assertEqual(code, 0, data)
self.assertEqual(data, {"ok": True, "skill": "bmad-good", "bmod": "ok", "findings": []})
def test_every_rule(self):
with tempfile.TemporaryDirectory() as tmp:
skill = self.write_skill(
tmp,
"my-skill",
"---\nname: other-name\ndescription: '" + "x" * 1025 + "'\nversion: 1\n---\n\n \n",
bmod="[skill\nbroken",
extra={"references/a.md": "[TODO: write]\n"},
)
code, data, _ = run("--check", skill)
self.assertEqual(code, 1)
self.assertEqual(
rules(data),
[
"bmod-invalid",
"body-empty",
"description-length",
"description-trigger",
"frontmatter-keys",
"name-folder-mismatch",
"name-format",
"todo-left",
],
)
todo = next(f for f in data["findings"] if f["rule"] == "todo-left")
self.assertEqual((todo["path"], todo["line"]), ("references/a.md", 1))
def test_any_name_and_missing_pieces(self):
with tempfile.TemporaryDirectory() as tmp:
skill = self.write_skill(
tmp,
"acme-notes",
"---\nname: acme-notes\ndescription: 'Notes. Use when writing.'\n---\n\nBody.\n",
bmod=None,
)
code, data, _ = run("--check", skill)
self.assertEqual(rules(data), ["name-format"])
self.assertEqual(data["bmod"], "absent")
code, data, _ = run("--check", skill, "--any-name")
self.assertEqual(code, 0, data)
empty = Path(tmp) / "bmad-empty"
empty.mkdir()
code, data, _ = run("--check", empty)
self.assertEqual(rules(data), ["skill-md-missing"])
no_fm = self.write_skill(tmp, "bmad-nofm", "# Just a heading\n", bmod="[other]\n")
code, data, _ = run("--check", no_fm)
self.assertEqual(rules(data), ["bmod-tables", "frontmatter-missing"])
self.assertEqual(data["bmod"], "invalid")
def test_record_rules(self):
with tempfile.TemporaryDirectory() as tmp:
record = self.write_skill(
tmp,
"bmod-acme",
"---\nname: bmod-acme\ndescription: 'Acme tools. Use when needed.'\n---\n\nBody.\n",
bmod='[bmod]\ncode = "acme"\nversion = "1.0.0"\nupdate_source = "github:acme/x"\n',
)
code, data, _ = run("--check", record)
self.assertEqual(rules(data), ["description-trigger"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,146 @@
"""Tests for the memory-agent shape's wake and init-sanctum templates: birth, resume after a cut-off birth, and the waking map."""
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ASSETS = Path(__file__).resolve().parents[2] / "shapes" / "memory-agent" / "assets"
TEMPLATES = [
"PERSONA-template.md",
"CREED-template.md",
"BOND-template.md",
"HOW-I-REMEMBER-template.md",
"CAPABILITIES-template.md",
]
def make_skill(root: Path) -> tuple[Path, Path]:
"""A built memory agent: scripts from the templates, sanctum seeds in assets/, one capability."""
project = root / "project"
skill = project / ".claude" / "skills" / "acme-agent-sage"
(skill / "scripts").mkdir(parents=True)
(skill / "assets").mkdir()
(skill / "references").mkdir()
shutil.copy(ASSETS / "wake-template.py", skill / "scripts" / "wake.py")
shutil.copy(ASSETS / "init-sanctum-template.py", skill / "scripts" / "init-sanctum.py")
for name in TEMPLATES:
shutil.copy(ASSETS / name, skill / "assets" / name)
(skill / "references" / "first-breath.md").write_text("birth\n", encoding="utf-8")
(skill / "references" / "intake.md").write_text(
"---\nname: Intake\ndescription: Keep and distill material\ncode: INTAKE\n---\n\nbody\n", encoding="utf-8"
)
(project / "_bmad").mkdir()
return project, skill
def run(script: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run([sys.executable, str(script), *map(str, args)], capture_output=True, text=True)
class MemoryAgentScriptsTest(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.project, self.skill = make_skill(Path(self._tmp.name))
self.sanctum = self.project / "_bmad" / "memory" / "acme-agent-sage"
self.wake = self.skill / "scripts" / "wake.py"
self.init = self.skill / "scripts" / "init-sanctum.py"
def tearDown(self):
self._tmp.cleanup()
def test_no_sanctum_routes_to_first_breath(self):
res = run(self.wake, self.project)
self.assertEqual(res.returncode, 0, res.stderr)
self.assertIn("MODE: FIRST_BREATH", res.stdout)
self.assertIn("NO SANCTUM", res.stdout)
def test_birth_writes_the_layout_without_config(self):
res = run(self.init, self.project, self.skill)
self.assertEqual(res.returncode, 0, res.stderr)
for name in ("PERSONA.md", "CREED.md", "BOND.md", "HOW-I-REMEMBER.md", "CAPABILITIES.md"):
self.assertTrue((self.sanctum / name).is_file(), name)
for rel in ("memory/sessions", "raw", "capabilities", "references"):
self.assertTrue((self.sanctum / rel).is_dir(), rel)
self.assertFalse((self.sanctum / "INDEX.md").exists())
self.assertFalse((self.sanctum / "references" / "first-breath.md").exists())
bond = (self.sanctum / "BOND.md").read_text(encoding="utf-8")
self.assertNotIn("friend", bond)
self.assertIn("learned at First Breath", bond)
self.assertIn("| [INTAKE] | Intake |", (self.sanctum / "CAPABILITIES.md").read_text(encoding="utf-8"))
def test_cut_off_birth_is_finished_by_the_next_run(self):
self.sanctum.mkdir(parents=True)
(self.sanctum / "PERSONA.md").write_text("# Persona\n\nName: Sage\n", encoding="utf-8")
res = run(self.wake, self.project)
self.assertIn("MODE: FIRST_BREATH", res.stdout)
self.assertIn("INCOMPLETE SANCTUM", res.stdout)
res = run(self.init, self.project, self.skill)
self.assertEqual(res.returncode, 0, res.stderr)
self.assertIn("Finished sanctum", res.stdout)
self.assertEqual((self.sanctum / "PERSONA.md").read_text(encoding="utf-8"), "# Persona\n\nName: Sage\n")
self.assertTrue((self.sanctum / "CREED.md").is_file())
res = run(self.wake, self.project)
self.assertIn("MODE: WAKING", res.stdout)
def test_finished_sanctum_is_left_alone(self):
run(self.init, self.project, self.skill)
(self.sanctum / "BOND.md").write_text("# Bond\n\nCall them: Brian\n", encoding="utf-8")
res = run(self.init, self.project, self.skill)
self.assertEqual(res.returncode, 0, res.stderr)
self.assertIn("already been born", res.stdout)
self.assertIn("Call them: Brian", (self.sanctum / "BOND.md").read_text(encoding="utf-8"))
def test_waking_prints_self_and_map_not_memory(self):
run(self.init, self.project, self.skill)
people = self.sanctum / "memory" / "people"
people.mkdir()
(people / "ruben.md").write_text("Partner. SECRET-PEOPLE-BODY\n", encoding="utf-8")
sessions = self.sanctum / "memory" / "sessions"
(sessions / "2026-10-01-first-breath.md").write_text("born\n", encoding="utf-8")
(sessions / "2026-10-03-pricing.md").write_text("talked\n", encoding="utf-8")
(self.sanctum / "memory" / "pending.md").write_text("Ask about the launch date.\n", encoding="utf-8")
raw = self.sanctum / "raw"
(raw / "2026-10-02-oss-call.md").write_text(
"---\nstatus: raw\ndistilled_to: []\n---\nwords\n", encoding="utf-8"
)
(raw / "2026-09-30-oss-notes.md").write_text("---\nstatus: distilled\n---\nwords\n", encoding="utf-8")
res = run(self.wake, self.project)
self.assertEqual(res.returncode, 0, res.stderr)
out = res.stdout
self.assertIn("MODE: WAKING", out)
for name in ("PERSONA.md", "CREED.md", "BOND.md", "HOW-I-REMEMBER.md", "CAPABILITIES.md"):
self.assertIn(f"===== {name} =====", out)
self.assertIn("memory/people/ (1 files)", out)
self.assertIn("memory/sessions/ (2 files)", out)
self.assertIn("raw/ (2 files)", out)
self.assertLess(
out.index("memory/sessions/2026-10-03-pricing.md"), out.index("memory/sessions/2026-10-01-first-breath.md")
)
self.assertIn("Never tended; session notes: 2", out)
(self.sanctum / "memory" / ".tended").write_text("2026-10-02\n", encoding="utf-8")
again = run(self.wake, self.project).stdout
self.assertIn("Tended: 2026-10-02; session notes since: 1", again)
self.assertIn("memory/sessions/ (2 files)", again)
self.assertNotIn(".tended", again.split("memory map")[1].split("Tended:")[0])
self.assertIn("Undistilled raw (1):", out)
self.assertIn("raw/2026-10-02-oss-call.md", out)
self.assertNotIn("2026-09-30-oss-notes.md", out.split("Undistilled raw")[1])
self.assertIn("===== pending.md =====", out)
self.assertIn("Ask about the launch date.", out)
self.assertNotIn("SECRET-PEOPLE-BODY", out)
def test_pulse_appends_pulse_file_when_present(self):
run(self.init, self.project, self.skill)
res = run(self.wake, self.project, "--pulse")
self.assertIn("MODE: PULSE", res.stdout)
self.assertNotIn("===== PULSE.md =====", res.stdout)
(self.sanctum / "PULSE.md").write_text("# Pulse\n\ntend\n", encoding="utf-8")
res = run(self.wake, self.project, "--pulse")
self.assertIn("===== PULSE.md =====", res.stdout)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,152 @@
"""Tests for prepass.py: the JSON contract and the shape inference."""
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "prepass.py"
KEYS = {
"skill",
"shape_hint",
"files",
"skill_md_tokens",
"total_tokens",
"has_customize",
"has_scripts",
"scripts",
"description_chars",
"has_use_when",
"frontmatter_ok",
"bmod_kind",
"path_findings",
"script_findings",
}
SKILL_MD = "---\nname: {name}\ndescription: 'Does a thing. Use when asked.'\n---\n\n# Body\n\n{body}\n"
GOOD_SCRIPT = '# /// script\n# requires-python = ">=3.11"\n# ///\nprint(1)\n'
def make_skill(root: Path, name="bmad-demo", body="Read `references/a.md`.", files=None, skill_md=True) -> Path:
skill = root / name
skill.mkdir(parents=True)
if skill_md:
(skill / "SKILL.md").write_text(SKILL_MD.format(name=name, body=body), encoding="utf-8")
for rel, text in (files or {}).items():
path = skill / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return skill
def prepass(skill: Path):
res = subprocess.run([sys.executable, str(SCRIPT), str(skill)], capture_output=True, text=True)
assert res.returncode == 0, res.stderr
return json.loads(res.stdout)
class PrepassTest(unittest.TestCase):
def test_contract(self):
with tempfile.TemporaryDirectory() as tmp:
skill = make_skill(
Path(tmp),
body="Read `references/a.md` and `references/missing.md`. Run `uv run scripts/go.py`.",
files={
"references/a.md": "A reference.\n",
"scripts/go.py": GOOD_SCRIPT,
"scripts/bare.py": "print(2)\n",
"scripts/tests/test_go.py": "import unittest\n",
"assets/SKILL-template.md": "template\n",
"customize.toml": "[workflow]\nx = 1\n",
".memlog.md": "- (note) hidden\n",
},
)
data = prepass(skill)
self.assertEqual(set(data), KEYS)
self.assertEqual(data["skill"], "bmad-demo")
kinds = {f["path"]: f["kind"] for f in data["files"]}
self.assertEqual(
kinds,
{
"SKILL.md": "entry",
"references/a.md": "prompt",
"scripts/go.py": "script",
"scripts/bare.py": "script",
"scripts/tests/test_go.py": "test",
"assets/SKILL-template.md": "asset",
"customize.toml": "config",
},
)
self.assertTrue(all(f["tokens"] > 0 for f in data["files"]))
self.assertEqual(data["total_tokens"], sum(f["tokens"] for f in data["files"]))
self.assertEqual(
data["skill_md_tokens"], kinds and next(f["tokens"] for f in data["files"] if f["path"] == "SKILL.md")
)
self.assertTrue(data["has_customize"])
self.assertTrue(data["has_scripts"])
self.assertEqual(
data["scripts"],
[
{"path": "scripts/bare.py", "has_pep723": False, "has_test": False},
{"path": "scripts/go.py", "has_pep723": True, "has_test": True},
],
)
self.assertEqual(data["description_chars"], len("Does a thing. Use when asked."))
self.assertTrue(data["has_use_when"])
self.assertTrue(data["frontmatter_ok"])
self.assertEqual(sorted(f["rule"] for f in data["path_findings"]), ["bare-script-call", "missing-file"])
self.assertEqual(sorted(f["rule"] for f in data["script_findings"]), ["pep723-missing", "test-missing"])
self.assertEqual(data["shape_hint"], "script-utility")
def test_shape_hints(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
cases = {
"bmad-plain": ({}, "plain-skill"),
"bmad-long": ({"scripts/x.py": GOOD_SCRIPT}, "script-utility"),
"bmad-agent": ({"customize.toml": "[agent]\nname = 'Ann'\n"}, "agent"),
"bmad-memory": ({"customize.toml": "[agent]\n", "scripts/wake.py": GOOD_SCRIPT}, "memory-agent"),
"bmad-single": ({"bmod.toml": "[bmod]\ncode = 'x'\n[skill]\n"}, "single-skill-module"),
"bmod-multi": ({"bmod.toml": "[bmod]\ncode = 'multi'\n"}, "multi-skill-module"),
}
for name, (files, expected) in cases.items():
skill = make_skill(root, name=name, files=files)
self.assertEqual(prepass(skill)["shape_hint"], expected, name)
rendered = make_skill(
root, name="bmad-rendered", body='Run `uv run "{project-root}/_bmad/scripts/render_skill.py"`.'
)
self.assertEqual(prepass(rendered)["shape_hint"], "rendered-skill")
memory_text = make_skill(root, name="bmad-mem2", body="Memory at `{project-root}/_bmad/memory/bmad-mem2/`.")
self.assertEqual(prepass(memory_text)["shape_hint"], "memory-agent")
long_body = "\n".join(f"Line {i}." for i in range(70))
long_skill = make_skill(root, name="bmad-long2", body=long_body, files={"scripts/x.py": GOOD_SCRIPT})
self.assertEqual(prepass(long_skill)["shape_hint"], "plain-skill")
def test_no_skill_md(self):
with tempfile.TemporaryDirectory() as tmp:
skill = make_skill(Path(tmp), skill_md=False, files={"notes.md": "x\n"})
data = prepass(skill)
self.assertEqual(data["shape_hint"], "unknown")
self.assertEqual(data["skill_md_tokens"], 0)
self.assertFalse(data["frontmatter_ok"])
self.assertEqual(data["description_chars"], 0)
self.assertFalse(data["has_use_when"])
def test_bad_frontmatter_is_not_ok(self):
with tempfile.TemporaryDirectory() as tmp:
skill = make_skill(
Path(tmp), files={"SKILL.md": "---\nname: wrong\ndescription: 'x'\nextra: 1\n---\n\nBody\n"}
)
data = prepass(skill)
self.assertFalse(data["frontmatter_ok"])
self.assertFalse(data["has_use_when"])
self.assertEqual(data["description_chars"], 1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,118 @@
"""Tests for process_template.py: variables, conditionals, nesting, leftover markers and metadata."""
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "process_template.py"
TEMPLATE = """---
name: {skillName}
---
# {displayName}
{if-memory}
Memory lives at {project-root}/_bmad/memory/{skillName}/.
{if-pulse}
Pulse is on.
{/if-pulse}
{/if-memory}
Always: {agent.name}
"""
def run(*args, template=TEMPLATE):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "t.md"
path.write_text(template, encoding="utf-8")
res = subprocess.run([sys.executable, str(SCRIPT), str(path), *args], capture_output=True, text=True)
out_file = Path(tmp) / "out.md"
output = out_file.read_text(encoding="utf-8") if out_file.exists() else None
return res, output
class ProcessTemplateTest(unittest.TestCase):
def test_variables_and_false_block_removed(self):
res, _ = run("--var", "skillName=bmad-x", "--var", "displayName=X", "--json")
self.assertEqual(res.returncode, 0, res.stderr)
self.assertIn("name: bmad-x", res.stdout)
self.assertIn("# X", res.stdout)
self.assertNotIn("Memory lives", res.stdout)
self.assertNotIn("{if-", res.stdout)
self.assertNotIn("\n\n\n", res.stdout)
meta = json.loads(res.stderr)
self.assertEqual(meta["vars_substituted"], ["skillName", "displayName"])
self.assertEqual(meta["conditions_false"], ["memory"])
self.assertEqual(meta["conditions_true"], [])
self.assertEqual(meta["tokens_remaining"], ["{agent.name}"])
def test_nested_true_blocks_kept(self):
res, _ = run("--var", "skillName=bmad-x", "--true", "memory", "--true", "pulse", "--json")
self.assertEqual(res.returncode, 0, res.stderr)
self.assertIn("Memory lives at {project-root}/_bmad/memory/bmad-x/.", res.stdout)
self.assertIn("Pulse is on.", res.stdout)
meta = json.loads(res.stderr)
self.assertEqual(sorted(meta["conditions_true"]), ["memory", "pulse"])
self.assertIn("{project-root}", meta["tokens_remaining"])
def test_outer_true_inner_false(self):
res, _ = run("--true", "memory")
self.assertEqual(res.returncode, 0, res.stderr)
self.assertIn("Memory lives", res.stdout)
self.assertNotIn("Pulse is on.", res.stdout)
def test_output_file_prints_metadata_on_stdout(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "t.md"
out = Path(tmp) / "out.md"
path.write_text(TEMPLATE, encoding="utf-8")
res = subprocess.run(
[sys.executable, str(SCRIPT), str(path), "-o", str(out), "--var", "skillName=s"],
capture_output=True,
text=True,
)
self.assertEqual(res.returncode, 0, res.stderr)
meta = json.loads(res.stdout)
self.assertEqual(meta["output_file"], str(out))
self.assertIn("name: s", out.read_text(encoding="utf-8"))
def test_plain_substitution_leaves_jinja_anchors_and_guidance_alone(self):
template = (
'{% raw %}{{epic_number}}{% endraw %} {{ workflow.{selector} }} {% if workflow.route == "x" %}on{% endif %}\n'
"{project-root}/{skill-root}/{slug}/{output_folder}/{active_initiative} {workflow.lenses}\n"
"{purpose: one paragraph on what the skill is for}\n"
"name: {skillName}\n"
)
res, _ = run("--var", "selector=route", "--var", "skillName=bmad-x", "--json", template=template)
self.assertEqual(res.returncode, 0, res.stderr)
self.assertEqual(
res.stdout,
'{% raw %}{{epic_number}}{% endraw %} {{ workflow.route }} {% if workflow.route == "x" %}on{% endif %}\n'
"{project-root}/{skill-root}/{slug}/{output_folder}/{active_initiative} {workflow.lenses}\n"
"{purpose: one paragraph on what the skill is for}\n"
"name: bmad-x\n",
)
meta = json.loads(res.stderr)
self.assertEqual(meta["vars_substituted"], ["selector", "skillName"])
def test_leftover_marker_exits_3(self):
res, _ = run(template="start {if-a} unclosed")
self.assertEqual(res.returncode, 3)
self.assertIn("{if-a}", res.stderr)
def test_bad_var_format_is_usage_error(self):
res, _ = run("--var", "novalue")
self.assertEqual(res.returncode, 2)
def test_missing_template(self):
res = subprocess.run([sys.executable, str(SCRIPT), "/nonexistent/t.md"], capture_output=True, text=True)
self.assertEqual(res.returncode, 2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,174 @@
"""Tests for read_session_log.py over a synthetic Claude Code transcript and a synthetic memlog."""
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "read_session_log.py"
def user(text, **extra):
return {"type": "user", "message": {"role": "user", "content": text}, **extra}
def tool_result():
return {
"type": "user",
"message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t", "content": "x"}]},
}
def assistant(*tool_uses):
blocks = [{"type": "text", "text": "assistant prose never copied"}]
blocks += [
{"type": "tool_use", "id": f"t{i}", "name": name, "input": inp} for i, (name, inp) in enumerate(tool_uses)
]
return {"type": "assistant", "message": {"role": "assistant", "content": blocks}}
TRANSCRIPT = [
{"type": "last-prompt", "lastPrompt": "noise record without a message"},
user("Build me a changelog skill that reads git history. " + "x" * 400),
assistant(
("Read", {"file_path": "/repo/skills/bmad-x/SKILL.md"}),
("Bash", {"command": "ls"}),
("Bash", {"command": "pwd"}),
),
tool_result(),
assistant(("Edit", {"file_path": "/repo/skills/bmad-x/SKILL.md", "old_string": "a", "new_string": "b"})),
user("No, don't touch SKILL.md, put it in references instead"),
assistant(("Write", {"file_path": "/repo/skills/bmad-x/references/history.md"})),
user("[Request interrupted by user for tool use]"),
user("<command-name>/bmad-eval</command-name>\n<command-args></command-args>"),
assistant(("Skill", {"skill": "bmad-eval"}), ("Read", {"file_path": "/repo/evals/cases.json"})),
user("build me a changelog skill that reads git history. " + "x" * 400),
user("secret meta content", isMeta=True),
user("sidechain content", isSidechain=True),
assistant(("Read", {"file_path": "/repo/skills/bmad-x/SKILL.md"}), ("Bash", {"command": "ls"})),
"this line is not json",
]
MEMLOG = """---
topic: bmad-changelog
updated: 2026-06-07T14:22
---
- (note) user picked the lean approach
- (direction) keep the skill to one file
- (decision by user) name it bmad-changelog
- (gap) no eval cases yet
- (assumption) git is installed
- (idea by user) also read tags
- (note) wait, the folder was wrong
"""
def write_transcript(folder: Path, name="s1.jsonl"):
path = folder / name
path.write_text("\n".join(r if isinstance(r, str) else json.dumps(r) for r in TRANSCRIPT) + "\n", encoding="utf-8")
return path
def run(*args, env=None):
res = subprocess.run(
[sys.executable, str(SCRIPT), *[str(a) for a in args]], capture_output=True, text=True, env=env
)
return res.returncode, json.loads(res.stdout) if res.stdout.strip() else None, res.stderr
class ReadSessionLogTest(unittest.TestCase):
def test_claude_code_digest(self):
with tempfile.TemporaryDirectory() as tmp:
path = write_transcript(Path(tmp))
code, data, err = run(path)
self.assertEqual(code, 0, err)
self.assertEqual(
set(data),
{"sources", "user_requests", "tool_sequences", "corrections", "files_touched", "skills_invoked"},
)
self.assertEqual(data["sources"], [{"path": str(path), "format": "claude-code", "entries": 14}])
requests = data["user_requests"]
self.assertEqual(requests[0]["count"], 2)
self.assertTrue(requests[0]["text"].startswith("Build me a changelog skill"))
self.assertEqual(len(requests[0]["text"]), 300)
self.assertTrue(requests[0]["text"].endswith("..."))
texts = " ".join(r["text"] for r in requests)
self.assertNotIn("secret meta", texts)
self.assertNotIn("sidechain", texts)
self.assertNotIn("assistant prose", json.dumps(data))
self.assertNotIn("<command-name>", texts)
self.assertEqual(
data["corrections"],
[
"No, don't touch SKILL.md, put it in references instead",
"[Request interrupted by user for tool use]",
],
)
self.assertEqual(data["skills_invoked"], [{"name": "bmad-eval", "count": 2}])
self.assertEqual(
data["files_touched"],
["/repo/evals/cases.json", "/repo/skills/bmad-x/SKILL.md", "/repo/skills/bmad-x/references/history.md"],
)
sequences = {tuple(s["tools"]): s["count"] for s in data["tool_sequences"]}
self.assertEqual(sequences[("Read", "Bash")], 1)
self.assertEqual(sequences[("Read", "Bash", "Edit")], 1)
self.assertEqual(sequences[("Write",)], 1)
self.assertEqual(sequences[("Skill", "Read")], 1)
def test_memlog_digest_and_max_items(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / ".memlog.md"
path.write_text(MEMLOG, encoding="utf-8")
code, data, err = run(path, "--format", "memlog")
self.assertEqual(code, 0, err)
self.assertEqual(data["sources"][0]["entries"], 7)
self.assertEqual(
[r["text"] for r in data["user_requests"]],
["keep the skill to one file", "name it bmad-changelog", "also read tags"],
)
self.assertEqual(data["corrections"], ["no eval cases yet", "wait, the folder was wrong"])
code, data, _ = run(path, "--max-items", "1")
self.assertEqual(len(data["user_requests"]), 1)
self.assertEqual(len(data["corrections"]), 1)
def test_folder_and_project_resolution(self):
with tempfile.TemporaryDirectory() as tmp:
config = Path(tmp) / "config"
project_dir = Path(tmp) / "work" / "my.repo"
project_dir.mkdir(parents=True)
encoded = str(project_dir.resolve()).replace("/", "-").replace(".", "-")
folder = config / "projects" / encoded
folder.mkdir(parents=True)
write_transcript(folder, "a.jsonl")
write_transcript(folder, "b.jsonl")
(folder / ".memlog.md").write_text(MEMLOG, encoding="utf-8")
env = dict(os.environ, CLAUDE_CONFIG_DIR=str(config))
code, data, err = run("--project", project_dir, env=env)
self.assertEqual(code, 0, err)
self.assertEqual([s["format"] for s in data["sources"]], ["claude-code", "claude-code", "memlog"])
self.assertEqual(data["skills_invoked"], [{"name": "bmad-eval", "count": 4}])
code, _, err = run("--project", Path(tmp) / "nowhere", env=env)
self.assertEqual(code, 2)
def test_unknown_format_is_skipped_and_missing_path_errors(self):
with tempfile.TemporaryDirectory() as tmp:
other = Path(tmp) / "notes.txt"
other.write_text("hello", encoding="utf-8")
code, data, err = run(other)
self.assertEqual(code, 0)
self.assertEqual(data["sources"], [])
self.assertIn("unknown format", err)
code, _, _ = run(Path(tmp) / "missing.jsonl")
self.assertEqual(code, 2)
code, _, _ = run()
self.assertEqual(code, 2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,128 @@
"""Tests for registry.py: records, members, single-skill modules, unregistered skills, symlink dedupe."""
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "registry.py"
def write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def make_project(root: Path) -> Path:
project = root / "project"
agents = project / ".agents" / "skills"
(project / "_bmad").mkdir(parents=True)
# core record
write(agents / "bmod-core-tools" / "SKILL.md", "---\nname: bmod-core-tools\ndescription: x\n---\n")
write(
agents / "bmod-core-tools" / "bmod.toml",
'[bmod]\ncode = "core-tools"\nversion = "6.13.0"\nupdate_source = "github:bmad-code-org/BMAD-METHOD/skills"\nskills = ["bmad"]\n',
)
write(agents / "bmad" / "SKILL.md", "---\nname: bmad\ndescription: x\n---\n")
write(
agents / "bmad" / "bmod.toml",
'[skill]\nbmod = "bmod-core-tools"\nsource = "github:bmad-code-org/BMAD-METHOD/skills"\n',
)
# an org module with an agent member and help
write(agents / "bmod-acme" / "SKILL.md", "---\nname: bmod-acme\ndescription: x\n---\n")
write(
agents / "bmod-acme" / "bmod.toml",
'[bmod]\ncode = "acme"\nversion = "1.0.0"\nupdate_source = "github:acme/skills"\nskills = ["acme-release-notes"]\n',
)
write(agents / "bmod-acme" / "help" / "help.md", "# Acme\n")
write(agents / "bmod-acme" / "roster.toml", "[[members]]\ncode = 'acme-agent-scout'\n")
write(agents / "acme-release-notes" / "SKILL.md", "---\nname: acme-release-notes\ndescription: x\n---\n")
write(agents / "acme-release-notes" / "bmod.toml", '[skill]\nbmod = "bmod-acme"\nsource = "github:acme/skills"\n')
write(agents / "acme-agent-scout" / "SKILL.md", "---\nname: acme-agent-scout\ndescription: x\n---\n")
write(agents / "acme-agent-scout" / "bmod.toml", '[skill]\nbmod = "bmod-acme"\nsource = "github:acme/skills"\n')
# a single-skill module
write(agents / "doc-standards" / "SKILL.md", "---\nname: doc-standards\ndescription: x\n---\n")
write(
agents / "doc-standards" / "bmod.toml",
'[bmod]\ncode = "docstd"\nversion = "0.1.0"\nupdate_source = "github:me/doc-standards"\n\n[skill]\n',
)
write(agents / "doc-standards" / "help" / "help.md", "# Doc standards\n")
# wired to BMad, registered nowhere
write(agents / "random-generator" / "SKILL.md", "---\nname: random-generator\ndescription: x\n---\n")
write(agents / "random-generator" / "customize.toml", "[workflow]\n")
write(
agents / "joke" / "SKILL.md",
"---\nname: joke\ndescription: x\n---\nRun `uv run {project-root}/_bmad/scripts/resolve_config.py`.\n",
)
# plain, outside BMad
write(agents / "plain" / "SKILL.md", "---\nname: plain\ndescription: x\n---\nJust do it.\n")
# .claude/skills mirrors .agents/skills through symlinks
claude = project / ".claude" / "skills"
claude.mkdir(parents=True)
for folder in agents.iterdir():
os.symlink(Path("..") / ".." / ".agents" / "skills" / folder.name, claude / folder.name)
return project
def run(project: Path, *extra: str) -> dict:
res = subprocess.run(
[sys.executable, str(SCRIPT), "--project-root", str(project), *extra], capture_output=True, text=True
)
assert res.returncode == 0, res.stderr
return json.loads(res.stdout)
class RegistryTest(unittest.TestCase):
def test_records_members_and_unregistered(self):
with tempfile.TemporaryDirectory() as tmp:
project = make_project(Path(tmp))
roots = [str(project / ".agents" / "skills"), str(project / ".claude" / "skills")]
data = run(project, "--root", roots[0], "--root", roots[1])
self.assertEqual(data["bmad"], {"present": True, "version": "6.13.0"})
self.assertFalse(data["skills_repo"])
by_code = {r["code"]: r for r in data["records"]}
self.assertEqual(sorted(by_code), ["acme", "core-tools", "docstd"])
acme = by_code["acme"]
self.assertEqual(acme["skills"], ["acme-agent-scout", "acme-release-notes"])
self.assertEqual(acme["prefix"], "acme-")
self.assertTrue(acme["has_help"])
self.assertTrue(acme["has_roster"])
self.assertFalse(acme["single_skill"])
self.assertEqual(acme["scope"], "project")
doc = by_code["docstd"]
self.assertTrue(doc["single_skill"])
self.assertEqual(doc["skills"], [])
self.assertEqual(doc["prefix"], "docstd-")
self.assertEqual(by_code["core-tools"]["prefix"], "bmad-")
self.assertEqual(sorted(u["skill"] for u in data["unregistered"]), ["joke", "random-generator"])
# the symlinked copies did not double anything
self.assertEqual(len(data["records"]), 3)
self.assertEqual(len(data["unregistered"]), 2)
def test_empty_project(self):
with tempfile.TemporaryDirectory() as tmp:
project = Path(tmp) / "empty"
project.mkdir()
data = run(project, "--root", str(project / "nowhere"))
self.assertEqual(data["bmad"], {"present": False, "version": None})
self.assertEqual(data["records"], [])
self.assertEqual(data["unregistered"], [])
def test_skills_repo_detected(self):
with tempfile.TemporaryDirectory() as tmp:
project = Path(tmp) / "repo"
write(project / "skills" / "bmod-x" / "SKILL.md", "---\nname: bmod-x\ndescription: x\n---\n")
write(
project / "skills" / "bmod-x" / "bmod.toml",
'[bmod]\ncode = "x"\nversion = "1"\nupdate_source = "github:a/b"\n',
)
data = run(project, "--root", str(project / "skills"))
self.assertTrue(data["skills_repo"])
self.assertEqual(data["records"][0]["code"], "x")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,184 @@
"""Tests for scan_legacy_module.py on fixtures shaped like the old multi-skill and single-skill modules."""
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "scan_legacy_module.py"
MODULE_YAML = """code: sam
name: "BMad Samples"
description: "Demo module"
module_version: 1.2.0
default_selected: false
module_greeting: >
The BMad Samples module is ready!
Enjoy.
agents:
- code: dw-agent
name: Oneira
title: Dream Guide
sample_output_folder:
prompt: "Where should sample output be saved?"
default: "{project-root}/_bmad-output"
result: "{project-root}/{value}"
tone:
prompt: "Which tone?"
default: warm
user_setting: true
single-select:
- value: warm
label: Warm
- value: dry
label: Dry
verbose:
prompt: "Verbose output?"
default: false
"""
HELP_CSV = (
"module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs\n"
'BMad Samples,sample-module-setup,Setup Samples Module,SS,"Install or update config.",configure,"{-H: headless}",anytime,,,false,{project-root}/_bmad,config.yaml\n'
'BMad Samples,bmad-agent-dream-weaver,Dream Capture,DL,"Capture a dream.",dream-log,,anytime,,,false,,journal entry\n'
'BMad Samples,bmad-excalidraw,Create Diagram,XD,"Create diagrams.",diagram-generation,,anytime,,,false,sample_output_folder,excalidraw file\n'
)
SETUP_SKILL_MD = "---\nname: sample-module-setup\ndescription: 'Setup. Use when installing.'\n---\n\nRead `./assets/module.yaml`; write `{project-root}/_bmad/config.yaml`.\n"
DREAM_SKILL_MD = (
"---\nname: bmad-agent-dream-weaver\ndescription: 'Dreams. Use when dreaming.'\n---\n\n"
"If `{project-root}/_bmad/config.yaml` has no `sam` section, run the `sample-module-setup` skill.\n"
"Personal settings live in `config.user.yaml`.\n"
)
EXCALIDRAW_SKILL_MD = "---\nname: bmad-excalidraw\ndescription: 'Diagrams. Use when drawing.'\n---\n\nRead `{project-root}/_bmad/config.toml`.\n"
def write(root: Path, files: dict[str, str]) -> None:
for relative, text in files.items():
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def run(folder: Path):
res = subprocess.run([sys.executable, str(SCRIPT), str(folder)], capture_output=True, text=True)
return res.returncode, json.loads(res.stdout) if res.stdout.strip() else None, res.stderr
class ScanLegacyModuleTest(unittest.TestCase):
def test_multi_skill_module_with_setup_skill(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "samples"
write(
root,
{
"sample-module-setup/SKILL.md": SETUP_SKILL_MD,
"sample-module-setup/assets/module.yaml": MODULE_YAML,
"sample-module-setup/assets/module-help.csv": HELP_CSV,
"sample-module-setup/scripts/merge-config.py": "print(1)\n",
"sample-module-setup/scripts/merge-help-csv.py": "print(1)\n",
"sample-module-setup/scripts/cleanup-legacy.py": "print(1)\n",
"bmad-agent-dream-weaver/SKILL.md": DREAM_SKILL_MD,
"bmad-agent-dream-weaver/references/memory.md": "Nothing legacy here.\n",
"bmad-excalidraw/SKILL.md": EXCALIDRAW_SKILL_MD,
"node_modules/x/module.yaml": "code: ignored\n",
},
)
code, data, err = run(root)
self.assertEqual(code, 0, err)
self.assertEqual(
set(data),
{"module", "config_keys", "help_rows", "skills", "legacy_reads", "setup_skill", "files_to_delete"},
)
self.assertEqual(
data["module"],
{
"code": "sam",
"name": "BMad Samples",
"version": "1.2.0",
"greeting": "The BMad Samples module is ready! Enjoy.",
"agents": [{"code": "dw-agent", "name": "Oneira", "title": "Dream Guide"}],
},
)
keys = {k["key"]: k for k in data["config_keys"]}
self.assertEqual(list(keys), ["sample_output_folder", "tone", "verbose"])
self.assertEqual(keys["sample_output_folder"]["kind"], "text")
self.assertEqual(keys["sample_output_folder"]["default"], "{project-root}/_bmad-output")
self.assertFalse(keys["sample_output_folder"]["user_setting"])
self.assertEqual(len(keys["sample_output_folder"]["unconvertible"]), 1)
self.assertIn("result template", keys["sample_output_folder"]["unconvertible"][0])
self.assertEqual(keys["tone"]["kind"], "single-select")
self.assertTrue(keys["tone"]["user_setting"])
self.assertEqual(keys["tone"]["prompt"], "Which tone?")
self.assertEqual(keys["verbose"]["kind"], "confirm")
self.assertEqual(keys["verbose"]["default"], False)
self.assertEqual(len(data["help_rows"]), 3)
self.assertEqual(data["help_rows"][1]["skill"], "bmad-agent-dream-weaver")
self.assertEqual(data["help_rows"][1]["menu-code"], "DL")
self.assertEqual(data["help_rows"][2]["output-location"], "sample_output_folder")
self.assertEqual(data["skills"], ["bmad-agent-dream-weaver", "bmad-excalidraw"])
self.assertEqual(data["setup_skill"], "sample-module-setup")
self.assertEqual(data["files_to_delete"], ["sample-module-setup/"])
self.assertEqual(
[(r["skill"], r["path"], r["line"]) for r in data["legacy_reads"]],
[
("bmad-agent-dream-weaver", "bmad-agent-dream-weaver/SKILL.md", 6),
("bmad-agent-dream-weaver", "bmad-agent-dream-weaver/SKILL.md", 7),
],
)
self.assertIn("sample-module-setup", data["legacy_reads"][0]["text"])
def test_single_skill_module_with_module_setup(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "bmad-agent-dream-weaver"
write(
root,
{
"SKILL.md": DREAM_SKILL_MD.replace(
"run the `sample-module-setup` skill", "load `assets/module-setup.md`"
),
"assets/module.yaml": "code: dw\nname: Dream Weaver\nmodule_version: 1.0.0\nmodule_greeting: Hi.\n",
"assets/module-help.csv": HELP_CSV.splitlines()[0] + "\n" + HELP_CSV.splitlines()[2] + "\n",
"assets/module-setup.md": "Writes `{project-root}/_bmad/config.yaml`.\n",
"scripts/merge-config.py": "print(1)\n",
"scripts/merge-help-csv.py": "print(1)\n",
"scripts/seed_tracker.py": "print(1)\n",
},
)
code, data, err = run(root)
self.assertEqual(code, 0, err)
self.assertEqual(data["module"]["code"], "dw")
self.assertEqual(data["module"]["agents"], [])
self.assertEqual(data["config_keys"], [])
self.assertIsNone(data["setup_skill"])
self.assertEqual(data["skills"], ["bmad-agent-dream-weaver"])
self.assertEqual(
data["files_to_delete"],
[
"assets/module-help.csv",
"assets/module-setup.md",
"assets/module.yaml",
"scripts/merge-config.py",
"scripts/merge-help-csv.py",
],
)
self.assertEqual([r["line"] for r in data["legacy_reads"]], [6, 7])
self.assertEqual(data["legacy_reads"][0]["skill"], "bmad-agent-dream-weaver")
def test_no_module_yaml_and_bad_path(self):
with tempfile.TemporaryDirectory() as tmp:
code, data, err = run(Path(tmp))
self.assertEqual(code, 1)
self.assertIn("no module.yaml", err)
res = subprocess.run([sys.executable, str(SCRIPT), "/nonexistent"], capture_output=True, text=True)
self.assertEqual(res.returncode, 2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,152 @@
"""Tests for scan_paths.py: each rule fires on its case and stays quiet on the conventions it enforces."""
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "scan_paths.py"
def make_skill(root: Path, files: dict[str, str]) -> Path:
skill = root / "bmad-demo"
for rel, text in files.items():
path = skill / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return skill
def scan(skill: Path):
res = subprocess.run([sys.executable, str(SCRIPT), str(skill)], capture_output=True, text=True)
return res.returncode, json.loads(res.stdout)
def rules(data):
return sorted((f["path"], f["rule"]) for f in data["findings"])
class ScanPathsTest(unittest.TestCase):
def test_python_call_fires_and_uv_run_does_not(self):
with tempfile.TemporaryDirectory() as tmp:
skill = make_skill(
Path(tmp),
{
"SKILL.md": (
"---\nname: bmad-demo\ndescription: 'Demo. Use when testing.'\n---\n\n"
"Run `python3 scripts/go.py` then `python -m json.tool out.json`.\n"
"```\npip install rich\n```\n"
"Written in Python with argparse. Fine: `uv run {skill-root}/scripts/go.py`.\n"
),
"scripts/go.py": "print(1)\n",
},
)
code, data = scan(skill)
self.assertEqual(code, 1)
hits = [f for f in data["findings"] if f["rule"] == "python-call"]
self.assertEqual([f["text"] for f in hits], ["python3 scripts/go.py", "python -m json.tool", "pip install"])
self.assertEqual(len(data["findings"]), 3, data["findings"])
def test_clean_skill_has_no_findings(self):
with tempfile.TemporaryDirectory() as tmp:
skill = make_skill(
Path(tmp),
{
"SKILL.md": (
"---\nname: bmad-demo\ndescription: 'Demo. Use when testing.'\n---\n\n"
"Run `uv run {skill-root}/scripts/go.py` then read `references/notes.md`.\n"
"Runtime: `uv run {project-root}/_bmad/scripts/memlog.py`.\n"
"Invoke the `bmad-eval` skill. Output goes to `{output_folder}/report.md`.\n"
),
"references/notes.md": "Back to `../SKILL.md` and `scripts/go.py`.\n",
"scripts/go.py": "print(1)\n",
"assets/SKILL-template.md": "Emits `references/made-up.md`.\n",
"sample-output.md": "Shows `references/other-made-up.md`.\n",
},
)
code, data = scan(skill)
self.assertEqual(code, 0, data)
self.assertEqual(data["findings"], [])
self.assertEqual(data["skill"], "bmad-demo")
self.assertEqual(data["files_scanned"], 4)
def test_each_rule_fires(self):
with tempfile.TemporaryDirectory() as tmp:
skill = make_skill(
Path(tmp),
{
"SKILL.md": (
"---\nname: bmad-demo\n---\n\n"
"Run `uv run scripts/go.py`.\n"
"Also `uv run --no-cache ./scripts/go.py`.\n"
"Old: {installed_path}/x.md\n"
"Mine: /Users/someone/skills/x.md and ~/.claude/skills/y.md\n"
"Read `references/missing.md`.\n"
"Borrow `skills/bmad-other/references/canon.md`.\n"
"Also `{project-root}/_bmad/bmad-other/SKILL.md` but not `{project-root}/_bmad/scripts/memlog.py`.\n"
"Describe it in module.yaml and module-help.csv.\n"
"```\nuv run scripts/in-fence.py\n```\n"
),
"references/a.md": "Sibling `../../bmad-other/SKILL.md`. Fine: `../SKILL.md`.\n",
"scripts/go.py": "print(1)\n",
},
)
code, data = scan(skill)
self.assertEqual(code, 1)
found = rules(data)
self.assertEqual(found.count(("SKILL.md", "bare-script-call")), 3)
self.assertIn(("SKILL.md", "installed-path"), found)
self.assertEqual(found.count(("SKILL.md", "absolute-path")), 2)
self.assertEqual(found.count(("SKILL.md", "missing-file")), 1)
self.assertEqual(found.count(("SKILL.md", "cross-skill-ref")), 2)
self.assertEqual(found.count(("SKILL.md", "old-module-format")), 2)
self.assertEqual(found.count(("references/a.md", "cross-skill-ref")), 1)
for f in data["findings"]:
self.assertEqual(set(f), {"path", "line", "rule", "text", "fix"})
self.assertGreater(f["line"], 0)
def test_allow_suppresses_a_rule(self):
with tempfile.TemporaryDirectory() as tmp:
skill = make_skill(
Path(tmp), {"SKILL.md": "Migrate module.yaml and module-help.csv; never `uv run scripts/x.py`.\n"}
)
code, data = scan(skill)
self.assertEqual(
sorted(f["rule"] for f in data["findings"]),
["bare-script-call", "old-module-format", "old-module-format"],
)
res = subprocess.run(
[
sys.executable,
str(SCRIPT),
str(skill),
"--allow",
"old-module-format",
"--allow",
"bare-script-call",
],
capture_output=True,
text=True,
)
self.assertEqual(res.returncode, 0)
self.assertEqual(json.loads(res.stdout)["findings"], [])
res = subprocess.run(
[sys.executable, str(SCRIPT), str(skill), "--allow", "nope"], capture_output=True, text=True
)
self.assertEqual(res.returncode, 2)
def test_prose_path_without_existing_folder_is_not_flagged(self):
with tempfile.TemporaryDirectory() as tmp:
skill = make_skill(Path(tmp), {"SKILL.md": "Writes `docs/plan.md` for the user.\n"})
code, data = scan(skill)
self.assertEqual(code, 0, data)
def test_not_a_directory(self):
res = subprocess.run([sys.executable, str(SCRIPT), "/nonexistent"], capture_output=True, text=True)
self.assertEqual(res.returncode, 2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,95 @@
"""Tests for scan_scripts.py: header, floor, test presence, network and model-id rules."""
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "scan_scripts.py"
GOOD = '# /// script\n# requires-python = ">=3.11"\n# ///\n"""Good."""\nimport json\nprint(json.dumps({}))\n'
OLD_FLOOR = '# /// script\n# requires-python = ">=3.9"\n# ///\nprint(1)\n'
NO_FLOOR = "# /// script\n# dependencies = []\n# ///\nprint(1)\n"
NO_HEADER = '#!/usr/bin/env python3\nimport requests\nMODEL = "claude-opus-4-1"\nprint(requests, MODEL)\n'
BROKEN = '# /// script\n# requires-python = ">=3.11"\n# ///\ndef (:\n'
CUSTOM_IO = '# /// script\n# requires-python = ">=3.11"\n# ///\nfrom pathlib import Path\np = Path("_bmad/custom") / "demo.user.toml"\nprint(p)\n'
def make_skill(root: Path, scripts: dict[str, str], tests: tuple[str, ...] = ()) -> Path:
skill = root / "bmad-demo"
(skill / "scripts" / "tests").mkdir(parents=True)
for name, text in scripts.items():
(skill / "scripts" / name).write_text(text, encoding="utf-8")
for name in tests:
(skill / "scripts" / "tests" / name).write_text("import unittest\n", encoding="utf-8")
return skill
def scan(skill: Path):
res = subprocess.run([sys.executable, str(SCRIPT), str(skill)], capture_output=True, text=True)
return res.returncode, json.loads(res.stdout)
class ScanScriptsTest(unittest.TestCase):
def test_custom_io_fires_on_override_paths(self):
with tempfile.TemporaryDirectory() as tmp:
skill = make_skill(Path(tmp), {"formats.py": CUSTOM_IO}, ("test_formats.py",))
code, data = scan(skill)
self.assertEqual(code, 1)
hits = sorted((f["rule"], f["text"]) for f in data["findings"])
self.assertEqual(hits, [("custom-io", "_bmad/custom"), ("custom-io", "demo.user.toml")])
def test_clean_script(self):
with tempfile.TemporaryDirectory() as tmp:
skill = make_skill(Path(tmp), {"good.py": GOOD}, ("test_good.py",))
code, data = scan(skill)
self.assertEqual(code, 0, data)
self.assertEqual(data["findings"], [])
self.assertEqual(
data["scripts"], [{"path": "scripts/good.py", "has_pep723": True, "floor": ">=3.11", "has_test": True}]
)
def test_rules_fire(self):
with tempfile.TemporaryDirectory() as tmp:
skill = make_skill(
Path(tmp),
{"old.py": OLD_FLOOR, "nofloor.py": NO_FLOOR, "bare.py": NO_HEADER, "broken.py": BROKEN},
("test_old.py", "test_nofloor.py", "test_broken.py"),
)
code, data = scan(skill)
self.assertEqual(code, 1)
found = sorted((f["path"], f["rule"]) for f in data["findings"])
self.assertEqual(
found,
[
("scripts/bare.py", "model-id"),
("scripts/bare.py", "network-call"),
("scripts/bare.py", "pep723-missing"),
("scripts/bare.py", "test-missing"),
("scripts/broken.py", "syntax-error"),
("scripts/nofloor.py", "pep723-floor"),
("scripts/old.py", "pep723-floor"),
],
)
by_path = {s["path"]: s for s in data["scripts"]}
self.assertFalse(by_path["scripts/bare.py"]["has_pep723"])
self.assertEqual(by_path["scripts/old.py"]["floor"], ">=3.9")
self.assertIsNone(by_path["scripts/nofloor.py"]["floor"])
def test_no_scripts_folder_is_clean(self):
with tempfile.TemporaryDirectory() as tmp:
skill = Path(tmp) / "bmad-demo"
skill.mkdir()
code, data = scan(skill)
self.assertEqual(code, 0)
self.assertEqual(data, {"skill": "bmad-demo", "scripts": [], "findings": []})
def test_not_a_directory(self):
res = subprocess.run([sys.executable, str(SCRIPT), "/nonexistent"], capture_output=True, text=True)
self.assertEqual(res.returncode, 2)
if __name__ == "__main__":
unittest.main()