feat(installer): cross-platform global config + lean per-project overrides

Phase 1 of the config refactor. Splits configuration into three clearly-owned
layers and removes per-install duplication:

- Global tier (~/.bmad/, $BMAD_HOME override): one-time identity + machine-wide
  defaults. config.user.toml holds scope:user core answers (user_name, language)
  so they're asked once per machine, not once per project.
- Per-module shipped-defaults floor (_bmad/{module}/module.toml): regenerated
  every install; the resolver's lowest-priority layer.
- Project overrides (_bmad/config.toml): lean — only emits deltas from module
  defaults. No more [agents.X] sections (agents live in module.toml floor).

Resolver chain (src/scripts/resolve_config.py) is now 7 tiers and supports
global-only operation (no _bmad/config.toml required). Customization cascade
(resolve_customization.py) splits cleanly from config: customize.toml files at
global, project, and custom tiers; never reads config.toml.

Installer (tools/installer/):
- New global-config.js: cross-platform resolver + dependency-free TOML reader.
- official-modules.js: silently reuses scope:user globals; logs a friendly note
  showing what was reused/seeded and where to edit it.
- manifest-generator.js: writes per-module module.toml with placeholder
  resolution for cross-key references (e.g. {output_folder}); routes core
  scope:user answers to ~/.bmad/config.user.toml.
- Removed --ask flag.

Tests: 11 new Python resolver tests, 8 new customization-cascade tests,
suites 35/37/38/45/46 in test-installation-components.js updated/added for the
new contract (with BMAD_HOME isolation to avoid touching real ~/.bmad).
This commit is contained in:
Brian Madison 2026-05-25 23:05:18 -05:00
parent 3bcd6c3cce
commit bec2c04a6d
8 changed files with 1616 additions and 222 deletions

View File

@ -1,12 +1,30 @@
#!/usr/bin/env python3 #!/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): Reads from up to seven tiers (highest priority last):
1. {project-root}/_bmad/config.toml (installer-owned team) 0. {project-root}/_bmad/{module}/module.toml (shipped module defaults floor)
2. {project-root}/_bmad/config.user.toml (installer-owned user) 1. {global-dir}/config.toml (global team / machine defaults)
3. {project-root}/_bmad/custom/config.toml (human-authored team, committed) 2. {global-dir}/config.user.toml (global personal defaults)
4. {project-root}/_bmad/custom/config.user.toml (human-authored user, gitignored) 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. 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 /abs/path/to/project
python3 resolve_config.py --project-root ... --key core python3 resolve_config.py --project-root ... --key core
python3 resolve_config.py --project-root ... --key agents python3 resolve_config.py --project-root ... --key agents
python3 resolve_config.py # global only
Merge rules (same as resolve_customization.py): Merge rules (same as resolve_customization.py):
- Scalars: override wins - Scalars: override wins
@ -26,6 +45,7 @@ Merge rules (same as resolve_customization.py):
import argparse import argparse
import json import json
import os
import sys import sys
from pathlib import Path from pathlib import Path
@ -123,6 +143,61 @@ def deep_merge(base, override):
return 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): def extract_key(data, dotted_key: str):
parts = dotted_key.split(".") parts = dotted_key.split(".")
current = data current = data
@ -136,11 +211,12 @@ def extract_key(data, dotted_key: str):
def main(): def main():
parser = argparse.ArgumentParser( 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( parser.add_argument(
"--project-root", "-p", required=True, "--project-root", "-p", required=False, default=None,
help="Absolute path to the project root (contains _bmad/)", help="Absolute path to the project root (contains _bmad/). Optional — "
"if omitted, only global layers ($BMAD_HOME or ~/.bmad) are read.",
) )
parser.add_argument( parser.add_argument(
"--key", "-k", action="append", default=[], "--key", "-k", action="append", default=[],
@ -148,17 +224,16 @@ def main():
) )
args = parser.parse_args() args = parser.parse_args()
project_root = Path(args.project_root).resolve() project_root = Path(args.project_root).resolve() if args.project_root else None
bmad_dir = project_root / "_bmad" global_dir = resolve_global_dir()
base_team = load_toml(bmad_dir / "config.toml", required=True) merged: dict = {}
base_user = load_toml(bmad_dir / "config.user.toml") # Floor: per-module shipped defaults (lowest priority).
custom_team = load_toml(bmad_dir / "custom" / "config.toml") for module_toml in collect_module_layers(project_root):
custom_user = load_toml(bmad_dir / "custom" / "config.user.toml") merged = deep_merge(merged, load_toml(module_toml))
# Then global → project → custom config layers on top.
merged = deep_merge(base_team, base_user) for _label, path in collect_config_layers(project_root, global_dir):
merged = deep_merge(merged, custom_team) merged = deep_merge(merged, load_toml(path))
merged = deep_merge(merged, custom_user)
if args.key: if args.key:
output = {} output = {}

View File

@ -1,13 +1,41 @@
#!/usr/bin/env python3 #!/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): Reads from (lowest highest priority):
1. {project-root}/_bmad/custom/{name}.user.toml (personal, gitignored) 1. {skill-root}/customize.toml (skill author defaults)
2. {project-root}/_bmad/custom/{name}.toml (team/org, committed) 2. [skills.X] sections inside four customize layers (lowesthighest):
3. {skill-root}/customize.toml (skill defaults) 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. Outputs merged JSON to stdout. Errors go to stderr.
@ -34,7 +62,10 @@ description/prompt.
""" """
import argparse import argparse
import fnmatch
import json import json
import os
import re
import sys import sys
from pathlib import Path from pathlib import Path
@ -166,6 +197,93 @@ def deep_merge(base, override):
return 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): def extract_key(data, dotted_key: str):
parts = dotted_key.split(".") parts = dotted_key.split(".")
current = data current = data
@ -187,7 +305,7 @@ def write_json_stdout(output):
def main(): def main():
parser = argparse.ArgumentParser( 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, add_help=True,
) )
parser.add_argument( parser.add_argument(
@ -202,6 +320,8 @@ def main():
skill_dir = Path(args.skill).resolve() skill_dir = Path(args.skill).resolve()
skill_name = skill_dir.name 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_path = skill_dir / "customize.toml"
defaults = load_toml(defaults_path, required=True) defaults = load_toml(defaults_path, required=True)
@ -211,16 +331,27 @@ def main():
# for standalone skills invoked directly). Using cwd first is unsafe when # for standalone skills invoked directly). Using cwd first is unsafe when
# an ancestor of cwd happens to have a stray _bmad/ from another project. # 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()) project_root = find_project_root(skill_dir) or find_project_root(Path.cwd())
global_dir = resolve_global_dir()
team = {} merged = defaults
user = {}
# 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: if project_root:
custom_dir = project_root / "_bmad" / "custom" custom_dir = project_root / "_bmad" / "custom"
team = load_toml(custom_dir / f"{skill_name}.toml") merged = deep_merge(merged, load_toml(custom_dir / f"{skill_name}.toml"))
user = load_toml(custom_dir / f"{skill_name}.user.toml") merged = deep_merge(merged, load_toml(custom_dir / f"{skill_name}.user.toml"))
merged = deep_merge(defaults, team)
merged = deep_merge(merged, user)
if args.key: if args.key:
output = {} output = {}

View File

@ -0,0 +1,173 @@
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(args, env_overrides=None):
env = os.environ.copy()
env["BMAD_HOME"] = env.get("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,
)
stderr = result.stderr.decode("utf-8", errors="replace")
if result.returncode != 0:
raise AssertionError(f"resolve_config failed ({result.returncode}): {stderr}")
return json.loads(result.stdout.decode("utf-8"))
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):
# No _bmad/config.toml; should not error
with tempfile.TemporaryDirectory() as proj:
data = run(["--project-root", proj])
self.assertEqual(data, {})
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,153 @@
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()
env.setdefault("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(''); 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`); 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 // getModulePath). Only the destination bmadDir is a temp dir, which the
// installer writes config.toml / config.user.toml / custom/ into. // installer writes config.toml / config.user.toml / custom/ into.
const tempBmadDir35 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-central-config-')); 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 { try {
const moduleConfigs = { const moduleConfigs = {
@ -1764,43 +1770,54 @@ async function runTests() {
assert(await fs.pathExists(teamPath), 'config.toml is written to disk'); assert(await fs.pathExists(teamPath), 'config.toml is written to disk');
assert(await fs.pathExists(userPath), 'config.user.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 teamContent = await fs.readFile(teamPath, 'utf8');
const userContent = await fs.readFile(userPath, 'utf8'); const userContent = await fs.readFile(userPath, 'utf8');
const globalContent = await fs.readFile(globalCorePath, 'utf8');
// [core] — team-scoped keys land in config.toml // [core] — lean: only deltas from module.yaml defaults remain.
assert(teamContent.includes('[core]'), 'config.toml has [core] section'); // project_name = "demo-project" differs from default ({directory_name}).
assert(teamContent.includes('document_output_language = "English"'), 'Team-scope core key lands in config.toml'); // document_output_language = "English" EQUALS default — stripped.
assert(teamContent.includes('output_folder = "_bmad-output"'), 'Team-scope output_folder lands in config.toml'); // output_folder = "_bmad-output" EQUALS default — stripped.
assert(teamContent.includes('project_name = "demo-project"'), 'project_name lands in [core] (core key as of #2279)'); 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('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'); assert(!teamContent.includes('communication_language'), 'communication_language (scope: user) is absent from config.toml');
// [core] — user-scoped keys land in config.user.toml // [core] user-scope no longer goes to project user file — it's routed
assert(userContent.includes('[core]'), 'config.user.toml has [core] section'); // to ~/.bmad/config.user.toml (Task D).
assert(userContent.includes('user_name = "TestUser"'), 'user_name lands in config.user.toml'); assert(!userContent.includes('user_name'), 'config.user.toml does NOT contain user_name (Task D: routed to global)');
assert(userContent.includes('communication_language = "Spanish"'), 'communication_language lands in config.user.toml'); assert(
assert(!userContent.includes('document_output_language'), 'Team-scope key is absent from config.user.toml'); !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 // Global file at ~/.bmad/config.user.toml carries the scope:user core values
const bmmTeamMatch = teamContent.match(/\[modules\.bmm\][\s\S]*?(?=\n\[|$)/); assert(globalContent.includes('[core]'), '~/.bmad/config.user.toml has [core] section');
assert(bmmTeamMatch !== null, 'config.toml has [modules.bmm] section'); assert(globalContent.includes('user_name = "TestUser"'), 'user_name lands in ~/.bmad/config.user.toml');
if (bmmTeamMatch) { assert(globalContent.includes('communication_language = "Spanish"'), 'communication_language lands in ~/.bmad/config.user.toml');
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');
}
// [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\[|$)/); 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) { 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\[|$)/); const extMatch = teamContent.match(/\[modules\.external-mod\][\s\S]*?(?=\n\[|$)/);
assert(extMatch !== null, 'Unknown-schema module survives with its own [modules.*] section'); assert(extMatch !== null, 'Unknown-schema module survives with its own [modules.*] section');
if (extMatch) { if (extMatch) {
@ -1810,20 +1827,23 @@ async function runTests() {
assert(!extBlock.includes('communication_language'), 'All core-key pollution stripped from unknown-schema module'); 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) // [agents.*] — Task F: NEVER emitted to config.toml. Roster lives in
assert(teamContent.includes('[agents.bmad-agent-analyst]'), 'config.toml has [agents.bmad-agent-analyst] table'); // _bmad/{module}/module.toml floor (written by writeModuleTomls).
assert(teamContent.includes('[agents.bmad-agent-dev]'), 'config.toml has [agents.bmad-agent-dev] table'); assert(!teamContent.includes('[agents.'), 'config.toml has NO [agents.*] sections (Task F)');
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');
assert(!userContent.includes('[agents.'), '[agents.*] tables are never written to config.user.toml'); 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(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(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');
} finally { } finally {
await fs.remove(tempBmadDir35).catch(() => {}); 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 +1886,56 @@ async function runTests() {
console.log(''); 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 // Phase 1 (Task F): writeCentralConfig no longer emits [agents.*] blocks.
// (e.g. external/marketplace). Its module.yaml isn't read, so its agents // The roster lives in _bmad/{module}/module.toml (the resolver floor).
// aren't in this.agents. writeCentralConfig must read the prior config.toml // This suite verifies the relocation — same data, different home.
// and keep those [agents.*] blocks so the roster doesn't silently shrink. //
const tempBmadDir37 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-agent-preserve-')); // 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 { try {
// Seed a prior config.toml with an agent from an external module // Set up bmm/ directory so writeModuleTomls has a place to write
const priorToml = [ await fs.ensureDir(path.join(tempBmadDir37, 'bmm'));
'# 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);
const generator37 = new ManifestGenerator(); const generator37 = new ManifestGenerator();
generator37.bmadDir = tempBmadDir37; generator37.bmadDir = tempBmadDir37;
generator37.bmadFolderName = path.basename(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(); await generator37.collectAgentsFromModuleYaml();
const freshModules = new Set(generator37.agents.map((a) => a.module)); await generator37.writeCentralConfig(tempBmadDir37, { core: {}, bmm: {} });
assert(freshModules.has('bmm'), 'bmm contributes fresh agents from src module.yaml'); await generator37.writeModuleTomls(tempBmadDir37);
assert(!freshModules.has('external-mod'), 'external-mod source is unavailable (preserved-module scenario)');
await generator37.writeCentralConfig(tempBmadDir37, { core: {}, bmm: {}, 'external-mod': {} });
const teamContent = await fs.readFile(path.join(tempBmadDir37, 'config.toml'), 'utf8'); const teamContent = await fs.readFile(path.join(tempBmadDir37, 'config.toml'), 'utf8');
const bmmTomlContent = await fs.readFile(path.join(tempBmadDir37, 'bmm', 'module.toml'), 'utf8');
assert( // Task F: config.toml carries no [agents.*] sections
teamContent.includes('[agents.external-hero]'), assert(!teamContent.includes('[agents.'), 'config.toml has no [agents.*] sections (Task F)');
'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');
// Freshly collected agents win over stale entries with the same code // The roster lives in module.toml instead
const maryMatches = teamContent.match(/\[agents\.bmad-agent-analyst\]/g) || []; assert(bmmTomlContent.includes('[agents.bmad-agent-analyst]'), 'bmm/module.toml carries [agents.bmad-agent-analyst]');
assert(maryMatches.length === 1, 'bmad-agent-analyst emitted exactly once (fresh wins; stale not duplicated)'); assert(bmmTomlContent.includes('[agents.bmad-agent-dev]'), 'bmm/module.toml carries [agents.bmad-agent-dev]');
assert(!teamContent.includes('Stale Mary'), 'Stale name from prior config.toml is discarded when fresh module.yaml is read'); 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 { } finally {
await fs.remove(tempBmadDir37).catch(() => {}); 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 +2016,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').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'); assert(byCode.get('bmad-fake-ext-agent-one').team === 'fake', 'explicit team from module.yaml is preserved');
// 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'));
// 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, { await generator38.writeCentralConfig(tempBmadDir38, {
core: {}, core: {},
bmm: {}, bmm: {},
'fake-ext': {}, 'fake-ext': {},
'fake-skills': {}, 'fake-skills': {},
}); });
await generator38.writeModuleTomls(tempBmadDir38);
const teamContent = await fs.readFile(path.join(tempBmadDir38, 'config.toml'), 'utf8'); 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'); // Task F: agents NEVER land in config.toml anymore
assert(teamContent.includes('[agents.bmad-fake-skills-agent]'), 'skills-layout external module agents also land in config.toml'); assert(!teamContent.includes('[agents.'), 'External module agents are NOT in config.toml (Task F)');
assert(teamContent.includes('First fake external agent.'), 'agent description from external module.yaml is written');
// 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 { } finally {
if (priorCacheEnv === undefined) { if (priorCacheEnv === undefined) {
delete process.env.BMAD_EXTERNAL_MODULES_CACHE; delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
@ -3238,6 +3277,254 @@ async function runTests() {
console.log(''); 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
assert(coreContent.includes('[modules.core]'), 'core/module.toml has [modules.core] section');
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('');
// ============================================================ // ============================================================
// Summary // Summary
// ============================================================ // ============================================================

View File

@ -3,6 +3,7 @@ const fs = require('../fs-native');
const yaml = require('yaml'); const yaml = require('yaml');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { resolveInstalledModuleYaml } = require('../project-root'); const { resolveInstalledModuleYaml } = require('../project-root');
const { globalUserConfigPath, loadGlobalConfig, parseSimpleToml } = require('../global-config');
const prompts = require('../prompts'); const prompts = require('../prompts');
// Load package.json for version info // Load package.json for version info
@ -82,11 +83,20 @@ class ManifestGenerator {
// Write manifest files and collect their paths // Write manifest files and collect their paths
const [teamConfigPath, userConfigPath] = await this.writeCentralConfig(bmadDir, options.moduleConfigs || {}); 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 = [ const manifestFiles = [
await this.writeMainManifest(cfgDir), await this.writeMainManifest(cfgDir),
await this.writeSkillManifest(cfgDir), await this.writeSkillManifest(cfgDir),
teamConfigPath, teamConfigPath,
userConfigPath, userConfigPath,
...moduleTomlPaths,
await this.writeFilesManifest(cfgDir), await this.writeFilesManifest(cfgDir),
]; ];
@ -419,25 +429,39 @@ class ManifestGenerator {
} }
/** /**
* Write central _bmad/config.toml with [core], [modules.<code>], [agents.<code>] tables. * Write central _bmad/config.toml as a LEAN OVERRIDE FILE only values
* Install-owned. Team-scope answers config.toml; user-scope answers config.user.toml. * the user actually changed from defaults land here. Defaults flow through
* Both files are regenerated on every install. User overrides live in * the module.toml floor (written by writeModuleTomls) and the global
* _bmad/custom/config.toml and _bmad/custom/config.user.toml (never touched by installer). * 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 * @returns {string[]} Paths to the written config files
*/ */
async writeCentralConfig(bmadDir, moduleConfigs) { async writeCentralConfig(bmadDir, moduleConfigs) {
const teamPath = path.join(bmadDir, 'config.toml'); const teamPath = path.join(bmadDir, 'config.toml');
const userPath = path.join(bmadDir, 'config.user.toml'); const userPath = path.join(bmadDir, 'config.user.toml');
// Load each module's source module.yaml to determine scope per prompt key. // Load each module's source module.yaml to determine:
// Default scope is 'team' when the prompt doesn't declare one. // 1. scope per prompt key (team vs user)
// When a module.yaml is unreadable we warn — for known official modules // 2. the canonical module code (for [modules.{code}] section names)
// this means user-scoped keys (e.g. user_name) could mis-file into the // 3. processed defaults per key (for delta detection)
// team config, so the operator should notice.
const scopeByModuleKey = {}; 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 codeByModuleName = {};
const defaultsByModuleKey = {};
for (const moduleName of this.updatedModules) { for (const moduleName of this.updatedModules) {
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName); const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
if (!moduleYamlPath) { if (!moduleYamlPath) {
@ -452,9 +476,13 @@ class ManifestGenerator {
if (!parsed || typeof parsed !== 'object') continue; if (!parsed || typeof parsed !== 'object') continue;
if (parsed.code) codeByModuleName[moduleName] = parsed.code; if (parsed.code) codeByModuleName[moduleName] = parsed.code;
scopeByModuleKey[moduleName] = {}; scopeByModuleKey[moduleName] = {};
defaultsByModuleKey[moduleName] = {};
for (const [key, value] of Object.entries(parsed)) { for (const [key, value] of Object.entries(parsed)) {
if (value && typeof value === 'object' && 'prompt' in value) { if (!value || typeof value !== 'object' || !('prompt' in value)) continue;
scopeByModuleKey[moduleName][key] = value.scope === 'user' ? 'user' : 'team'; scopeByModuleKey[moduleName][key] = value.scope === 'user' ? 'user' : 'team';
const processedDefault = computeProcessedDefault(value, moduleConfigs);
if (processedDefault !== undefined) {
defaultsByModuleKey[moduleName][key] = processedDefault;
} }
} }
} catch (error) { } catch (error) {
@ -465,6 +493,12 @@ class ManifestGenerator {
} }
} }
// 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 // 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 // the only keys allowed in [core]; they must be stripped from every
// non-core module bucket because legacy _bmad/{mod}/config.yaml files // non-core module bucket because legacy _bmad/{mod}/config.yaml files
@ -494,6 +528,23 @@ class ManifestGenerator {
return { team, user }; 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 = [ const teamHeader = [
'# ─────────────────────────────────────────────────────────────────', '# ─────────────────────────────────────────────────────────────────',
'# Installer-managed. Regenerated on every install — treat as read-only.', '# Installer-managed. Regenerated on every install — treat as read-only.',
@ -526,9 +577,10 @@ class ManifestGenerator {
const teamLines = [...teamHeader]; const teamLines = [...teamHeader];
const userLines = [...userHeader]; 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 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) { if (Object.keys(coreTeam).length > 0) {
teamLines.push('[core]'); teamLines.push('[core]');
for (const [key, value] of Object.entries(coreTeam)) { for (const [key, value] of Object.entries(coreTeam)) {
@ -536,28 +588,24 @@ class ManifestGenerator {
} }
teamLines.push(''); teamLines.push('');
} }
if (Object.keys(coreUser).length > 0) { // [core] user-scope: never written to the project user.toml. Task D routes
userLines.push('[core]'); // these to ~/.bmad/config.user.toml via writeGlobalUserCore (called by
for (const [key, value] of Object.entries(coreUser)) { // generateManifests after this method returns). config.user.toml stays
userLines.push(`${key} = ${formatTomlValue(value)}`); // empty unless the user has manually pinned a per-project override.
}
userLines.push('');
}
// [modules.<code>] — split per module // [modules.<code>] — emit only deltas; skip section if no deltas.
for (const moduleName of this.updatedModules) { for (const moduleName of this.updatedModules) {
if (moduleName === 'core') continue; if (moduleName === 'core') continue;
const cfg = moduleConfigs[moduleName]; const cfg = moduleConfigs[moduleName];
if (!cfg || Object.keys(cfg).length === 0) continue; 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; 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 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) { if (Object.keys(modTeam).length > 0) {
teamLines.push(`[modules.${sectionKey}]`); teamLines.push(`[modules.${sectionKey}]`);
for (const [key, value] of Object.entries(modTeam)) { for (const [key, value] of Object.entries(modTeam)) {
@ -574,43 +622,10 @@ class ManifestGenerator {
} }
} }
// [agents.<code>] — always team (agent roster is organizational). // [agents.<code>] — intentionally NOT emitted (Task F). The roster lives
// Freshly collected agents come from module.yaml this run. If a module // in the per-module module.toml floor. Users who want to override or
// was preserved (e.g. during quickUpdate when its source isn't available), // add agents per-project edit _bmad/custom/config.toml; that file is
// its module.yaml wasn't read — so its agents aren't in `this.agents` and // never touched by the installer.
// 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, '');
}
const teamContent = teamLines.join('\n').replace(/\n+$/, '\n'); const teamContent = teamLines.join('\n').replace(/\n+$/, '\n');
const userContent = userLines.join('\n').replace(/\n+$/, '\n'); const userContent = userLines.join('\n').replace(/\n+$/, '\n');
@ -619,6 +634,203 @@ class ManifestGenerator {
return [teamPath, userPath]; 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));
// Read-merge-write so we don't trample any pre-existing scopes / sections
// the user might have written to ~/.bmad/config.user.toml by hand or via
// another tool.
let existing = {};
if (await fs.pathExists(globalPath)) {
try {
existing = parseSimpleToml(await fs.readFile(globalPath, 'utf8'));
} catch {
existing = {};
}
}
const mergedCore = { ...existing.core, ...userScopeValues };
const mergedConfig = { ...existing, core: mergedCore };
const lines = [
'# ─────────────────────────────────────────────────────────────────',
'# 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.',
'# ─────────────────────────────────────────────────────────────────',
'',
];
for (const [section, table] of Object.entries(mergedConfig)) {
if (!table || typeof table !== 'object' || Array.isArray(table)) continue;
lines.push(`[${section}]`);
for (const [key, value] of Object.entries(table)) {
if (value === undefined || value === null || value === '') continue;
lines.push(`${key} = ${formatTomlValue(value)}`);
}
lines.push('');
}
const content = lines.join('\n').replace(/\n+$/, '\n');
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) {
lines.push(`[modules.${moduleCode}]`);
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 * 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. * on first install only. Installer never touches these files again after creation.
@ -806,6 +1018,136 @@ class ManifestGenerator {
* Handles strings (quoted + escaped), booleans, numbers, and arrays of scalars. * Handles strings (quoted + escaped), booleans, numbers, and arrays of scalars.
* Objects are not expected at this emit path. * Objects are not expected at this emit path.
*/ */
/**
* Compute the processed default value for a module.yaml question item, using
* the already-resolved moduleConfigs map for cross-key references like
* `{output_folder}`. Used by writeCentralConfig to detect default-equal
* values that should NOT be re-emitted into the lean config.toml.
*
* Steps:
* 1. Substitute {key} references against any module's already-collected
* value (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, leaving the caller's delta
* check to fall through unchanged.
*
* @param {object} item - one module.yaml question schema
* @param {object} moduleConfigs - already-resolved per-module configs
* @returns {*} processed default value (string/scalar) or undefined
*/
function computeProcessedDefault(item, moduleConfigs) {
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;
}
for (const mod of Object.values(moduleConfigs || {})) {
if (mod && typeof mod === 'object' && mod[refKey] !== undefined) {
let resolved = mod[refKey];
if (typeof resolved === 'string' && resolved.startsWith('{project-root}/')) {
resolved = resolved.slice('{project-root}/'.length);
}
return resolved;
}
}
return match;
});
}
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) { function formatTomlValue(value) {
if (value === null || value === undefined) return '""'; if (value === null || value === undefined) return '""';
if (typeof value === 'boolean') return value ? 'true' : 'false'; if (typeof value === 'boolean') return value ? 'true' : 'false';

View File

@ -0,0 +1,171 @@
/**
* 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(override.trim());
}
return path.join(os.homedir(), '.bmad');
}
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) {
return raw
.slice(1, -1)
.replaceAll(String.raw`\"`, '"')
.replaceAll(String.raw`\\`, '\\')
.replaceAll(String.raw`\n`, '\n')
.replaceAll(String.raw`\r`, '\r')
.replaceAll(String.raw`\t`, '\t');
}
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 { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
const { CLIUtils } = require('../cli-utils'); const { CLIUtils } = require('../cli-utils');
const { ExternalModuleManager } = require('./external-manager'); const { ExternalModuleManager } = require('./external-manager');
const { loadGlobalConfig } = require('../global-config');
class OfficialModules { class OfficialModules {
constructor(options = {}) { constructor(options = {}) {
@ -1019,11 +1020,26 @@ class OfficialModules {
* @param {string} projectDir - Target project directory * @param {string} projectDir - Target project directory
* @param {Object} options - Additional options * @param {Object} options - Additional options
* @param {boolean} options.skipPrompts - Skip prompts and use defaults (for --yes flag) * @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 = {}) { async collectAllConfigurations(modules, projectDir, options = {}) {
this.skipPrompts = options.skipPrompts || false; this.skipPrompts = options.skipPrompts || false;
this.modulesToCustomize = undefined; this.modulesToCustomize = undefined;
await this.loadExistingConfig(projectDir); 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) // Check if core was already collected (e.g., in early collection phase)
const coreAlreadyCollected = this.collectedConfig.core && Object.keys(this.collectedConfig.core).length > 0; const coreAlreadyCollected = this.collectedConfig.core && Object.keys(this.collectedConfig.core).length > 0;
@ -1045,44 +1061,17 @@ class OfficialModules {
await this.collectModuleConfig(moduleName, projectDir); await this.collectModuleConfig(moduleName, projectDir);
} }
// Show batch configuration gateway for non-core modules // Non-core modules are always silent: accept module.yaml defaults without
// Scan all non-core module schemas for display names and config metadata // 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 = []; let scannedModules = [];
if (!this.skipPrompts && nonCoreModules.length > 0) { if (!this.skipPrompts && nonCoreModules.length > 0) {
this.modulesToCustomize = new Set();
scannedModules = await this.scanModuleSchemas(nonCoreModules); 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 // Collect remaining non-core modules
@ -1507,6 +1496,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) // Collect all answers (static + prompted)
let allAnswers = { ...staticAnswers }; let allAnswers = { ...staticAnswers };