basic BMAD setup
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user