This commit is contained in:
Brian 2026-05-30 14:07:26 +09:00 committed by GitHub
commit b9dea6f6e5
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 (lowesthighest):
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

@ -1693,7 +1693,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`);
@ -1702,6 +1702,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 = {
@ -1764,43 +1770,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) {
@ -1810,20 +1827,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;
}
}
}
@ -1866,66 +1917,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;
}
}
}
@ -2006,17 +2047,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;
@ -2988,6 +3058,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');
@ -3046,6 +3122,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`;
@ -3087,6 +3174,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-'));
@ -3152,15 +3259,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(() => {});
}
@ -3174,11 +3290,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(() => {});
}
@ -3234,6 +3356,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),
});
}