Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions bmad_method/README.md
Original file line number Diff line number Diff line change
@@ -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/<module>/`;
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.
27 changes: 27 additions & 0 deletions bmad_method/__init__.py
Original file line number Diff line number Diff line change
@@ -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__"]
131 changes: 131 additions & 0 deletions bmad_method/cli.py
Original file line number Diff line number Diff line change
@@ -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="<command>")

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())
116 changes: 116 additions & 0 deletions bmad_method/ide.py
Original file line number Diff line number Diff line change
@@ -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/<canonicalId>/``).

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}
Loading