This commit is contained in:
Brian 2026-06-12 14:35:06 +09:00 committed by GitHub
commit 810a8756a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1973 additions and 301 deletions

View File

@ -1,12 +1,30 @@
#!/usr/bin/env python3
"""
Resolve BMad's central config using four-layer TOML merge.
Resolve BMad's central config using a layered TOML merge.
Reads from four layers (highest priority last):
1. {project-root}/_bmad/config.toml (installer-owned team)
2. {project-root}/_bmad/config.user.toml (installer-owned user)
3. {project-root}/_bmad/custom/config.toml (human-authored team, committed)
4. {project-root}/_bmad/custom/config.user.toml (human-authored user, gitignored)
Reads from up to seven tiers (highest priority last):
0. {project-root}/_bmad/{module}/module.toml (shipped module defaults — floor)
1. {global-dir}/config.toml (global team / machine defaults)
2. {global-dir}/config.user.toml (global personal defaults)
3. {project-root}/_bmad/config.toml (installer-owned team)
4. {project-root}/_bmad/config.user.toml (installer-owned user)
5. {project-root}/_bmad/custom/config.toml (human-authored team, committed)
6. {project-root}/_bmad/custom/config.user.toml (human-authored user, gitignored)
Tier 0 ("module floor") carries each installed module's shipped defaults:
its [modules.X] paths and [agents.X] roster. Authors write module.yaml at
source; the installer converts to module.toml at install-time, giving the
resolver a TOML-only read path (no PyYAML dependency). Discovery is by
glob: any subdirectory of _bmad/ containing a module.toml counts.
All layers are optional. If a file is missing it is silently skipped.
If no file is found anywhere, an empty object is emitted.
{global-dir} resolves to $BMAD_HOME if set, otherwise ~/.bmad. Path.home()
gives the right answer on macOS, Linux, WSL, and Windows.
--project-root is optional. With no project root, only the global layers
are consulted (useful for standalone skill invocations).
Outputs merged JSON to stdout. Errors go to stderr.
@ -16,6 +34,7 @@ no virtualenv — plain `python3` is sufficient.
python3 resolve_config.py --project-root /abs/path/to/project
python3 resolve_config.py --project-root ... --key core
python3 resolve_config.py --project-root ... --key agents
python3 resolve_config.py # global only
Merge rules (same as resolve_customization.py):
- Scalars: override wins
@ -26,6 +45,7 @@ Merge rules (same as resolve_customization.py):
import argparse
import json
import os
import sys
from pathlib import Path
@ -123,6 +143,61 @@ def deep_merge(base, override):
return override
def resolve_global_dir() -> Path:
"""Locate the cross-platform global BMad config directory.
Honors $BMAD_HOME (useful for CI, multi-account setups, or relocating
config off a slow network home). Otherwise ~/.bmad — Path.home() does
the right thing on macOS, Linux, WSL, and Windows.
"""
override = os.environ.get("BMAD_HOME")
if override:
return Path(override).expanduser().resolve()
return Path.home() / ".bmad"
def collect_module_layers(project_root: Path | None) -> list[Path]:
"""Return per-module module.toml paths discovered in this project.
Floor of the resolver chain — these are the shipped module defaults
that the installer realizes from source module.yaml on install. Authors
don't edit module.toml directly; it's a build artifact.
Discovery is purely file-system based: any direct subdirectory of
_bmad/ that contains a module.toml is treated as an installed module.
Returned in sorted order for deterministic merge order (irrelevant
in practice because modules don't share keys — each writes its own
[modules.{code}] and own [agents.{agent-code}] entries — but
determinism is cheap).
"""
if project_root is None:
return []
bmad_dir = project_root / "_bmad"
if not bmad_dir.is_dir():
return []
return sorted(bmad_dir.glob("*/module.toml"))
def collect_config_layers(project_root: Path | None, global_dir: Path) -> list[tuple[str, Path]]:
"""Return (label, path) pairs in lowest→highest priority order.
All layers are optional; load_toml returns {} for any missing file.
"""
layers: list[tuple[str, Path]] = [
("global team", global_dir / "config.toml"),
("global user", global_dir / "config.user.toml"),
]
if project_root is not None:
bmad_dir = project_root / "_bmad"
layers.extend([
("project team", bmad_dir / "config.toml"),
("project user", bmad_dir / "config.user.toml"),
("project custom team", bmad_dir / "custom" / "config.toml"),
("project custom user", bmad_dir / "custom" / "config.user.toml"),
])
return layers
def extract_key(data, dotted_key: str):
parts = dotted_key.split(".")
current = data
@ -136,11 +211,12 @@ def extract_key(data, dotted_key: str):
def main():
parser = argparse.ArgumentParser(
description="Resolve BMad central config using four-layer TOML merge.",
description="Resolve BMad central config using a layered TOML merge.",
)
parser.add_argument(
"--project-root", "-p", required=True,
help="Absolute path to the project root (contains _bmad/)",
"--project-root", "-p", required=False, default=None,
help="Absolute path to the project root (contains _bmad/). Optional — "
"if omitted, only global layers ($BMAD_HOME or ~/.bmad) are read.",
)
parser.add_argument(
"--key", "-k", action="append", default=[],
@ -148,17 +224,27 @@ def main():
)
args = parser.parse_args()
project_root = Path(args.project_root).resolve()
bmad_dir = project_root / "_bmad"
project_root = Path(args.project_root).resolve() if args.project_root else None
global_dir = resolve_global_dir()
base_team = load_toml(bmad_dir / "config.toml", required=True)
base_user = load_toml(bmad_dir / "config.user.toml")
custom_team = load_toml(bmad_dir / "custom" / "config.toml")
custom_user = load_toml(bmad_dir / "custom" / "config.user.toml")
# If the caller explicitly named a project root, that's a promise it exists
# and has been installed. Fail loudly on a missing _bmad/ rather than
# silently returning {} — that masked broken installs in the old required=
# True behavior. Global-only mode (no --project-root) stays permissive.
if project_root is not None and not (project_root / "_bmad").is_dir():
sys.stderr.write(
f"error: --project-root {project_root} has no _bmad/ directory "
f"(install not present, or wrong path)\n"
)
sys.exit(1)
merged = deep_merge(base_team, base_user)
merged = deep_merge(merged, custom_team)
merged = deep_merge(merged, custom_user)
merged: dict = {}
# Floor: per-module shipped defaults (lowest priority).
for module_toml in collect_module_layers(project_root):
merged = deep_merge(merged, load_toml(module_toml))
# Then global → project → custom config layers on top.
for _label, path in collect_config_layers(project_root, global_dir):
merged = deep_merge(merged, load_toml(path))
if args.key:
output = {}

View File

@ -1,13 +1,41 @@
#!/usr/bin/env python3
"""
Resolve customization for a BMad skill using three-layer TOML merge.
Resolve customization for a BMad skill using a layered TOML merge.
Reads customization from three layers (highest priority first):
1. {project-root}/_bmad/custom/{name}.user.toml (personal, gitignored)
2. {project-root}/_bmad/custom/{name}.toml (team/org, committed)
3. {skill-root}/customize.toml (skill defaults)
Reads from (lowest → highest priority):
1. {skill-root}/customize.toml (skill author defaults)
2. [skills.X] sections inside four customize layers (lowest→highest):
a. {global-dir}/customize.toml (global team / machine)
b. {global-dir}/customize.user.toml (global personal)
c. {project-root}/_bmad/custom/customize.toml (project team, committed)
d. {project-root}/_bmad/custom/customize.user.toml (project personal, gitignored)
3. {project-root}/_bmad/custom/{name}.toml (per-skill team override)
4. {project-root}/_bmad/custom/{name}.user.toml (per-skill personal override)
Skill name is derived from the basename of the skill directory.
config.toml is NOT consulted by this resolver — identity/agents live there,
skill behavior overrides live in customize.toml. Clean split.
There is no installer-tier customize.toml — the installer manages identity
(config.toml), not skill behavior. customize.{,user.}toml is purely human
or `bmad-customize`-skill-authored.
Skill name is derived from the basename of the skill directory. If the
skill lives under `{module}-skills/...`, a qualified name `{module}/{skill}`
is also computed and used for pattern matching.
Inside a customize layer, [skills.X] sections cascade by specificity (most
specific wins within the layer):
[skills."*"] # catchall
[skills."bmad-*"] # bare-name glob
[skills."bmm/*"] # module-scoped glob
[skills.bmad-prd] # bare exact
[skills."bmm/bmad-prd"] # qualified exact (most specific)
Patterns are matched against both the bare skill name and (if available)
the qualified `module/skill` name. Specificity scoring: exact > wildcard,
longer-pattern > shorter, `*` is the lowest.
{global-dir} = $BMAD_HOME if set, otherwise ~/.bmad (cross-platform).
Outputs merged JSON to stdout. Errors go to stderr.
@ -34,7 +62,10 @@ description/prompt.
"""
import argparse
import fnmatch
import json
import os
import re
import sys
from pathlib import Path
@ -166,6 +197,93 @@ def deep_merge(base, override):
return override
_MODULE_SKILLS_RE = re.compile(r"^(?P<module>[A-Za-z0-9_-]+)-skills$")
def detect_skill_module(skill_dir: Path) -> str | None:
"""Walk up from the skill dir looking for an ancestor named `{module}-skills`.
Returns the module slug (e.g. 'bmm', 'core') or None if the skill isn't
inside a recognizable module tree (standalone skill, test fixture, etc.).
"""
for ancestor in skill_dir.parents:
match = _MODULE_SKILLS_RE.match(ancestor.name)
if match:
return match.group("module")
return None
def resolve_global_dir() -> Path:
"""Locate the cross-platform global BMad config directory ($BMAD_HOME or ~/.bmad)."""
override = os.environ.get("BMAD_HOME")
if override:
return Path(override).expanduser().resolve()
return Path.home() / ".bmad"
def collect_customize_layers(project_root: Path | None, global_dir: Path) -> list[Path]:
"""Return customize.toml file paths in lowest→highest priority order.
Four layers (all optional):
1. {global-dir}/customize.toml — global team / machine
2. {global-dir}/customize.user.toml — global personal
3. _bmad/custom/customize.toml — project team, committed
4. _bmad/custom/customize.user.toml — project personal, gitignored
Installer-managed _bmad/config*.toml is NOT scanned: customize is a
separate concern from identity, and the installer does not author it.
"""
layers: list[Path] = [
global_dir / "customize.toml",
global_dir / "customize.user.toml",
]
if project_root is not None:
custom_dir = project_root / "_bmad" / "custom"
layers.extend([
custom_dir / "customize.toml",
custom_dir / "customize.user.toml",
])
return layers
def _pattern_specificity(pattern: str) -> tuple[int, int]:
"""Score a [skills.X] pattern for specificity ordering (ascending = less specific).
Tier 2 (exact, no wildcards): most specific. Tie-break by pattern length.
Tier 1 (wildcard, not pure '*'): mid. Longer patterns are more specific.
Tier 0 (bare '*'): catchall.
"""
if pattern == "*":
return (0, 0)
if "*" in pattern or "?" in pattern or "[" in pattern:
return (1, len(pattern))
return (2, len(pattern))
def extract_skill_overrides(layer_data: dict, qualified: str | None, bare: str) -> dict:
"""Build a single-skill override dict from a layer's [skills.X] table.
Matches every pattern that fnmatches against the bare or qualified name,
then deep-merges them in ascending specificity so the most specific wins.
"""
skills_table = layer_data.get("skills")
if not isinstance(skills_table, dict):
return {}
matched: list[tuple[tuple[int, int], dict]] = []
for pattern, override in skills_table.items():
if not isinstance(override, dict):
continue
if fnmatch.fnmatchcase(bare, pattern) or (
qualified is not None and fnmatch.fnmatchcase(qualified, pattern)
):
matched.append((_pattern_specificity(pattern), override))
matched.sort(key=lambda item: item[0])
result: dict = {}
for _, override in matched:
result = deep_merge(result, override)
return result
def extract_key(data, dotted_key: str):
parts = dotted_key.split(".")
current = data
@ -187,7 +305,7 @@ def write_json_stdout(output):
def main():
parser = argparse.ArgumentParser(
description="Resolve customization for a BMad skill using three-layer TOML merge.",
description="Resolve customization for a BMad skill using a layered TOML merge with [skills.X] cascade.",
add_help=True,
)
parser.add_argument(
@ -202,6 +320,8 @@ def main():
skill_dir = Path(args.skill).resolve()
skill_name = skill_dir.name
module = detect_skill_module(skill_dir)
qualified = f"{module}/{skill_name}" if module else None
defaults_path = skill_dir / "customize.toml"
defaults = load_toml(defaults_path, required=True)
@ -211,16 +331,27 @@ def main():
# for standalone skills invoked directly). Using cwd first is unsafe when
# an ancestor of cwd happens to have a stray _bmad/ from another project.
project_root = find_project_root(skill_dir) or find_project_root(Path.cwd())
global_dir = resolve_global_dir()
team = {}
user = {}
merged = defaults
# Walk the customize layers low→high, applying any [skills.X] sections
# that match this skill. This is the cross-cutting override surface:
# users can cascade values across all skills, all skills in a module, or
# one specific skill without editing per-skill files.
for layer_path in collect_customize_layers(project_root, global_dir):
layer_data = load_toml(layer_path)
skill_override = extract_skill_overrides(layer_data, qualified, skill_name)
if skill_override:
merged = deep_merge(merged, skill_override)
# Per-skill override files (highest priority — most explicit). These keep
# working for back-compat and remain the right tool when an override is
# large or wholly skill-specific.
if project_root:
custom_dir = project_root / "_bmad" / "custom"
team = load_toml(custom_dir / f"{skill_name}.toml")
user = load_toml(custom_dir / f"{skill_name}.user.toml")
merged = deep_merge(defaults, team)
merged = deep_merge(merged, user)
merged = deep_merge(merged, load_toml(custom_dir / f"{skill_name}.toml"))
merged = deep_merge(merged, load_toml(custom_dir / f"{skill_name}.user.toml"))
if args.key:
output = {}

View File

@ -0,0 +1,197 @@
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "resolve_config.py"
def run_raw(args, env_overrides=None):
"""Run resolver, return CompletedProcess-like object with decoded streams.
Use this when the test expects a non-zero exit (e.g. fail-fast checks)."""
env = os.environ.copy()
# Force BMAD_HOME to a guaranteed-missing path so any value the developer
# has set in their shell can't leak the real ~/.bmad into a test that
# expected an empty global. Tests that need a populated global pass it via
# env_overrides below.
env["BMAD_HOME"] = "/nonexistent-bmad-home-default"
if env_overrides:
env.update(env_overrides)
result = subprocess.run(
[sys.executable, str(SCRIPT), *args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
check=False,
)
result.stdout = result.stdout.decode("utf-8", errors="replace")
result.stderr = result.stderr.decode("utf-8", errors="replace")
return result
def run(args, env_overrides=None):
result = run_raw(args, env_overrides=env_overrides)
if result.returncode != 0:
raise AssertionError(f"resolve_config failed ({result.returncode}): {result.stderr}")
return json.loads(result.stdout)
class ResolveConfigTests(unittest.TestCase):
def test_no_project_root_no_global_returns_empty(self):
with tempfile.TemporaryDirectory() as empty_global:
data = run([], env_overrides={"BMAD_HOME": empty_global})
self.assertEqual(data, {})
def test_global_only_when_no_project_root(self):
with tempfile.TemporaryDirectory() as global_dir:
(Path(global_dir) / "config.toml").write_text(
'[core]\nuser_name = "Globie"\n', encoding="utf-8"
)
data = run([], env_overrides={"BMAD_HOME": global_dir})
self.assertEqual(data["core"]["user_name"], "Globie")
def test_project_overrides_global(self):
with tempfile.TemporaryDirectory() as global_dir, tempfile.TemporaryDirectory() as proj:
(Path(global_dir) / "config.toml").write_text(
'[core]\nuser_name = "Globie"\ncommunication_language = "French"\n',
encoding="utf-8",
)
bmad = Path(proj) / "_bmad"
bmad.mkdir()
(bmad / "config.toml").write_text(
'[core]\nuser_name = "ProjectUser"\n', encoding="utf-8"
)
data = run(
["--project-root", proj], env_overrides={"BMAD_HOME": global_dir}
)
# Project wins on user_name; global fills in communication_language
self.assertEqual(data["core"]["user_name"], "ProjectUser")
self.assertEqual(data["core"]["communication_language"], "French")
def test_custom_user_beats_everything(self):
with tempfile.TemporaryDirectory() as global_dir, tempfile.TemporaryDirectory() as proj:
(Path(global_dir) / "config.user.toml").write_text(
'[core]\nuser_name = "Globie"\n', encoding="utf-8"
)
bmad = Path(proj) / "_bmad"
(bmad / "custom").mkdir(parents=True)
(bmad / "config.toml").write_text(
'[core]\nuser_name = "Installer"\n', encoding="utf-8"
)
(bmad / "custom" / "config.user.toml").write_text(
'[core]\nuser_name = "Pinned"\n', encoding="utf-8"
)
data = run(
["--project-root", proj], env_overrides={"BMAD_HOME": global_dir}
)
self.assertEqual(data["core"]["user_name"], "Pinned")
def test_project_config_optional(self):
# _bmad/ exists (installed project) but no config.toml inside — that's
# the lean / global-only case and must not error.
with tempfile.TemporaryDirectory() as proj:
(Path(proj) / "_bmad").mkdir()
data = run(["--project-root", proj])
self.assertEqual(data, {})
def test_project_root_without_bmad_dir_errors(self):
# --project-root pointing at a directory with no _bmad/ is treated
# as a broken install (typo, wiped install) — resolver exits non-zero
# rather than silently returning {}. Global-only mode (no
# --project-root) keeps the permissive behavior.
with tempfile.TemporaryDirectory() as proj:
result = run_raw(["--project-root", proj])
self.assertNotEqual(result.returncode, 0)
self.assertIn("no _bmad/ directory", result.stderr)
def test_module_floor_contributes_when_no_overrides(self):
# Module-shipped defaults from _bmad/{module}/module.toml should
# appear when nothing else specifies them.
with tempfile.TemporaryDirectory() as proj:
bmad = Path(proj) / "_bmad" / "bmm"
bmad.mkdir(parents=True)
(bmad / "module.toml").write_text(
'[modules.bmm]\nplanning_artifacts = "/from/module/yaml"\n'
'[agents.bmad-agent-analyst]\nname = "Mary"\nmodule = "bmm"\n',
encoding="utf-8",
)
data = run(["--project-root", proj])
self.assertEqual(data["modules"]["bmm"]["planning_artifacts"], "/from/module/yaml")
self.assertEqual(data["agents"]["bmad-agent-analyst"]["name"], "Mary")
def test_config_toml_overrides_module_floor(self):
# Config layers sit above the module floor — explicit overrides win.
with tempfile.TemporaryDirectory() as proj:
bmad = Path(proj) / "_bmad"
mod = bmad / "bmm"
mod.mkdir(parents=True)
(mod / "module.toml").write_text(
'[modules.bmm]\nplanning_artifacts = "/module/default"\n',
encoding="utf-8",
)
(bmad / "config.toml").write_text(
'[modules.bmm]\nplanning_artifacts = "/user/override"\n',
encoding="utf-8",
)
data = run(["--project-root", proj])
self.assertEqual(
data["modules"]["bmm"]["planning_artifacts"], "/user/override"
)
def test_multiple_modules_merge_independently(self):
# Each module writes its own [modules.X] / [agents.X] subtree;
# the merge should not collide across modules.
with tempfile.TemporaryDirectory() as proj:
bmad = Path(proj) / "_bmad"
(bmad / "bmm").mkdir(parents=True)
(bmad / "bmb").mkdir(parents=True)
(bmad / "bmm" / "module.toml").write_text(
'[modules.bmm]\nplanning_artifacts = "/bmm/path"\n',
encoding="utf-8",
)
(bmad / "bmb" / "module.toml").write_text(
'[modules.bmb]\nbmad_builder_output_folder = "/bmb/path"\n',
encoding="utf-8",
)
data = run(["--project-root", proj])
self.assertEqual(data["modules"]["bmm"]["planning_artifacts"], "/bmm/path")
self.assertEqual(
data["modules"]["bmb"]["bmad_builder_output_folder"], "/bmb/path"
)
def test_module_floor_ignored_when_no_project_root(self):
# Without --project-root, no per-project files (including module
# floor) should be read. Just global.
with tempfile.TemporaryDirectory() as g:
(Path(g) / "config.toml").write_text(
'[core]\nuser_name = "Globie"\n', encoding="utf-8"
)
data = run([], env_overrides={"BMAD_HOME": g})
self.assertEqual(data, {"core": {"user_name": "Globie"}})
def test_non_module_dirs_skipped(self):
# _bmad/_config, _bmad/custom, _bmad/scripts must NOT be treated as
# modules even though they're direct children of _bmad/.
with tempfile.TemporaryDirectory() as proj:
bmad = Path(proj) / "_bmad"
for sub in ("_config", "custom", "scripts"):
(bmad / sub).mkdir(parents=True)
# No module.toml anywhere — resolver should return {}, not error
data = run(["--project-root", proj])
self.assertEqual(data, {})
def test_bmad_home_env_var_honored(self):
with tempfile.TemporaryDirectory() as global_dir:
(Path(global_dir) / "config.toml").write_text(
'[core]\nuser_name = "FromEnv"\n', encoding="utf-8"
)
data = run([], env_overrides={"BMAD_HOME": global_dir})
self.assertEqual(data["core"]["user_name"], "FromEnv")
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,156 @@
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "resolve_customization.py"
def make_skill(parent: Path, name: str, defaults_toml: str, module: str | None = None) -> Path:
"""Create a fake skill dir, optionally under a `{module}-skills/` ancestor."""
if module:
base = parent / f"{module}-skills"
base.mkdir(parents=True, exist_ok=True)
else:
base = parent
skill_dir = base / name
skill_dir.mkdir()
(skill_dir / "customize.toml").write_text(defaults_toml, encoding="utf-8")
return skill_dir
def run(skill_dir: Path, key=None, env_overrides=None):
env = os.environ.copy()
# Force BMAD_HOME to a guaranteed-missing path so the developer's real
# ~/.bmad never leaks into a test expecting an empty global. Tests that
# need a populated global override via env_overrides below.
env["BMAD_HOME"] = "/nonexistent-bmad-home-default"
if env_overrides:
env.update(env_overrides)
args = [sys.executable, str(SCRIPT), "--skill", str(skill_dir)]
if key:
args.extend(["--key", key])
result = subprocess.run(
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, check=False
)
stderr = result.stderr.decode("utf-8", errors="replace")
if result.returncode != 0:
raise AssertionError(f"resolve_customization failed ({result.returncode}): {stderr}")
return json.loads(result.stdout.decode("utf-8"))
class ResolveCustomizationCascadeTests(unittest.TestCase):
def test_defaults_pass_through_when_no_overrides(self):
with tempfile.TemporaryDirectory() as t:
skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n')
data = run(skill)
self.assertEqual(data["knobs"]["depth"], "low")
def test_skills_section_in_global_customize_overrides_default(self):
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as global_dir:
skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n')
(Path(global_dir) / "customize.toml").write_text(
'[skills.bmad-prd.knobs]\ndepth = "high"\n',
encoding="utf-8",
)
data = run(skill, env_overrides={"BMAD_HOME": global_dir})
self.assertEqual(data["knobs"]["depth"], "high")
def test_config_toml_is_not_consulted_for_skills_section(self):
# [skills.X] in config.toml must NOT be honored — only customize.toml is.
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g:
skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n')
(Path(g) / "config.toml").write_text(
'[skills.bmad-prd.knobs]\ndepth = "should-be-ignored"\n',
encoding="utf-8",
)
data = run(skill, env_overrides={"BMAD_HOME": g})
self.assertEqual(data["knobs"]["depth"], "low")
def test_specificity_within_layer(self):
# Within ONE customize layer: exact skill name beats wildcard.
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g:
skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n')
(Path(g) / "customize.toml").write_text(
"[skills.\"*\".knobs]\ndepth = \"catchall\"\n"
"[skills.bmad-prd.knobs]\ndepth = \"exact\"\n",
encoding="utf-8",
)
data = run(skill, env_overrides={"BMAD_HOME": g})
self.assertEqual(data["knobs"]["depth"], "exact")
def test_layer_precedence_overrides_specificity(self):
# Across layers: higher layer wins even with less-specific pattern.
# Project custom user (highest customize layer) uses '*';
# global (lowest) uses exact — project custom should still win.
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g:
project = Path(t) / "project"
bmad = project / "_bmad"
(bmad / "custom").mkdir(parents=True)
skill = make_skill(project, "bmad-prd", '[knobs]\ndepth = "low"\n')
(Path(g) / "customize.toml").write_text(
"[skills.bmad-prd.knobs]\ndepth = \"global-exact\"\n",
encoding="utf-8",
)
(bmad / "custom" / "customize.user.toml").write_text(
"[skills.\"*\".knobs]\ndepth = \"custom-wildcard\"\n",
encoding="utf-8",
)
data = run(skill, env_overrides={"BMAD_HOME": g})
self.assertEqual(data["knobs"]["depth"], "custom-wildcard")
def test_per_skill_custom_file_beats_customize_section(self):
# Per-skill _bmad/custom/{skill}.user.toml is still the highest tier.
with tempfile.TemporaryDirectory() as t:
project = Path(t) / "project"
bmad = project / "_bmad"
(bmad / "custom").mkdir(parents=True)
skill = make_skill(project, "bmad-prd", '[knobs]\ndepth = "low"\n')
(bmad / "custom" / "customize.user.toml").write_text(
"[skills.bmad-prd.knobs]\ndepth = \"via-section\"\n",
encoding="utf-8",
)
(bmad / "custom" / "bmad-prd.user.toml").write_text(
"[knobs]\ndepth = \"via-per-skill-file\"\n",
encoding="utf-8",
)
data = run(skill)
self.assertEqual(data["knobs"]["depth"], "via-per-skill-file")
def test_qualified_module_pattern_matches(self):
# Skill under bmm-skills/ → qualified name 'bmm/bmad-prd'.
# Pattern '[skills."bmm/*"]' should match.
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g:
skill = make_skill(
Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n', module="bmm"
)
(Path(g) / "customize.toml").write_text(
"[skills.\"bmm/*\".knobs]\ndepth = \"all-bmm\"\n",
encoding="utf-8",
)
data = run(skill, env_overrides={"BMAD_HOME": g})
self.assertEqual(data["knobs"]["depth"], "all-bmm")
def test_qualified_exact_beats_module_wildcard(self):
# Within one layer: 'bmm/bmad-prd' (exact) beats 'bmm/*' (wildcard).
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g:
skill = make_skill(
Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n', module="bmm"
)
(Path(g) / "customize.toml").write_text(
"[skills.\"bmm/*\".knobs]\ndepth = \"module-wide\"\n"
"[skills.\"bmm/bmad-prd\".knobs]\ndepth = \"pinned\"\n",
encoding="utf-8",
)
data = run(skill, env_overrides={"BMAD_HOME": g})
self.assertEqual(data["knobs"]["depth"], "pinned")
if __name__ == "__main__":
unittest.main()

View File

@ -1728,7 +1728,7 @@ async function runTests() {
console.log('');
// ============================================================
// Test Suite 35: Central Config Emission
// Test Suite 35: Central Config Emission (lean overrides + global routing)
// ============================================================
console.log(`${colors.yellow}Test Suite 35: Central Config Emission${colors.reset}\n`);
@ -1737,6 +1737,12 @@ async function runTests() {
// getModulePath). Only the destination bmadDir is a temp dir, which the
// installer writes config.toml / config.user.toml / custom/ into.
const tempBmadDir35 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-central-config-'));
// Phase 1: writeCentralConfig now writes scope:user core values to
// ~/.bmad/config.user.toml. Isolate via BMAD_HOME so we don't pollute
// the developer's real global config.
const tempGlobalDir35 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-global-'));
const priorBmadHome35 = process.env.BMAD_HOME;
process.env.BMAD_HOME = tempGlobalDir35;
try {
const moduleConfigs = {
@ -1799,43 +1805,54 @@ async function runTests() {
assert(await fs.pathExists(teamPath), 'config.toml is written to disk');
assert(await fs.pathExists(userPath), 'config.user.toml is written to disk');
// generateManifests would call writeGlobalUserCore right after this; we
// call it directly here to test the full contract.
const globalCorePath = await generator35.writeGlobalUserCore(moduleConfigs);
assert(globalCorePath !== null, 'writeGlobalUserCore writes a file when scope:user values exist');
assert(globalCorePath.startsWith(tempGlobalDir35), 'writeGlobalUserCore targets $BMAD_HOME');
assert(await fs.pathExists(globalCorePath), '~/.bmad/config.user.toml is written to disk');
const teamContent = await fs.readFile(teamPath, 'utf8');
const userContent = await fs.readFile(userPath, 'utf8');
const globalContent = await fs.readFile(globalCorePath, 'utf8');
// [core] — team-scoped keys land in config.toml
assert(teamContent.includes('[core]'), 'config.toml has [core] section');
assert(teamContent.includes('document_output_language = "English"'), 'Team-scope core key lands in config.toml');
assert(teamContent.includes('output_folder = "_bmad-output"'), 'Team-scope output_folder lands in config.toml');
assert(teamContent.includes('project_name = "demo-project"'), 'project_name lands in [core] (core key as of #2279)');
// [core] — lean: only deltas from module.yaml defaults remain.
// project_name = "demo-project" differs from default ({directory_name}).
// document_output_language = "English" EQUALS default — stripped.
// output_folder = "_bmad-output" EQUALS default — stripped.
assert(teamContent.includes('[core]'), 'config.toml has [core] section (carries project_name delta)');
assert(teamContent.includes('project_name = "demo-project"'), 'Delta core key (project_name) lands in config.toml');
assert(!teamContent.includes('document_output_language'), 'Default core values are stripped from config.toml (Task F)');
assert(!teamContent.includes('user_name'), 'user_name (scope: user) is absent from config.toml');
assert(!teamContent.includes('communication_language'), 'communication_language (scope: user) is absent from config.toml');
// [core] — user-scoped keys land in config.user.toml
assert(userContent.includes('[core]'), 'config.user.toml has [core] section');
assert(userContent.includes('user_name = "TestUser"'), 'user_name lands in config.user.toml');
assert(userContent.includes('communication_language = "Spanish"'), 'communication_language lands in config.user.toml');
assert(!userContent.includes('document_output_language'), 'Team-scope key is absent from config.user.toml');
// [core] user-scope no longer goes to project user file — it's routed
// to ~/.bmad/config.user.toml (Task D).
assert(!userContent.includes('user_name'), 'config.user.toml does NOT contain user_name (Task D: routed to global)');
assert(
!userContent.includes('communication_language'),
'config.user.toml does NOT contain communication_language (Task D: routed to global)',
);
// [modules.bmm] — core-key pollution stripped; own user-scope key routed to user file
const bmmTeamMatch = teamContent.match(/\[modules\.bmm\][\s\S]*?(?=\n\[|$)/);
assert(bmmTeamMatch !== null, 'config.toml has [modules.bmm] section');
if (bmmTeamMatch) {
const bmmTeamBlock = bmmTeamMatch[0];
assert(bmmTeamBlock.includes('planning_artifacts'), 'bmm-owned team-scope key (planning_artifacts) lands under [modules.bmm]');
assert(!bmmTeamBlock.includes('project_name'), 'project_name stripped from [modules.bmm] (now a core key, #2279)');
assert(!bmmTeamBlock.includes('stale-bmm-copy'), 'stale bmm-copy of project_name not leaked into config.toml');
assert(!bmmTeamBlock.includes('user_name'), 'user_name stripped from [modules.bmm] (core-key pollution)');
assert(!bmmTeamBlock.includes('communication_language'), 'communication_language stripped from [modules.bmm]');
assert(!bmmTeamBlock.includes('user_skill_level'), 'user_skill_level (scope: user) absent from [modules.bmm] in config.toml');
}
// Global file at ~/.bmad/config.user.toml carries the scope:user core values
assert(globalContent.includes('[core]'), '~/.bmad/config.user.toml has [core] section');
assert(globalContent.includes('user_name = "TestUser"'), 'user_name lands in ~/.bmad/config.user.toml');
assert(globalContent.includes('communication_language = "Spanish"'), 'communication_language lands in ~/.bmad/config.user.toml');
// [modules.bmm] — bmm answers in this fixture are ALL defaults (the test
// intentionally seeds the same values module.yaml defaults to). With
// Task F, an all-defaults section is omitted entirely.
// user_skill_level = "expert" IS a delta from default "intermediate"
// → that one key remains in [modules.bmm] in config.user.toml.
assert(!teamContent.includes('[modules.bmm]'), 'config.toml has NO [modules.bmm] section when all values equal defaults (Task F)');
const bmmUserMatch = userContent.match(/\[modules\.bmm\][\s\S]*?(?=\n\[|$)/);
assert(bmmUserMatch !== null, 'config.user.toml has [modules.bmm] section');
assert(bmmUserMatch !== null, 'config.user.toml has [modules.bmm] section (carries the user_skill_level delta)');
if (bmmUserMatch) {
assert(bmmUserMatch[0].includes('user_skill_level = "expert"'), 'user_skill_level lands in config.user.toml [modules.bmm]');
assert(bmmUserMatch[0].includes('user_skill_level = "expert"'), 'user_skill_level delta lands in config.user.toml');
}
// [modules.external-mod] — unknown schema, falls through as team; core keys still stripped
// [modules.external-mod] — unknown schema, no defaults to compare. Falls
// through as team; core-key pollution still stripped.
const extMatch = teamContent.match(/\[modules\.external-mod\][\s\S]*?(?=\n\[|$)/);
assert(extMatch !== null, 'Unknown-schema module survives with its own [modules.*] section');
if (extMatch) {
@ -1845,20 +1862,54 @@ async function runTests() {
assert(!extBlock.includes('communication_language'), 'All core-key pollution stripped from unknown-schema module');
}
// [agents.*] — agent roster from bmm module.yaml baked into config.toml (team-only)
assert(teamContent.includes('[agents.bmad-agent-analyst]'), 'config.toml has [agents.bmad-agent-analyst] table');
assert(teamContent.includes('[agents.bmad-agent-dev]'), 'config.toml has [agents.bmad-agent-dev] table');
assert(teamContent.includes('module = "bmm"'), 'Agent entry serializes module field');
assert(teamContent.includes('team = "software-development"'), 'Agent entry serializes team field');
assert(teamContent.includes('name = "Mary"'), 'Agent entry serializes name');
assert(teamContent.includes('icon = "📊"'), 'Agent entry serializes icon');
// [agents.*] — Task F: NEVER emitted to config.toml. Roster lives in
// _bmad/{module}/module.toml floor (written by writeModuleTomls).
assert(!teamContent.includes('[agents.'), 'config.toml has NO [agents.*] sections (Task F)');
assert(!userContent.includes('[agents.'), '[agents.*] tables are never written to config.user.toml');
// Header comments present on both files
// Header comments present on both project files
assert(teamContent.includes('Installer-managed. Regenerated on every install'), 'config.toml has installer-managed header');
assert(userContent.includes('Holds install answers scoped to YOU personally.'), 'config.user.toml header clarifies user scope');
assert(globalContent.includes('Global personal BMad config'), '~/.bmad/config.user.toml has global-personal header');
// writeGlobalUserCore must preserve hand-edits in shapes the old
// round-trip parser silently dropped — arrays, single-quoted strings,
// dotted/quoted keys, \uXXXX escapes, custom sections.
const handEdited = [
'# user-authored — should survive installer rewrites',
'[core]',
'user_name = "WillBeOverwritten"',
'nickname = "blank-on-purpose"',
'',
'[custom_section]',
"literal = 'single-quoted string'",
'tags = ["a", "b", "c"]',
String.raw`unicode = "Märy"`,
'"weird.key" = "quoted-dotted key"',
'',
].join('\n');
await fs.writeFile(globalCorePath, handEdited, 'utf8');
await generator35.writeGlobalUserCore({ core: { user_name: 'Updated', communication_language: 'Italian' } });
const afterReplay = await fs.readFile(globalCorePath, 'utf8');
assert(afterReplay.includes('user_name = "Updated"'), 'writeGlobalUserCore updates the key we own');
assert(afterReplay.includes('communication_language = "Italian"'), 'writeGlobalUserCore writes new scope:user key');
assert(afterReplay.includes("literal = 'single-quoted string'"), 'writeGlobalUserCore preserves single-quoted string values');
assert(afterReplay.includes('tags = ["a", "b", "c"]'), 'writeGlobalUserCore preserves array values');
assert(afterReplay.includes(String.raw`unicode = "Märy"`), String.raw`writeGlobalUserCore preserves \uXXXX escapes verbatim`);
assert(afterReplay.includes('"weird.key" = "quoted-dotted key"'), 'writeGlobalUserCore preserves quoted/dotted keys');
assert(afterReplay.includes('[custom_section]'), 'writeGlobalUserCore preserves user-authored sections');
assert(
afterReplay.includes('nickname = "blank-on-purpose"'),
'writeGlobalUserCore preserves hand-written keys outside the installer schema',
);
} finally {
await fs.remove(tempBmadDir35).catch(() => {});
await fs.remove(tempGlobalDir35).catch(() => {});
if (priorBmadHome35 === undefined) {
delete process.env.BMAD_HOME;
} else {
process.env.BMAD_HOME = priorBmadHome35;
}
}
}
@ -1901,66 +1952,56 @@ async function runTests() {
console.log('');
// ============================================================
// Test Suite 37: Agent Preservation for Non-Contributing Modules
// Test Suite 37: Agent roster routes through module.toml, not config.toml
// ============================================================
console.log(`${colors.yellow}Test Suite 37: Agent Preservation for Non-Contributing Modules${colors.reset}\n`);
console.log(`${colors.yellow}Test Suite 37: Agent roster routes through module.toml${colors.reset}\n`);
{
// Scenario: quickUpdate preserves a module whose source isn't available
// (e.g. external/marketplace). Its module.yaml isn't read, so its agents
// aren't in this.agents. writeCentralConfig must read the prior config.toml
// and keep those [agents.*] blocks so the roster doesn't silently shrink.
const tempBmadDir37 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-agent-preserve-'));
// Phase 1 (Task F): writeCentralConfig no longer emits [agents.*] blocks.
// The roster lives in _bmad/{module}/module.toml (the resolver floor).
// This suite verifies the relocation — same data, different home.
//
// Preservation across reinstalls (the OLD concern of this suite) is now
// a non-issue: writeModuleTomls always reads the source module.yaml at
// install time and rewrites module.toml from scratch. There's no "stale
// entry" to preserve because the file gets fully regenerated.
const tempBmadDir37 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-agents-floor-'));
const tempGlobalDir37 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-global-37-'));
const priorBmadHome37 = process.env.BMAD_HOME;
process.env.BMAD_HOME = tempGlobalDir37;
try {
// Seed a prior config.toml with an agent from an external module
const priorToml = [
'# prior',
'',
'[agents.bmad-agent-analyst]',
'module = "bmm"',
'team = "bmm"',
'name = "Stale Mary"',
'',
'[agents.external-hero]',
'module = "external-mod"',
'team = "external-mod"',
'name = "Hero"',
'title = "External Agent"',
'icon = "🦸"',
'description = "Ships with the marketplace module."',
'',
].join('\n');
await fs.writeFile(path.join(tempBmadDir37, 'config.toml'), priorToml);
// Set up bmm/ directory so writeModuleTomls has a place to write
await fs.ensureDir(path.join(tempBmadDir37, 'bmm'));
const generator37 = new ManifestGenerator();
generator37.bmadDir = tempBmadDir37;
generator37.bmadFolderName = path.basename(tempBmadDir37);
generator37.updatedModules = ['core', 'bmm', 'external-mod'];
generator37.updatedModules = ['core', 'bmm'];
// bmm source is available; external-mod is not — it's a preserved module
await generator37.collectAgentsFromModuleYaml();
const freshModules = new Set(generator37.agents.map((a) => a.module));
assert(freshModules.has('bmm'), 'bmm contributes fresh agents from src module.yaml');
assert(!freshModules.has('external-mod'), 'external-mod source is unavailable (preserved-module scenario)');
await generator37.writeCentralConfig(tempBmadDir37, { core: {}, bmm: {}, 'external-mod': {} });
await generator37.writeCentralConfig(tempBmadDir37, { core: {}, bmm: {} });
await generator37.writeModuleTomls(tempBmadDir37);
const teamContent = await fs.readFile(path.join(tempBmadDir37, 'config.toml'), 'utf8');
const bmmTomlContent = await fs.readFile(path.join(tempBmadDir37, 'bmm', 'module.toml'), 'utf8');
assert(
teamContent.includes('[agents.external-hero]'),
'Preserved [agents.external-hero] block survives rewrite even though external-mod source was unavailable',
);
assert(teamContent.includes('Ships with the marketplace module.'), 'Preserved block keeps its original description');
assert(teamContent.includes('module = "external-mod"'), 'Preserved block keeps its module field');
// Task F: config.toml carries no [agents.*] sections
assert(!teamContent.includes('[agents.'), 'config.toml has no [agents.*] sections (Task F)');
// Freshly collected agents win over stale entries with the same code
const maryMatches = teamContent.match(/\[agents\.bmad-agent-analyst\]/g) || [];
assert(maryMatches.length === 1, 'bmad-agent-analyst emitted exactly once (fresh wins; stale not duplicated)');
assert(!teamContent.includes('Stale Mary'), 'Stale name from prior config.toml is discarded when fresh module.yaml is read');
// The roster lives in module.toml instead
assert(bmmTomlContent.includes('[agents.bmad-agent-analyst]'), 'bmm/module.toml carries [agents.bmad-agent-analyst]');
assert(bmmTomlContent.includes('[agents.bmad-agent-dev]'), 'bmm/module.toml carries [agents.bmad-agent-dev]');
assert(bmmTomlContent.includes('module = "bmm"'), 'Agent block in module.toml has module field');
assert(bmmTomlContent.includes('name = "Mary"'), 'Agent block in module.toml has name');
} finally {
await fs.remove(tempBmadDir37).catch(() => {});
await fs.remove(tempGlobalDir37).catch(() => {});
if (priorBmadHome37 === undefined) {
delete process.env.BMAD_HOME;
} else {
process.env.BMAD_HOME = priorBmadHome37;
}
}
}
@ -2041,17 +2082,46 @@ async function runTests() {
assert(byCode.get('bmad-fake-ext-agent-one').module === 'fake-ext', 'agent.module matches the owning external module name');
assert(byCode.get('bmad-fake-ext-agent-one').team === 'fake', 'explicit team from module.yaml is preserved');
await generator38.writeCentralConfig(tempBmadDir38, {
core: {},
bmm: {},
'fake-ext': {},
'fake-skills': {},
});
// Set up per-module dirs so writeModuleTomls has somewhere to write
await fs.ensureDir(path.join(tempBmadDir38, 'fake-ext'));
await fs.ensureDir(path.join(tempBmadDir38, 'fake-skills'));
const teamContent = await fs.readFile(path.join(tempBmadDir38, 'config.toml'), 'utf8');
assert(teamContent.includes('[agents.bmad-fake-ext-agent-one]'), 'external-module agents land in config.toml [agents.*] section');
assert(teamContent.includes('[agents.bmad-fake-skills-agent]'), 'skills-layout external module agents also land in config.toml');
assert(teamContent.includes('First fake external agent.'), 'agent description from external module.yaml is written');
// Isolate BMAD_HOME for the global-config write step
const tempGlobalDir38 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-global-38-'));
const priorBmadHome38 = process.env.BMAD_HOME;
process.env.BMAD_HOME = tempGlobalDir38;
try {
await generator38.writeCentralConfig(tempBmadDir38, {
core: {},
bmm: {},
'fake-ext': {},
'fake-skills': {},
});
await generator38.writeModuleTomls(tempBmadDir38);
const teamContent = await fs.readFile(path.join(tempBmadDir38, 'config.toml'), 'utf8');
// Task F: agents NEVER land in config.toml anymore
assert(!teamContent.includes('[agents.'), 'External module agents are NOT in config.toml (Task F)');
// Instead, each external module gets its own _bmad/{module}/module.toml floor
const fakeExtToml = await fs.readFile(path.join(tempBmadDir38, 'fake-ext', 'module.toml'), 'utf8');
const fakeSkillsToml = await fs.readFile(path.join(tempBmadDir38, 'fake-skills', 'module.toml'), 'utf8');
assert(fakeExtToml.includes('[agents.bmad-fake-ext-agent-one]'), 'external-module agents land in their own module.toml floor');
assert(
fakeSkillsToml.includes('[agents.bmad-fake-skills-agent]'),
'skills-layout external module agents land in their own module.toml floor',
);
assert(fakeExtToml.includes('First fake external agent.'), 'agent description from external module.yaml lands in module.toml');
} finally {
await fs.remove(tempGlobalDir38).catch(() => {});
if (priorBmadHome38 === undefined) {
delete process.env.BMAD_HOME;
} else {
process.env.BMAD_HOME = priorBmadHome38;
}
}
} finally {
if (priorCacheEnv === undefined) {
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
@ -3023,6 +3093,12 @@ async function runTests() {
// Test Suite 44: --set <module>.<key>=<value> CLI overrides (#1663)
// ============================================================
console.log(`${colors.yellow}Test Suite 44: --set CLI overrides${colors.reset}\n`);
// Isolate $BMAD_HOME for the whole suite — applySetOverrides now consults
// ~/.bmad/config.user.toml for routing decisions, and we don't want tests
// touching the developer's actual global identity.
const tempGlobalDir44 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-global-'));
const priorBmadHome44 = process.env.BMAD_HOME;
process.env.BMAD_HOME = tempGlobalDir44;
try {
const { parseSetEntry, parseSetEntries, applySetOverrides, upsertTomlKey, tomlString } = require('../tools/installer/set-overrides');
const { discoverOfficialModuleYamls, formatOptionsList } = require('../tools/installer/list-options');
@ -3081,6 +3157,17 @@ async function runTests() {
assert(tomlString(String.raw`back\slash`) === String.raw`"back\\slash"`, 'tomlString escapes backslashes');
assert(tomlString('line1\nline2') === String.raw`"line1\nline2"`, 'tomlString escapes newlines');
// ---- tomlString: type inference (--set bmm.workers=4 must be int) ----
assert(tomlString('true') === 'true', 'tomlString emits bare bool for "true"');
assert(tomlString('false') === 'false', 'tomlString emits bare bool for "false"');
assert(tomlString('4') === '4', 'tomlString emits bare integer for digit string');
assert(tomlString('-17') === '-17', 'tomlString emits bare integer for negative');
assert(tomlString('3.14') === '3.14', 'tomlString emits bare float for decimal');
assert(tomlString('-0.5') === '-0.5', 'tomlString emits bare float for negative decimal');
assert(tomlString('1e10') === '"1e10"', 'tomlString quotes scientific notation (not in inferred set)');
assert(tomlString('4.') === '"4."', 'tomlString quotes incomplete float (preserves as string)');
assert(tomlString('"true"') === String.raw`"\"true\""`, 'tomlString preserves explicitly-quoted "true" as string');
// ---- upsertTomlKey: insert into existing section ---------------------
{
const before = `[core]\nuser_name = "Brian"\n\n[modules.bmm]\nproject_knowledge = "{project-root}/docs"\n`;
@ -3122,6 +3209,26 @@ async function runTests() {
assert(!withoutTrailing.endsWith('\n'), 'upsertTomlKey preserves absence of trailing newline');
}
// ---- upsertTomlKey: `#` inside string value is NOT a comment ---------
// Per TOML spec, basic strings may contain unescaped `#`. The previous
// /\s+#/ scanner truncated such values, producing malformed TOML.
{
const before = `[core]\nproject_name = "hello # world"\n`;
const after = upsertTomlKey(before, '[core]', 'project_name', '"updated # value"');
assert(after.includes('project_name = "updated # value"'), 'upsertTomlKey writes value containing # intact');
assert(!after.includes('# world'), 'upsertTomlKey does not preserve a fake comment that lived inside the old string');
}
// ---- upsertTomlKey: section header with inline comment is found -----
{
const before = `[core] # personal identity\nuser_name = "old"\n`;
const after = upsertTomlKey(before, '[core]', 'user_name', '"Brian"');
assert(after.includes('user_name = "Brian"'), 'upsertTomlKey updates key under header with inline comment');
// Should not have appended a duplicate [core] block at EOF.
const headerOccurrences = (after.match(/^\[core]/gm) || []).length;
assert(headerOccurrences === 1, `upsertTomlKey reuses existing [core] header (got ${headerOccurrences} headers)`);
}
// ---- applySetOverrides happy path ------------------------------------
{
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-'));
@ -3187,15 +3294,24 @@ async function runTests() {
await fs.writeFile(path.join(bmadDir, 'config.toml'), '[core]\nuser_name = "Brian"\n', 'utf8');
await fs.ensureDir(path.join(bmadDir, 'core'));
await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'user_name: Brian\n', 'utf8');
// Override targets a key only in team config; routes to team. user.toml
// never gets created in this case (correct — no user-scope writes).
// Override targets a core scope:user key. Per Task D's 3-tier routing,
// core.user_name lands in ~/.bmad/config.user.toml regardless of whether
// project user.toml exists — that's where the resolver picks it up.
// Project files (team and user) are left untouched.
await applySetOverrides({ core: { user_name: 'Updated' } }, bmadDir);
const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8');
assert(team.includes('user_name = "Updated"'), 'applySetOverrides updates team key when user.toml is absent');
const globalContent = await fs.readFile(path.join(tempGlobalDir44, 'config.user.toml'), 'utf8');
assert(
globalContent.includes('user_name = "Updated"'),
'applySetOverrides routes core.user_name to global when project user.toml is absent',
);
assert(!team.includes('user_name = "Updated"'), 'applySetOverrides does not write core scope:user keys to project team config');
assert(
!(await fs.pathExists(path.join(bmadDir, 'config.user.toml'))),
'applySetOverrides does not create config.user.toml unnecessarily',
'applySetOverrides does not create project config.user.toml for scope:user core keys',
);
// Reset global file for the next test block.
await fs.remove(path.join(tempGlobalDir44, 'config.user.toml')).catch(() => {});
await fs.remove(tmp).catch(() => {});
}
@ -3209,11 +3325,17 @@ async function runTests() {
await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'user_name: Brian\n', 'utf8');
// bmm is not installed (no `_bmad/bmm/config.yaml`). The override for
// bmm should be silently skipped, no `[modules.bmm]` section created.
// core.user_name is scope:user → routed to global (~/.bmad).
const applied = await applySetOverrides({ bmm: { foo: 'bar' }, core: { user_name: 'Updated' } }, bmadDir);
const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8');
const globalContent = await fs.readFile(path.join(tempGlobalDir44, 'config.user.toml'), 'utf8');
assert(!team.includes('[modules.bmm]'), 'applySetOverrides does NOT create section for uninstalled module');
assert(team.includes('user_name = "Updated"'), 'applySetOverrides still applies overrides for installed modules');
assert(
globalContent.includes('user_name = "Updated"'),
'applySetOverrides still applies overrides for installed modules (routed to global for scope:user core)',
);
assert(applied.length === 1 && applied[0].module === 'core', 'applySetOverrides reports only the installed-module entries');
await fs.remove(path.join(tempGlobalDir44, 'config.user.toml')).catch(() => {});
await fs.remove(tmp).catch(() => {});
}
@ -3269,6 +3391,264 @@ async function runTests() {
console.log(`${colors.red}Test Suite 44 setup failed: ${error.message}${colors.reset}`);
console.log(error.stack);
failed++;
} finally {
if (priorBmadHome44 === undefined) {
delete process.env.BMAD_HOME;
} else {
process.env.BMAD_HOME = priorBmadHome44;
}
await fs.remove(tempGlobalDir44).catch(() => {});
}
console.log('');
// ============================================================
// Test Suite 45: Per-module module.toml floor (writeModuleTomls)
// ============================================================
console.log(`${colors.yellow}Test Suite 45: Per-module module.toml floor${colors.reset}\n`);
try {
const tempBmadDir45 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-module-toml-'));
try {
// Real bmm src tree provides module.yaml. Installer expects each
// installed module directory to exist before writeModuleTomls runs.
await fs.ensureDir(path.join(tempBmadDir45, 'bmm'));
await fs.ensureDir(path.join(tempBmadDir45, 'core'));
const generator45 = new ManifestGenerator();
generator45.bmadDir = tempBmadDir45;
generator45.bmadFolderName = path.basename(tempBmadDir45);
generator45.updatedModules = ['core', 'bmm'];
// Collect agents (needed for [agents.X] blocks per module)
await generator45.collectAgentsFromModuleYaml();
assert(generator45.agents.length >= 6, 'agents collected before writeModuleTomls');
const written = await generator45.writeModuleTomls(tempBmadDir45);
assert(Array.isArray(written), 'writeModuleTomls returns array of written paths');
assert(written.length > 0, 'writeModuleTomls writes at least one module.toml');
const bmmTomlPath = path.join(tempBmadDir45, 'bmm', 'module.toml');
assert(await fs.pathExists(bmmTomlPath), 'bmm/module.toml is written to disk');
const bmmContent = await fs.readFile(bmmTomlPath, 'utf8');
// Header comment present
assert(bmmContent.includes('Module-shipped defaults'), 'module.toml has header comment');
assert(bmmContent.includes('Build artifact'), 'module.toml warns against hand-editing');
// [modules.bmm] section with defaults from module.yaml
assert(bmmContent.includes('[modules.bmm]'), 'bmm/module.toml has [modules.bmm] section');
assert(/planning_artifacts\s*=\s*"[^"]+"/.test(bmmContent), '[modules.bmm] carries planning_artifacts default');
// {project-root} preserved literal (runtime substitution); {output_folder}
// resolved at install time against core's default output_folder. Final
// shape matches the legacy config.yaml convention.
assert(
bmmContent.includes('{project-root}/_bmad-output/planning-artifacts'),
'Cross-key placeholders resolved at install time ({output_folder} → "_bmad-output"); {project-root} preserved',
);
assert(!bmmContent.includes('{output_folder}'), '{output_folder} placeholder is NOT left literal — resolved against module defaults');
// [agents.X] blocks for module-owned agents
assert(bmmContent.includes('[agents.bmad-agent-analyst]'), 'bmm/module.toml has [agents.bmad-agent-analyst]');
assert(bmmContent.includes('[agents.bmad-agent-dev]'), 'bmm/module.toml has [agents.bmad-agent-dev]');
assert(bmmContent.includes('module = "bmm"'), 'Agent block carries module field');
assert(bmmContent.includes('name = "Mary"'), 'Agent block carries name');
assert(bmmContent.includes('icon = "📊"'), 'Agent block carries emoji icon');
// module.toml is SCOPED to one module — bmm should not carry core's agents
// (core/module.yaml ships no agents today, but the principle stands)
const coreTomlPath = path.join(tempBmadDir45, 'core', 'module.toml');
assert(await fs.pathExists(coreTomlPath), 'core/module.toml is written even without agents');
const coreContent = await fs.readFile(coreTomlPath, 'utf8');
// bmm agents must NOT appear in core/module.toml
assert(!coreContent.includes('[agents.bmad-agent-analyst]'), 'bmm-owned agents do NOT leak into core/module.toml');
// [core] questions (user_name, project_name, etc.) are core's defaults
// and must live at top-level [core] — resolvers + consumers read core.*
// from that namespace, not from a nested [modules.core] subtree.
assert(coreContent.includes('[core]'), 'core/module.toml has top-level [core] section');
assert(!coreContent.includes('[modules.core]'), 'core defaults do NOT live under [modules.core]');
assert(coreContent.includes('user_name = "BMad"'), 'core/module.toml carries user_name default ("BMad" from module.yaml)');
assert(coreContent.includes('document_output_language = "English"'), 'core/module.toml carries document_output_language default');
// Question UI machinery NEVER appears as a TOML key in module.toml
// (Match on line-start "key =" so we don't catch the word "prompt" inside
// a description string — e.g. "Speaks like a terminal prompt".)
assert(!/^prompt\s*=/m.test(bmmContent), 'No prompt key in module.toml');
assert(!/^scope\s*=/m.test(bmmContent), 'No scope key in module.toml');
assert(!bmmContent.includes('single-select'), 'No single-select machinery in module.toml');
} finally {
await fs.remove(tempBmadDir45).catch(() => {});
}
} catch (error) {
console.log(`${colors.red}Test Suite 45 setup failed: ${error.message}${colors.reset}`);
console.log(error.stack);
failed++;
}
console.log('');
// ============================================================
// Test Suite 46: Phase 1 end-to-end — fresh machine + second project
// ============================================================
console.log(`${colors.yellow}Test Suite 46: Phase 1 end-to-end (fresh machine + second project)${colors.reset}\n`);
try {
const tempGlobalDir46 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-e2e-global-'));
const priorBmadHome46 = process.env.BMAD_HOME;
process.env.BMAD_HOME = tempGlobalDir46;
try {
// ────────────────────────────────────────────────────────────
// Part A: Fresh-machine install, project #1
// ────────────────────────────────────────────────────────────
const project1 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-e2e-proj1-'));
const bmad1 = path.join(project1, '_bmad');
await fs.ensureDir(path.join(bmad1, 'core'));
await fs.ensureDir(path.join(bmad1, 'bmm'));
// Simulated installer output for project #1: user kept defaults for
// every core key EXCEPT project_name, which is per-project.
const project1Configs = {
core: {
user_name: 'Brian',
project_name: 'first-project',
communication_language: 'English',
document_output_language: 'English',
output_folder: '{project-root}/_bmad-output',
},
bmm: {
user_skill_level: 'intermediate',
planning_artifacts: '{project-root}/_bmad-output/planning-artifacts',
implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts',
project_knowledge: '{project-root}/docs',
},
};
const gen1 = new ManifestGenerator();
gen1.bmadDir = bmad1;
gen1.bmadFolderName = path.basename(bmad1);
gen1.updatedModules = ['core', 'bmm'];
await gen1.collectAgentsFromModuleYaml();
await gen1.writeCentralConfig(bmad1, project1Configs);
await gen1.writeModuleTomls(bmad1);
await gen1.writeGlobalUserCore(project1Configs);
// Project #1: config.toml is lean — only project_name (the one delta)
const team1 = await fs.readFile(path.join(bmad1, 'config.toml'), 'utf8');
assert(team1.includes('project_name = "first-project"'), 'P1 config.toml carries project_name delta');
assert(!team1.includes('user_name'), 'P1 config.toml has no user_name (scope:user routed to global)');
assert(!team1.includes('document_output_language'), 'P1 config.toml strips default document_output_language');
assert(!team1.includes('[agents.'), 'P1 config.toml has NO [agents.*] sections');
assert(!team1.includes('[modules.bmm]'), 'P1 config.toml has NO [modules.bmm] section (all defaults)');
// Project #1: config.user.toml is just the header
const user1 = await fs.readFile(path.join(bmad1, 'config.user.toml'), 'utf8');
assert(!user1.includes('user_name'), 'P1 config.user.toml has no user_name');
assert(!user1.includes('[modules.bmm]'), 'P1 config.user.toml has no [modules.bmm]');
// Global file populated with scope:user core values from project #1
const globalPath = path.join(tempGlobalDir46, 'config.user.toml');
assert(await fs.pathExists(globalPath), 'Global ~/.bmad/config.user.toml created on first install');
const global1 = await fs.readFile(globalPath, 'utf8');
assert(global1.includes('user_name = "Brian"'), 'Global identity carries Brian after P1');
assert(global1.includes('communication_language = "English"'), 'Global identity carries language after P1');
// module.toml floor: cross-key placeholders resolved at install time
const bmm1 = await fs.readFile(path.join(bmad1, 'bmm', 'module.toml'), 'utf8');
assert(
bmm1.includes('planning_artifacts = "{project-root}/_bmad-output/planning-artifacts"'),
'P1 bmm/module.toml has cross-key-resolved planning_artifacts',
);
assert(bmm1.includes('[agents.bmad-agent-analyst]'), 'P1 bmm/module.toml carries agent roster');
// ────────────────────────────────────────────────────────────
// Part B: Second project install on the same machine
// - Global identity from P1 should be picked up by the OfficialModules
// loader (loadGlobalConfig) as a silent default-source for scope:user
// core questions (D) and a seed for non-user-scope ones (E).
// - Resolver chain at runtime: global.core.user_name → reaches skills
// even though P2 never wrote it locally.
// ────────────────────────────────────────────────────────────
const { OfficialModules } = require('../tools/installer/modules/official-modules');
const project2 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-e2e-proj2-'));
const bmad2 = path.join(project2, '_bmad');
await fs.ensureDir(bmad2);
const om = new OfficialModules();
// Simulate what collectAllConfigurations does on entry: load global.
om.globalConfig = await require('../tools/installer/global-config').loadGlobalConfig();
assert(om.globalConfig.merged.core, 'OfficialModules.loadGlobalConfig returns populated [core]');
assert(om.globalConfig.merged.core.user_name === 'Brian', 'Global config from P1 visible to OfficialModules on P2 install');
assert(om.globalConfig.merged.core.communication_language === 'English', 'Global identity round-trips through TOML correctly');
// ────────────────────────────────────────────────────────────
// Part C: Python resolver sees identity via global even when P2's
// project _bmad has no [core] user_name. Validates the full layered
// resolver chain end-to-end.
// ────────────────────────────────────────────────────────────
// Write a minimal P2 install (just project_name in config.toml)
const gen2 = new ManifestGenerator();
gen2.bmadDir = bmad2;
gen2.bmadFolderName = path.basename(bmad2);
gen2.updatedModules = ['core', 'bmm'];
await fs.ensureDir(path.join(bmad2, 'core'));
await fs.ensureDir(path.join(bmad2, 'bmm'));
const project2Configs = {
core: {
user_name: 'Brian', // Reused from global silently (D) — would normally bypass write
project_name: 'second-project',
communication_language: 'English',
document_output_language: 'English',
output_folder: '{project-root}/_bmad-output',
},
bmm: {
user_skill_level: 'intermediate',
planning_artifacts: '{project-root}/_bmad-output/planning-artifacts',
implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts',
project_knowledge: '{project-root}/docs',
},
};
await gen2.collectAgentsFromModuleYaml();
await gen2.writeCentralConfig(bmad2, project2Configs);
await gen2.writeModuleTomls(bmad2);
// Note: do NOT call writeGlobalUserCore again — values would be a no-op
// merge, but in the real install they'd silently skip.
const { execSync } = require('node:child_process');
const resolverPath = path.resolve(__dirname, '..', 'src/scripts/resolve_config.py');
const resolved = JSON.parse(
execSync(
`python3 ${resolverPath} --project-root ${project2} ` +
`--key core.user_name --key core.project_name --key agents.bmad-agent-analyst.name`,
{ encoding: 'utf8', env: { ...process.env, BMAD_HOME: tempGlobalDir46 } },
),
);
assert(resolved['core.user_name'] === 'Brian', 'Resolver returns user_name from global layer (P2 lean config.toml omitted it)');
assert(
resolved['core.project_name'] === 'second-project',
'Resolver returns project_name from P2 config.toml (project layer beats global)',
);
assert(resolved['agents.bmad-agent-analyst.name'] === 'Mary', 'Resolver returns agent name from P2 module.toml floor');
await fs.remove(project1).catch(() => {});
await fs.remove(project2).catch(() => {});
} finally {
await fs.remove(tempGlobalDir46).catch(() => {});
if (priorBmadHome46 === undefined) {
delete process.env.BMAD_HOME;
} else {
process.env.BMAD_HOME = priorBmadHome46;
}
}
} catch (error) {
console.log(`${colors.red}Test Suite 46 setup failed: ${error.message}${colors.reset}`);
console.log(error.stack);
failed++;
}
console.log('');

View File

@ -3,6 +3,8 @@ const fs = require('../fs-native');
const yaml = require('yaml');
const crypto = require('node:crypto');
const { resolveInstalledModuleYaml } = require('../project-root');
const { globalUserConfigPath, loadGlobalConfig } = require('../global-config');
const { upsertTomlKey } = require('../set-overrides');
const prompts = require('../prompts');
// Load package.json for version info
@ -82,11 +84,20 @@ class ManifestGenerator {
// Write manifest files and collect their paths
const [teamConfigPath, userConfigPath] = await this.writeCentralConfig(bmadDir, options.moduleConfigs || {});
// Per-module module.toml floor — shipped defaults + agent roster, read
// by resolve_config.py as the lowest-priority layer. Independent of the
// central config.toml; remains stable across user customizations.
const moduleTomlPaths = await this.writeModuleTomls(bmadDir);
// Task D: route scope:user core answers to ~/.bmad/config.user.toml.
// Identity persists across projects on this machine, so re-installs in
// other directories don't re-prompt for the same name/language.
await this.writeGlobalUserCore(options.moduleConfigs || {});
const manifestFiles = [
await this.writeMainManifest(cfgDir),
await this.writeSkillManifest(cfgDir),
teamConfigPath,
userConfigPath,
...moduleTomlPaths,
await this.writeFilesManifest(cfgDir),
];
@ -419,25 +430,47 @@ class ManifestGenerator {
}
/**
* Write central _bmad/config.toml with [core], [modules.<code>], [agents.<code>] tables.
* Install-owned. Team-scope answers → config.toml; user-scope answers → config.user.toml.
* Both files are regenerated on every install. User overrides live in
* _bmad/custom/config.toml and _bmad/custom/config.user.toml (never touched by installer).
* Write central _bmad/config.toml as a LEAN OVERRIDE FILE — only values
* the user actually changed from defaults land here. Defaults flow through
* the module.toml floor (written by writeModuleTomls) and the global
* config layer (~/.bmad/...).
*
* Specifically (Phase 1 — tasks D + F):
* - [core] team-scope: emit only keys whose value differs from BOTH the
* module.yaml default AND the global value (if any). Identity that
* equals the global wins via the resolver chain regardless.
* - [core] user-scope (user_name, communication_language): NEVER written
* here. Routed to ~/.bmad/config.user.toml via writeGlobalUserCore.
* - [modules.X]: emit only keys whose value differs from the module's
* processed default. Skip the section entirely if all keys are default.
* - [agents.X]: NEVER emitted. The roster lives in module.toml floor;
* custom agents go in _bmad/custom/config.toml (never touched by us).
*
* Install-owned: both files are regenerated on every install. User
* overrides live in _bmad/custom/config.toml and _bmad/custom/config.user.toml
* (never touched by installer).
*
* @returns {string[]} Paths to the written config files
*/
async writeCentralConfig(bmadDir, moduleConfigs) {
const teamPath = path.join(bmadDir, 'config.toml');
const userPath = path.join(bmadDir, 'config.user.toml');
// Load each module's source module.yaml to determine scope per prompt key.
// Default scope is 'team' when the prompt doesn't declare one.
// When a module.yaml is unreadable we warn — for known official modules
// this means user-scoped keys (e.g. user_name) could mis-file into the
// team config, so the operator should notice.
// Load each module's source module.yaml to determine:
// 1. scope per prompt key (team vs user)
// 2. the canonical module code (for [modules.{code}] section names)
// 3. processed defaults per key (for delta detection)
//
// Pass 1: parse every module.yaml and capture its raw shipped defaults.
// We use those — NOT the user's answered moduleConfigs — to resolve
// cross-key placeholders like `{output_folder}`. Otherwise a user override
// of output_folder would make every derived default (e.g. planning_artifacts)
// match the user's value and get stripped from config.toml as "default",
// even though module.toml's floor still carries the shipped path.
const scopeByModuleKey = {};
// Maps installer moduleName (may be full display name) → module code field
// from module.yaml, so TOML sections use [modules.<code>] not [modules.<name>].
const codeByModuleName = {};
const defaultsByModuleKey = {};
const parsedByModule = {};
for (const moduleName of this.updatedModules) {
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
if (!moduleYamlPath) {
@ -450,13 +483,8 @@ class ManifestGenerator {
try {
const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
if (!parsed || typeof parsed !== 'object') continue;
parsedByModule[moduleName] = parsed;
if (parsed.code) codeByModuleName[moduleName] = parsed.code;
scopeByModuleKey[moduleName] = {};
for (const [key, value] of Object.entries(parsed)) {
if (value && typeof value === 'object' && 'prompt' in value) {
scopeByModuleKey[moduleName][key] = value.scope === 'user' ? 'user' : 'team';
}
}
} catch (error) {
console.warn(
`[warn] writeCentralConfig: could not parse module.yaml for '${moduleName}' (${error.message}). ` +
@ -465,6 +493,41 @@ class ManifestGenerator {
}
}
// Build the cross-key defaults map (same logic writeModuleTomls uses).
// Shipped defaults only — never user answers.
const crossKeyDefaults = {};
for (const parsed of Object.values(parsedByModule)) {
const raw = extractModuleDefaults(parsed);
for (const [key, value] of Object.entries(raw)) {
if (crossKeyDefaults[key] !== undefined) continue;
let stripped = value;
if (typeof stripped === 'string' && stripped.startsWith('{project-root}/')) {
stripped = stripped.slice('{project-root}/'.length);
}
crossKeyDefaults[key] = stripped;
}
}
// Pass 2: compute scopes and processed defaults using the symmetric map.
for (const [moduleName, parsed] of Object.entries(parsedByModule)) {
scopeByModuleKey[moduleName] = {};
defaultsByModuleKey[moduleName] = {};
for (const [key, value] of Object.entries(parsed)) {
if (!value || typeof value !== 'object' || !('prompt' in value)) continue;
scopeByModuleKey[moduleName][key] = value.scope === 'user' ? 'user' : 'team';
const processedDefault = computeProcessedDefault(value, crossKeyDefaults);
if (processedDefault !== undefined) {
defaultsByModuleKey[moduleName][key] = processedDefault;
}
}
}
// Load the global config snapshot for [core] delta detection. If a key's
// current value equals the global value, no need to duplicate it into the
// project file — the resolver finds it globally.
const globalSnapshot = await loadGlobalConfig().catch(() => ({ merged: {} }));
const globalCore = (globalSnapshot.merged && globalSnapshot.merged.core) || {};
// Core keys are always known (core module.yaml is built-in). These are
// the only keys allowed in [core]; they must be stripped from every
// non-core module bucket because legacy _bmad/{mod}/config.yaml files
@ -494,6 +557,23 @@ class ManifestGenerator {
return { team, user };
};
// Drop entries whose value equals an already-known default. Tasks F + D
// both want config.toml to be a *delta* file — anything that matches
// either the module.yaml default or the global config gets resolved
// through the layer chain at read time, so writing it here is dead weight.
const stripDefaults = (entries, perKeyDefaults = {}, fallbackDefaults = {}) => {
const result = {};
for (const [key, value] of Object.entries(entries)) {
const moduleDefault = perKeyDefaults[key];
const fallbackDefault = fallbackDefaults[key];
const isDefault =
(moduleDefault !== undefined && deepEqualScalar(moduleDefault, value)) ||
(fallbackDefault !== undefined && deepEqualScalar(fallbackDefault, value));
if (!isDefault) result[key] = value;
}
return result;
};
const teamHeader = [
'# ─────────────────────────────────────────────────────────────────',
'# Installer-managed. Regenerated on every install — treat as read-only.',
@ -526,9 +606,10 @@ class ManifestGenerator {
const teamLines = [...teamHeader];
const userLines = [...userHeader];
// [core] — split into team and user
// [core] team — emit only deltas from module.yaml default AND global value.
const coreConfig = moduleConfigs.core || {};
const { team: coreTeam, user: coreUser } = partition('core', coreConfig);
const { team: coreTeamRaw } = partition('core', coreConfig);
const coreTeam = stripDefaults(coreTeamRaw, defaultsByModuleKey.core || {}, globalCore);
if (Object.keys(coreTeam).length > 0) {
teamLines.push('[core]');
for (const [key, value] of Object.entries(coreTeam)) {
@ -536,28 +617,24 @@ class ManifestGenerator {
}
teamLines.push('');
}
if (Object.keys(coreUser).length > 0) {
userLines.push('[core]');
for (const [key, value] of Object.entries(coreUser)) {
userLines.push(`${key} = ${formatTomlValue(value)}`);
}
userLines.push('');
}
// [core] user-scope: never written to the project user.toml. Task D routes
// these to ~/.bmad/config.user.toml via writeGlobalUserCore (called by
// generateManifests after this method returns). config.user.toml stays
// empty unless the user has manually pinned a per-project override.
// [modules.<code>] — split per module
// [modules.<code>] — emit only deltas; skip section if no deltas.
for (const moduleName of this.updatedModules) {
if (moduleName === 'core') continue;
const cfg = moduleConfigs[moduleName];
if (!cfg || Object.keys(cfg).length === 0) continue;
// Use the module's code field from module.yaml as the TOML key so the
// section is [modules.mdo] not [modules.MDO: Maxio DevOps Operations].
const sectionKey = codeByModuleName[moduleName] || moduleName;
// Only filter out spread-from-core pollution when we actually know
// this module's prompt schema. For external/marketplace modules whose
// module.yaml isn't in the src tree, fall through as all-team so we
// don't drop their real answers.
const haveSchema = Object.keys(scopeByModuleKey[moduleName] || {}).length > 0;
const { team: modTeam, user: modUser } = partition(moduleName, cfg, haveSchema);
const { team: modTeamRaw, user: modUserRaw } = partition(moduleName, cfg, haveSchema);
const moduleDefaults = defaultsByModuleKey[moduleName] || {};
const modTeam = stripDefaults(modTeamRaw, moduleDefaults);
const modUser = stripDefaults(modUserRaw, moduleDefaults);
if (Object.keys(modTeam).length > 0) {
teamLines.push(`[modules.${sectionKey}]`);
for (const [key, value] of Object.entries(modTeam)) {
@ -574,43 +651,10 @@ class ManifestGenerator {
}
}
// [agents.<code>] — always team (agent roster is organizational).
// Freshly collected agents come from module.yaml this run. If a module
// was preserved (e.g. during quickUpdate when its source isn't available),
// its module.yaml wasn't read — so its agents aren't in `this.agents` and
// would silently disappear from the roster. Preserve those existing
// [agents.*] blocks verbatim from the prior config.toml.
const freshAgentCodes = new Set(this.agents.map((a) => a.code));
const contributingModules = new Set(this.agents.map((a) => a.module));
const preservedModules = this.updatedModules.filter((m) => !contributingModules.has(m));
const preservedBlocks = [];
if (preservedModules.length > 0 && (await fs.pathExists(teamPath))) {
try {
const prev = await fs.readFile(teamPath, 'utf8');
for (const block of extractAgentBlocks(prev)) {
if (freshAgentCodes.has(block.code)) continue;
if (block.module && preservedModules.includes(block.module)) {
preservedBlocks.push(block.body);
}
}
} catch (error) {
console.warn(`[warn] writeCentralConfig: could not read prior config.toml to preserve agents: ${error.message}`);
}
}
for (const agent of this.agents) {
const agentLines = [`[agents.${agent.code}]`, `module = ${formatTomlValue(agent.module)}`, `team = ${formatTomlValue(agent.team)}`];
if (agent.name) agentLines.push(`name = ${formatTomlValue(agent.name)}`);
if (agent.title) agentLines.push(`title = ${formatTomlValue(agent.title)}`);
if (agent.icon) agentLines.push(`icon = ${formatTomlValue(agent.icon)}`);
if (agent.description) agentLines.push(`description = ${formatTomlValue(agent.description)}`);
agentLines.push('');
teamLines.push(...agentLines);
}
for (const body of preservedBlocks) {
teamLines.push(body, '');
}
// [agents.<code>] — intentionally NOT emitted (Task F). The roster lives
// in the per-module module.toml floor. Users who want to override or
// add agents per-project edit _bmad/custom/config.toml; that file is
// never touched by the installer.
const teamContent = teamLines.join('\n').replace(/\n+$/, '\n');
const userContent = userLines.join('\n').replace(/\n+$/, '\n');
@ -619,6 +663,198 @@ class ManifestGenerator {
return [teamPath, userPath];
}
/**
* Write scope:user core values to ~/.bmad/config.user.toml (Task D).
* Merge-preserves any existing global content the user hand-edited.
*
* Why a global write step at all? Identity values (user_name,
* communication_language) are properties of the human, not the project.
* Asking them at every install is friction. Phase 1 stores them globally
* so they're answered once per machine.
*
* @param {object} moduleConfigs - the fully-resolved moduleConfigs map
* @returns {Promise<string|null>} the path written, or null if no values
*/
async writeGlobalUserCore(moduleConfigs) {
const coreScopes = {};
// We need core's module.yaml scope map. Build it lazily here so this
// method is callable without re-doing the writeCentralConfig setup.
const coreYamlPath = await resolveInstalledModuleYaml('core');
if (!coreYamlPath) return null;
try {
const parsed = yaml.parse(await fs.readFile(coreYamlPath, 'utf8'));
if (parsed && typeof parsed === 'object') {
for (const [key, value] of Object.entries(parsed)) {
if (value && typeof value === 'object' && 'prompt' in value) {
coreScopes[key] = value.scope === 'user' ? 'user' : 'team';
}
}
}
} catch {
return null;
}
const userScopeValues = {};
for (const [key, value] of Object.entries(moduleConfigs.core || {})) {
if (coreScopes[key] === 'user' && value !== undefined && value !== null && value !== '') {
userScopeValues[key] = value;
}
}
if (Object.keys(userScopeValues).length === 0) return null;
const globalPath = globalUserConfigPath();
await fs.ensureDir(path.dirname(globalPath));
// Line-surgery upsert into the existing file (or a fresh one with the
// installer header). We only touch the [core] keys we own. Every other
// section, comment, and value passes through byte-for-byte — including
// shapes the previous round-trip parser quietly dropped (arrays,
// single-quoted strings, dotted/quoted keys, \uXXXX escapes, etc.).
let content;
if (await fs.pathExists(globalPath)) {
content = await fs.readFile(globalPath, 'utf8');
} else {
content =
[
'# ─────────────────────────────────────────────────────────────────',
'# Global personal BMad config — values tied to YOU as a user, not',
'# any specific project. Installer writes scope:user identity here',
'# (user_name, communication_language) so re-installs across projects',
"# don't re-ask the same questions.",
'#',
'# Location precedence: $BMAD_HOME if set, else ~/.bmad',
'# Resolver tier: lower than project-level _bmad/*.toml.',
'# ─────────────────────────────────────────────────────────────────',
'',
].join('\n') + '\n';
}
for (const [key, value] of Object.entries(userScopeValues)) {
content = upsertTomlKey(content, '[core]', key, formatTomlValue(value));
}
await fs.writeFile(globalPath, content);
return globalPath;
}
/**
* Write per-module `_bmad/{module}/module.toml` files — the "module floor"
* read by resolve_config.py as the lowest-priority layer.
*
* Each file contains the shipped defaults for one module:
* [modules.{code}] paths and other module-shape values (from
* module.yaml question defaults, with `result:`
* template applied + cross-key placeholders like
* `{output_folder}` resolved against other modules'
* defaults at install time)
* [agents.{agent-code}] one block per agent owned by this module
*
* `{project-root}` is preserved literally — runtime substitution by skills.
* Other cross-key references resolve against module.yaml DEFAULTS only,
* not the user's actual answers. This keeps module.toml stable as a "what
* the module ships" snapshot independent of per-project customization.
* User overrides land in _bmad/config.toml above the floor.
*
* Source of truth is the authored module.yaml. This file is a build artifact
* — regenerated on every install, never hand-edited.
*
* @param {string} bmadDir
* @returns {Promise<string[]>} Paths to all written module.toml files
*/
async writeModuleTomls(bmadDir) {
// Pass 1: parse every installed module.yaml, extract its raw defaults
// (just `{value}` substituted; cross-key placeholders left literal).
const moduleData = [];
for (const moduleName of this.updatedModules) {
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
if (!moduleYamlPath) continue;
let moduleDef;
try {
moduleDef = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
} catch (error) {
console.warn(
`[warn] writeModuleTomls: could not parse module.yaml for '${moduleName}' (${error.message}). ` +
`Skipping module.toml for this module.`,
);
continue;
}
if (!moduleDef || typeof moduleDef !== 'object') continue;
const moduleDir = path.join(bmadDir, moduleName);
if (!(await fs.pathExists(moduleDir))) continue;
const moduleCode = typeof moduleDef.code === 'string' ? moduleDef.code : moduleName;
const rawDefaults = extractModuleDefaults(moduleDef);
moduleData.push({ moduleName, moduleCode, moduleDir, rawDefaults });
}
// Build a flat cross-key lookup map from every module's raw defaults.
// First-define wins (deterministic given sorted updatedModules input).
// Values are stripped of a leading `{project-root}/` so substitutions
// re-compose cleanly when consumed in a `{project-root}/{key}/...` slot
// — matches the installer's processResultTemplate convention.
const crossKeyMap = {};
for (const { rawDefaults } of moduleData) {
for (const [key, value] of Object.entries(rawDefaults)) {
if (crossKeyMap[key] !== undefined) continue;
let stripped = value;
if (typeof stripped === 'string' && stripped.startsWith('{project-root}/')) {
stripped = stripped.slice('{project-root}/'.length);
}
crossKeyMap[key] = stripped;
}
}
// Pass 2: resolve cross-key placeholders in each module's defaults and
// write the final module.toml file.
const written = [];
for (const { moduleName, moduleCode, moduleDir, rawDefaults } of moduleData) {
const resolvedDefaults = {};
for (const [key, value] of Object.entries(rawDefaults)) {
resolvedDefaults[key] = resolveCrossKeyPlaceholders(value, crossKeyMap);
}
const lines = [
'# Module-shipped defaults. Build artifact — do not edit by hand.',
"# Source: this module's module.yaml (authored at source).",
'# Regenerated on every install.',
'#',
'# Read by _bmad/scripts/resolve_config.py as the lowest-priority',
'# floor of the config layer chain. Project _bmad/config.toml and',
'# user overrides in _bmad/custom/ sit above this and win.',
'',
];
if (Object.keys(resolvedDefaults).length > 0) {
// Core's defaults belong under top-level [core] — that's where
// writeCentralConfig emits core deltas and where resolve_config.py
// consumers read core.* from. Everything else gets the per-module
// [modules.<code>] namespace.
const sectionHeader = moduleCode === 'core' ? '[core]' : `[modules.${moduleCode}]`;
lines.push(sectionHeader);
for (const [key, value] of Object.entries(resolvedDefaults)) {
lines.push(`${key} = ${formatTomlValue(value)}`);
}
lines.push('');
}
const moduleAgents = this.agents.filter((a) => a.module === moduleName);
for (const agent of moduleAgents) {
lines.push(`[agents.${agent.code}]`, `module = ${formatTomlValue(agent.module)}`, `team = ${formatTomlValue(agent.team)}`);
if (agent.name) lines.push(`name = ${formatTomlValue(agent.name)}`);
if (agent.title) lines.push(`title = ${formatTomlValue(agent.title)}`);
if (agent.icon) lines.push(`icon = ${formatTomlValue(agent.icon)}`);
if (agent.description) lines.push(`description = ${formatTomlValue(agent.description)}`);
lines.push('');
}
const outputPath = path.join(moduleDir, 'module.toml');
const content = lines.join('\n').replace(/\n+$/, '\n');
await fs.writeFile(outputPath, content);
written.push(outputPath);
}
return written;
}
/**
* Create empty _bmad/custom/config.toml and _bmad/custom/config.user.toml stubs
* on first install only. Installer never touches these files again after creation.
@ -806,6 +1042,130 @@ class ManifestGenerator {
* Handles strings (quoted + escaped), booleans, numbers, and arrays of scalars.
* Objects are not expected at this emit path.
*/
/**
* Compute the processed default value for a module.yaml question item.
* Resolves `{key}` cross-references against the flat `crossKeyDefaults` lookup
* (shipped defaults, never user answers — see writeCentralConfig comment).
* Used by writeCentralConfig to detect default-equal values that should NOT
* be re-emitted into the lean config.toml. Matches the lookup table that
* writeModuleTomls uses, so module.toml's floor and config.toml's delta
* detection agree on what "default" means.
*
* Steps:
* 1. Substitute {key} references against crossKeyDefaults (with leading
* "{project-root}/" stripped, matching the installer's
* processResultTemplate behavior).
* 2. Apply the result: template with {value} substituted.
*
* Returns undefined for items without a default.
*
* @param {object} item - one module.yaml question schema
* @param {Record<string, *>} crossKeyDefaults - flat shipped-defaults lookup
* @returns {*} processed default value (string/scalar) or undefined
*/
function computeProcessedDefault(item, crossKeyDefaults) {
if (!item || item.default === undefined || item.default === null) return;
let value = item.default;
if (typeof value === 'string') {
value = value.replaceAll(/{([^}]+)}/g, (match, refKey) => {
if (refKey === 'project-root' || refKey === 'value' || refKey === 'directory_name') {
return match;
}
const replacement = (crossKeyDefaults || {})[refKey];
return replacement === undefined ? match : String(replacement);
});
}
if (typeof item.result === 'string' && value !== undefined) {
return item.result.replaceAll('{value}', String(value));
}
return value;
}
/**
* Resolve `{key}` cross-references in a string value against a flat
* `{key: value}` lookup map. `{project-root}` and `{directory_name}` are
* preserved literal — they're runtime placeholders, substituted by the skill
* or resolver when the value is consumed. Unknown keys are left literal too.
*
* Used by writeModuleTomls so that module.toml's [modules.X] keys carry the
* same shape as the installer's resolved config (e.g.
* `"{project-root}/_bmad-output/planning-artifacts"`) — making the floor a
* drop-in for the central config when the latter omits a value as default.
*
* @param {*} value - typically a string; non-strings returned unchanged
* @param {Record<string, *>} crossKeyMap
* @returns {*}
*/
function resolveCrossKeyPlaceholders(value, crossKeyMap) {
if (typeof value !== 'string') return value;
return value.replaceAll(/{([^}]+)}/g, (match, key) => {
if (key === 'project-root' || key === 'directory_name' || key === 'value') {
return match;
}
const replacement = crossKeyMap[key];
return replacement === undefined ? match : String(replacement);
});
}
/**
* Scalar / shallow equality for delta detection. Handles strings, numbers,
* booleans, and arrays of scalars (the only shapes module.yaml defaults
* produce). Different types compare unequal.
*/
function deepEqualScalar(a, b) {
if (a === b) return true;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((item, i) => deepEqualScalar(item, b[i]));
}
return false;
}
/**
* Module.yaml top-level keys that are metadata, not question knobs. These
* never appear in a module's [modules.{code}] floor section.
*/
const MODULE_DEFAULTS_SKIP = new Set([
'code',
'name',
'description',
'default_selected',
'header',
'subheader',
'agents',
'directories',
'dependencies',
'prompt',
]);
/**
* Extract shipped defaults from a parsed module.yaml. For each question-style
* key (object with a `default` field), capture the default and apply its
* `result:` template with `{value}` substituted. Cross-key placeholders like
* `{output_folder}` are left as literal strings — see writeModuleTomls() doc
* for why and how that's handled in phase 1.
*
* @param {object} moduleDef - parsed module.yaml content
* @returns {Record<string, string|number|boolean|Array<*>>}
*/
function extractModuleDefaults(moduleDef) {
const defaults = {};
for (const [key, value] of Object.entries(moduleDef)) {
if (MODULE_DEFAULTS_SKIP.has(key)) continue;
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
if (!('default' in value)) continue;
let resolved = value.default;
if (typeof value.result === 'string' && resolved !== undefined) {
// Apply `result:` template with `{value}` substituted by default. Leave
// other placeholders (`{project-root}`, `{output_folder}`, ...) literal.
resolved = value.result.replaceAll('{value}', String(resolved));
}
defaults[key] = resolved;
}
return defaults;
}
function formatTomlValue(value) {
if (value === null || value === undefined) return '""';
if (typeof value === 'boolean') return value ? 'true' : 'false';
@ -821,39 +1181,4 @@ function formatTomlValue(value) {
return `"${escaped}"`;
}
/**
* Extract [agents.<code>] blocks from a previously-emitted config.toml.
* We only need this for roster preservation — the file is our own controlled
* output, so a simple line scanner is safer than adding a TOML parser
* dependency. Each block runs from its `[agents.<code>]` header until the
* next `[` heading or EOF; the `module = "..."` line inside drives which
* entries we keep on the next write.
* @returns {Array<{code: string, module: string | null, body: string}>}
*/
function extractAgentBlocks(tomlContent) {
const blocks = [];
const lines = tomlContent.split('\n');
let i = 0;
while (i < lines.length) {
const header = lines[i].match(/^\[agents\.([^\]]+)]\s*$/);
if (!header) {
i++;
continue;
}
const code = header[1];
const blockLines = [lines[i]];
let moduleName = null;
i++;
while (i < lines.length && !lines[i].startsWith('[')) {
blockLines.push(lines[i]);
const m = lines[i].match(/^module\s*=\s*"((?:[^"\\]|\\.)*)"\s*$/);
if (m) moduleName = m[1];
i++;
}
while (blockLines.length > 1 && blockLines.at(-1) === '') blockLines.pop();
blocks.push({ code, module: moduleName, body: blockLines.join('\n') });
}
return blocks;
}
module.exports = { ManifestGenerator };

View File

@ -0,0 +1,180 @@
/**
* Helpers for the cross-platform global BMad config directory.
*
* The "global" tier is read-only to most installer code paths — only core
* scope:user answers (user_name, communication_language) and identity defaults
* are written there, and only by the post-install global-write step. Everything
* else reads.
*
* Location precedence:
* 1. $BMAD_HOME (for CI / corporate / multi-account setups)
* 2. ~/.bmad
*
* Works on macOS, Linux, WSL, and Windows: os.homedir() returns the
* platform-appropriate home (and on WSL, the Linux home — each WSL distro has
* its own global config).
*/
const path = require('node:path');
const os = require('node:os');
const fs = require('./fs-native');
function resolveGlobalDir() {
const override = process.env.BMAD_HOME;
if (override && override.trim()) {
return path.resolve(expandTilde(override.trim()));
}
return path.join(os.homedir(), '.bmad');
}
// JS counterpart to Python's Path.expanduser() — keeps installer/resolver
// agreement when BMAD_HOME is set in non-shell contexts (Docker, .env files,
// Windows env var GUI) where the shell never expands `~`.
function expandTilde(input) {
if (input === '~') return os.homedir();
if (input.startsWith('~/') || input.startsWith('~\\')) {
return path.join(os.homedir(), input.slice(2));
}
return input;
}
function globalTeamConfigPath() {
return path.join(resolveGlobalDir(), 'config.toml');
}
function globalUserConfigPath() {
return path.join(resolveGlobalDir(), 'config.user.toml');
}
/**
* Parse a minimal subset of TOML — enough for the installer-owned files:
* top-level tables ([section] / [section.sub]) and simple scalar values
* (string, number, boolean). No arrays of tables, inline tables, datetimes,
* or multiline strings — those don't appear in files we author. Reader stays
* dependency-free; we only consume what we emit.
*
* For an unrecognized shape, the offending line is silently dropped (rather
* than erroring) to keep the installer resilient against hand-edits that
* went slightly outside the documented schema.
*/
function parseSimpleToml(content) {
const result = {};
let currentTable = result;
for (const rawLine of content.split('\n')) {
const line = stripInlineComment(rawLine).trim();
if (!line) continue;
const sectionMatch = line.match(/^\[([^\]]+)]\s*$/);
if (sectionMatch) {
const parts = sectionMatch[1].split('.').map((p) => p.trim());
currentTable = result;
for (const part of parts) {
if (!currentTable[part] || typeof currentTable[part] !== 'object' || Array.isArray(currentTable[part])) {
currentTable[part] = {};
}
currentTable = currentTable[part];
}
continue;
}
const kvMatch = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/);
if (kvMatch) {
const [, key, rawValue] = kvMatch;
const parsed = parseTomlScalar(rawValue.trim());
if (parsed !== undefined) {
currentTable[key] = parsed;
}
}
}
return result;
}
/**
* Strip a trailing `# comment` from a TOML line, but only when the `#` lives
* outside a double-quoted string. We don't author multiline strings or
* literal strings, so a single double-quote scanner is sufficient.
*/
function stripInlineComment(line) {
let inString = false;
let escaped = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (ch === '#' && !inString) {
return line.slice(0, i);
}
}
return line;
}
function parseTomlScalar(raw) {
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
// Single-pass unescape — sequential replaceAll lets `\\n` (backslash + n)
// collapse into a newline because the second pass sees the just-produced
// `\n` and treats it as the escape sequence. One regex avoids that.
const escapes = { '\\\\': '\\', '\\"': '"', '\\n': '\n', '\\r': '\r', '\\t': '\t' };
return raw.slice(1, -1).replaceAll(/\\["\\nrt]/g, (m) => escapes[m] ?? m);
}
if (raw === 'true') return true;
if (raw === 'false') return false;
if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw);
return; // dropped silently — see header comment
}
/**
* Load both global TOML files. Either may be missing; returns merged result.
* Files are read but never written by this helper.
*
* @returns {Promise<{ team: object, user: object, merged: object }>}
*/
async function loadGlobalConfig() {
const team = await readTomlFile(globalTeamConfigPath());
const user = await readTomlFile(globalUserConfigPath());
// Shallow-deep merge: user table wins over team at every key path. The
// installer only consults the merged view for default-seeding, so this is
// sufficient (we don't need the full structural-merge of resolve_config.py).
const merged = mergeDeep(team, user);
return { team, user, merged };
}
async function readTomlFile(filePath) {
if (!(await fs.pathExists(filePath))) return {};
try {
const content = await fs.readFile(filePath, 'utf8');
return parseSimpleToml(content);
} catch {
return {};
}
}
function mergeDeep(base, override) {
if (!override || typeof override !== 'object' || Array.isArray(override)) return override === undefined ? base : override;
if (!base || typeof base !== 'object' || Array.isArray(base)) return override;
const result = { ...base };
for (const [key, value] of Object.entries(override)) {
result[key] = mergeDeep(result[key], value);
}
return result;
}
module.exports = {
resolveGlobalDir,
globalTeamConfigPath,
globalUserConfigPath,
parseSimpleToml,
loadGlobalConfig,
};

View File

@ -5,6 +5,7 @@ const prompts = require('../prompts');
const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
const { CLIUtils } = require('../cli-utils');
const { ExternalModuleManager } = require('./external-manager');
const { loadGlobalConfig } = require('../global-config');
class OfficialModules {
constructor(options = {}) {
@ -1019,11 +1020,26 @@ class OfficialModules {
* @param {string} projectDir - Target project directory
* @param {Object} options - Additional options
* @param {boolean} options.skipPrompts - Skip prompts and use defaults (for --yes flag)
*
* Non-core modules are always silent: they accept module.yaml defaults
* without prompting. Users adjust values later via the `bmad-customize`
* skill or direct edits to _bmad/custom/config.toml / customize.toml.
* Core is still fully prompted (identity questions live there) — though
* scope:user core values that are already in ~/.bmad/config.user.toml
* are silently reused (see below).
*/
async collectAllConfigurations(modules, projectDir, options = {}) {
this.skipPrompts = options.skipPrompts || false;
this.modulesToCustomize = undefined;
await this.loadExistingConfig(projectDir);
// Read the cross-platform global config (~/.bmad or $BMAD_HOME). Used to:
// - Skip core scope:user questions whose value is already known globally
// (task D — ask identity once per machine, not once per project).
// - Seed defaults for non-user-scope core questions (task E — let users
// pre-pin default project_name/output_folder/etc. globally if they want).
// Project-installed values still beat global at resolve time; this is
// purely about what we ASK during install.
this.globalConfig = await loadGlobalConfig();
// Check if core was already collected (e.g., in early collection phase)
const coreAlreadyCollected = this.collectedConfig.core && Object.keys(this.collectedConfig.core).length > 0;
@ -1045,44 +1061,17 @@ class OfficialModules {
await this.collectModuleConfig(moduleName, projectDir);
}
// Show batch configuration gateway for non-core modules
// Scan all non-core module schemas for display names and config metadata
// Non-core modules are always silent: accept module.yaml defaults without
// prompting. Users adjust via `bmad-customize` later or by editing
// _bmad/custom/{config,customize}.toml. Setting modulesToCustomize to an
// empty Set bypasses the legacy per-module "Accept Defaults?" confirm
// (collectModuleConfig only fires that when modulesToCustomize is
// undefined). scanModuleSchemas is still called so the spinner can show
// friendly display names while applying defaults.
let scannedModules = [];
if (!this.skipPrompts && nonCoreModules.length > 0) {
this.modulesToCustomize = new Set();
scannedModules = await this.scanModuleSchemas(nonCoreModules);
const customizableModules = scannedModules.filter((m) => m.questionCount > 0);
if (customizableModules.length > 0) {
const configMode = await prompts.select({
message: 'Module configuration',
choices: [
{ name: 'Express Setup', value: 'express', hint: 'accept all defaults (recommended)' },
{ name: 'Customize', value: 'customize', hint: 'choose modules to configure' },
],
default: 'express',
});
if (configMode === 'customize') {
const choices = customizableModules.map((m) => ({
name: `${m.displayName} (${m.questionCount} option${m.questionCount === 1 ? '' : 's'})`,
value: m.moduleName,
hint: m.hasFieldsWithoutDefaults ? 'has fields without defaults' : undefined,
checked: m.hasFieldsWithoutDefaults,
}));
const selected = await prompts.multiselect({
message: 'Select modules to customize:',
choices,
required: false,
});
this.modulesToCustomize = new Set(selected);
} else {
// Express mode: no modules to customize
this.modulesToCustomize = new Set();
}
} else {
// All non-core modules have zero config - no gateway needed
this.modulesToCustomize = new Set();
}
}
// Collect remaining non-core modules
@ -1155,6 +1144,18 @@ class OfficialModules {
if (!this._existingConfig) {
await this.loadExistingConfig(projectDir);
}
// Lazy-load global config so identity fallbacks below can consult
// ~/.bmad/config.user.toml. quickUpdate doesn't go through
// collectAllConfigurations, so this.globalConfig would otherwise be unset
// and user_name would silently default to the OS username — overwriting
// the value the user previously committed to global.
if (!this.globalConfig) {
try {
this.globalConfig = await loadGlobalConfig();
} catch {
this.globalConfig = { merged: {} };
}
}
// Initialize allAnswers if not already initialized
if (!this.allAnswers) {
@ -1231,12 +1232,14 @@ class OfficialModules {
}
this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] };
// Special handling for user_name: ensure it has a value
// Special handling for user_name: ensure it has a value. Prefer the
// global value (~/.bmad/config.user.toml) before the OS username, or
// we'll silently overwrite the user's prior global identity.
if (
moduleName === 'core' &&
(!this.collectedConfig[moduleName].user_name || this.collectedConfig[moduleName].user_name === '[USER_NAME]')
) {
this.collectedConfig[moduleName].user_name = this.getDefaultUsername();
this.collectedConfig[moduleName].user_name = this._identityFallback('user_name');
}
// Also populate allAnswers for cross-referencing
@ -1244,18 +1247,20 @@ class OfficialModules {
// Ensure user_name is properly set in allAnswers too
let finalValue = value;
if (moduleName === 'core' && key === 'user_name' && (!value || value === '[USER_NAME]')) {
finalValue = this.getDefaultUsername();
finalValue = this._identityFallback('user_name');
}
this.allAnswers[`${moduleName}_${key}`] = finalValue;
}
} else if (moduleName === 'core') {
// No existing core config - ensure we at least have user_name
// No existing core config - ensure we at least have user_name.
// Same global-first preference as above.
if (!this.collectedConfig[moduleName]) {
this.collectedConfig[moduleName] = {};
}
if (!this.collectedConfig[moduleName].user_name) {
this.collectedConfig[moduleName].user_name = this.getDefaultUsername();
this.allAnswers[`${moduleName}_user_name`] = this.getDefaultUsername();
const fallback = this._identityFallback('user_name');
this.collectedConfig[moduleName].user_name = fallback;
this.allAnswers[`${moduleName}_user_name`] = fallback;
}
}
@ -1433,6 +1438,22 @@ class OfficialModules {
return result;
}
/**
* Fall back through identity sources for a core scope:user key. Prefers the
* global value (~/.bmad/config.user.toml) so quickUpdate / re-install never
* silently overwrites a previously-set identity with the OS username.
* Only user_name has an OS-derived ultimate fallback; other keys return
* undefined so the caller can decide.
*/
_identityFallback(key) {
const globalCore = (this.globalConfig && this.globalConfig.merged && this.globalConfig.merged.core) || {};
if (globalCore[key] !== undefined && globalCore[key] !== '' && globalCore[key] !== null) {
return globalCore[key];
}
if (key === 'user_name') return this.getDefaultUsername();
return;
}
/**
* Collect configuration for a single module
* @param {string} moduleName - Module name
@ -1507,6 +1528,79 @@ class OfficialModules {
}
}
// Tasks D + E: for the core module, consult the global config (~/.bmad).
// D — scope:user keys (user_name, communication_language): if already
// known globally, accept silently. Don't prompt; store the final
// form directly in collectedConfig so cross-references still work.
// E — non-user-scope keys (project_name, output_folder, ...): if known
// globally, seed the prompt default from there. User can still
// change it per-project.
// Track silently-reused keys so the user knows where the values came from
// (otherwise they'd see questions they previously answered just disappear).
const reusedFromGlobal = [];
const seededFromGlobal = [];
if (moduleName === 'core' && this.globalConfig && this.globalConfig.merged && this.globalConfig.merged.core) {
const globalCore = this.globalConfig.merged.core;
const remaining = [];
for (const question of questions) {
const key = question.name.replace(`${moduleName}_`, '');
const item = moduleConfig[key];
const globalValue = globalCore[key];
if (globalValue === undefined || globalValue === null || globalValue === '') {
remaining.push(question);
continue;
}
if (item && item.scope === 'user') {
// D: silent reuse. Stash final form into collectedConfig and skip
// adding this question to the prompt list. The post-answer
// result-template loop never sees this key.
this.collectedConfig[moduleName] = this.collectedConfig[moduleName] || {};
this.collectedConfig[moduleName][key] = globalValue;
reusedFromGlobal.push({ key, value: globalValue });
continue;
}
// E: pre-seed the prompt default. Strip {project-root}/ for clean
// display — the result: template will add it back after the user
// accepts or edits.
question.default = this.cleanPromptValue(globalValue);
seededFromGlobal.push({ key, value: question.default });
remaining.push(question);
}
questions.length = 0;
questions.push(...remaining);
}
// Tell the user when global values are in play. Silent reuse (D) is the
// important one — otherwise a question that fired on a prior install
// would just vanish, leaving the user wondering what happened. Seeded
// defaults (E) get a softer mention since the user still sees the value
// in the prompt.
if (!this.skipPrompts && (reusedFromGlobal.length > 0 || seededFromGlobal.length > 0)) {
const { globalUserConfigPath } = require('../global-config');
const globalPath = globalUserConfigPath();
const lines = [];
if (reusedFromGlobal.length > 0) {
lines.push('Using values from your global BMad config:');
for (const { key, value } of reusedFromGlobal) {
lines.push(` ${key} = ${JSON.stringify(value)}`);
}
}
if (seededFromGlobal.length > 0) {
if (lines.length > 0) lines.push('');
lines.push('Defaults pre-filled from your global BMad config:');
for (const { key, value } of seededFromGlobal) {
lines.push(` ${key} = ${JSON.stringify(value)}`);
}
}
lines.push('', `To change these, edit ${globalPath}`, '(or unset them there to be prompted on the next install).');
await prompts.log.info(lines.join('\n'));
}
// Collect all answers (static + prompted)
let allAnswers = { ...staticAnswers };
@ -1870,9 +1964,10 @@ class OfficialModules {
existingValue = this.normalizeExistingValueForPrompt(existingValue, moduleName, item, moduleConfig);
}
// Special handling for user_name: default to system user
// Special handling for user_name: prefer global identity (~/.bmad) over
// OS username so the prompt's default reflects what the user already chose.
if (moduleName === 'core' && key === 'user_name' && !existingValue) {
item.default = this.getDefaultUsername();
item.default = this._identityFallback('user_name');
}
// Determine question type and default value

View File

@ -25,6 +25,8 @@ const PROTOTYPE_POLLUTING_NAMES = new Set(['__proto__', 'prototype', 'constructo
const path = require('node:path');
const fs = require('./fs-native');
const yaml = require('yaml');
const { globalUserConfigPath } = require('./global-config');
const { resolveInstalledModuleYaml } = require('./project-root');
/**
* Parse a single `--set <module>.<key>=<value>` entry.
@ -83,11 +85,25 @@ function parseSetEntries(entries) {
}
/**
* Encode a JS string as a TOML basic string (double-quoted with escapes).
* @param {string} value
* Encode a `--set` value as a TOML literal. Types are inferred from the value
* so `--set bmm.workers=4` writes `workers = 4` (integer), not `"4"` (string).
*
* Rules (mirror how TOML would interpret the literal hand-typed in a config):
* - `true` / `false` → boolean
* - `-?\d+` → integer
* - `-?\d+\.\d+` → float
* - everything else → quoted basic string
*
* To force a string that looks like a bool/number, wrap in literal quotes:
* --set foo.x='"true"' → x = "true"
*
* @param {string} value raw value as received from the --set flag
*/
function tomlString(value) {
const s = String(value);
if (s === 'true' || s === 'false') return s;
if (/^-?\d+$/.test(s)) return s;
if (/^-?\d+\.\d+$/.test(s)) return s;
// Per the TOML spec, basic strings escape `\`, `"`, and control characters.
return (
'"' +
@ -142,8 +158,10 @@ function upsertTomlKey(content, section, key, valueToml) {
const hadTrailingNewline = lines.length > 0 && lines.at(-1) === '';
if (hadTrailingNewline) lines.pop();
// Locate the target section.
const sectionStart = lines.findIndex((line) => line.trim() === section);
// Locate the target section. Tolerates a trailing inline comment on the
// header (`[core] # personal`) and a header line with non-newline
// trailing whitespace — `line.trim() === section` would miss both.
const sectionStart = lines.findIndex((line) => isSectionHeader(line, section));
if (sectionStart === -1) {
// Section doesn't exist — append a new block. Pad with a blank line if
// the file is non-empty so sections stay visually separated.
@ -168,11 +186,12 @@ function upsertTomlKey(content, section, key, valueToml) {
const match = lines[i].match(keyPattern);
if (match) {
const indent = match[1];
// Preserve trailing comment if present. We split on the first `#` that
// is preceded by whitespace — TOML strings can't contain unescaped `#`
// in basic-string form so this is safe for the values we emit.
// Preserve trailing comment if present. `findInlineCommentStart` tracks
// double-quoted string state so a `#` inside a value like
// `"path with # hash"` isn't mistaken for a comment marker (per TOML
// spec, basic strings may contain unescaped `#`).
const tail = match[2];
const commentIdx = tail.search(/\s+#/);
const commentIdx = findInlineCommentStart(tail);
const commentSuffix = commentIdx === -1 ? '' : tail.slice(commentIdx);
lines[i] = `${indent}${key} = ${valueToml}${commentSuffix}`;
return lines.join('\n') + (hadTrailingNewline ? '\n' : '');
@ -194,6 +213,51 @@ function escapeRegExp(s) {
return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
}
/**
* Match a TOML section header line against a target like `[core]`. Tolerates
* leading/trailing whitespace and a trailing inline comment, which the
* previous `line.trim() === section` check missed.
*/
function isSectionHeader(line, target) {
const trimmed = line.trimStart();
if (!trimmed.startsWith(target)) return false;
const after = trimmed.slice(target.length);
return /^\s*(?:#.*)?$/.test(after);
}
/**
* Find the start index of an inline `# comment` in a TOML value tail,
* tracking double-quoted string state so a `#` inside a string literal is
* not treated as a comment. Returns the index of the whitespace run that
* precedes the `#` (matching the contract of the old `/\s+#/` regex), or
* -1 if there's no inline comment.
*/
function findInlineCommentStart(text) {
let inString = false;
let escaped = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (ch === '#' && !inString) {
let j = i;
while (j > 0 && /\s/.test(text[j - 1])) j--;
if (j < i) return j;
}
}
return -1;
}
/**
* Look up `[section] key` in a TOML file. Returns true if the file exists,
* the section is present, and `key` is set within it. Used by
@ -205,29 +269,66 @@ async function tomlHasKey(filePath, section, key) {
if (!(await fs.pathExists(filePath))) return false;
const content = await fs.readFile(filePath, 'utf8');
const lines = content.split('\n');
const sectionStart = lines.findIndex((line) => line.trim() === section);
if (sectionStart === -1) return false;
const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
for (let i = sectionStart + 1; i < lines.length; i++) {
if (/^\s*\[/.test(lines[i])) return false;
if (keyPattern.test(lines[i])) return true;
// Walk every line tracking whether we're inside the target section. This
// both tolerates inline-commented headers (`[core] # personal`) and
// handles the edge case where the same section appears more than once
// (legal in TOML — tomllib merges them — but the previous `findIndex`
// only checked the first block, misrouting `--set`).
let inTargetSection = false;
for (const line of lines) {
if (/^\s*\[/.test(line)) {
inTargetSection = isSectionHeader(line, section);
continue;
}
if (inTargetSection && keyPattern.test(line)) return true;
}
return false;
}
/**
* Look up which prompt keys in `core/module.yaml` are declared `scope: user`.
* Used so `--set` routes core scope:user keys (user_name, communication_language)
* to the global identity file the installer's writeGlobalUserCore writes to,
* rather than polluting project config.toml as a team-scope key.
* Returns an empty set if core isn't installed or the schema can't be parsed.
*/
async function loadCoreUserScopeKeys() {
const result = new Set();
try {
const corePath = await resolveInstalledModuleYaml('core');
if (!corePath) return result;
const parsed = yaml.parse(await fs.readFile(corePath, 'utf8'));
if (!parsed || typeof parsed !== 'object') return result;
for (const [key, value] of Object.entries(parsed)) {
if (value && typeof value === 'object' && 'prompt' in value && value.scope === 'user') {
result.add(key);
}
}
} catch {
// Schema unavailable — fall back to two-tier routing.
}
return result;
}
/**
* Apply parsed `--set` overrides to the central TOML files written by the
* installer. Called at the end of an install / quick-update.
*
* Routing per (module, key):
* 1. If `_bmad/config.user.toml` already has `[section] key`, update there
* (user-scope key like `core.user_name`, `bmm.user_skill_level`).
* 2. Otherwise update `_bmad/config.toml` (team scope, the default).
* 1. If `~/.bmad/config.user.toml` already has `[section] key`, update there
* (global identity store — same place writeGlobalUserCore writes to).
* 2. Else if `_bmad/config.user.toml` already has `[section] key`, update
* there (project-scoped personal override).
* 3. Else if the key is a known core scope:user key (user_name,
* communication_language per core/module.yaml), route to global. Otherwise
* writeCentralConfig's next-install partition would strip the value out
* of project files.
* 4. Otherwise update `_bmad/config.toml` (team scope, the default).
*
* The schema-correct user/team partition lives in `manifest-generator`. We
* intentionally don't re-read module schemas here — the only goal is to
* match the file the installer just wrote the key to. For brand-new keys
* (not in either file yet), team scope is the safe default.
* The schema-correct partition lives in `manifest-generator`. We only reach
* for the core schema (small, always present) so the first-run case for
* `--set core.user_name=...` doesn't land in the wrong file.
*
* @param {Object<string, Object<string, string>>} overrides
* @param {string} bmadDir absolute path to `_bmad/`
@ -240,6 +341,8 @@ async function applySetOverrides(overrides, bmadDir) {
const teamPath = path.join(bmadDir, 'config.toml');
const userPath = path.join(bmadDir, 'config.user.toml');
const globalPath = globalUserConfigPath();
const coreUserKeys = await loadCoreUserScopeKeys();
for (const moduleCode of Object.keys(overrides)) {
// Skip overrides for modules not actually installed. The installer writes
@ -258,16 +361,35 @@ async function applySetOverrides(overrides, bmadDir) {
const value = moduleOverrides[key];
const valueToml = tomlString(value);
const userOwnsIt = await tomlHasKey(userPath, section, key);
const targetPath = userOwnsIt ? userPath : teamPath;
// 3-tier routing: prefer the file that already owns the key; otherwise
// honor core's user-scope partition (so `--set core.user_name` lands in
// ~/.bmad on a fresh install, not in project team config).
const globalOwnsIt = moduleCode === 'core' && (await tomlHasKey(globalPath, section, key));
const userOwnsIt = !globalOwnsIt && (await tomlHasKey(userPath, section, key));
const isCoreUserScope = moduleCode === 'core' && coreUserKeys.has(key) && !userOwnsIt;
let targetPath;
let scope;
if (globalOwnsIt || isCoreUserScope) {
targetPath = globalPath;
scope = 'user';
} else if (userOwnsIt) {
targetPath = userPath;
scope = 'user';
} else {
targetPath = teamPath;
scope = 'team';
}
// The team file always exists post-install; the user file only exists
// if the install wrote at least one user-scope key. If we're routing to
// it but it doesn't exist yet, create it with a minimal header so it
// has the same shape as installer-written user toml.
// The team file always exists post-install; the user/global files only
// exist once the installer has reason to write to them. If we're routing
// to one that doesn't exist yet, create it with a minimal header so it
// has the same shape as installer-written files.
let content = '';
if (await fs.pathExists(targetPath)) {
content = await fs.readFile(targetPath, 'utf8');
} else if (targetPath === globalPath) {
await fs.ensureDir(path.dirname(globalPath));
content = '# Global personal BMad config (see ~/.bmad).\n';
} else {
content = '# Personal overrides for _bmad/config.toml.\n';
}
@ -277,8 +399,8 @@ async function applySetOverrides(overrides, bmadDir) {
applied.push({
module: moduleCode,
key,
scope: userOwnsIt ? 'user' : 'team',
file: path.basename(targetPath),
scope,
file: targetPath === globalPath ? '~/.bmad/config.user.toml' : path.basename(targetPath),
});
}