diff --git a/.gitignore b/.gitignore index b903b294a9..f528ff6267 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ design-artifacts/ # Python __pycache__/ .pytest_cache/ +.venv/ +/dist/ +bmad_method/_payload/ # BMad run artifacts (memlogs are per-run working memory, never committed) .memlog.md diff --git a/bmad_method/README.md b/bmad_method/README.md new file mode 100644 index 0000000000..ece5c1c0b3 --- /dev/null +++ b/bmad_method/README.md @@ -0,0 +1,47 @@ +# bmad-method (Python installer) + +A **pure-Python** installer for the [BMAD Method](https://github.com/bmad-code-org/BMAD-METHOD): +`pip install bmad-method` gives you a `bmad` command that scaffolds BMAD's +skills, agents, and workflows into a project for use with AI IDEs - **with no +npm / Node toolchain required**. + +> **Proof of concept / draft.** The BMAD payload, name, and wordmark belong to +> the upstream project. Publishing under a `bmad-*` name on any index is +> **gated on the upstream redistribution / trademark ruling** - see the bundled +> `TRADEMARK.md`. + +## Usage + +```bash +# Non-interactive scaffold into the current directory +bmad install --directory . --modules bmm --tools claude-code --yes + +# See which IDE/tool targets are supported +bmad list-tools +``` + +This creates a `_bmad/` directory (config + manifests + runtime scripts) and +installs the skills into your tool's skills directory (e.g. `.claude/skills/` +for Claude Code). Launch your AI agent and invoke the `bmad-help` skill to get +started. + +## What it does + +The BMAD payload - skills, agents, modules, runtime scripts - is just data +files (Markdown / YAML / Python). Only the *installer* was Node. This package +reimplements the install actions in Python: + +1. copies the module payload into `_bmad//`; +2. generates the central config (`_bmad/config.toml`, per-module `config.yaml`) + and manifests (`_bmad/_config/*`); +3. copies each skill into the selected tool's skills directory. + +The scaffolded runtime is dependency-free and uses only the standard library +(`tomllib`, hence **Python 3.11+**). The installer itself depends on `PyYAML` +to parse `module.yaml` / `SKILL.md` frontmatter. + +## Scope + +This PoC targets fresh, non-interactive installs of the built-in `core` and +`bmm` modules. Updates, external/marketplace modules, and interactive prompts +are handled by the upstream Node installer and are out of scope here. diff --git a/bmad_method/__init__.py b/bmad_method/__init__.py new file mode 100644 index 0000000000..c2c160aabb --- /dev/null +++ b/bmad_method/__init__.py @@ -0,0 +1,27 @@ +"""BMAD Method - pure-Python installer. + +``pip install bmad-method`` provides a ``bmad`` command that scaffolds the BMAD +Method (skills, agents, workflows) into a project for use with AI IDEs - with no +npm/Node toolchain required. + +This is a proof-of-concept Python packaging of the installer; the BMAD payload +and trademark belong to the upstream project (see the bundled ``TRADEMARK.md``). +""" + +from __future__ import annotations + +from .installer import InstallConfig, InstallResult, install + +try: # Populated from installed distribution metadata. + from importlib.metadata import PackageNotFoundError, version as _pkg_version + + try: + __version__ = _pkg_version("bmad-method") + except PackageNotFoundError: # running from a source checkout + from .payload import bmad_version + + __version__ = bmad_version() +except Exception: # pragma: no cover - extreme fallback + __version__ = "0.0.0" + +__all__ = ["InstallConfig", "InstallResult", "install", "__version__"] diff --git a/bmad_method/cli.py b/bmad_method/cli.py new file mode 100644 index 0000000000..6339141f60 --- /dev/null +++ b/bmad_method/cli.py @@ -0,0 +1,131 @@ +"""Command-line entry point for the ``bmad`` / ``bmad-method`` console scripts.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from . import __version__ +from . import ide +from .installer import InstallConfig, install + + +def _split_csv(value: str | None) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="bmad", + description="BMAD Method - install BMAD skills, agents, and workflows into a project (no npm required).", + ) + parser.add_argument("--version", action="version", version=f"bmad-method {__version__}") + + sub = parser.add_subparsers(dest="command", metavar="") + + install_cmd = sub.add_parser( + "install", + help="Install BMAD modules and configure IDE/tool integrations.", + description="Install BMAD modules and configure IDE/tool integrations.", + ) + install_cmd.add_argument( + "--directory", + default=".", + help="Installation directory (default: current directory).", + ) + # type=_split_csv makes each of these arrive as a list; argparse applies the + # converter to the string default too ("bmm" -> ["bmm"], "" -> []). + install_cmd.add_argument( + "--modules", + default="bmm", + type=_split_csv, + help='Comma-separated module IDs to install (default: "bmm"). "core" is always included.', + ) + install_cmd.add_argument( + "--tools", + default="", + type=_split_csv, + help='Comma-separated tool/IDE IDs to configure (e.g. "claude-code"). Required for a fresh install.', + ) + install_cmd.add_argument("--user-name", dest="user_name", default=None, help="Name for agents to use.") + install_cmd.add_argument("--communication-language", dest="communication_language", default=None) + install_cmd.add_argument("--document-output-language", dest="document_output_language", default=None) + install_cmd.add_argument("--output-folder", dest="output_folder", default=None) + install_cmd.add_argument( + "-y", "--yes", action="store_true", help="Accept defaults and run non-interactively (required)." + ) + install_cmd.set_defaults(func=_cmd_install) + + list_cmd = sub.add_parser("list-tools", help="List supported tool/IDE IDs and exit.") + list_cmd.set_defaults(func=_cmd_list_tools) + + return parser + + +def _cmd_list_tools(_args: argparse.Namespace) -> int: + for tool in ide.known_tools(): + print(f" {tool:<20} -> {ide.target_dir_for(tool)}") + return 0 + + +def _cmd_install(args: argparse.Namespace) -> int: + tools = args.tools # already split into a list by type=_split_csv + if not args.yes: + print( + "This installer runs non-interactively; pass --yes to proceed.", + file=sys.stderr, + ) + return 2 + if not tools: + print( + "No --tools specified. Pass e.g. --tools claude-code (run 'bmad list-tools' for options).", + file=sys.stderr, + ) + return 2 + + unknown = [t for t in tools if t not in ide.known_tools()] + if unknown: + print( + f"Unknown tool(s): {', '.join(unknown)}. Run 'bmad list-tools' for valid IDs.", + file=sys.stderr, + ) + return 2 + + config = InstallConfig( + directory=Path(args.directory), + modules=args.modules, + tools=tools, + user_name=args.user_name, + communication_language=args.communication_language, + document_output_language=args.document_output_language, + output_folder=args.output_folder, + yes=True, + ) + + result = install(config) + + print("") + print(" BMAD is ready to use!") + print(f" Modules: {', '.join(result.modules)}") + print(f" Installed to: {result.bmad_dir}") + for tool, info in result.ide_results.items(): + print(f" {tool}: {info['skills']} skills -> {info['target_dir']}") + print("") + print(" Launch your AI agent from your project folder and invoke the bmad-help skill.") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + if not args.command: + parser.print_help() + return 0 + return args.func(args) + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/bmad_method/ide.py b/bmad_method/ide.py new file mode 100644 index 0000000000..cc116476a7 --- /dev/null +++ b/bmad_method/ide.py @@ -0,0 +1,116 @@ +"""Config-driven IDE/tool skill installation. + +Ports the essential path of the Node ``ConfigDrivenIdeSetup``: read the target +directory for a tool from ``platform-codes.yaml`` and copy each installed skill +directory verbatim into it (e.g. Claude Code -> ``.claude/skills//``). + +Command-pointer generation and the per-IDE cleanup/marker logic are out of scope +for this PoC - the skill copy is what makes skills usable in the tool. +""" + +from __future__ import annotations + +import csv +import functools +import shutil +from pathlib import Path + +import yaml + +from .payload import platform_codes_path + +BMAD_FOLDER_NAME = "_bmad" + +# Editor/OS artifacts and Python bytecode caches are never copied into skills. +# __pycache__/*.pyc matters here because the packaged payload sits in +# site-packages, where pip byte-compiles the shipped skill scripts. +_SKILL_COPY_SKIP_NAMES = {".DS_Store", "Thumbs.db", "desktop.ini", "__pycache__"} +_SKILL_COPY_SKIP_SUFFIXES = ("~", ".swp", ".swo", ".bak", ".pyc") + + +class UnknownToolError(ValueError): + """Raised when a requested tool is not present in platform-codes.yaml.""" + + +@functools.lru_cache(maxsize=1) +def _platforms() -> dict: + data = yaml.safe_load(platform_codes_path().read_text(encoding="utf-8")) or {} + return data.get("platforms", {}) or {} + + +def known_tools() -> list[str]: + return sorted( + code + for code, cfg in _platforms().items() + if isinstance(cfg, dict) and (cfg.get("installer") or {}).get("target_dir") + ) + + +def target_dir_for(tool: str) -> str: + platforms = _platforms() + if tool not in platforms: + raise UnknownToolError( + f"Unknown tool '{tool}'. Known tools: {', '.join(known_tools())}" + ) + installer = platforms[tool].get("installer") or {} + target = installer.get("target_dir") + if not target: + raise UnknownToolError(f"Tool '{tool}' has no target_dir in platform-codes.yaml") + return target + + +def _skill_copy_keep(name: str) -> bool: + """Whether an entry (file or dir) should be copied into a skill. + + Mirrors the Node installer's verbatim-copy filter (_config-driven.js). + `.gitkeep` is the one dotfile intentionally kept - it's how a skill ships + an otherwise-empty placeholder directory. Every other dotfile (VCS/editor/OS + metadata) plus bytecode caches and editor swap/backup files are dropped. + """ + if ( (name in _SKILL_COPY_SKIP_NAMES) or + any(name.endswith(suffix) for suffix in _SKILL_COPY_SKIP_SUFFIXES) or + (name.startswith(".") and name != ".gitkeep") ): + return False + return True + + +def _skill_copy_ignore(_dir: str, names: list[str]) -> set[str]: + """copytree `ignore` callback: applies `_skill_copy_keep` at every depth so + nested entries are filtered identically to top-level ones (matching Node's + recursive filter).""" + return {name for name in names if not _skill_copy_keep(name)} + + +def _copy_skill_dir(source_dir: Path, skill_dir: Path) -> None: + if skill_dir.exists(): + shutil.rmtree(skill_dir) + shutil.copytree(source_dir, skill_dir, ignore=_skill_copy_ignore) + + +def setup_tool(tool: str, project_root: Path, bmad_dir: Path) -> dict: + """Install all skills from the manifest into a tool's skills directory.""" + target = target_dir_for(tool) + target_path = project_root / target + target_path.mkdir(parents=True, exist_ok=True) + + csv_path = bmad_dir / "_config" / "skill-manifest.csv" + if not csv_path.exists(): + return {"tool": tool, "target_dir": target, "skills": 0} + + prefix = BMAD_FOLDER_NAME + "/" + count = 0 + with csv_path.open(encoding="utf-8", newline="") as fh: + for record in csv.DictReader(fh): + canonical_id = record.get("canonicalId") + if not canonical_id: + continue + rel = record.get("path") or "" + if rel.startswith(prefix): + rel = rel[len(prefix) :] + source_dir = (bmad_dir / rel).parent + if not source_dir.exists(): + continue + _copy_skill_dir(source_dir, target_path / canonical_id) + count += 1 + + return {"tool": tool, "target_dir": target, "skills": count} diff --git a/bmad_method/installer.py b/bmad_method/installer.py new file mode 100644 index 0000000000..a35d6e0052 --- /dev/null +++ b/bmad_method/installer.py @@ -0,0 +1,878 @@ +"""Pure-Python port of BMAD's minimal non-interactive install. + +Reproduces what the Node installer produces for a fresh, non-interactive +``bmad install --directory . --modules bmm --tools claude-code --yes`` run, +with no Node/npm required. The output layout (``_bmad/`` config + manifests and +the IDE ``skills/`` directory) matches the Node installer closely enough to be a +drop-in scaffold; see ``tests/python`` for the parity checks. + +Scope (deliberate PoC boundaries): fresh installs only - no update/quick-update, +no external/marketplace modules, no custom-source modules, no interactive +prompts. Everything here is driven by the built-in ``core`` and ``bmm`` payloads +plus the config-driven IDE registry. +""" + +from __future__ import annotations + +import getpass +import hashlib +import json +import os +import re +import shutil +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +import yaml + +from . import ide +from .payload import bmad_version, module_source + +BMAD_FOLDER_NAME = "_bmad" + +# Directories under _bmad/ that are not installable modules. +NON_MODULE_DIRS = {"_config", "_memory", "memory", "docs", "scripts", "custom"} + +# Files/dirs never shipped from src/scripts/ into a user's _bmad/scripts/. +_SCRIPT_SKIP_DIRS = {"tests", "__pycache__", ".pytest_cache"} + +MODULE_HELP_CSV_HEADER = ( + "module,skill,display-name,menu-code,description,action,args,phase," + "preceded-by,followed-by,required,output-location,outputs" +) +_MODULE_HELP_COLUMNS = 13 +_MODULE_HELP_PHASE_INDEX = 7 + + +@dataclass +class InstallConfig: + directory: Path + modules: list[str] # excluding 'core'; core is always prepended + tools: list[str] + user_name: str | None = None + communication_language: str | None = None + document_output_language: str | None = None + output_folder: str | None = None + yes: bool = True + + +@dataclass +class InstallResult: + project_root: Path + bmad_dir: Path + modules: list[str] + tools: list[str] + skill_count: int + ide_results: dict = field(default_factory=dict) + + +# --------------------------- value formatting --------------------------- + + +def format_toml_value(value) -> str: + """Format a scalar/list as a TOML literal. + + TOML basic strings and JSON strings escape the same way, so json.dumps + matches the Node installer's TOML output byte-for-byte. ensure_ascii=False is + essential; the default would \\uXXXX-escape non-ASCII (emoji, em dashes) and + break parity. + None is special-cased (JSON emits null, we need ""). + """ + if value is None: + return '""' + return json.dumps(value, ensure_ascii=False) + + +_YAML_INDICATORS = set("!&*?|>%@`\"'#,[]{} ") +_YAML_RESERVED = {"true", "false", "null", "~", "yes", "no", "on", "off"} + + +def _yaml_looks_numeric(s: str) -> bool: + try: + float(s) + return True + except ValueError: + return False + + +def format_yaml_scalar(s: str) -> str: + """Emit a YAML scalar, double-quoting only when a plain scalar is unsafe. + + Calibrated against the Node installer's ``yaml.stringify`` output for the + values this installer produces (e.g. ``{project-root}/...`` gets quoted, + ``_bmad-output`` and ``English`` stay plain). + """ + if s == "": + return '""' + needs_quote = ( + s[0] in _YAML_INDICATORS + or s[-1] == " " + or ": " in s + or " #" in s + or "\n" in s + or s.lower() in _YAML_RESERVED + or _yaml_looks_numeric(s) + ) + if not needs_quote: + return s + escaped = s.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def _csv_always_quote(value) -> str: + return '"' + str("" if value is None else value).replace('"', '""') + '"' + + +def _csv_escape_field(value) -> str: + """Quote only when needed (mirrors installer.escapeCSVField).""" + if value is None: + return "" + s = str(value) + if "," in s or '"' in s or "\n" in s: + return '"' + s.replace('"', '""') + '"' + return s + + +def _parse_csv_line(line: str) -> list[str]: + """Split one CSV line into fields the same way the Node installer does. + + We can't use `csv.reader` here. These fields get merged into bmad-help.csv, + which has to come out identical to the file Node writes, and the two parsers + disagree when a quote sits in the middle of a field. For example, given + `a"b,c`, Node returns ['ab,c'] but `csv.reader` returns ['a"b', 'c']. They + agree on today's files, but module-help.csv can come from third-party + modules, so we copy Node's behaviour to stay safe. + + (When a CSV is only read, and doesn't need to match Node's output, this + installer uses the standard `csv` module instead.) + """ + result: list[str] = [] + current = "" + in_quotes = False + i = 0 + while i < len(line): + char = line[i] + nxt = line[i + 1] if i + 1 < len(line) else None + if char == '"': + if in_quotes and nxt == '"': + current += '"' + i += 1 + else: + in_quotes = not in_quotes + elif char == "," and not in_quotes: + result.append(current) + current = "" + else: + current += char + i += 1 + result.append(current) + return result + + +def _clean_for_csv(text: str) -> str: + if not text: + return "" + return re.sub(r"\s+", " ", text.strip()) + + +def _iso_now() -> str: + # Match JS `new Date().toISOString()` (e.g. 2026-07-21T18:48:55.616Z): + # millisecond precision with a `Z` suffix. isoformat() emits the offset as + # `+00:00`, so swap it for `Z`. + return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +# --------------------------- module.yaml model --------------------------- + + +class ModuleSpec: + def __init__(self, code: str, raw: dict): + self.code = raw.get("code", code) + self.raw = raw + # Config fields: any top-level key whose value is a mapping with a prompt. + self.config_fields: dict[str, dict] = { + k: v + for k, v in raw.items() + if isinstance(v, dict) and "prompt" in v + } + self.scope: dict[str, str] = { + k: ("user" if v.get("scope") == "user" else "team") + for k, v in self.config_fields.items() + } + self.agents: list[dict] = raw.get("agents") or [] + self.directories: list[str] = raw.get("directories") or [] + + +def load_module_spec(module: str) -> ModuleSpec: + raw = yaml.safe_load(module_source(module).joinpath("module.yaml").read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raw = {} + return ModuleSpec(module, raw) + + +# ------------------------- placeholder resolution ------------------------- + + +def _resolve_placeholders(s: str, collected: dict, dirname: str) -> str: + def repl(match: re.Match) -> str: + key = match.group(1) + if key == "project-root": + return "{project-root}" + if key == "directory_name": + return dirname + for cfg in collected.values(): + if key in cfg: + val = cfg[key] + if isinstance(val, str) and val.startswith("{project-root}/"): + val = val[len("{project-root}/") :] + return str(val) + return match.group(0) + + return re.sub(r"\{([^}]+)\}", repl, s) + + +def _resolve_module_config(spec: ModuleSpec, collected: dict, dirname: str) -> dict: + """Resolve a non-core module's config values in non-interactive mode.""" + out: dict = {} + for key, field_def in spec.config_fields.items(): + default = field_def.get("default", "") + value = _resolve_placeholders(str(default), collected, dirname) + if value.startswith("{project-root}/"): + value = value[len("{project-root}/") :] + result_tmpl = field_def.get("result") + if result_tmpl is not None: + resolved = str(result_tmpl).replace("{value}", value) + resolved = _resolve_placeholders(resolved, collected, dirname) + else: + resolved = value + out[key] = resolved + return out + + +def _seed_core_config(config: InstallConfig, project_root: Path) -> dict: + """Core config values for a fresh --yes install (raw defaults, no templates).""" + try: + username = getpass.getuser() + except Exception: + username = "User" + default_username = username[:1].upper() + username[1:] if username else "User" + return { + "user_name": config.user_name or default_username, + # Use the resolved directory name so `--directory .` yields the real + # folder name rather than an empty string. + "project_name": project_root.name, + "communication_language": config.communication_language or "English", + "document_output_language": config.document_output_language or "English", + "output_folder": config.output_folder or "_bmad-output", + } + + +# --------------------------- file operations --------------------------- + + +def _copy_shared_scripts(src_scripts: Path, dest_scripts: Path, tracked: list[Path]) -> None: + """Copy src/scripts/* -> _bmad/scripts/, skipping tests and caches. + + dest_scripts is always the installer-owned /_bmad/scripts. Like the + Node installer, we wipe it first so scripts renamed or removed upstream don't + linger. The delete is guarded: a symlink or file at that path is unlinked + (removing only the entry, never followed), and only a real directory is + removed recursively. So, rmtree can't delete through a symlink pointing + outside the install. + """ + if dest_scripts.is_symlink() or dest_scripts.is_file(): + dest_scripts.unlink() + elif dest_scripts.is_dir(): + shutil.rmtree(dest_scripts) + dest_scripts.mkdir(parents=True, exist_ok=True) + + for root, dirs, files in os.walk(src_scripts): + # Prune skip dirs in place so os.walk never descends into them; sort both + # dirs and files for deterministic (name-ordered) output. + dirs[:] = sorted(d for d in dirs if d not in _SCRIPT_SKIP_DIRS) + dst_root = dest_scripts / Path(root).relative_to(src_scripts) + dst_root.mkdir(parents=True, exist_ok=True) + for name in sorted(files): + if name.endswith(".pyc"): + continue + target = dst_root / name + shutil.copy2(Path(root) / name, target) + tracked.append(target) + + +def _list_files(root: Path) -> list[str]: + """All files under root, as POSIX-relative paths, name-sorted per directory. + + The per-directory sort (dirs and files each ordered by name) is the point: + it matches the Node installer's sorted readdir walk, which sets the stable + tie-break in files-manifest.csv for rows that share a (module, type, name) + key. + Verified to reproduce Node's files-manifest ordering byte-for-byte. + """ + files: list[str] = [] + for dirpath, dirs, names in os.walk(root): + dirs[:] = sorted(dirs) + rel = Path(dirpath).relative_to(root) + for name in sorted(names): + files.append(name if rel == Path(".") else (rel / name).as_posix()) + return files + + +def _copy_module(module: str, bmad_dir: Path, tracked: list[Path]) -> None: + """Copy a module's source tree into _bmad// with the install filters.""" + source = module_source(module) + target = bmad_dir / module + if target.exists(): + shutil.rmtree(target) + + for rel in _list_files(source): + parts = rel.split("/") + if parts[0] == "sub-modules": + continue + if any(seg.lower().endswith("-sidecar") for seg in parts[:-1]): + continue + # Never ship Python bytecode caches. Unlike the Node installer (which + # runs from a clean checkout), our payload lives in site-packages where + # pip byte-compiles the shipped scripts, seeding __pycache__/*.pyc. + if "__pycache__" in parts or rel.endswith(".pyc"): + continue + if rel == "module.yaml" or rel == "config.yaml": + continue + src_file = source / rel + if rel.startswith("agents/") and rel.endswith(".md"): + content = src_file.read_text(encoding="utf-8", errors="replace") + if re.search(r']*\slocalskip="true"[^>]*>', content): + continue + dst_file = target / rel + dst_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src_file, dst_file) + tracked.append(dst_file) + + +def _create_module_directories(spec: ModuleSpec, module_cfg: dict, project_root: Path) -> list[Path]: + """Create directories declared in module.yaml's `directories` key.""" + created: list[Path] = [] + for entry in spec.directories: + if not isinstance(entry, str): + continue + m = re.fullmatch(r"\{([^}]+)\}", entry.strip()) + if not m: + continue + key = m.group(1) + value = module_cfg.get(key) + if not isinstance(value, str) or not value: + continue + dir_path = re.sub(r"^\{project-root\}/?", "", value).replace("{project-root}", "") + full = (project_root / dir_path).resolve() + root = project_root.resolve() + if full != root and root not in full.parents: + continue + full.mkdir(parents=True, exist_ok=True) + created.append(full) + return created + + +# --------------------------- skill discovery --------------------------- + + +def _parse_skill_md(skill_md: Path, dir_name: str) -> dict | None: + try: + raw = skill_md.read_text(encoding="utf-8") + except OSError: + return None + content = raw.replace("\r\n", "\n").replace("\r", "\n") + match = re.match(r"^---\n([\s\S]*?)\n---", content) + if not match: + return None + try: + meta = yaml.safe_load(match.group(1)) + except yaml.YAMLError: + return None + if not isinstance(meta, dict): + return None + name = meta.get("name") + description = meta.get("description") + if not isinstance(name, str) or not isinstance(description, str) or not name or not description: + return None + if name != dir_name: + # Mirrors the Node installer's hard skip on name/dir mismatch. + print(f"Error: SKILL.md name '{name}' does not match directory name '{dir_name}' - skipping") + return None + return {"name": name, "description": description} + + +def _collect_skills(bmad_dir: Path, modules: list[str]) -> list[dict]: + """Discover SKILL.md entrypoints across installed modules.""" + skills: list[dict] = [] + for module in modules: + module_path = bmad_dir / module + if not module_path.is_dir(): + continue + + def walk(d: Path) -> None: + skill_md = d / "SKILL.md" + meta = _parse_skill_md(skill_md, d.name) if skill_md.exists() else None + if meta: + rel = d.relative_to(module_path).as_posix() + install_path = ( + f"{BMAD_FOLDER_NAME}/{module}/{rel}/SKILL.md" + if rel + else f"{BMAD_FOLDER_NAME}/{module}/SKILL.md" + ) + skills.append( + { + "canonicalId": d.name, + "name": meta["name"], + "description": _clean_for_csv(meta["description"]), + "module": module, + "path": install_path, + } + ) + return # do not descend into a discovered skill + for child in sorted(d.iterdir(), key=lambda p: p.name): + if not child.is_dir(): + continue + if child.name.startswith(".") or child.name.startswith("_"): + continue + walk(child) + + walk(module_path) + return skills + + +def _collect_agents(specs: dict[str, ModuleSpec], modules: list[str]) -> list[dict]: + agents: list[dict] = [] + for module in modules: + spec = specs.get(module) + if not spec: + continue + for entry in spec.agents: + code = entry.get("code") + if not isinstance(code, str): + continue + agents.append( + { + "code": code, + "name": entry.get("name", ""), + "title": entry.get("title", ""), + "icon": entry.get("icon", ""), + "description": entry.get("description", ""), + "module": module, + "team": entry.get("team", module), + } + ) + return agents + + +# --------------------------- config generation --------------------------- + + +def _write_module_config_yaml(bmad_dir: Path, modules: list[str], collected: dict, version: str, tracked: list[Path]) -> None: + core_cfg = collected.get("core", {}) + core_keys = set(core_cfg.keys()) + iso = _iso_now() + for module in modules: + module_path = bmad_dir / module + if not module_path.is_dir(): + continue + cfg = collected.get(module, {}) + header = ( + f"# {module.upper()} Module Configuration\n" + f"# Generated by BMAD installer\n" + f"# Version: {version}\n" + f"# Date: {iso}\n\n" + ) + module_lines: list[str] = [] + core_lines: list[str] = [] + if module == "core": + for key, value in cfg.items(): + module_lines.append(f"{key}: {format_yaml_scalar(str(value))}") + else: + merged = {**cfg, **core_cfg} + for key, value in merged.items(): + line = f"{key}: {format_yaml_scalar(str(value))}" + if key in core_keys: + core_lines.append(line) + else: + module_lines.append(line) + body = "\n".join(module_lines) + if core_lines: + # Node's yaml.stringify emits a trailing newline that becomes a blank + # line between the module block and the core-values comment section. + body += "\n\n# Core Configuration Values\n" + "\n".join(core_lines) + content = header + body + if not content.endswith("\n"): + content += "\n" + config_path = module_path / "config.yaml" + config_path.write_text(content, encoding="utf-8") + tracked.append(config_path) + + +# The central-config headers/stubs must match the Node installer byte-for-byte. +# They contain a U+2500 box rule (65 dashes) and U+2014 em-dashes. Building them +# via chr() keeps THIS source pure-ASCII, so an editor/formatter that "dumbs +# down" Unicode punctuation cannot silently break byte-parity (which it did once, +# turning U+2014 into a hyphen and lengthening the rule). See the header +# regression test in tests/python. +_EM_DASH = chr(0x2014) # em dash +_CONFIG_RULE = "# " + chr(0x2500) * 65 # "# " + 65x U+2500 box-drawing rule + +_TEAM_HEADER = [ + _CONFIG_RULE, + f"# Installer-managed. Regenerated on every install {_EM_DASH} treat as read-only.", + "#", + "# Direct edits to this file will be overwritten on the next install.", + "# To change an install answer durably, re-run the installer (your prior", + "# answers are remembered as defaults). To pin a value regardless of", + "# install answers, or to add custom agents / override descriptors, use:", + "# _bmad/custom/config.toml (team, committed)", + "# _bmad/custom/config.user.toml (personal, gitignored)", + "# Those files are never touched by the installer.", + _CONFIG_RULE, + "", +] + +_USER_HEADER = [ + _CONFIG_RULE, + f"# Installer-managed. Regenerated on every install {_EM_DASH} treat as read-only.", + "# Holds install answers scoped to YOU personally.", + "#", + "# Direct edits to this file will be overwritten on the next install.", + "# To change an answer durably, re-run the installer (your prior answers", + "# are remembered as defaults). For pinned overrides or custom sections", + "# the installer does not know about, use _bmad/custom/config.user.toml", + f"# {_EM_DASH} it is never touched by the installer.", + _CONFIG_RULE, + "", +] + + +def _partition(module: str, cfg: dict, scope: dict, core_keys: set, only_declared: bool) -> tuple[dict, dict]: + team: dict = {} + user: dict = {} + is_core = module == "core" + for key, value in cfg.items(): + if not is_core and key in core_keys: + continue + if only_declared and key not in scope: + continue + if scope.get(key) == "user": + user[key] = value + else: + team[key] = value + return team, user + + +def _write_central_config(bmad_dir: Path, modules: list[str], specs: dict[str, ModuleSpec], collected: dict, agents: list[dict], tracked: list[Path]) -> None: + core_scope = specs["core"].scope if "core" in specs else {} + core_keys = set(core_scope.keys()) + + team_lines = list(_TEAM_HEADER) + user_lines = list(_USER_HEADER) + + # [core] + core_cfg = collected.get("core", {}) + core_team, core_user = _partition("core", core_cfg, core_scope, core_keys, only_declared=False) + if core_team: + team_lines.append("[core]") + team_lines += [f"{k} = {format_toml_value(v)}" for k, v in core_team.items()] + team_lines.append("") + if core_user: + user_lines.append("[core]") + user_lines += [f"{k} = {format_toml_value(v)}" for k, v in core_user.items()] + user_lines.append("") + + # [modules.] + for module in modules: + if module == "core": + continue + cfg = collected.get(module, {}) + if not cfg: + continue + spec = specs.get(module) + section = spec.code if spec else module + scope = spec.scope if spec else {} + have_schema = len(scope) > 0 + mod_team, mod_user = _partition(module, cfg, scope, core_keys, only_declared=have_schema) + if mod_team: + team_lines.append(f"[modules.{section}]") + team_lines += [f"{k} = {format_toml_value(v)}" for k, v in mod_team.items()] + team_lines.append("") + if mod_user: + user_lines.append(f"[modules.{section}]") + user_lines += [f"{k} = {format_toml_value(v)}" for k, v in mod_user.items()] + user_lines.append("") + + # [agents.] - always team scope. + for agent in agents: + block = [ + f"[agents.{agent['code']}]", + f"module = {format_toml_value(agent['module'])}", + f"team = {format_toml_value(agent['team'])}", + ] + if agent.get("name"): + block.append(f"name = {format_toml_value(agent['name'])}") + if agent.get("title"): + block.append(f"title = {format_toml_value(agent['title'])}") + if agent.get("icon"): + block.append(f"icon = {format_toml_value(agent['icon'])}") + if agent.get("description"): + block.append(f"description = {format_toml_value(agent['description'])}") + block.append("") + team_lines += block + + team_content = re.sub(r"\n+$", "\n", "\n".join(team_lines)) + user_content = re.sub(r"\n+$", "\n", "\n".join(user_lines)) + team_path = bmad_dir / "config.toml" + user_path = bmad_dir / "config.user.toml" + team_path.write_text(team_content, encoding="utf-8") + user_path.write_text(user_content, encoding="utf-8") + tracked.append(team_path) + tracked.append(user_path) + + +def _write_main_manifest(bmad_dir: Path, modules: list[str], tools: list[str], version: str, tracked: list[Path]) -> None: + iso = _iso_now() + lines = [ + "installation:", + f" version: {version}", + f" installDate: {iso}", + f" lastUpdated: {iso}", + "modules:", + ] + for module in modules: + lines += [ + f" - name: {module}", + f" version: {version}", + f" installDate: {iso}", + f" lastUpdated: {iso}", + " source: built-in", + " npmPackage: null", + " repoUrl: null", + ] + lines.append("ides:") + for tool in tools: + lines.append(f" - {tool}") + content = "\n".join(lines) + "\n" + path = bmad_dir / "_config" / "manifest.yaml" + path.write_text(content, encoding="utf-8") + tracked.append(path) + + +def _write_skill_manifest(bmad_dir: Path, skills: list[dict]) -> None: + out = ["canonicalId,name,description,module,path"] + for skill in skills: + out.append( + ",".join( + _csv_always_quote(skill[col]) + for col in ("canonicalId", "name", "description", "module", "path") + ) + ) + content = "\n".join(out) + "\n" + (bmad_dir / "_config" / "skill-manifest.csv").write_text(content, encoding="utf-8") + + +def _sha256(path: Path) -> str: + try: + return hashlib.sha256(path.read_bytes()).hexdigest() + except OSError: + return "" + + +def _write_files_manifest(bmad_dir: Path, tracked: list[Path]) -> None: + rows = [] + seen = set() + for path in tracked: + try: + rel = path.relative_to(bmad_dir).as_posix() + except ValueError: + continue + if rel in seen: + continue + seen.add(rel) + ext = path.suffix.lower() + name = path.name[: len(path.name) - len(ext)] if ext else path.name + module = rel.split("/")[0] + type_ = ext[1:] if ext else "file" + rows.append((type_, name, module, rel, _sha256(path))) + # Sort by module, type, name (case-insensitive approximation of localeCompare). + rows.sort(key=lambda r: (r[2].lower(), r[0].lower(), r[1].lower())) + out = ["type,name,module,path,hash"] + for type_, name, module, rel, digest in rows: + out.append(",".join(_csv_always_quote(v) for v in (type_, name, module, rel, digest))) + content = "\n".join(out) + "\n" + (bmad_dir / "_config" / "files-manifest.csv").write_text(content, encoding="utf-8") + + +_CUSTOM_TEAM_STUB = [ + "# Team / enterprise overrides for _bmad/config.toml.", + f"# Committed to the repo {_EM_DASH} applies to every developer on the project.", + "# Tables deep-merge over base config; keyed entries merge by key.", + "# Example: override an agent descriptor, or add a new agent.", + "#", + "# [agents.bmad-agent-pm]", + '# description = "Prefers short, bulleted PRDs over narrative drafts."', + "", +] + +_CUSTOM_USER_STUB = [ + "# Personal overrides for _bmad/config.toml.", + f"# NOT committed (gitignored) {_EM_DASH} applies only to your local install.", + "# Wins over both base config and team overrides.", + "", +] + + +def _ensure_custom_stubs(bmad_dir: Path) -> None: + custom = bmad_dir / "custom" + custom.mkdir(parents=True, exist_ok=True) + team = custom / "config.toml" + if not team.exists(): + team.write_text("\n".join(_CUSTOM_TEAM_STUB), encoding="utf-8") + user = custom / "config.user.toml" + if not user.exists(): + user.write_text("\n".join(_CUSTOM_USER_STUB), encoding="utf-8") + + +def _merge_help_catalogs(bmad_dir: Path, modules: list[str]) -> None: + ordered = ["core"] + [m for m in modules if m != "core"] + decorated = [] + index = 0 + for module in ordered: + help_path = bmad_dir / module / "module-help.csv" + if not help_path.exists(): + continue + for line in help_path.read_text(encoding="utf-8").split("\n"): + if not line.strip() or line.startswith("#"): + continue + if line.startswith("module,"): + continue + cols = _parse_csv_line(line) + if len(cols) < _MODULE_HELP_COLUMNS - 1: + continue + cols = cols[:_MODULE_HELP_COLUMNS] + while len(cols) < _MODULE_HELP_COLUMNS: + cols.append("") + if (not cols[0] or not cols[0].strip()) and module != "core": + cols[0] = module + row = ",".join(_csv_escape_field(c) for c in cols) + decorated.append((row, index, cols)) + index += 1 + decorated.sort(key=lambda d: (d[2][0].lower(), d[2][_MODULE_HELP_PHASE_INDEX], d[1])) + rows = [MODULE_HELP_CSV_HEADER] + [d[0] for d in decorated] + (bmad_dir / "_config" / "bmad-help.csv").write_text("\n".join(rows), encoding="utf-8") + + +# --------------------------- orchestration --------------------------- + + +def install(config: InstallConfig) -> InstallResult: + project_root = config.directory.resolve() + project_root.mkdir(parents=True, exist_ok=True) + bmad_dir = project_root / BMAD_FOLDER_NAME + dirname = project_root.name + version = bmad_version() + + modules = ["core"] + [m for m in config.modules if m != "core"] + specs = {m: load_module_spec(m) for m in modules} + + tracked: list[Path] = [] + (bmad_dir / "_config").mkdir(parents=True, exist_ok=True) + + # 1. Shared runtime scripts + custom/.gitignore. + _copy_shared_scripts(_scripts_source(), bmad_dir / "scripts", tracked) + custom_dir = bmad_dir / "custom" + custom_dir.mkdir(parents=True, exist_ok=True) + gitignore = custom_dir / ".gitignore" + if not gitignore.exists(): + gitignore.write_text("*.user.toml\n", encoding="utf-8") + tracked.append(gitignore) + + # 2. Module payloads. + for module in modules: + _copy_module(module, bmad_dir, tracked) + + # 3. Config values. + collected: dict = {"core": _seed_core_config(config, project_root)} + for module in modules: + if module == "core": + continue + collected[module] = _resolve_module_config(specs[module], collected, dirname) + + # 4. Module directories declared in module.yaml. + for module in modules: + _create_module_directories(specs[module], collected.get(module, {}), project_root) + + # 5. Per-module config.yaml. + _write_module_config_yaml(bmad_dir, modules, collected, version, tracked) + + # 6. Manifests + central config. + skills = _collect_skills(bmad_dir, modules) + agents = _collect_agents(specs, modules) + _write_central_config(bmad_dir, modules, specs, collected, agents, tracked) + _write_main_manifest(bmad_dir, modules, config.tools, version, tracked) + _write_skill_manifest(bmad_dir, skills) + _write_files_manifest(bmad_dir, tracked) + _ensure_custom_stubs(bmad_dir) + _merge_help_catalogs(bmad_dir, modules) + + # 7. Configure IDE/tool skill directories. + ide_results: dict = {} + for tool in config.tools: + ide_results[tool] = ide.setup_tool(tool, project_root, bmad_dir) + + # 8. Skills are self-contained in the IDE dirs now - drop them from _bmad/. + _cleanup_skill_dirs(bmad_dir) + + return InstallResult( + project_root=project_root, + bmad_dir=bmad_dir, + modules=modules, + tools=list(config.tools), + skill_count=len(skills), + ide_results=ide_results, + ) + + +def _scripts_source() -> Path: + from .payload import src_path + + return src_path("scripts") + + +def _cleanup_skill_dirs(bmad_dir: Path) -> None: + """Remove skill source dirs from _bmad/ after they were copied to IDE dirs.""" + csv_path = bmad_dir / "_config" / "skill-manifest.csv" + if not csv_path.exists(): + return + import csv as _csv + + prefix = BMAD_FOLDER_NAME + "/" + with csv_path.open(encoding="utf-8", newline="") as fh: + for record in _csv.DictReader(fh): + rel = record.get("path") or "" + if rel.startswith(prefix): + rel = rel[len(prefix) :] + source_dir = (bmad_dir / rel).parent + if source_dir.exists(): + shutil.rmtree(source_dir, ignore_errors=True) + _remove_empty_parents(source_dir.parent, bmad_dir) + + +def _remove_empty_parents(start: Path, bmad_dir: Path) -> None: + current = start + while True: + try: + rel = current.relative_to(bmad_dir) + except ValueError: + break + if rel == Path("."): + break + try: + if any(current.iterdir()): + break + current.rmdir() + except OSError: + break + current = current.parent diff --git a/bmad_method/payload.py b/bmad_method/payload.py new file mode 100644 index 0000000000..3f065171c8 --- /dev/null +++ b/bmad_method/payload.py @@ -0,0 +1,86 @@ +"""Locate the BMAD payload (skills/agents/modules/scripts + metadata). + +The payload is the ``src/`` tree plus a few metadata files the installer reads. +It lives in two places depending on how the code is running: + +* **Installed wheel** - force-included at ``bmad_method/_payload/`` (see + ``pyproject.toml``), so the package is self-contained. +* **Repo checkout** - the code runs straight from the repository, where the + payload is the repo root itself (``src/`` next to ``bmad_method/``). + +``payload_root()`` returns whichever base directory contains ``src/`` so the +rest of the installer is agnostic to how it was invoked. +""" + +from __future__ import annotations + +import functools +import json +from pathlib import Path + + +class PayloadError(RuntimeError): + """Raised when the BMAD payload cannot be located.""" + + +@functools.lru_cache(maxsize=1) +def payload_root() -> Path: + """Return the directory that contains the BMAD ``src/`` payload.""" + here = Path(__file__).resolve().parent + + packaged = here / "_payload" + if (packaged / "src" / "core-skills").is_dir(): + return packaged + + repo = here.parent + if (repo / "src" / "core-skills").is_dir(): + return repo + + raise PayloadError( + "Could not locate the BMAD payload. Expected either " + f"{packaged / 'src'} (installed) or {repo / 'src'} (repo checkout)." + ) + + +def src_path(*segments: str) -> Path: + """Join ``segments`` onto the payload's ``src/`` directory.""" + return payload_root().joinpath("src", *segments) + + +def module_source(module: str) -> Path: + """Resolve a module code to its source directory in the payload. + + ``core`` and ``bmm`` are built-in and live directly under ``src/``; any + other module is looked up under ``src/modules/``. + """ + if module == "core": + return src_path("core-skills") + if module == "bmm": + return src_path("bmm-skills") + return src_path("modules", module) + + +def platform_codes_path() -> Path: + """Path to ``platform-codes.yaml`` (IDE/tool target-dir registry).""" + packaged = payload_root() / "platform-codes.yaml" + if packaged.is_file(): + return packaged + # Repo checkout fallback: the canonical file lives under tools/installer. + repo_copy = payload_root() / "tools" / "installer" / "ide" / "platform-codes.yaml" + if repo_copy.is_file(): + return repo_copy + raise PayloadError(f"platform-codes.yaml not found near {packaged}") + + +@functools.lru_cache(maxsize=1) +def bmad_version() -> str: + """The BMAD content version, read from the payload's ``package.json``.""" + for candidate in (payload_root() / "package.json", payload_root() / ".." / "package.json"): + try: + data = json.loads(Path(candidate).read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + version = data.get("version") + if version: + return str(version) + return "0.0.0" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000..59059beff2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,75 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "bmad-method" +dynamic = ["version"] +description = "Breakthrough Method of Agile AI-driven Development - pure-Python installer (pip install, no npm)" +readme = "bmad_method/README.md" +# Floor is 3.11 because BMAD's bundled runtime scripts (resolve_config.py, +# resolve_customization.py) use the stdlib `tomllib`, added in 3.11. +requires-python = ">=3.11" +license = { file = "LICENSE" } +authors = [{ name = "Brian (BMad) Madison" }] +maintainers = [{ name = "BMAD Python packaging (PoC)" }] +keywords = ["agile", "ai", "orchestrator", "development", "methodology", "agents", "bmad"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development", +] +# The installer parses module.yaml / SKILL.md frontmatter (YAML). The scaffolded +# runtime itself stays dependency-free (stdlib only). +dependencies = ["pyyaml>=6"] + +[project.scripts] +bmad = "bmad_method.cli:main" +bmad-method = "bmad_method.cli:main" + +[project.urls] +Homepage = "https://github.com/bmad-code-org/BMAD-METHOD" +Documentation = "https://docs.bmad-method.org" +Repository = "https://github.com/bmad-code-org/BMAD-METHOD" + +[project.optional-dependencies] +test = ["pytest>=7"] + +# Version tracks the BMAD content version declared in package.json, so the +# PyPI release and the scaffolded _bmad/ report the same version. +[tool.hatch.version] +path = "package.json" +pattern = '"version":\s*"(?P[^"]+)"' + +[tool.hatch.build.targets.wheel] +packages = ["bmad_method"] + +# BMAD's payload (skills, agents, modules, runtime scripts) is plain data files. +# Ship the src/ tree and the license/trademark/version metadata inside the wheel +# under bmad_method/_payload/ so the installed package is self-contained and needs +# no repo checkout. force-include maps repo paths → wheel paths without physically +# duplicating them in the source tree. +[tool.hatch.build.targets.wheel.force-include] +"src" = "bmad_method/_payload/src" +"tools/installer/ide/platform-codes.yaml" = "bmad_method/_payload/platform-codes.yaml" +"package.json" = "bmad_method/_payload/package.json" +"LICENSE" = "bmad_method/_payload/LICENSE" +"TRADEMARK.md" = "bmad_method/_payload/TRADEMARK.md" + +[tool.hatch.build.targets.sdist] +# Keep the sdist lean but self-buildable: the package, its payload sources, and +# the metadata files hatch reads at build time. +include = [ + "/bmad_method", + "/src", + "/tools/installer/ide/platform-codes.yaml", + "/package.json", + "/LICENSE", + "/TRADEMARK.md", +] diff --git a/tests/python/test_installer.py b/tests/python/test_installer.py new file mode 100644 index 0000000000..65ea81ffd0 --- /dev/null +++ b/tests/python/test_installer.py @@ -0,0 +1,256 @@ +"""Tests for the pure-Python BMAD installer (bmad_method). + +Covers the two things the Phase-1 PoC promised: `bmad --help` runs, and a +non-interactive scaffold into a temp dir succeeds and is actually usable - down +to the shipped runtime script reading the generated config. +""" + +from __future__ import annotations + +import csv +import json +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] + +import bmad_method +from bmad_method.installer import InstallConfig, install + + +def _run_cli(*args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-m", "bmad_method.cli", *args], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + + +# --------------------------- CLI smoke tests --------------------------- + + +def test_help_runs(): + result = _run_cli("--help") + assert result.returncode == 0 + assert "install" in result.stdout + assert "list-tools" in result.stdout + + +def test_version_runs(): + result = _run_cli("--version") + assert result.returncode == 0 + assert bmad_method.__version__ in result.stdout + + +def test_list_tools_includes_claude_code(): + result = _run_cli("list-tools") + assert result.returncode == 0 + assert "claude-code" in result.stdout + assert ".claude/skills" in result.stdout + + +def test_install_requires_yes(tmp_path): + result = _run_cli("install", "--directory", str(tmp_path), "--tools", "claude-code") + assert result.returncode == 2 + assert "yes" in result.stderr.lower() + + +def test_install_rejects_unknown_tool(tmp_path): + result = _run_cli("install", "--directory", str(tmp_path), "--tools", "not-a-tool", "--yes") + assert result.returncode == 2 + assert "unknown tool" in result.stderr.lower() + + +# --------------------------- scaffold via API --------------------------- + + +@pytest.fixture(scope="module") +def scaffold(tmp_path_factory): + project = tmp_path_factory.mktemp("bmad_project") + result = install( + InstallConfig( + directory=project, + modules=["bmm"], + tools=["claude-code"], + yes=True, + ) + ) + return result + + +def test_scaffold_creates_expected_layout(scaffold): + bmad = scaffold.bmad_dir + assert bmad.is_dir() + assert (bmad / "config.toml").is_file() + assert (bmad / "config.user.toml").is_file() + assert (bmad / "_config" / "manifest.yaml").is_file() + assert (bmad / "_config" / "skill-manifest.csv").is_file() + assert (bmad / "_config" / "files-manifest.csv").is_file() + assert (bmad / "_config" / "bmad-help.csv").is_file() + # Shared runtime scripts shipped, dev tests excluded. + assert (bmad / "scripts" / "resolve_config.py").is_file() + assert not (bmad / "scripts" / "tests").exists() + # Custom override stubs + gitignore. + assert (bmad / "custom" / "config.toml").is_file() + assert (bmad / "custom" / ".gitignore").read_text().strip() == "*.user.toml" + # The one directory created eagerly by core. + assert (scaffold.project_root / "_bmad-output").is_dir() + # core is always installed alongside the requested module. + assert scaffold.modules == ["core", "bmm"] + + +def test_config_toml_is_valid_and_populated(scaffold): + with (scaffold.bmad_dir / "config.toml").open("rb") as fh: + config = tomllib.load(fh) + assert config["core"]["project_name"] == scaffold.project_root.name + assert config["core"]["output_folder"] == "_bmad-output" + assert config["modules"]["bmm"]["project_knowledge"] == "{project-root}/docs" + assert config["agents"]["bmad-agent-pm"]["name"] == "John" + assert config["agents"]["bmad-agent-pm"]["module"] == "bmm" + + # User-scoped answers land in config.user.toml, not config.toml. + with (scaffold.bmad_dir / "config.user.toml").open("rb") as fh: + user = tomllib.load(fh) + assert "user_name" in user["core"] + assert "user_name" not in config.get("core", {}) + assert user["modules"]["bmm"]["user_skill_level"] == "intermediate" + + +def test_skills_installed_into_claude(scaffold): + manifest = scaffold.bmad_dir / "_config" / "skill-manifest.csv" + with manifest.open(encoding="utf-8", newline="") as fh: + rows = list(csv.DictReader(fh)) + canonical_ids = {r["canonicalId"] for r in rows} + assert len(canonical_ids) == scaffold.skill_count > 0 + + skills_dir = scaffold.project_root / ".claude" / "skills" + installed = {p.name for p in skills_dir.iterdir() if p.is_dir()} + assert installed == canonical_ids + # Every installed skill is self-contained (has its SKILL.md). + for cid in canonical_ids: + assert (skills_dir / cid / "SKILL.md").is_file() + # Well-known skills are present. + assert "bmad-help" in canonical_ids + assert "bmad-agent-pm" in canonical_ids + + +def test_skill_dirs_removed_from_bmad(scaffold): + # After IDE install, skills live only in .claude/skills - not under _bmad/. + assert not list((scaffold.bmad_dir / "core").rglob("SKILL.md")) + assert not list((scaffold.bmad_dir / "bmm").rglob("SKILL.md")) + # Module-level files remain. + assert (scaffold.bmad_dir / "core" / "config.yaml").is_file() + assert (scaffold.bmad_dir / "bmm" / "module-help.csv").is_file() + + +def test_runtime_resolve_config_reads_scaffold(scaffold): + """The shipped, stdlib-only runtime script reads our generated config.""" + script = scaffold.bmad_dir / "scripts" / "resolve_config.py" + result = subprocess.run( + [sys.executable, str(script), "--project-root", str(scaffold.project_root)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + resolved = json.loads(result.stdout) + assert resolved["core"]["project_name"] == scaffold.project_root.name + assert resolved["agents"]["bmad-agent-pm"]["name"] == "John" + + +def test_install_with_dot_directory_uses_folder_name(tmp_path, monkeypatch): + # Regression: `bmad install --directory .` must set project_name to the real + # folder name, not an empty string from Path(".").name. + project = tmp_path / "my-cool-project" + project.mkdir() + monkeypatch.chdir(project) + result = install(InstallConfig(directory=Path("."), modules=["bmm"], tools=["claude-code"], yes=True)) + with (result.bmad_dir / "config.toml").open("rb") as fh: + config = tomllib.load(fh) + assert config["core"]["project_name"] == "my-cool-project" + + +def test_central_config_header_bytes_match_node(scaffold): + # Byte-parity guard: the Node installer emits a U+2500 rule (exactly 65 + # dashes) and U+2014 em-dashes in these headers. A formatter that "dumbs + # down" the source Unicode would turn U+2014 into a hyphen and change the + # rule length, silently breaking parity. Assert the exact codepoints so that + # regression fails here (no Node required). + rule = "# " + chr(0x2500) * 65 # U+2500 box-drawing, exactly 65 dashes + em = chr(0x2014) # em dash + + config = (scaffold.bmad_dir / "config.toml").read_text(encoding="utf-8") + assert rule in config + assert f"install {em} treat as read-only" in config + # The dumbed-down forms must NOT appear. + assert "# " + chr(0x2500) * 66 not in config + assert "install - treat as read-only" not in config + + user = (scaffold.bmad_dir / "config.user.toml").read_text(encoding="utf-8") + assert rule in user + assert f"# {em} it is never touched by the installer." in user + + team_stub = (scaffold.bmad_dir / "custom" / "config.toml").read_text(encoding="utf-8") + assert f"repo {em} applies to every developer" in team_stub + + user_stub = (scaffold.bmad_dir / "custom" / "config.user.toml").read_text(encoding="utf-8") + assert f"(gitignored) {em} applies only" in user_stub + + +def test_skill_copy_filter_is_recursive(tmp_path): + # The skill copy must filter identically at every depth (mirrors Node): + # `.gitkeep` is the one kept dotfile; other dotfiles, bytecode caches, and + # editor/OS artifacts are dropped nested as well as at the top level. + from bmad_method.ide import _copy_skill_dir + + src = tmp_path / "skill" + (src / "sub" / "__pycache__").mkdir(parents=True) + (src / "SKILL.md").write_text("x") + (src / ".gitkeep").write_text("") + (src / ".hidden").write_text("secret") + (src / "sub" / ".gitkeep").write_text("") + (src / "sub" / ".hidden").write_text("secret") + (src / "sub" / "normal.txt").write_text("ok") + (src / "sub" / ".DS_Store").write_text("junk") + (src / "sub" / "__pycache__" / "m.cpython-313.pyc").write_text("bytecode") + (src / "sub" / "tool.pyc").write_text("bytecode") + + dst = tmp_path / "out" + _copy_skill_dir(src, dst) + copied = {p.relative_to(dst).as_posix() for p in dst.rglob("*") if p.is_file()} + assert copied == {".gitkeep", "SKILL.md", "sub/.gitkeep", "sub/normal.txt"} + assert not (dst / "sub" / "__pycache__").exists() + + +def test_shared_scripts_wipe_does_not_follow_symlink(tmp_path): + # Guard on the recursive delete in _copy_shared_scripts: if _bmad/scripts is + # a symlink, we must unlink it (removing only the link), never rmtree through + # it into whatever it points at. + from bmad_method.installer import _copy_shared_scripts + from bmad_method.payload import src_path + + important = tmp_path / "important" + important.mkdir() + (important / "keepme.txt").write_text("do not delete") + + dest = tmp_path / "_bmad" / "scripts" + dest.parent.mkdir(parents=True) + dest.symlink_to(important, target_is_directory=True) + + _copy_shared_scripts(src_path("scripts"), dest, []) + + assert (important / "keepme.txt").exists() # target untouched + assert dest.is_dir() and not dest.is_symlink() # replaced by a real dir + assert (dest / "resolve_config.py").is_file() + + +def test_install_is_idempotent(tmp_path): + cfg = InstallConfig(directory=tmp_path, modules=["bmm"], tools=["claude-code"], yes=True) + first = install(cfg) + second = install(cfg) + assert first.skill_count == second.skill_count + assert (tmp_path / ".claude" / "skills" / "bmad-help" / "SKILL.md").is_file()