diff --git a/src/scripts/resolve_config.py b/src/scripts/resolve_config.py index eb9e20288..1e20b8978 100644 --- a/src/scripts/resolve_config.py +++ b/src/scripts/resolve_config.py @@ -1,12 +1,30 @@ #!/usr/bin/env python3 """ -Resolve BMad's central config using four-layer TOML merge. +Resolve BMad's central config using a layered TOML merge. -Reads from four layers (highest priority last): - 1. {project-root}/_bmad/config.toml (installer-owned team) - 2. {project-root}/_bmad/config.user.toml (installer-owned user) - 3. {project-root}/_bmad/custom/config.toml (human-authored team, committed) - 4. {project-root}/_bmad/custom/config.user.toml (human-authored user, gitignored) +Reads from up to seven tiers (highest priority last): + 0. {project-root}/_bmad/{module}/module.toml (shipped module defaults — floor) + 1. {global-dir}/config.toml (global team / machine defaults) + 2. {global-dir}/config.user.toml (global personal defaults) + 3. {project-root}/_bmad/config.toml (installer-owned team) + 4. {project-root}/_bmad/config.user.toml (installer-owned user) + 5. {project-root}/_bmad/custom/config.toml (human-authored team, committed) + 6. {project-root}/_bmad/custom/config.user.toml (human-authored user, gitignored) + +Tier 0 ("module floor") carries each installed module's shipped defaults: +its [modules.X] paths and [agents.X] roster. Authors write module.yaml at +source; the installer converts to module.toml at install-time, giving the +resolver a TOML-only read path (no PyYAML dependency). Discovery is by +glob: any subdirectory of _bmad/ containing a module.toml counts. + +All layers are optional. If a file is missing it is silently skipped. +If no file is found anywhere, an empty object is emitted. + +{global-dir} resolves to $BMAD_HOME if set, otherwise ~/.bmad. Path.home() +gives the right answer on macOS, Linux, WSL, and Windows. + +--project-root is optional. With no project root, only the global layers +are consulted (useful for standalone skill invocations). Outputs merged JSON to stdout. Errors go to stderr. @@ -16,6 +34,7 @@ no virtualenv — plain `python3` is sufficient. python3 resolve_config.py --project-root /abs/path/to/project python3 resolve_config.py --project-root ... --key core python3 resolve_config.py --project-root ... --key agents + python3 resolve_config.py # global only Merge rules (same as resolve_customization.py): - Scalars: override wins @@ -26,6 +45,7 @@ Merge rules (same as resolve_customization.py): import argparse import json +import os import sys from pathlib import Path @@ -123,6 +143,61 @@ def deep_merge(base, override): return override +def resolve_global_dir() -> Path: + """Locate the cross-platform global BMad config directory. + + Honors $BMAD_HOME (useful for CI, multi-account setups, or relocating + config off a slow network home). Otherwise ~/.bmad — Path.home() does + the right thing on macOS, Linux, WSL, and Windows. + """ + override = os.environ.get("BMAD_HOME") + if override: + return Path(override).expanduser().resolve() + return Path.home() / ".bmad" + + +def collect_module_layers(project_root: Path | None) -> list[Path]: + """Return per-module module.toml paths discovered in this project. + + Floor of the resolver chain — these are the shipped module defaults + that the installer realizes from source module.yaml on install. Authors + don't edit module.toml directly; it's a build artifact. + + Discovery is purely file-system based: any direct subdirectory of + _bmad/ that contains a module.toml is treated as an installed module. + Returned in sorted order for deterministic merge order (irrelevant + in practice because modules don't share keys — each writes its own + [modules.{code}] and own [agents.{agent-code}] entries — but + determinism is cheap). + """ + if project_root is None: + return [] + bmad_dir = project_root / "_bmad" + if not bmad_dir.is_dir(): + return [] + return sorted(bmad_dir.glob("*/module.toml")) + + +def collect_config_layers(project_root: Path | None, global_dir: Path) -> list[tuple[str, Path]]: + """Return (label, path) pairs in lowest→highest priority order. + + All layers are optional; load_toml returns {} for any missing file. + """ + layers: list[tuple[str, Path]] = [ + ("global team", global_dir / "config.toml"), + ("global user", global_dir / "config.user.toml"), + ] + if project_root is not None: + bmad_dir = project_root / "_bmad" + layers.extend([ + ("project team", bmad_dir / "config.toml"), + ("project user", bmad_dir / "config.user.toml"), + ("project custom team", bmad_dir / "custom" / "config.toml"), + ("project custom user", bmad_dir / "custom" / "config.user.toml"), + ]) + return layers + + def extract_key(data, dotted_key: str): parts = dotted_key.split(".") current = data @@ -136,11 +211,12 @@ def extract_key(data, dotted_key: str): def main(): parser = argparse.ArgumentParser( - description="Resolve BMad central config using four-layer TOML merge.", + description="Resolve BMad central config using a layered TOML merge.", ) parser.add_argument( - "--project-root", "-p", required=True, - help="Absolute path to the project root (contains _bmad/)", + "--project-root", "-p", required=False, default=None, + help="Absolute path to the project root (contains _bmad/). Optional — " + "if omitted, only global layers ($BMAD_HOME or ~/.bmad) are read.", ) parser.add_argument( "--key", "-k", action="append", default=[], @@ -148,17 +224,27 @@ def main(): ) args = parser.parse_args() - project_root = Path(args.project_root).resolve() - bmad_dir = project_root / "_bmad" + project_root = Path(args.project_root).resolve() if args.project_root else None + global_dir = resolve_global_dir() - base_team = load_toml(bmad_dir / "config.toml", required=True) - base_user = load_toml(bmad_dir / "config.user.toml") - custom_team = load_toml(bmad_dir / "custom" / "config.toml") - custom_user = load_toml(bmad_dir / "custom" / "config.user.toml") + # If the caller explicitly named a project root, that's a promise it exists + # and has been installed. Fail loudly on a missing _bmad/ rather than + # silently returning {} — that masked broken installs in the old required= + # True behavior. Global-only mode (no --project-root) stays permissive. + if project_root is not None and not (project_root / "_bmad").is_dir(): + sys.stderr.write( + f"error: --project-root {project_root} has no _bmad/ directory " + f"(install not present, or wrong path)\n" + ) + sys.exit(1) - merged = deep_merge(base_team, base_user) - merged = deep_merge(merged, custom_team) - merged = deep_merge(merged, custom_user) + merged: dict = {} + # Floor: per-module shipped defaults (lowest priority). + for module_toml in collect_module_layers(project_root): + merged = deep_merge(merged, load_toml(module_toml)) + # Then global → project → custom config layers on top. + for _label, path in collect_config_layers(project_root, global_dir): + merged = deep_merge(merged, load_toml(path)) if args.key: output = {} diff --git a/src/scripts/resolve_customization.py b/src/scripts/resolve_customization.py index 3299e1ade..23c546803 100755 --- a/src/scripts/resolve_customization.py +++ b/src/scripts/resolve_customization.py @@ -1,13 +1,41 @@ #!/usr/bin/env python3 """ -Resolve customization for a BMad skill using three-layer TOML merge. +Resolve customization for a BMad skill using a layered TOML merge. -Reads customization from three layers (highest priority first): - 1. {project-root}/_bmad/custom/{name}.user.toml (personal, gitignored) - 2. {project-root}/_bmad/custom/{name}.toml (team/org, committed) - 3. {skill-root}/customize.toml (skill defaults) +Reads from (lowest → highest priority): + 1. {skill-root}/customize.toml (skill author defaults) + 2. [skills.X] sections inside four customize layers (lowest→highest): + a. {global-dir}/customize.toml (global team / machine) + b. {global-dir}/customize.user.toml (global personal) + c. {project-root}/_bmad/custom/customize.toml (project team, committed) + d. {project-root}/_bmad/custom/customize.user.toml (project personal, gitignored) + 3. {project-root}/_bmad/custom/{name}.toml (per-skill team override) + 4. {project-root}/_bmad/custom/{name}.user.toml (per-skill personal override) -Skill name is derived from the basename of the skill directory. +config.toml is NOT consulted by this resolver — identity/agents live there, +skill behavior overrides live in customize.toml. Clean split. + +There is no installer-tier customize.toml — the installer manages identity +(config.toml), not skill behavior. customize.{,user.}toml is purely human +or `bmad-customize`-skill-authored. + +Skill name is derived from the basename of the skill directory. If the +skill lives under `{module}-skills/...`, a qualified name `{module}/{skill}` +is also computed and used for pattern matching. + +Inside a customize layer, [skills.X] sections cascade by specificity (most +specific wins within the layer): + [skills."*"] # catchall + [skills."bmad-*"] # bare-name glob + [skills."bmm/*"] # module-scoped glob + [skills.bmad-prd] # bare exact + [skills."bmm/bmad-prd"] # qualified exact (most specific) + +Patterns are matched against both the bare skill name and (if available) +the qualified `module/skill` name. Specificity scoring: exact > wildcard, +longer-pattern > shorter, `*` is the lowest. + +{global-dir} = $BMAD_HOME if set, otherwise ~/.bmad (cross-platform). Outputs merged JSON to stdout. Errors go to stderr. @@ -34,7 +62,10 @@ description/prompt. """ import argparse +import fnmatch import json +import os +import re import sys from pathlib import Path @@ -166,6 +197,93 @@ def deep_merge(base, override): return override +_MODULE_SKILLS_RE = re.compile(r"^(?P[A-Za-z0-9_-]+)-skills$") + + +def detect_skill_module(skill_dir: Path) -> str | None: + """Walk up from the skill dir looking for an ancestor named `{module}-skills`. + + Returns the module slug (e.g. 'bmm', 'core') or None if the skill isn't + inside a recognizable module tree (standalone skill, test fixture, etc.). + """ + for ancestor in skill_dir.parents: + match = _MODULE_SKILLS_RE.match(ancestor.name) + if match: + return match.group("module") + return None + + +def resolve_global_dir() -> Path: + """Locate the cross-platform global BMad config directory ($BMAD_HOME or ~/.bmad).""" + override = os.environ.get("BMAD_HOME") + if override: + return Path(override).expanduser().resolve() + return Path.home() / ".bmad" + + +def collect_customize_layers(project_root: Path | None, global_dir: Path) -> list[Path]: + """Return customize.toml file paths in lowest→highest priority order. + + Four layers (all optional): + 1. {global-dir}/customize.toml — global team / machine + 2. {global-dir}/customize.user.toml — global personal + 3. _bmad/custom/customize.toml — project team, committed + 4. _bmad/custom/customize.user.toml — project personal, gitignored + + Installer-managed _bmad/config*.toml is NOT scanned: customize is a + separate concern from identity, and the installer does not author it. + """ + layers: list[Path] = [ + global_dir / "customize.toml", + global_dir / "customize.user.toml", + ] + if project_root is not None: + custom_dir = project_root / "_bmad" / "custom" + layers.extend([ + custom_dir / "customize.toml", + custom_dir / "customize.user.toml", + ]) + return layers + + +def _pattern_specificity(pattern: str) -> tuple[int, int]: + """Score a [skills.X] pattern for specificity ordering (ascending = less specific). + + Tier 2 (exact, no wildcards): most specific. Tie-break by pattern length. + Tier 1 (wildcard, not pure '*'): mid. Longer patterns are more specific. + Tier 0 (bare '*'): catchall. + """ + if pattern == "*": + return (0, 0) + if "*" in pattern or "?" in pattern or "[" in pattern: + return (1, len(pattern)) + return (2, len(pattern)) + + +def extract_skill_overrides(layer_data: dict, qualified: str | None, bare: str) -> dict: + """Build a single-skill override dict from a layer's [skills.X] table. + + Matches every pattern that fnmatches against the bare or qualified name, + then deep-merges them in ascending specificity so the most specific wins. + """ + skills_table = layer_data.get("skills") + if not isinstance(skills_table, dict): + return {} + matched: list[tuple[tuple[int, int], dict]] = [] + for pattern, override in skills_table.items(): + if not isinstance(override, dict): + continue + if fnmatch.fnmatchcase(bare, pattern) or ( + qualified is not None and fnmatch.fnmatchcase(qualified, pattern) + ): + matched.append((_pattern_specificity(pattern), override)) + matched.sort(key=lambda item: item[0]) + result: dict = {} + for _, override in matched: + result = deep_merge(result, override) + return result + + def extract_key(data, dotted_key: str): parts = dotted_key.split(".") current = data @@ -187,7 +305,7 @@ def write_json_stdout(output): def main(): parser = argparse.ArgumentParser( - description="Resolve customization for a BMad skill using three-layer TOML merge.", + description="Resolve customization for a BMad skill using a layered TOML merge with [skills.X] cascade.", add_help=True, ) parser.add_argument( @@ -202,6 +320,8 @@ def main(): skill_dir = Path(args.skill).resolve() skill_name = skill_dir.name + module = detect_skill_module(skill_dir) + qualified = f"{module}/{skill_name}" if module else None defaults_path = skill_dir / "customize.toml" defaults = load_toml(defaults_path, required=True) @@ -211,16 +331,27 @@ def main(): # for standalone skills invoked directly). Using cwd first is unsafe when # an ancestor of cwd happens to have a stray _bmad/ from another project. project_root = find_project_root(skill_dir) or find_project_root(Path.cwd()) + global_dir = resolve_global_dir() - team = {} - user = {} + merged = defaults + + # Walk the customize layers low→high, applying any [skills.X] sections + # that match this skill. This is the cross-cutting override surface: + # users can cascade values across all skills, all skills in a module, or + # one specific skill without editing per-skill files. + for layer_path in collect_customize_layers(project_root, global_dir): + layer_data = load_toml(layer_path) + skill_override = extract_skill_overrides(layer_data, qualified, skill_name) + if skill_override: + merged = deep_merge(merged, skill_override) + + # Per-skill override files (highest priority — most explicit). These keep + # working for back-compat and remain the right tool when an override is + # large or wholly skill-specific. if project_root: custom_dir = project_root / "_bmad" / "custom" - team = load_toml(custom_dir / f"{skill_name}.toml") - user = load_toml(custom_dir / f"{skill_name}.user.toml") - - merged = deep_merge(defaults, team) - merged = deep_merge(merged, user) + merged = deep_merge(merged, load_toml(custom_dir / f"{skill_name}.toml")) + merged = deep_merge(merged, load_toml(custom_dir / f"{skill_name}.user.toml")) if args.key: output = {} diff --git a/src/scripts/tests/test_resolve_config.py b/src/scripts/tests/test_resolve_config.py new file mode 100644 index 000000000..78ba64704 --- /dev/null +++ b/src/scripts/tests/test_resolve_config.py @@ -0,0 +1,197 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "resolve_config.py" + + +def run_raw(args, env_overrides=None): + """Run resolver, return CompletedProcess-like object with decoded streams. + Use this when the test expects a non-zero exit (e.g. fail-fast checks).""" + env = os.environ.copy() + # Force BMAD_HOME to a guaranteed-missing path so any value the developer + # has set in their shell can't leak the real ~/.bmad into a test that + # expected an empty global. Tests that need a populated global pass it via + # env_overrides below. + env["BMAD_HOME"] = "/nonexistent-bmad-home-default" + if env_overrides: + env.update(env_overrides) + result = subprocess.run( + [sys.executable, str(SCRIPT), *args], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + check=False, + ) + result.stdout = result.stdout.decode("utf-8", errors="replace") + result.stderr = result.stderr.decode("utf-8", errors="replace") + return result + + +def run(args, env_overrides=None): + result = run_raw(args, env_overrides=env_overrides) + if result.returncode != 0: + raise AssertionError(f"resolve_config failed ({result.returncode}): {result.stderr}") + return json.loads(result.stdout) + + +class ResolveConfigTests(unittest.TestCase): + def test_no_project_root_no_global_returns_empty(self): + with tempfile.TemporaryDirectory() as empty_global: + data = run([], env_overrides={"BMAD_HOME": empty_global}) + self.assertEqual(data, {}) + + def test_global_only_when_no_project_root(self): + with tempfile.TemporaryDirectory() as global_dir: + (Path(global_dir) / "config.toml").write_text( + '[core]\nuser_name = "Globie"\n', encoding="utf-8" + ) + data = run([], env_overrides={"BMAD_HOME": global_dir}) + self.assertEqual(data["core"]["user_name"], "Globie") + + def test_project_overrides_global(self): + with tempfile.TemporaryDirectory() as global_dir, tempfile.TemporaryDirectory() as proj: + (Path(global_dir) / "config.toml").write_text( + '[core]\nuser_name = "Globie"\ncommunication_language = "French"\n', + encoding="utf-8", + ) + bmad = Path(proj) / "_bmad" + bmad.mkdir() + (bmad / "config.toml").write_text( + '[core]\nuser_name = "ProjectUser"\n', encoding="utf-8" + ) + data = run( + ["--project-root", proj], env_overrides={"BMAD_HOME": global_dir} + ) + # Project wins on user_name; global fills in communication_language + self.assertEqual(data["core"]["user_name"], "ProjectUser") + self.assertEqual(data["core"]["communication_language"], "French") + + def test_custom_user_beats_everything(self): + with tempfile.TemporaryDirectory() as global_dir, tempfile.TemporaryDirectory() as proj: + (Path(global_dir) / "config.user.toml").write_text( + '[core]\nuser_name = "Globie"\n', encoding="utf-8" + ) + bmad = Path(proj) / "_bmad" + (bmad / "custom").mkdir(parents=True) + (bmad / "config.toml").write_text( + '[core]\nuser_name = "Installer"\n', encoding="utf-8" + ) + (bmad / "custom" / "config.user.toml").write_text( + '[core]\nuser_name = "Pinned"\n', encoding="utf-8" + ) + data = run( + ["--project-root", proj], env_overrides={"BMAD_HOME": global_dir} + ) + self.assertEqual(data["core"]["user_name"], "Pinned") + + def test_project_config_optional(self): + # _bmad/ exists (installed project) but no config.toml inside — that's + # the lean / global-only case and must not error. + with tempfile.TemporaryDirectory() as proj: + (Path(proj) / "_bmad").mkdir() + data = run(["--project-root", proj]) + self.assertEqual(data, {}) + + def test_project_root_without_bmad_dir_errors(self): + # --project-root pointing at a directory with no _bmad/ is treated + # as a broken install (typo, wiped install) — resolver exits non-zero + # rather than silently returning {}. Global-only mode (no + # --project-root) keeps the permissive behavior. + with tempfile.TemporaryDirectory() as proj: + result = run_raw(["--project-root", proj]) + self.assertNotEqual(result.returncode, 0) + self.assertIn("no _bmad/ directory", result.stderr) + + def test_module_floor_contributes_when_no_overrides(self): + # Module-shipped defaults from _bmad/{module}/module.toml should + # appear when nothing else specifies them. + with tempfile.TemporaryDirectory() as proj: + bmad = Path(proj) / "_bmad" / "bmm" + bmad.mkdir(parents=True) + (bmad / "module.toml").write_text( + '[modules.bmm]\nplanning_artifacts = "/from/module/yaml"\n' + '[agents.bmad-agent-analyst]\nname = "Mary"\nmodule = "bmm"\n', + encoding="utf-8", + ) + data = run(["--project-root", proj]) + self.assertEqual(data["modules"]["bmm"]["planning_artifacts"], "/from/module/yaml") + self.assertEqual(data["agents"]["bmad-agent-analyst"]["name"], "Mary") + + def test_config_toml_overrides_module_floor(self): + # Config layers sit above the module floor — explicit overrides win. + with tempfile.TemporaryDirectory() as proj: + bmad = Path(proj) / "_bmad" + mod = bmad / "bmm" + mod.mkdir(parents=True) + (mod / "module.toml").write_text( + '[modules.bmm]\nplanning_artifacts = "/module/default"\n', + encoding="utf-8", + ) + (bmad / "config.toml").write_text( + '[modules.bmm]\nplanning_artifacts = "/user/override"\n', + encoding="utf-8", + ) + data = run(["--project-root", proj]) + self.assertEqual( + data["modules"]["bmm"]["planning_artifacts"], "/user/override" + ) + + def test_multiple_modules_merge_independently(self): + # Each module writes its own [modules.X] / [agents.X] subtree; + # the merge should not collide across modules. + with tempfile.TemporaryDirectory() as proj: + bmad = Path(proj) / "_bmad" + (bmad / "bmm").mkdir(parents=True) + (bmad / "bmb").mkdir(parents=True) + (bmad / "bmm" / "module.toml").write_text( + '[modules.bmm]\nplanning_artifacts = "/bmm/path"\n', + encoding="utf-8", + ) + (bmad / "bmb" / "module.toml").write_text( + '[modules.bmb]\nbmad_builder_output_folder = "/bmb/path"\n', + encoding="utf-8", + ) + data = run(["--project-root", proj]) + self.assertEqual(data["modules"]["bmm"]["planning_artifacts"], "/bmm/path") + self.assertEqual( + data["modules"]["bmb"]["bmad_builder_output_folder"], "/bmb/path" + ) + + def test_module_floor_ignored_when_no_project_root(self): + # Without --project-root, no per-project files (including module + # floor) should be read. Just global. + with tempfile.TemporaryDirectory() as g: + (Path(g) / "config.toml").write_text( + '[core]\nuser_name = "Globie"\n', encoding="utf-8" + ) + data = run([], env_overrides={"BMAD_HOME": g}) + self.assertEqual(data, {"core": {"user_name": "Globie"}}) + + def test_non_module_dirs_skipped(self): + # _bmad/_config, _bmad/custom, _bmad/scripts must NOT be treated as + # modules even though they're direct children of _bmad/. + with tempfile.TemporaryDirectory() as proj: + bmad = Path(proj) / "_bmad" + for sub in ("_config", "custom", "scripts"): + (bmad / sub).mkdir(parents=True) + # No module.toml anywhere — resolver should return {}, not error + data = run(["--project-root", proj]) + self.assertEqual(data, {}) + + def test_bmad_home_env_var_honored(self): + with tempfile.TemporaryDirectory() as global_dir: + (Path(global_dir) / "config.toml").write_text( + '[core]\nuser_name = "FromEnv"\n', encoding="utf-8" + ) + data = run([], env_overrides={"BMAD_HOME": global_dir}) + self.assertEqual(data["core"]["user_name"], "FromEnv") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/scripts/tests/test_resolve_customization_cascade.py b/src/scripts/tests/test_resolve_customization_cascade.py new file mode 100644 index 000000000..9c63b5103 --- /dev/null +++ b/src/scripts/tests/test_resolve_customization_cascade.py @@ -0,0 +1,156 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "resolve_customization.py" + + +def make_skill(parent: Path, name: str, defaults_toml: str, module: str | None = None) -> Path: + """Create a fake skill dir, optionally under a `{module}-skills/` ancestor.""" + if module: + base = parent / f"{module}-skills" + base.mkdir(parents=True, exist_ok=True) + else: + base = parent + skill_dir = base / name + skill_dir.mkdir() + (skill_dir / "customize.toml").write_text(defaults_toml, encoding="utf-8") + return skill_dir + + +def run(skill_dir: Path, key=None, env_overrides=None): + env = os.environ.copy() + # Force BMAD_HOME to a guaranteed-missing path so the developer's real + # ~/.bmad never leaks into a test expecting an empty global. Tests that + # need a populated global override via env_overrides below. + env["BMAD_HOME"] = "/nonexistent-bmad-home-default" + if env_overrides: + env.update(env_overrides) + args = [sys.executable, str(SCRIPT), "--skill", str(skill_dir)] + if key: + args.extend(["--key", key]) + result = subprocess.run( + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, check=False + ) + stderr = result.stderr.decode("utf-8", errors="replace") + if result.returncode != 0: + raise AssertionError(f"resolve_customization failed ({result.returncode}): {stderr}") + return json.loads(result.stdout.decode("utf-8")) + + +class ResolveCustomizationCascadeTests(unittest.TestCase): + def test_defaults_pass_through_when_no_overrides(self): + with tempfile.TemporaryDirectory() as t: + skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n') + data = run(skill) + self.assertEqual(data["knobs"]["depth"], "low") + + def test_skills_section_in_global_customize_overrides_default(self): + with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as global_dir: + skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n') + (Path(global_dir) / "customize.toml").write_text( + '[skills.bmad-prd.knobs]\ndepth = "high"\n', + encoding="utf-8", + ) + data = run(skill, env_overrides={"BMAD_HOME": global_dir}) + self.assertEqual(data["knobs"]["depth"], "high") + + def test_config_toml_is_not_consulted_for_skills_section(self): + # [skills.X] in config.toml must NOT be honored — only customize.toml is. + with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g: + skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n') + (Path(g) / "config.toml").write_text( + '[skills.bmad-prd.knobs]\ndepth = "should-be-ignored"\n', + encoding="utf-8", + ) + data = run(skill, env_overrides={"BMAD_HOME": g}) + self.assertEqual(data["knobs"]["depth"], "low") + + def test_specificity_within_layer(self): + # Within ONE customize layer: exact skill name beats wildcard. + with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g: + skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n') + (Path(g) / "customize.toml").write_text( + "[skills.\"*\".knobs]\ndepth = \"catchall\"\n" + "[skills.bmad-prd.knobs]\ndepth = \"exact\"\n", + encoding="utf-8", + ) + data = run(skill, env_overrides={"BMAD_HOME": g}) + self.assertEqual(data["knobs"]["depth"], "exact") + + def test_layer_precedence_overrides_specificity(self): + # Across layers: higher layer wins even with less-specific pattern. + # Project custom user (highest customize layer) uses '*'; + # global (lowest) uses exact — project custom should still win. + with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g: + project = Path(t) / "project" + bmad = project / "_bmad" + (bmad / "custom").mkdir(parents=True) + skill = make_skill(project, "bmad-prd", '[knobs]\ndepth = "low"\n') + + (Path(g) / "customize.toml").write_text( + "[skills.bmad-prd.knobs]\ndepth = \"global-exact\"\n", + encoding="utf-8", + ) + (bmad / "custom" / "customize.user.toml").write_text( + "[skills.\"*\".knobs]\ndepth = \"custom-wildcard\"\n", + encoding="utf-8", + ) + data = run(skill, env_overrides={"BMAD_HOME": g}) + self.assertEqual(data["knobs"]["depth"], "custom-wildcard") + + def test_per_skill_custom_file_beats_customize_section(self): + # Per-skill _bmad/custom/{skill}.user.toml is still the highest tier. + with tempfile.TemporaryDirectory() as t: + project = Path(t) / "project" + bmad = project / "_bmad" + (bmad / "custom").mkdir(parents=True) + skill = make_skill(project, "bmad-prd", '[knobs]\ndepth = "low"\n') + + (bmad / "custom" / "customize.user.toml").write_text( + "[skills.bmad-prd.knobs]\ndepth = \"via-section\"\n", + encoding="utf-8", + ) + (bmad / "custom" / "bmad-prd.user.toml").write_text( + "[knobs]\ndepth = \"via-per-skill-file\"\n", + encoding="utf-8", + ) + data = run(skill) + self.assertEqual(data["knobs"]["depth"], "via-per-skill-file") + + def test_qualified_module_pattern_matches(self): + # Skill under bmm-skills/ → qualified name 'bmm/bmad-prd'. + # Pattern '[skills."bmm/*"]' should match. + with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g: + skill = make_skill( + Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n', module="bmm" + ) + (Path(g) / "customize.toml").write_text( + "[skills.\"bmm/*\".knobs]\ndepth = \"all-bmm\"\n", + encoding="utf-8", + ) + data = run(skill, env_overrides={"BMAD_HOME": g}) + self.assertEqual(data["knobs"]["depth"], "all-bmm") + + def test_qualified_exact_beats_module_wildcard(self): + # Within one layer: 'bmm/bmad-prd' (exact) beats 'bmm/*' (wildcard). + with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g: + skill = make_skill( + Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n', module="bmm" + ) + (Path(g) / "customize.toml").write_text( + "[skills.\"bmm/*\".knobs]\ndepth = \"module-wide\"\n" + "[skills.\"bmm/bmad-prd\".knobs]\ndepth = \"pinned\"\n", + encoding="utf-8", + ) + data = run(skill, env_overrides={"BMAD_HOME": g}) + self.assertEqual(data["knobs"]["depth"], "pinned") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 808ee6faa..9a3286ee9 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -1693,7 +1693,7 @@ async function runTests() { console.log(''); // ============================================================ - // Test Suite 35: Central Config Emission + // Test Suite 35: Central Config Emission (lean overrides + global routing) // ============================================================ console.log(`${colors.yellow}Test Suite 35: Central Config Emission${colors.reset}\n`); @@ -1702,6 +1702,12 @@ async function runTests() { // getModulePath). Only the destination bmadDir is a temp dir, which the // installer writes config.toml / config.user.toml / custom/ into. const tempBmadDir35 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-central-config-')); + // Phase 1: writeCentralConfig now writes scope:user core values to + // ~/.bmad/config.user.toml. Isolate via BMAD_HOME so we don't pollute + // the developer's real global config. + const tempGlobalDir35 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-global-')); + const priorBmadHome35 = process.env.BMAD_HOME; + process.env.BMAD_HOME = tempGlobalDir35; try { const moduleConfigs = { @@ -1764,43 +1770,54 @@ async function runTests() { assert(await fs.pathExists(teamPath), 'config.toml is written to disk'); assert(await fs.pathExists(userPath), 'config.user.toml is written to disk'); + // generateManifests would call writeGlobalUserCore right after this; we + // call it directly here to test the full contract. + const globalCorePath = await generator35.writeGlobalUserCore(moduleConfigs); + assert(globalCorePath !== null, 'writeGlobalUserCore writes a file when scope:user values exist'); + assert(globalCorePath.startsWith(tempGlobalDir35), 'writeGlobalUserCore targets $BMAD_HOME'); + assert(await fs.pathExists(globalCorePath), '~/.bmad/config.user.toml is written to disk'); + const teamContent = await fs.readFile(teamPath, 'utf8'); const userContent = await fs.readFile(userPath, 'utf8'); + const globalContent = await fs.readFile(globalCorePath, 'utf8'); - // [core] — team-scoped keys land in config.toml - assert(teamContent.includes('[core]'), 'config.toml has [core] section'); - assert(teamContent.includes('document_output_language = "English"'), 'Team-scope core key lands in config.toml'); - assert(teamContent.includes('output_folder = "_bmad-output"'), 'Team-scope output_folder lands in config.toml'); - assert(teamContent.includes('project_name = "demo-project"'), 'project_name lands in [core] (core key as of #2279)'); + // [core] — lean: only deltas from module.yaml defaults remain. + // project_name = "demo-project" differs from default ({directory_name}). + // document_output_language = "English" EQUALS default — stripped. + // output_folder = "_bmad-output" EQUALS default — stripped. + assert(teamContent.includes('[core]'), 'config.toml has [core] section (carries project_name delta)'); + assert(teamContent.includes('project_name = "demo-project"'), 'Delta core key (project_name) lands in config.toml'); + assert(!teamContent.includes('document_output_language'), 'Default core values are stripped from config.toml (Task F)'); assert(!teamContent.includes('user_name'), 'user_name (scope: user) is absent from config.toml'); assert(!teamContent.includes('communication_language'), 'communication_language (scope: user) is absent from config.toml'); - // [core] — user-scoped keys land in config.user.toml - assert(userContent.includes('[core]'), 'config.user.toml has [core] section'); - assert(userContent.includes('user_name = "TestUser"'), 'user_name lands in config.user.toml'); - assert(userContent.includes('communication_language = "Spanish"'), 'communication_language lands in config.user.toml'); - assert(!userContent.includes('document_output_language'), 'Team-scope key is absent from config.user.toml'); + // [core] user-scope no longer goes to project user file — it's routed + // to ~/.bmad/config.user.toml (Task D). + assert(!userContent.includes('user_name'), 'config.user.toml does NOT contain user_name (Task D: routed to global)'); + assert( + !userContent.includes('communication_language'), + 'config.user.toml does NOT contain communication_language (Task D: routed to global)', + ); - // [modules.bmm] — core-key pollution stripped; own user-scope key routed to user file - const bmmTeamMatch = teamContent.match(/\[modules\.bmm\][\s\S]*?(?=\n\[|$)/); - assert(bmmTeamMatch !== null, 'config.toml has [modules.bmm] section'); - if (bmmTeamMatch) { - const bmmTeamBlock = bmmTeamMatch[0]; - assert(bmmTeamBlock.includes('planning_artifacts'), 'bmm-owned team-scope key (planning_artifacts) lands under [modules.bmm]'); - assert(!bmmTeamBlock.includes('project_name'), 'project_name stripped from [modules.bmm] (now a core key, #2279)'); - assert(!bmmTeamBlock.includes('stale-bmm-copy'), 'stale bmm-copy of project_name not leaked into config.toml'); - assert(!bmmTeamBlock.includes('user_name'), 'user_name stripped from [modules.bmm] (core-key pollution)'); - assert(!bmmTeamBlock.includes('communication_language'), 'communication_language stripped from [modules.bmm]'); - assert(!bmmTeamBlock.includes('user_skill_level'), 'user_skill_level (scope: user) absent from [modules.bmm] in config.toml'); - } + // Global file at ~/.bmad/config.user.toml carries the scope:user core values + assert(globalContent.includes('[core]'), '~/.bmad/config.user.toml has [core] section'); + assert(globalContent.includes('user_name = "TestUser"'), 'user_name lands in ~/.bmad/config.user.toml'); + assert(globalContent.includes('communication_language = "Spanish"'), 'communication_language lands in ~/.bmad/config.user.toml'); + // [modules.bmm] — bmm answers in this fixture are ALL defaults (the test + // intentionally seeds the same values module.yaml defaults to). With + // Task F, an all-defaults section is omitted entirely. + // user_skill_level = "expert" IS a delta from default "intermediate" + // → that one key remains in [modules.bmm] in config.user.toml. + assert(!teamContent.includes('[modules.bmm]'), 'config.toml has NO [modules.bmm] section when all values equal defaults (Task F)'); const bmmUserMatch = userContent.match(/\[modules\.bmm\][\s\S]*?(?=\n\[|$)/); - assert(bmmUserMatch !== null, 'config.user.toml has [modules.bmm] section'); + assert(bmmUserMatch !== null, 'config.user.toml has [modules.bmm] section (carries the user_skill_level delta)'); if (bmmUserMatch) { - assert(bmmUserMatch[0].includes('user_skill_level = "expert"'), 'user_skill_level lands in config.user.toml [modules.bmm]'); + assert(bmmUserMatch[0].includes('user_skill_level = "expert"'), 'user_skill_level delta lands in config.user.toml'); } - // [modules.external-mod] — unknown schema, falls through as team; core keys still stripped + // [modules.external-mod] — unknown schema, no defaults to compare. Falls + // through as team; core-key pollution still stripped. const extMatch = teamContent.match(/\[modules\.external-mod\][\s\S]*?(?=\n\[|$)/); assert(extMatch !== null, 'Unknown-schema module survives with its own [modules.*] section'); if (extMatch) { @@ -1810,20 +1827,54 @@ async function runTests() { assert(!extBlock.includes('communication_language'), 'All core-key pollution stripped from unknown-schema module'); } - // [agents.*] — agent roster from bmm module.yaml baked into config.toml (team-only) - assert(teamContent.includes('[agents.bmad-agent-analyst]'), 'config.toml has [agents.bmad-agent-analyst] table'); - assert(teamContent.includes('[agents.bmad-agent-dev]'), 'config.toml has [agents.bmad-agent-dev] table'); - assert(teamContent.includes('module = "bmm"'), 'Agent entry serializes module field'); - assert(teamContent.includes('team = "software-development"'), 'Agent entry serializes team field'); - assert(teamContent.includes('name = "Mary"'), 'Agent entry serializes name'); - assert(teamContent.includes('icon = "📊"'), 'Agent entry serializes icon'); + // [agents.*] — Task F: NEVER emitted to config.toml. Roster lives in + // _bmad/{module}/module.toml floor (written by writeModuleTomls). + assert(!teamContent.includes('[agents.'), 'config.toml has NO [agents.*] sections (Task F)'); assert(!userContent.includes('[agents.'), '[agents.*] tables are never written to config.user.toml'); - // Header comments present on both files + // Header comments present on both project files assert(teamContent.includes('Installer-managed. Regenerated on every install'), 'config.toml has installer-managed header'); assert(userContent.includes('Holds install answers scoped to YOU personally.'), 'config.user.toml header clarifies user scope'); + assert(globalContent.includes('Global personal BMad config'), '~/.bmad/config.user.toml has global-personal header'); + + // writeGlobalUserCore must preserve hand-edits in shapes the old + // round-trip parser silently dropped — arrays, single-quoted strings, + // dotted/quoted keys, \uXXXX escapes, custom sections. + const handEdited = [ + '# user-authored — should survive installer rewrites', + '[core]', + 'user_name = "WillBeOverwritten"', + 'nickname = "blank-on-purpose"', + '', + '[custom_section]', + "literal = 'single-quoted string'", + 'tags = ["a", "b", "c"]', + String.raw`unicode = "Märy"`, + '"weird.key" = "quoted-dotted key"', + '', + ].join('\n'); + await fs.writeFile(globalCorePath, handEdited, 'utf8'); + await generator35.writeGlobalUserCore({ core: { user_name: 'Updated', communication_language: 'Italian' } }); + const afterReplay = await fs.readFile(globalCorePath, 'utf8'); + assert(afterReplay.includes('user_name = "Updated"'), 'writeGlobalUserCore updates the key we own'); + assert(afterReplay.includes('communication_language = "Italian"'), 'writeGlobalUserCore writes new scope:user key'); + assert(afterReplay.includes("literal = 'single-quoted string'"), 'writeGlobalUserCore preserves single-quoted string values'); + assert(afterReplay.includes('tags = ["a", "b", "c"]'), 'writeGlobalUserCore preserves array values'); + assert(afterReplay.includes(String.raw`unicode = "Märy"`), String.raw`writeGlobalUserCore preserves \uXXXX escapes verbatim`); + assert(afterReplay.includes('"weird.key" = "quoted-dotted key"'), 'writeGlobalUserCore preserves quoted/dotted keys'); + assert(afterReplay.includes('[custom_section]'), 'writeGlobalUserCore preserves user-authored sections'); + assert( + afterReplay.includes('nickname = "blank-on-purpose"'), + 'writeGlobalUserCore preserves hand-written keys outside the installer schema', + ); } finally { await fs.remove(tempBmadDir35).catch(() => {}); + await fs.remove(tempGlobalDir35).catch(() => {}); + if (priorBmadHome35 === undefined) { + delete process.env.BMAD_HOME; + } else { + process.env.BMAD_HOME = priorBmadHome35; + } } } @@ -1866,66 +1917,56 @@ async function runTests() { console.log(''); // ============================================================ - // Test Suite 37: Agent Preservation for Non-Contributing Modules + // Test Suite 37: Agent roster routes through module.toml, not config.toml // ============================================================ - console.log(`${colors.yellow}Test Suite 37: Agent Preservation for Non-Contributing Modules${colors.reset}\n`); + console.log(`${colors.yellow}Test Suite 37: Agent roster routes through module.toml${colors.reset}\n`); { - // Scenario: quickUpdate preserves a module whose source isn't available - // (e.g. external/marketplace). Its module.yaml isn't read, so its agents - // aren't in this.agents. writeCentralConfig must read the prior config.toml - // and keep those [agents.*] blocks so the roster doesn't silently shrink. - const tempBmadDir37 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-agent-preserve-')); + // Phase 1 (Task F): writeCentralConfig no longer emits [agents.*] blocks. + // The roster lives in _bmad/{module}/module.toml (the resolver floor). + // This suite verifies the relocation — same data, different home. + // + // Preservation across reinstalls (the OLD concern of this suite) is now + // a non-issue: writeModuleTomls always reads the source module.yaml at + // install time and rewrites module.toml from scratch. There's no "stale + // entry" to preserve because the file gets fully regenerated. + const tempBmadDir37 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-agents-floor-')); + const tempGlobalDir37 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-global-37-')); + const priorBmadHome37 = process.env.BMAD_HOME; + process.env.BMAD_HOME = tempGlobalDir37; try { - // Seed a prior config.toml with an agent from an external module - const priorToml = [ - '# prior', - '', - '[agents.bmad-agent-analyst]', - 'module = "bmm"', - 'team = "bmm"', - 'name = "Stale Mary"', - '', - '[agents.external-hero]', - 'module = "external-mod"', - 'team = "external-mod"', - 'name = "Hero"', - 'title = "External Agent"', - 'icon = "🦸"', - 'description = "Ships with the marketplace module."', - '', - ].join('\n'); - await fs.writeFile(path.join(tempBmadDir37, 'config.toml'), priorToml); + // Set up bmm/ directory so writeModuleTomls has a place to write + await fs.ensureDir(path.join(tempBmadDir37, 'bmm')); const generator37 = new ManifestGenerator(); generator37.bmadDir = tempBmadDir37; generator37.bmadFolderName = path.basename(tempBmadDir37); - generator37.updatedModules = ['core', 'bmm', 'external-mod']; + generator37.updatedModules = ['core', 'bmm']; - // bmm source is available; external-mod is not — it's a preserved module await generator37.collectAgentsFromModuleYaml(); - const freshModules = new Set(generator37.agents.map((a) => a.module)); - assert(freshModules.has('bmm'), 'bmm contributes fresh agents from src module.yaml'); - assert(!freshModules.has('external-mod'), 'external-mod source is unavailable (preserved-module scenario)'); - - await generator37.writeCentralConfig(tempBmadDir37, { core: {}, bmm: {}, 'external-mod': {} }); + await generator37.writeCentralConfig(tempBmadDir37, { core: {}, bmm: {} }); + await generator37.writeModuleTomls(tempBmadDir37); const teamContent = await fs.readFile(path.join(tempBmadDir37, 'config.toml'), 'utf8'); + const bmmTomlContent = await fs.readFile(path.join(tempBmadDir37, 'bmm', 'module.toml'), 'utf8'); - assert( - teamContent.includes('[agents.external-hero]'), - 'Preserved [agents.external-hero] block survives rewrite even though external-mod source was unavailable', - ); - assert(teamContent.includes('Ships with the marketplace module.'), 'Preserved block keeps its original description'); - assert(teamContent.includes('module = "external-mod"'), 'Preserved block keeps its module field'); + // Task F: config.toml carries no [agents.*] sections + assert(!teamContent.includes('[agents.'), 'config.toml has no [agents.*] sections (Task F)'); - // Freshly collected agents win over stale entries with the same code - const maryMatches = teamContent.match(/\[agents\.bmad-agent-analyst\]/g) || []; - assert(maryMatches.length === 1, 'bmad-agent-analyst emitted exactly once (fresh wins; stale not duplicated)'); - assert(!teamContent.includes('Stale Mary'), 'Stale name from prior config.toml is discarded when fresh module.yaml is read'); + // The roster lives in module.toml instead + assert(bmmTomlContent.includes('[agents.bmad-agent-analyst]'), 'bmm/module.toml carries [agents.bmad-agent-analyst]'); + assert(bmmTomlContent.includes('[agents.bmad-agent-dev]'), 'bmm/module.toml carries [agents.bmad-agent-dev]'); + assert(bmmTomlContent.includes('module = "bmm"'), 'Agent block in module.toml has module field'); + assert(bmmTomlContent.includes('name = "Mary"'), 'Agent block in module.toml has name'); } finally { await fs.remove(tempBmadDir37).catch(() => {}); + await fs.remove(tempGlobalDir37).catch(() => {}); + if (priorBmadHome37 === undefined) { + delete process.env.BMAD_HOME; + } else { + process.env.BMAD_HOME = priorBmadHome37; + } } } @@ -2006,17 +2047,46 @@ async function runTests() { assert(byCode.get('bmad-fake-ext-agent-one').module === 'fake-ext', 'agent.module matches the owning external module name'); assert(byCode.get('bmad-fake-ext-agent-one').team === 'fake', 'explicit team from module.yaml is preserved'); - await generator38.writeCentralConfig(tempBmadDir38, { - core: {}, - bmm: {}, - 'fake-ext': {}, - 'fake-skills': {}, - }); + // Set up per-module dirs so writeModuleTomls has somewhere to write + await fs.ensureDir(path.join(tempBmadDir38, 'fake-ext')); + await fs.ensureDir(path.join(tempBmadDir38, 'fake-skills')); - const teamContent = await fs.readFile(path.join(tempBmadDir38, 'config.toml'), 'utf8'); - assert(teamContent.includes('[agents.bmad-fake-ext-agent-one]'), 'external-module agents land in config.toml [agents.*] section'); - assert(teamContent.includes('[agents.bmad-fake-skills-agent]'), 'skills-layout external module agents also land in config.toml'); - assert(teamContent.includes('First fake external agent.'), 'agent description from external module.yaml is written'); + // Isolate BMAD_HOME for the global-config write step + const tempGlobalDir38 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-global-38-')); + const priorBmadHome38 = process.env.BMAD_HOME; + process.env.BMAD_HOME = tempGlobalDir38; + + try { + await generator38.writeCentralConfig(tempBmadDir38, { + core: {}, + bmm: {}, + 'fake-ext': {}, + 'fake-skills': {}, + }); + await generator38.writeModuleTomls(tempBmadDir38); + + const teamContent = await fs.readFile(path.join(tempBmadDir38, 'config.toml'), 'utf8'); + // Task F: agents NEVER land in config.toml anymore + assert(!teamContent.includes('[agents.'), 'External module agents are NOT in config.toml (Task F)'); + + // Instead, each external module gets its own _bmad/{module}/module.toml floor + const fakeExtToml = await fs.readFile(path.join(tempBmadDir38, 'fake-ext', 'module.toml'), 'utf8'); + const fakeSkillsToml = await fs.readFile(path.join(tempBmadDir38, 'fake-skills', 'module.toml'), 'utf8'); + + assert(fakeExtToml.includes('[agents.bmad-fake-ext-agent-one]'), 'external-module agents land in their own module.toml floor'); + assert( + fakeSkillsToml.includes('[agents.bmad-fake-skills-agent]'), + 'skills-layout external module agents land in their own module.toml floor', + ); + assert(fakeExtToml.includes('First fake external agent.'), 'agent description from external module.yaml lands in module.toml'); + } finally { + await fs.remove(tempGlobalDir38).catch(() => {}); + if (priorBmadHome38 === undefined) { + delete process.env.BMAD_HOME; + } else { + process.env.BMAD_HOME = priorBmadHome38; + } + } } finally { if (priorCacheEnv === undefined) { delete process.env.BMAD_EXTERNAL_MODULES_CACHE; @@ -2988,6 +3058,12 @@ async function runTests() { // Test Suite 44: --set .= CLI overrides (#1663) // ============================================================ console.log(`${colors.yellow}Test Suite 44: --set CLI overrides${colors.reset}\n`); + // Isolate $BMAD_HOME for the whole suite — applySetOverrides now consults + // ~/.bmad/config.user.toml for routing decisions, and we don't want tests + // touching the developer's actual global identity. + const tempGlobalDir44 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-global-')); + const priorBmadHome44 = process.env.BMAD_HOME; + process.env.BMAD_HOME = tempGlobalDir44; try { const { parseSetEntry, parseSetEntries, applySetOverrides, upsertTomlKey, tomlString } = require('../tools/installer/set-overrides'); const { discoverOfficialModuleYamls, formatOptionsList } = require('../tools/installer/list-options'); @@ -3046,6 +3122,17 @@ async function runTests() { assert(tomlString(String.raw`back\slash`) === String.raw`"back\\slash"`, 'tomlString escapes backslashes'); assert(tomlString('line1\nline2') === String.raw`"line1\nline2"`, 'tomlString escapes newlines'); + // ---- tomlString: type inference (--set bmm.workers=4 must be int) ---- + assert(tomlString('true') === 'true', 'tomlString emits bare bool for "true"'); + assert(tomlString('false') === 'false', 'tomlString emits bare bool for "false"'); + assert(tomlString('4') === '4', 'tomlString emits bare integer for digit string'); + assert(tomlString('-17') === '-17', 'tomlString emits bare integer for negative'); + assert(tomlString('3.14') === '3.14', 'tomlString emits bare float for decimal'); + assert(tomlString('-0.5') === '-0.5', 'tomlString emits bare float for negative decimal'); + assert(tomlString('1e10') === '"1e10"', 'tomlString quotes scientific notation (not in inferred set)'); + assert(tomlString('4.') === '"4."', 'tomlString quotes incomplete float (preserves as string)'); + assert(tomlString('"true"') === String.raw`"\"true\""`, 'tomlString preserves explicitly-quoted "true" as string'); + // ---- upsertTomlKey: insert into existing section --------------------- { const before = `[core]\nuser_name = "Brian"\n\n[modules.bmm]\nproject_knowledge = "{project-root}/docs"\n`; @@ -3087,6 +3174,26 @@ async function runTests() { assert(!withoutTrailing.endsWith('\n'), 'upsertTomlKey preserves absence of trailing newline'); } + // ---- upsertTomlKey: `#` inside string value is NOT a comment --------- + // Per TOML spec, basic strings may contain unescaped `#`. The previous + // /\s+#/ scanner truncated such values, producing malformed TOML. + { + const before = `[core]\nproject_name = "hello # world"\n`; + const after = upsertTomlKey(before, '[core]', 'project_name', '"updated # value"'); + assert(after.includes('project_name = "updated # value"'), 'upsertTomlKey writes value containing # intact'); + assert(!after.includes('# world'), 'upsertTomlKey does not preserve a fake comment that lived inside the old string'); + } + + // ---- upsertTomlKey: section header with inline comment is found ----- + { + const before = `[core] # personal identity\nuser_name = "old"\n`; + const after = upsertTomlKey(before, '[core]', 'user_name', '"Brian"'); + assert(after.includes('user_name = "Brian"'), 'upsertTomlKey updates key under header with inline comment'); + // Should not have appended a duplicate [core] block at EOF. + const headerOccurrences = (after.match(/^\[core]/gm) || []).length; + assert(headerOccurrences === 1, `upsertTomlKey reuses existing [core] header (got ${headerOccurrences} headers)`); + } + // ---- applySetOverrides happy path ------------------------------------ { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-')); @@ -3152,15 +3259,24 @@ async function runTests() { await fs.writeFile(path.join(bmadDir, 'config.toml'), '[core]\nuser_name = "Brian"\n', 'utf8'); await fs.ensureDir(path.join(bmadDir, 'core')); await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'user_name: Brian\n', 'utf8'); - // Override targets a key only in team config; routes to team. user.toml - // never gets created in this case (correct — no user-scope writes). + // Override targets a core scope:user key. Per Task D's 3-tier routing, + // core.user_name lands in ~/.bmad/config.user.toml regardless of whether + // project user.toml exists — that's where the resolver picks it up. + // Project files (team and user) are left untouched. await applySetOverrides({ core: { user_name: 'Updated' } }, bmadDir); const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8'); - assert(team.includes('user_name = "Updated"'), 'applySetOverrides updates team key when user.toml is absent'); + const globalContent = await fs.readFile(path.join(tempGlobalDir44, 'config.user.toml'), 'utf8'); + assert( + globalContent.includes('user_name = "Updated"'), + 'applySetOverrides routes core.user_name to global when project user.toml is absent', + ); + assert(!team.includes('user_name = "Updated"'), 'applySetOverrides does not write core scope:user keys to project team config'); assert( !(await fs.pathExists(path.join(bmadDir, 'config.user.toml'))), - 'applySetOverrides does not create config.user.toml unnecessarily', + 'applySetOverrides does not create project config.user.toml for scope:user core keys', ); + // Reset global file for the next test block. + await fs.remove(path.join(tempGlobalDir44, 'config.user.toml')).catch(() => {}); await fs.remove(tmp).catch(() => {}); } @@ -3174,11 +3290,17 @@ async function runTests() { await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'user_name: Brian\n', 'utf8'); // bmm is not installed (no `_bmad/bmm/config.yaml`). The override for // bmm should be silently skipped, no `[modules.bmm]` section created. + // core.user_name is scope:user → routed to global (~/.bmad). const applied = await applySetOverrides({ bmm: { foo: 'bar' }, core: { user_name: 'Updated' } }, bmadDir); const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8'); + const globalContent = await fs.readFile(path.join(tempGlobalDir44, 'config.user.toml'), 'utf8'); assert(!team.includes('[modules.bmm]'), 'applySetOverrides does NOT create section for uninstalled module'); - assert(team.includes('user_name = "Updated"'), 'applySetOverrides still applies overrides for installed modules'); + assert( + globalContent.includes('user_name = "Updated"'), + 'applySetOverrides still applies overrides for installed modules (routed to global for scope:user core)', + ); assert(applied.length === 1 && applied[0].module === 'core', 'applySetOverrides reports only the installed-module entries'); + await fs.remove(path.join(tempGlobalDir44, 'config.user.toml')).catch(() => {}); await fs.remove(tmp).catch(() => {}); } @@ -3234,6 +3356,264 @@ async function runTests() { console.log(`${colors.red}Test Suite 44 setup failed: ${error.message}${colors.reset}`); console.log(error.stack); failed++; + } finally { + if (priorBmadHome44 === undefined) { + delete process.env.BMAD_HOME; + } else { + process.env.BMAD_HOME = priorBmadHome44; + } + await fs.remove(tempGlobalDir44).catch(() => {}); + } + + console.log(''); + + // ============================================================ + // Test Suite 45: Per-module module.toml floor (writeModuleTomls) + // ============================================================ + console.log(`${colors.yellow}Test Suite 45: Per-module module.toml floor${colors.reset}\n`); + + try { + const tempBmadDir45 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-module-toml-')); + + try { + // Real bmm src tree provides module.yaml. Installer expects each + // installed module directory to exist before writeModuleTomls runs. + await fs.ensureDir(path.join(tempBmadDir45, 'bmm')); + await fs.ensureDir(path.join(tempBmadDir45, 'core')); + + const generator45 = new ManifestGenerator(); + generator45.bmadDir = tempBmadDir45; + generator45.bmadFolderName = path.basename(tempBmadDir45); + generator45.updatedModules = ['core', 'bmm']; + + // Collect agents (needed for [agents.X] blocks per module) + await generator45.collectAgentsFromModuleYaml(); + assert(generator45.agents.length >= 6, 'agents collected before writeModuleTomls'); + + const written = await generator45.writeModuleTomls(tempBmadDir45); + assert(Array.isArray(written), 'writeModuleTomls returns array of written paths'); + assert(written.length > 0, 'writeModuleTomls writes at least one module.toml'); + + const bmmTomlPath = path.join(tempBmadDir45, 'bmm', 'module.toml'); + assert(await fs.pathExists(bmmTomlPath), 'bmm/module.toml is written to disk'); + + const bmmContent = await fs.readFile(bmmTomlPath, 'utf8'); + + // Header comment present + assert(bmmContent.includes('Module-shipped defaults'), 'module.toml has header comment'); + assert(bmmContent.includes('Build artifact'), 'module.toml warns against hand-editing'); + + // [modules.bmm] section with defaults from module.yaml + assert(bmmContent.includes('[modules.bmm]'), 'bmm/module.toml has [modules.bmm] section'); + assert(/planning_artifacts\s*=\s*"[^"]+"/.test(bmmContent), '[modules.bmm] carries planning_artifacts default'); + // {project-root} preserved literal (runtime substitution); {output_folder} + // resolved at install time against core's default output_folder. Final + // shape matches the legacy config.yaml convention. + assert( + bmmContent.includes('{project-root}/_bmad-output/planning-artifacts'), + 'Cross-key placeholders resolved at install time ({output_folder} → "_bmad-output"); {project-root} preserved', + ); + assert(!bmmContent.includes('{output_folder}'), '{output_folder} placeholder is NOT left literal — resolved against module defaults'); + + // [agents.X] blocks for module-owned agents + assert(bmmContent.includes('[agents.bmad-agent-analyst]'), 'bmm/module.toml has [agents.bmad-agent-analyst]'); + assert(bmmContent.includes('[agents.bmad-agent-dev]'), 'bmm/module.toml has [agents.bmad-agent-dev]'); + assert(bmmContent.includes('module = "bmm"'), 'Agent block carries module field'); + assert(bmmContent.includes('name = "Mary"'), 'Agent block carries name'); + assert(bmmContent.includes('icon = "📊"'), 'Agent block carries emoji icon'); + + // module.toml is SCOPED to one module — bmm should not carry core's agents + // (core/module.yaml ships no agents today, but the principle stands) + const coreTomlPath = path.join(tempBmadDir45, 'core', 'module.toml'); + assert(await fs.pathExists(coreTomlPath), 'core/module.toml is written even without agents'); + + const coreContent = await fs.readFile(coreTomlPath, 'utf8'); + // bmm agents must NOT appear in core/module.toml + assert(!coreContent.includes('[agents.bmad-agent-analyst]'), 'bmm-owned agents do NOT leak into core/module.toml'); + + // [core] questions (user_name, project_name, etc.) are core's defaults + // and must live at top-level [core] — resolvers + consumers read core.* + // from that namespace, not from a nested [modules.core] subtree. + assert(coreContent.includes('[core]'), 'core/module.toml has top-level [core] section'); + assert(!coreContent.includes('[modules.core]'), 'core defaults do NOT live under [modules.core]'); + assert(coreContent.includes('user_name = "BMad"'), 'core/module.toml carries user_name default ("BMad" from module.yaml)'); + assert(coreContent.includes('document_output_language = "English"'), 'core/module.toml carries document_output_language default'); + + // Question UI machinery NEVER appears as a TOML key in module.toml + // (Match on line-start "key =" so we don't catch the word "prompt" inside + // a description string — e.g. "Speaks like a terminal prompt".) + assert(!/^prompt\s*=/m.test(bmmContent), 'No prompt key in module.toml'); + assert(!/^scope\s*=/m.test(bmmContent), 'No scope key in module.toml'); + assert(!bmmContent.includes('single-select'), 'No single-select machinery in module.toml'); + } finally { + await fs.remove(tempBmadDir45).catch(() => {}); + } + } catch (error) { + console.log(`${colors.red}Test Suite 45 setup failed: ${error.message}${colors.reset}`); + console.log(error.stack); + failed++; + } + + console.log(''); + + // ============================================================ + // Test Suite 46: Phase 1 end-to-end — fresh machine + second project + // ============================================================ + console.log(`${colors.yellow}Test Suite 46: Phase 1 end-to-end (fresh machine + second project)${colors.reset}\n`); + + try { + const tempGlobalDir46 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-e2e-global-')); + const priorBmadHome46 = process.env.BMAD_HOME; + process.env.BMAD_HOME = tempGlobalDir46; + + try { + // ──────────────────────────────────────────────────────────── + // Part A: Fresh-machine install, project #1 + // ──────────────────────────────────────────────────────────── + const project1 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-e2e-proj1-')); + const bmad1 = path.join(project1, '_bmad'); + await fs.ensureDir(path.join(bmad1, 'core')); + await fs.ensureDir(path.join(bmad1, 'bmm')); + + // Simulated installer output for project #1: user kept defaults for + // every core key EXCEPT project_name, which is per-project. + const project1Configs = { + core: { + user_name: 'Brian', + project_name: 'first-project', + communication_language: 'English', + document_output_language: 'English', + output_folder: '{project-root}/_bmad-output', + }, + bmm: { + user_skill_level: 'intermediate', + planning_artifacts: '{project-root}/_bmad-output/planning-artifacts', + implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts', + project_knowledge: '{project-root}/docs', + }, + }; + + const gen1 = new ManifestGenerator(); + gen1.bmadDir = bmad1; + gen1.bmadFolderName = path.basename(bmad1); + gen1.updatedModules = ['core', 'bmm']; + await gen1.collectAgentsFromModuleYaml(); + await gen1.writeCentralConfig(bmad1, project1Configs); + await gen1.writeModuleTomls(bmad1); + await gen1.writeGlobalUserCore(project1Configs); + + // Project #1: config.toml is lean — only project_name (the one delta) + const team1 = await fs.readFile(path.join(bmad1, 'config.toml'), 'utf8'); + assert(team1.includes('project_name = "first-project"'), 'P1 config.toml carries project_name delta'); + assert(!team1.includes('user_name'), 'P1 config.toml has no user_name (scope:user routed to global)'); + assert(!team1.includes('document_output_language'), 'P1 config.toml strips default document_output_language'); + assert(!team1.includes('[agents.'), 'P1 config.toml has NO [agents.*] sections'); + assert(!team1.includes('[modules.bmm]'), 'P1 config.toml has NO [modules.bmm] section (all defaults)'); + + // Project #1: config.user.toml is just the header + const user1 = await fs.readFile(path.join(bmad1, 'config.user.toml'), 'utf8'); + assert(!user1.includes('user_name'), 'P1 config.user.toml has no user_name'); + assert(!user1.includes('[modules.bmm]'), 'P1 config.user.toml has no [modules.bmm]'); + + // Global file populated with scope:user core values from project #1 + const globalPath = path.join(tempGlobalDir46, 'config.user.toml'); + assert(await fs.pathExists(globalPath), 'Global ~/.bmad/config.user.toml created on first install'); + const global1 = await fs.readFile(globalPath, 'utf8'); + assert(global1.includes('user_name = "Brian"'), 'Global identity carries Brian after P1'); + assert(global1.includes('communication_language = "English"'), 'Global identity carries language after P1'); + + // module.toml floor: cross-key placeholders resolved at install time + const bmm1 = await fs.readFile(path.join(bmad1, 'bmm', 'module.toml'), 'utf8'); + assert( + bmm1.includes('planning_artifacts = "{project-root}/_bmad-output/planning-artifacts"'), + 'P1 bmm/module.toml has cross-key-resolved planning_artifacts', + ); + assert(bmm1.includes('[agents.bmad-agent-analyst]'), 'P1 bmm/module.toml carries agent roster'); + + // ──────────────────────────────────────────────────────────── + // Part B: Second project install on the same machine + // - Global identity from P1 should be picked up by the OfficialModules + // loader (loadGlobalConfig) as a silent default-source for scope:user + // core questions (D) and a seed for non-user-scope ones (E). + // - Resolver chain at runtime: global.core.user_name → reaches skills + // even though P2 never wrote it locally. + // ──────────────────────────────────────────────────────────── + const { OfficialModules } = require('../tools/installer/modules/official-modules'); + + const project2 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-e2e-proj2-')); + const bmad2 = path.join(project2, '_bmad'); + await fs.ensureDir(bmad2); + + const om = new OfficialModules(); + // Simulate what collectAllConfigurations does on entry: load global. + om.globalConfig = await require('../tools/installer/global-config').loadGlobalConfig(); + assert(om.globalConfig.merged.core, 'OfficialModules.loadGlobalConfig returns populated [core]'); + assert(om.globalConfig.merged.core.user_name === 'Brian', 'Global config from P1 visible to OfficialModules on P2 install'); + assert(om.globalConfig.merged.core.communication_language === 'English', 'Global identity round-trips through TOML correctly'); + + // ──────────────────────────────────────────────────────────── + // Part C: Python resolver sees identity via global even when P2's + // project _bmad has no [core] user_name. Validates the full layered + // resolver chain end-to-end. + // ──────────────────────────────────────────────────────────── + // Write a minimal P2 install (just project_name in config.toml) + const gen2 = new ManifestGenerator(); + gen2.bmadDir = bmad2; + gen2.bmadFolderName = path.basename(bmad2); + gen2.updatedModules = ['core', 'bmm']; + await fs.ensureDir(path.join(bmad2, 'core')); + await fs.ensureDir(path.join(bmad2, 'bmm')); + const project2Configs = { + core: { + user_name: 'Brian', // Reused from global silently (D) — would normally bypass write + project_name: 'second-project', + communication_language: 'English', + document_output_language: 'English', + output_folder: '{project-root}/_bmad-output', + }, + bmm: { + user_skill_level: 'intermediate', + planning_artifacts: '{project-root}/_bmad-output/planning-artifacts', + implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts', + project_knowledge: '{project-root}/docs', + }, + }; + await gen2.collectAgentsFromModuleYaml(); + await gen2.writeCentralConfig(bmad2, project2Configs); + await gen2.writeModuleTomls(bmad2); + // Note: do NOT call writeGlobalUserCore again — values would be a no-op + // merge, but in the real install they'd silently skip. + + const { execSync } = require('node:child_process'); + const resolverPath = path.resolve(__dirname, '..', 'src/scripts/resolve_config.py'); + const resolved = JSON.parse( + execSync( + `python3 ${resolverPath} --project-root ${project2} ` + + `--key core.user_name --key core.project_name --key agents.bmad-agent-analyst.name`, + { encoding: 'utf8', env: { ...process.env, BMAD_HOME: tempGlobalDir46 } }, + ), + ); + assert(resolved['core.user_name'] === 'Brian', 'Resolver returns user_name from global layer (P2 lean config.toml omitted it)'); + assert( + resolved['core.project_name'] === 'second-project', + 'Resolver returns project_name from P2 config.toml (project layer beats global)', + ); + assert(resolved['agents.bmad-agent-analyst.name'] === 'Mary', 'Resolver returns agent name from P2 module.toml floor'); + + await fs.remove(project1).catch(() => {}); + await fs.remove(project2).catch(() => {}); + } finally { + await fs.remove(tempGlobalDir46).catch(() => {}); + if (priorBmadHome46 === undefined) { + delete process.env.BMAD_HOME; + } else { + process.env.BMAD_HOME = priorBmadHome46; + } + } + } catch (error) { + console.log(`${colors.red}Test Suite 46 setup failed: ${error.message}${colors.reset}`); + console.log(error.stack); + failed++; } console.log(''); diff --git a/tools/installer/core/manifest-generator.js b/tools/installer/core/manifest-generator.js index f7b5d0084..bbde7640e 100644 --- a/tools/installer/core/manifest-generator.js +++ b/tools/installer/core/manifest-generator.js @@ -3,6 +3,8 @@ const fs = require('../fs-native'); const yaml = require('yaml'); const crypto = require('node:crypto'); const { resolveInstalledModuleYaml } = require('../project-root'); +const { globalUserConfigPath, loadGlobalConfig } = require('../global-config'); +const { upsertTomlKey } = require('../set-overrides'); const prompts = require('../prompts'); // Load package.json for version info @@ -82,11 +84,20 @@ class ManifestGenerator { // Write manifest files and collect their paths const [teamConfigPath, userConfigPath] = await this.writeCentralConfig(bmadDir, options.moduleConfigs || {}); + // Per-module module.toml floor — shipped defaults + agent roster, read + // by resolve_config.py as the lowest-priority layer. Independent of the + // central config.toml; remains stable across user customizations. + const moduleTomlPaths = await this.writeModuleTomls(bmadDir); + // Task D: route scope:user core answers to ~/.bmad/config.user.toml. + // Identity persists across projects on this machine, so re-installs in + // other directories don't re-prompt for the same name/language. + await this.writeGlobalUserCore(options.moduleConfigs || {}); const manifestFiles = [ await this.writeMainManifest(cfgDir), await this.writeSkillManifest(cfgDir), teamConfigPath, userConfigPath, + ...moduleTomlPaths, await this.writeFilesManifest(cfgDir), ]; @@ -419,25 +430,47 @@ class ManifestGenerator { } /** - * Write central _bmad/config.toml with [core], [modules.], [agents.] tables. - * Install-owned. Team-scope answers → config.toml; user-scope answers → config.user.toml. - * Both files are regenerated on every install. User overrides live in - * _bmad/custom/config.toml and _bmad/custom/config.user.toml (never touched by installer). + * Write central _bmad/config.toml as a LEAN OVERRIDE FILE — only values + * the user actually changed from defaults land here. Defaults flow through + * the module.toml floor (written by writeModuleTomls) and the global + * config layer (~/.bmad/...). + * + * Specifically (Phase 1 — tasks D + F): + * - [core] team-scope: emit only keys whose value differs from BOTH the + * module.yaml default AND the global value (if any). Identity that + * equals the global wins via the resolver chain regardless. + * - [core] user-scope (user_name, communication_language): NEVER written + * here. Routed to ~/.bmad/config.user.toml via writeGlobalUserCore. + * - [modules.X]: emit only keys whose value differs from the module's + * processed default. Skip the section entirely if all keys are default. + * - [agents.X]: NEVER emitted. The roster lives in module.toml floor; + * custom agents go in _bmad/custom/config.toml (never touched by us). + * + * Install-owned: both files are regenerated on every install. User + * overrides live in _bmad/custom/config.toml and _bmad/custom/config.user.toml + * (never touched by installer). + * * @returns {string[]} Paths to the written config files */ async writeCentralConfig(bmadDir, moduleConfigs) { const teamPath = path.join(bmadDir, 'config.toml'); const userPath = path.join(bmadDir, 'config.user.toml'); - // Load each module's source module.yaml to determine scope per prompt key. - // Default scope is 'team' when the prompt doesn't declare one. - // When a module.yaml is unreadable we warn — for known official modules - // this means user-scoped keys (e.g. user_name) could mis-file into the - // team config, so the operator should notice. + // Load each module's source module.yaml to determine: + // 1. scope per prompt key (team vs user) + // 2. the canonical module code (for [modules.{code}] section names) + // 3. processed defaults per key (for delta detection) + // + // Pass 1: parse every module.yaml and capture its raw shipped defaults. + // We use those — NOT the user's answered moduleConfigs — to resolve + // cross-key placeholders like `{output_folder}`. Otherwise a user override + // of output_folder would make every derived default (e.g. planning_artifacts) + // match the user's value and get stripped from config.toml as "default", + // even though module.toml's floor still carries the shipped path. const scopeByModuleKey = {}; - // Maps installer moduleName (may be full display name) → module code field - // from module.yaml, so TOML sections use [modules.] not [modules.]. const codeByModuleName = {}; + const defaultsByModuleKey = {}; + const parsedByModule = {}; for (const moduleName of this.updatedModules) { const moduleYamlPath = await resolveInstalledModuleYaml(moduleName); if (!moduleYamlPath) { @@ -450,13 +483,8 @@ class ManifestGenerator { try { const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8')); if (!parsed || typeof parsed !== 'object') continue; + parsedByModule[moduleName] = parsed; if (parsed.code) codeByModuleName[moduleName] = parsed.code; - scopeByModuleKey[moduleName] = {}; - for (const [key, value] of Object.entries(parsed)) { - if (value && typeof value === 'object' && 'prompt' in value) { - scopeByModuleKey[moduleName][key] = value.scope === 'user' ? 'user' : 'team'; - } - } } catch (error) { console.warn( `[warn] writeCentralConfig: could not parse module.yaml for '${moduleName}' (${error.message}). ` + @@ -465,6 +493,41 @@ class ManifestGenerator { } } + // Build the cross-key defaults map (same logic writeModuleTomls uses). + // Shipped defaults only — never user answers. + const crossKeyDefaults = {}; + for (const parsed of Object.values(parsedByModule)) { + const raw = extractModuleDefaults(parsed); + for (const [key, value] of Object.entries(raw)) { + if (crossKeyDefaults[key] !== undefined) continue; + let stripped = value; + if (typeof stripped === 'string' && stripped.startsWith('{project-root}/')) { + stripped = stripped.slice('{project-root}/'.length); + } + crossKeyDefaults[key] = stripped; + } + } + + // Pass 2: compute scopes and processed defaults using the symmetric map. + for (const [moduleName, parsed] of Object.entries(parsedByModule)) { + scopeByModuleKey[moduleName] = {}; + defaultsByModuleKey[moduleName] = {}; + for (const [key, value] of Object.entries(parsed)) { + if (!value || typeof value !== 'object' || !('prompt' in value)) continue; + scopeByModuleKey[moduleName][key] = value.scope === 'user' ? 'user' : 'team'; + const processedDefault = computeProcessedDefault(value, crossKeyDefaults); + if (processedDefault !== undefined) { + defaultsByModuleKey[moduleName][key] = processedDefault; + } + } + } + + // Load the global config snapshot for [core] delta detection. If a key's + // current value equals the global value, no need to duplicate it into the + // project file — the resolver finds it globally. + const globalSnapshot = await loadGlobalConfig().catch(() => ({ merged: {} })); + const globalCore = (globalSnapshot.merged && globalSnapshot.merged.core) || {}; + // Core keys are always known (core module.yaml is built-in). These are // the only keys allowed in [core]; they must be stripped from every // non-core module bucket because legacy _bmad/{mod}/config.yaml files @@ -494,6 +557,23 @@ class ManifestGenerator { return { team, user }; }; + // Drop entries whose value equals an already-known default. Tasks F + D + // both want config.toml to be a *delta* file — anything that matches + // either the module.yaml default or the global config gets resolved + // through the layer chain at read time, so writing it here is dead weight. + const stripDefaults = (entries, perKeyDefaults = {}, fallbackDefaults = {}) => { + const result = {}; + for (const [key, value] of Object.entries(entries)) { + const moduleDefault = perKeyDefaults[key]; + const fallbackDefault = fallbackDefaults[key]; + const isDefault = + (moduleDefault !== undefined && deepEqualScalar(moduleDefault, value)) || + (fallbackDefault !== undefined && deepEqualScalar(fallbackDefault, value)); + if (!isDefault) result[key] = value; + } + return result; + }; + const teamHeader = [ '# ─────────────────────────────────────────────────────────────────', '# Installer-managed. Regenerated on every install — treat as read-only.', @@ -526,9 +606,10 @@ class ManifestGenerator { const teamLines = [...teamHeader]; const userLines = [...userHeader]; - // [core] — split into team and user + // [core] team — emit only deltas from module.yaml default AND global value. const coreConfig = moduleConfigs.core || {}; - const { team: coreTeam, user: coreUser } = partition('core', coreConfig); + const { team: coreTeamRaw } = partition('core', coreConfig); + const coreTeam = stripDefaults(coreTeamRaw, defaultsByModuleKey.core || {}, globalCore); if (Object.keys(coreTeam).length > 0) { teamLines.push('[core]'); for (const [key, value] of Object.entries(coreTeam)) { @@ -536,28 +617,24 @@ class ManifestGenerator { } teamLines.push(''); } - if (Object.keys(coreUser).length > 0) { - userLines.push('[core]'); - for (const [key, value] of Object.entries(coreUser)) { - userLines.push(`${key} = ${formatTomlValue(value)}`); - } - userLines.push(''); - } + // [core] user-scope: never written to the project user.toml. Task D routes + // these to ~/.bmad/config.user.toml via writeGlobalUserCore (called by + // generateManifests after this method returns). config.user.toml stays + // empty unless the user has manually pinned a per-project override. - // [modules.] — split per module + // [modules.] — emit only deltas; skip section if no deltas. for (const moduleName of this.updatedModules) { if (moduleName === 'core') continue; const cfg = moduleConfigs[moduleName]; if (!cfg || Object.keys(cfg).length === 0) continue; - // Use the module's code field from module.yaml as the TOML key so the - // section is [modules.mdo] not [modules.MDO: Maxio DevOps Operations]. const sectionKey = codeByModuleName[moduleName] || moduleName; - // Only filter out spread-from-core pollution when we actually know - // this module's prompt schema. For external/marketplace modules whose - // module.yaml isn't in the src tree, fall through as all-team so we - // don't drop their real answers. const haveSchema = Object.keys(scopeByModuleKey[moduleName] || {}).length > 0; - const { team: modTeam, user: modUser } = partition(moduleName, cfg, haveSchema); + const { team: modTeamRaw, user: modUserRaw } = partition(moduleName, cfg, haveSchema); + + const moduleDefaults = defaultsByModuleKey[moduleName] || {}; + const modTeam = stripDefaults(modTeamRaw, moduleDefaults); + const modUser = stripDefaults(modUserRaw, moduleDefaults); + if (Object.keys(modTeam).length > 0) { teamLines.push(`[modules.${sectionKey}]`); for (const [key, value] of Object.entries(modTeam)) { @@ -574,43 +651,10 @@ class ManifestGenerator { } } - // [agents.] — always team (agent roster is organizational). - // Freshly collected agents come from module.yaml this run. If a module - // was preserved (e.g. during quickUpdate when its source isn't available), - // its module.yaml wasn't read — so its agents aren't in `this.agents` and - // would silently disappear from the roster. Preserve those existing - // [agents.*] blocks verbatim from the prior config.toml. - const freshAgentCodes = new Set(this.agents.map((a) => a.code)); - const contributingModules = new Set(this.agents.map((a) => a.module)); - const preservedModules = this.updatedModules.filter((m) => !contributingModules.has(m)); - const preservedBlocks = []; - if (preservedModules.length > 0 && (await fs.pathExists(teamPath))) { - try { - const prev = await fs.readFile(teamPath, 'utf8'); - for (const block of extractAgentBlocks(prev)) { - if (freshAgentCodes.has(block.code)) continue; - if (block.module && preservedModules.includes(block.module)) { - preservedBlocks.push(block.body); - } - } - } catch (error) { - console.warn(`[warn] writeCentralConfig: could not read prior config.toml to preserve agents: ${error.message}`); - } - } - - for (const agent of this.agents) { - const agentLines = [`[agents.${agent.code}]`, `module = ${formatTomlValue(agent.module)}`, `team = ${formatTomlValue(agent.team)}`]; - if (agent.name) agentLines.push(`name = ${formatTomlValue(agent.name)}`); - if (agent.title) agentLines.push(`title = ${formatTomlValue(agent.title)}`); - if (agent.icon) agentLines.push(`icon = ${formatTomlValue(agent.icon)}`); - if (agent.description) agentLines.push(`description = ${formatTomlValue(agent.description)}`); - agentLines.push(''); - teamLines.push(...agentLines); - } - - for (const body of preservedBlocks) { - teamLines.push(body, ''); - } + // [agents.] — intentionally NOT emitted (Task F). The roster lives + // in the per-module module.toml floor. Users who want to override or + // add agents per-project edit _bmad/custom/config.toml; that file is + // never touched by the installer. const teamContent = teamLines.join('\n').replace(/\n+$/, '\n'); const userContent = userLines.join('\n').replace(/\n+$/, '\n'); @@ -619,6 +663,198 @@ class ManifestGenerator { return [teamPath, userPath]; } + /** + * Write scope:user core values to ~/.bmad/config.user.toml (Task D). + * Merge-preserves any existing global content the user hand-edited. + * + * Why a global write step at all? Identity values (user_name, + * communication_language) are properties of the human, not the project. + * Asking them at every install is friction. Phase 1 stores them globally + * so they're answered once per machine. + * + * @param {object} moduleConfigs - the fully-resolved moduleConfigs map + * @returns {Promise} the path written, or null if no values + */ + async writeGlobalUserCore(moduleConfigs) { + const coreScopes = {}; + // We need core's module.yaml scope map. Build it lazily here so this + // method is callable without re-doing the writeCentralConfig setup. + const coreYamlPath = await resolveInstalledModuleYaml('core'); + if (!coreYamlPath) return null; + try { + const parsed = yaml.parse(await fs.readFile(coreYamlPath, 'utf8')); + if (parsed && typeof parsed === 'object') { + for (const [key, value] of Object.entries(parsed)) { + if (value && typeof value === 'object' && 'prompt' in value) { + coreScopes[key] = value.scope === 'user' ? 'user' : 'team'; + } + } + } + } catch { + return null; + } + + const userScopeValues = {}; + for (const [key, value] of Object.entries(moduleConfigs.core || {})) { + if (coreScopes[key] === 'user' && value !== undefined && value !== null && value !== '') { + userScopeValues[key] = value; + } + } + if (Object.keys(userScopeValues).length === 0) return null; + + const globalPath = globalUserConfigPath(); + await fs.ensureDir(path.dirname(globalPath)); + + // Line-surgery upsert into the existing file (or a fresh one with the + // installer header). We only touch the [core] keys we own. Every other + // section, comment, and value passes through byte-for-byte — including + // shapes the previous round-trip parser quietly dropped (arrays, + // single-quoted strings, dotted/quoted keys, \uXXXX escapes, etc.). + let content; + if (await fs.pathExists(globalPath)) { + content = await fs.readFile(globalPath, 'utf8'); + } else { + content = + [ + '# ─────────────────────────────────────────────────────────────────', + '# Global personal BMad config — values tied to YOU as a user, not', + '# any specific project. Installer writes scope:user identity here', + '# (user_name, communication_language) so re-installs across projects', + "# don't re-ask the same questions.", + '#', + '# Location precedence: $BMAD_HOME if set, else ~/.bmad', + '# Resolver tier: lower than project-level _bmad/*.toml.', + '# ─────────────────────────────────────────────────────────────────', + '', + ].join('\n') + '\n'; + } + for (const [key, value] of Object.entries(userScopeValues)) { + content = upsertTomlKey(content, '[core]', key, formatTomlValue(value)); + } + await fs.writeFile(globalPath, content); + return globalPath; + } + + /** + * Write per-module `_bmad/{module}/module.toml` files — the "module floor" + * read by resolve_config.py as the lowest-priority layer. + * + * Each file contains the shipped defaults for one module: + * [modules.{code}] paths and other module-shape values (from + * module.yaml question defaults, with `result:` + * template applied + cross-key placeholders like + * `{output_folder}` resolved against other modules' + * defaults at install time) + * [agents.{agent-code}] one block per agent owned by this module + * + * `{project-root}` is preserved literally — runtime substitution by skills. + * Other cross-key references resolve against module.yaml DEFAULTS only, + * not the user's actual answers. This keeps module.toml stable as a "what + * the module ships" snapshot independent of per-project customization. + * User overrides land in _bmad/config.toml above the floor. + * + * Source of truth is the authored module.yaml. This file is a build artifact + * — regenerated on every install, never hand-edited. + * + * @param {string} bmadDir + * @returns {Promise} Paths to all written module.toml files + */ + async writeModuleTomls(bmadDir) { + // Pass 1: parse every installed module.yaml, extract its raw defaults + // (just `{value}` substituted; cross-key placeholders left literal). + const moduleData = []; + for (const moduleName of this.updatedModules) { + const moduleYamlPath = await resolveInstalledModuleYaml(moduleName); + if (!moduleYamlPath) continue; + + let moduleDef; + try { + moduleDef = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8')); + } catch (error) { + console.warn( + `[warn] writeModuleTomls: could not parse module.yaml for '${moduleName}' (${error.message}). ` + + `Skipping module.toml for this module.`, + ); + continue; + } + if (!moduleDef || typeof moduleDef !== 'object') continue; + + const moduleDir = path.join(bmadDir, moduleName); + if (!(await fs.pathExists(moduleDir))) continue; + + const moduleCode = typeof moduleDef.code === 'string' ? moduleDef.code : moduleName; + const rawDefaults = extractModuleDefaults(moduleDef); + moduleData.push({ moduleName, moduleCode, moduleDir, rawDefaults }); + } + + // Build a flat cross-key lookup map from every module's raw defaults. + // First-define wins (deterministic given sorted updatedModules input). + // Values are stripped of a leading `{project-root}/` so substitutions + // re-compose cleanly when consumed in a `{project-root}/{key}/...` slot + // — matches the installer's processResultTemplate convention. + const crossKeyMap = {}; + for (const { rawDefaults } of moduleData) { + for (const [key, value] of Object.entries(rawDefaults)) { + if (crossKeyMap[key] !== undefined) continue; + let stripped = value; + if (typeof stripped === 'string' && stripped.startsWith('{project-root}/')) { + stripped = stripped.slice('{project-root}/'.length); + } + crossKeyMap[key] = stripped; + } + } + + // Pass 2: resolve cross-key placeholders in each module's defaults and + // write the final module.toml file. + const written = []; + for (const { moduleName, moduleCode, moduleDir, rawDefaults } of moduleData) { + const resolvedDefaults = {}; + for (const [key, value] of Object.entries(rawDefaults)) { + resolvedDefaults[key] = resolveCrossKeyPlaceholders(value, crossKeyMap); + } + + const lines = [ + '# Module-shipped defaults. Build artifact — do not edit by hand.', + "# Source: this module's module.yaml (authored at source).", + '# Regenerated on every install.', + '#', + '# Read by _bmad/scripts/resolve_config.py as the lowest-priority', + '# floor of the config layer chain. Project _bmad/config.toml and', + '# user overrides in _bmad/custom/ sit above this and win.', + '', + ]; + + if (Object.keys(resolvedDefaults).length > 0) { + // Core's defaults belong under top-level [core] — that's where + // writeCentralConfig emits core deltas and where resolve_config.py + // consumers read core.* from. Everything else gets the per-module + // [modules.] namespace. + const sectionHeader = moduleCode === 'core' ? '[core]' : `[modules.${moduleCode}]`; + lines.push(sectionHeader); + for (const [key, value] of Object.entries(resolvedDefaults)) { + lines.push(`${key} = ${formatTomlValue(value)}`); + } + lines.push(''); + } + + const moduleAgents = this.agents.filter((a) => a.module === moduleName); + for (const agent of moduleAgents) { + lines.push(`[agents.${agent.code}]`, `module = ${formatTomlValue(agent.module)}`, `team = ${formatTomlValue(agent.team)}`); + if (agent.name) lines.push(`name = ${formatTomlValue(agent.name)}`); + if (agent.title) lines.push(`title = ${formatTomlValue(agent.title)}`); + if (agent.icon) lines.push(`icon = ${formatTomlValue(agent.icon)}`); + if (agent.description) lines.push(`description = ${formatTomlValue(agent.description)}`); + lines.push(''); + } + + const outputPath = path.join(moduleDir, 'module.toml'); + const content = lines.join('\n').replace(/\n+$/, '\n'); + await fs.writeFile(outputPath, content); + written.push(outputPath); + } + return written; + } + /** * Create empty _bmad/custom/config.toml and _bmad/custom/config.user.toml stubs * on first install only. Installer never touches these files again after creation. @@ -806,6 +1042,130 @@ class ManifestGenerator { * Handles strings (quoted + escaped), booleans, numbers, and arrays of scalars. * Objects are not expected at this emit path. */ +/** + * Compute the processed default value for a module.yaml question item. + * Resolves `{key}` cross-references against the flat `crossKeyDefaults` lookup + * (shipped defaults, never user answers — see writeCentralConfig comment). + * Used by writeCentralConfig to detect default-equal values that should NOT + * be re-emitted into the lean config.toml. Matches the lookup table that + * writeModuleTomls uses, so module.toml's floor and config.toml's delta + * detection agree on what "default" means. + * + * Steps: + * 1. Substitute {key} references against crossKeyDefaults (with leading + * "{project-root}/" stripped, matching the installer's + * processResultTemplate behavior). + * 2. Apply the result: template with {value} substituted. + * + * Returns undefined for items without a default. + * + * @param {object} item - one module.yaml question schema + * @param {Record} crossKeyDefaults - flat shipped-defaults lookup + * @returns {*} processed default value (string/scalar) or undefined + */ +function computeProcessedDefault(item, crossKeyDefaults) { + if (!item || item.default === undefined || item.default === null) return; + let value = item.default; + if (typeof value === 'string') { + value = value.replaceAll(/{([^}]+)}/g, (match, refKey) => { + if (refKey === 'project-root' || refKey === 'value' || refKey === 'directory_name') { + return match; + } + const replacement = (crossKeyDefaults || {})[refKey]; + return replacement === undefined ? match : String(replacement); + }); + } + if (typeof item.result === 'string' && value !== undefined) { + return item.result.replaceAll('{value}', String(value)); + } + return value; +} + +/** + * Resolve `{key}` cross-references in a string value against a flat + * `{key: value}` lookup map. `{project-root}` and `{directory_name}` are + * preserved literal — they're runtime placeholders, substituted by the skill + * or resolver when the value is consumed. Unknown keys are left literal too. + * + * Used by writeModuleTomls so that module.toml's [modules.X] keys carry the + * same shape as the installer's resolved config (e.g. + * `"{project-root}/_bmad-output/planning-artifacts"`) — making the floor a + * drop-in for the central config when the latter omits a value as default. + * + * @param {*} value - typically a string; non-strings returned unchanged + * @param {Record} 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>} + */ +function extractModuleDefaults(moduleDef) { + const defaults = {}; + for (const [key, value] of Object.entries(moduleDef)) { + if (MODULE_DEFAULTS_SKIP.has(key)) continue; + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + if (!('default' in value)) continue; + + let resolved = value.default; + if (typeof value.result === 'string' && resolved !== undefined) { + // Apply `result:` template with `{value}` substituted by default. Leave + // other placeholders (`{project-root}`, `{output_folder}`, ...) literal. + resolved = value.result.replaceAll('{value}', String(resolved)); + } + defaults[key] = resolved; + } + return defaults; +} + function formatTomlValue(value) { if (value === null || value === undefined) return '""'; if (typeof value === 'boolean') return value ? 'true' : 'false'; @@ -821,39 +1181,4 @@ function formatTomlValue(value) { return `"${escaped}"`; } -/** - * Extract [agents.] blocks from a previously-emitted config.toml. - * We only need this for roster preservation — the file is our own controlled - * output, so a simple line scanner is safer than adding a TOML parser - * dependency. Each block runs from its `[agents.]` header until the - * next `[` heading or EOF; the `module = "..."` line inside drives which - * entries we keep on the next write. - * @returns {Array<{code: string, module: string | null, body: string}>} - */ -function extractAgentBlocks(tomlContent) { - const blocks = []; - const lines = tomlContent.split('\n'); - let i = 0; - while (i < lines.length) { - const header = lines[i].match(/^\[agents\.([^\]]+)]\s*$/); - if (!header) { - i++; - continue; - } - const code = header[1]; - const blockLines = [lines[i]]; - let moduleName = null; - i++; - while (i < lines.length && !lines[i].startsWith('[')) { - blockLines.push(lines[i]); - const m = lines[i].match(/^module\s*=\s*"((?:[^"\\]|\\.)*)"\s*$/); - if (m) moduleName = m[1]; - i++; - } - while (blockLines.length > 1 && blockLines.at(-1) === '') blockLines.pop(); - blocks.push({ code, module: moduleName, body: blockLines.join('\n') }); - } - return blocks; -} - module.exports = { ManifestGenerator }; diff --git a/tools/installer/global-config.js b/tools/installer/global-config.js new file mode 100644 index 000000000..aee0a5337 --- /dev/null +++ b/tools/installer/global-config.js @@ -0,0 +1,180 @@ +/** + * Helpers for the cross-platform global BMad config directory. + * + * The "global" tier is read-only to most installer code paths — only core + * scope:user answers (user_name, communication_language) and identity defaults + * are written there, and only by the post-install global-write step. Everything + * else reads. + * + * Location precedence: + * 1. $BMAD_HOME (for CI / corporate / multi-account setups) + * 2. ~/.bmad + * + * Works on macOS, Linux, WSL, and Windows: os.homedir() returns the + * platform-appropriate home (and on WSL, the Linux home — each WSL distro has + * its own global config). + */ + +const path = require('node:path'); +const os = require('node:os'); +const fs = require('./fs-native'); + +function resolveGlobalDir() { + const override = process.env.BMAD_HOME; + if (override && override.trim()) { + return path.resolve(expandTilde(override.trim())); + } + return path.join(os.homedir(), '.bmad'); +} + +// JS counterpart to Python's Path.expanduser() — keeps installer/resolver +// agreement when BMAD_HOME is set in non-shell contexts (Docker, .env files, +// Windows env var GUI) where the shell never expands `~`. +function expandTilde(input) { + if (input === '~') return os.homedir(); + if (input.startsWith('~/') || input.startsWith('~\\')) { + return path.join(os.homedir(), input.slice(2)); + } + return input; +} + +function globalTeamConfigPath() { + return path.join(resolveGlobalDir(), 'config.toml'); +} + +function globalUserConfigPath() { + return path.join(resolveGlobalDir(), 'config.user.toml'); +} + +/** + * Parse a minimal subset of TOML — enough for the installer-owned files: + * top-level tables ([section] / [section.sub]) and simple scalar values + * (string, number, boolean). No arrays of tables, inline tables, datetimes, + * or multiline strings — those don't appear in files we author. Reader stays + * dependency-free; we only consume what we emit. + * + * For an unrecognized shape, the offending line is silently dropped (rather + * than erroring) to keep the installer resilient against hand-edits that + * went slightly outside the documented schema. + */ +function parseSimpleToml(content) { + const result = {}; + let currentTable = result; + + for (const rawLine of content.split('\n')) { + const line = stripInlineComment(rawLine).trim(); + if (!line) continue; + + const sectionMatch = line.match(/^\[([^\]]+)]\s*$/); + if (sectionMatch) { + const parts = sectionMatch[1].split('.').map((p) => p.trim()); + currentTable = result; + for (const part of parts) { + if (!currentTable[part] || typeof currentTable[part] !== 'object' || Array.isArray(currentTable[part])) { + currentTable[part] = {}; + } + currentTable = currentTable[part]; + } + continue; + } + + const kvMatch = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/); + if (kvMatch) { + const [, key, rawValue] = kvMatch; + const parsed = parseTomlScalar(rawValue.trim()); + if (parsed !== undefined) { + currentTable[key] = parsed; + } + } + } + + return result; +} + +/** + * Strip a trailing `# comment` from a TOML line, but only when the `#` lives + * outside a double-quoted string. We don't author multiline strings or + * literal strings, so a single double-quote scanner is sufficient. + */ +function stripInlineComment(line) { + let inString = false; + let escaped = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\') { + escaped = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (ch === '#' && !inString) { + return line.slice(0, i); + } + } + return line; +} + +function parseTomlScalar(raw) { + if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) { + // Single-pass unescape — sequential replaceAll lets `\\n` (backslash + n) + // collapse into a newline because the second pass sees the just-produced + // `\n` and treats it as the escape sequence. One regex avoids that. + const escapes = { '\\\\': '\\', '\\"': '"', '\\n': '\n', '\\r': '\r', '\\t': '\t' }; + return raw.slice(1, -1).replaceAll(/\\["\\nrt]/g, (m) => escapes[m] ?? m); + } + if (raw === 'true') return true; + if (raw === 'false') return false; + if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10); + if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw); + return; // dropped silently — see header comment +} + +/** + * Load both global TOML files. Either may be missing; returns merged result. + * Files are read but never written by this helper. + * + * @returns {Promise<{ team: object, user: object, merged: object }>} + */ +async function loadGlobalConfig() { + const team = await readTomlFile(globalTeamConfigPath()); + const user = await readTomlFile(globalUserConfigPath()); + // Shallow-deep merge: user table wins over team at every key path. The + // installer only consults the merged view for default-seeding, so this is + // sufficient (we don't need the full structural-merge of resolve_config.py). + const merged = mergeDeep(team, user); + return { team, user, merged }; +} + +async function readTomlFile(filePath) { + if (!(await fs.pathExists(filePath))) return {}; + try { + const content = await fs.readFile(filePath, 'utf8'); + return parseSimpleToml(content); + } catch { + return {}; + } +} + +function mergeDeep(base, override) { + if (!override || typeof override !== 'object' || Array.isArray(override)) return override === undefined ? base : override; + if (!base || typeof base !== 'object' || Array.isArray(base)) return override; + const result = { ...base }; + for (const [key, value] of Object.entries(override)) { + result[key] = mergeDeep(result[key], value); + } + return result; +} + +module.exports = { + resolveGlobalDir, + globalTeamConfigPath, + globalUserConfigPath, + parseSimpleToml, + loadGlobalConfig, +}; diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index db2933427..d2f22f010 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -5,6 +5,7 @@ const prompts = require('../prompts'); const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root'); const { CLIUtils } = require('../cli-utils'); const { ExternalModuleManager } = require('./external-manager'); +const { loadGlobalConfig } = require('../global-config'); class OfficialModules { constructor(options = {}) { @@ -1019,11 +1020,26 @@ class OfficialModules { * @param {string} projectDir - Target project directory * @param {Object} options - Additional options * @param {boolean} options.skipPrompts - Skip prompts and use defaults (for --yes flag) + * + * Non-core modules are always silent: they accept module.yaml defaults + * without prompting. Users adjust values later via the `bmad-customize` + * skill or direct edits to _bmad/custom/config.toml / customize.toml. + * Core is still fully prompted (identity questions live there) — though + * scope:user core values that are already in ~/.bmad/config.user.toml + * are silently reused (see below). */ async collectAllConfigurations(modules, projectDir, options = {}) { this.skipPrompts = options.skipPrompts || false; this.modulesToCustomize = undefined; await this.loadExistingConfig(projectDir); + // Read the cross-platform global config (~/.bmad or $BMAD_HOME). Used to: + // - Skip core scope:user questions whose value is already known globally + // (task D — ask identity once per machine, not once per project). + // - Seed defaults for non-user-scope core questions (task E — let users + // pre-pin default project_name/output_folder/etc. globally if they want). + // Project-installed values still beat global at resolve time; this is + // purely about what we ASK during install. + this.globalConfig = await loadGlobalConfig(); // Check if core was already collected (e.g., in early collection phase) const coreAlreadyCollected = this.collectedConfig.core && Object.keys(this.collectedConfig.core).length > 0; @@ -1045,44 +1061,17 @@ class OfficialModules { await this.collectModuleConfig(moduleName, projectDir); } - // Show batch configuration gateway for non-core modules - // Scan all non-core module schemas for display names and config metadata + // Non-core modules are always silent: accept module.yaml defaults without + // prompting. Users adjust via `bmad-customize` later or by editing + // _bmad/custom/{config,customize}.toml. Setting modulesToCustomize to an + // empty Set bypasses the legacy per-module "Accept Defaults?" confirm + // (collectModuleConfig only fires that when modulesToCustomize is + // undefined). scanModuleSchemas is still called so the spinner can show + // friendly display names while applying defaults. let scannedModules = []; if (!this.skipPrompts && nonCoreModules.length > 0) { + this.modulesToCustomize = new Set(); scannedModules = await this.scanModuleSchemas(nonCoreModules); - const customizableModules = scannedModules.filter((m) => m.questionCount > 0); - - if (customizableModules.length > 0) { - const configMode = await prompts.select({ - message: 'Module configuration', - choices: [ - { name: 'Express Setup', value: 'express', hint: 'accept all defaults (recommended)' }, - { name: 'Customize', value: 'customize', hint: 'choose modules to configure' }, - ], - default: 'express', - }); - - if (configMode === 'customize') { - const choices = customizableModules.map((m) => ({ - name: `${m.displayName} (${m.questionCount} option${m.questionCount === 1 ? '' : 's'})`, - value: m.moduleName, - hint: m.hasFieldsWithoutDefaults ? 'has fields without defaults' : undefined, - checked: m.hasFieldsWithoutDefaults, - })); - const selected = await prompts.multiselect({ - message: 'Select modules to customize:', - choices, - required: false, - }); - this.modulesToCustomize = new Set(selected); - } else { - // Express mode: no modules to customize - this.modulesToCustomize = new Set(); - } - } else { - // All non-core modules have zero config - no gateway needed - this.modulesToCustomize = new Set(); - } } // Collect remaining non-core modules @@ -1155,6 +1144,18 @@ class OfficialModules { if (!this._existingConfig) { await this.loadExistingConfig(projectDir); } + // Lazy-load global config so identity fallbacks below can consult + // ~/.bmad/config.user.toml. quickUpdate doesn't go through + // collectAllConfigurations, so this.globalConfig would otherwise be unset + // and user_name would silently default to the OS username — overwriting + // the value the user previously committed to global. + if (!this.globalConfig) { + try { + this.globalConfig = await loadGlobalConfig(); + } catch { + this.globalConfig = { merged: {} }; + } + } // Initialize allAnswers if not already initialized if (!this.allAnswers) { @@ -1231,12 +1232,14 @@ class OfficialModules { } this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] }; - // Special handling for user_name: ensure it has a value + // Special handling for user_name: ensure it has a value. Prefer the + // global value (~/.bmad/config.user.toml) before the OS username, or + // we'll silently overwrite the user's prior global identity. if ( moduleName === 'core' && (!this.collectedConfig[moduleName].user_name || this.collectedConfig[moduleName].user_name === '[USER_NAME]') ) { - this.collectedConfig[moduleName].user_name = this.getDefaultUsername(); + this.collectedConfig[moduleName].user_name = this._identityFallback('user_name'); } // Also populate allAnswers for cross-referencing @@ -1244,18 +1247,20 @@ class OfficialModules { // Ensure user_name is properly set in allAnswers too let finalValue = value; if (moduleName === 'core' && key === 'user_name' && (!value || value === '[USER_NAME]')) { - finalValue = this.getDefaultUsername(); + finalValue = this._identityFallback('user_name'); } this.allAnswers[`${moduleName}_${key}`] = finalValue; } } else if (moduleName === 'core') { - // No existing core config - ensure we at least have user_name + // No existing core config - ensure we at least have user_name. + // Same global-first preference as above. if (!this.collectedConfig[moduleName]) { this.collectedConfig[moduleName] = {}; } if (!this.collectedConfig[moduleName].user_name) { - this.collectedConfig[moduleName].user_name = this.getDefaultUsername(); - this.allAnswers[`${moduleName}_user_name`] = this.getDefaultUsername(); + const fallback = this._identityFallback('user_name'); + this.collectedConfig[moduleName].user_name = fallback; + this.allAnswers[`${moduleName}_user_name`] = fallback; } } @@ -1433,6 +1438,22 @@ class OfficialModules { return result; } + /** + * Fall back through identity sources for a core scope:user key. Prefers the + * global value (~/.bmad/config.user.toml) so quickUpdate / re-install never + * silently overwrites a previously-set identity with the OS username. + * Only user_name has an OS-derived ultimate fallback; other keys return + * undefined so the caller can decide. + */ + _identityFallback(key) { + const globalCore = (this.globalConfig && this.globalConfig.merged && this.globalConfig.merged.core) || {}; + if (globalCore[key] !== undefined && globalCore[key] !== '' && globalCore[key] !== null) { + return globalCore[key]; + } + if (key === 'user_name') return this.getDefaultUsername(); + return; + } + /** * Collect configuration for a single module * @param {string} moduleName - Module name @@ -1507,6 +1528,79 @@ class OfficialModules { } } + // Tasks D + E: for the core module, consult the global config (~/.bmad). + // D — scope:user keys (user_name, communication_language): if already + // known globally, accept silently. Don't prompt; store the final + // form directly in collectedConfig so cross-references still work. + // E — non-user-scope keys (project_name, output_folder, ...): if known + // globally, seed the prompt default from there. User can still + // change it per-project. + // Track silently-reused keys so the user knows where the values came from + // (otherwise they'd see questions they previously answered just disappear). + const reusedFromGlobal = []; + const seededFromGlobal = []; + if (moduleName === 'core' && this.globalConfig && this.globalConfig.merged && this.globalConfig.merged.core) { + const globalCore = this.globalConfig.merged.core; + const remaining = []; + + for (const question of questions) { + const key = question.name.replace(`${moduleName}_`, ''); + const item = moduleConfig[key]; + const globalValue = globalCore[key]; + + if (globalValue === undefined || globalValue === null || globalValue === '') { + remaining.push(question); + continue; + } + + if (item && item.scope === 'user') { + // D: silent reuse. Stash final form into collectedConfig and skip + // adding this question to the prompt list. The post-answer + // result-template loop never sees this key. + this.collectedConfig[moduleName] = this.collectedConfig[moduleName] || {}; + this.collectedConfig[moduleName][key] = globalValue; + reusedFromGlobal.push({ key, value: globalValue }); + continue; + } + + // E: pre-seed the prompt default. Strip {project-root}/ for clean + // display — the result: template will add it back after the user + // accepts or edits. + question.default = this.cleanPromptValue(globalValue); + seededFromGlobal.push({ key, value: question.default }); + remaining.push(question); + } + + questions.length = 0; + questions.push(...remaining); + } + + // Tell the user when global values are in play. Silent reuse (D) is the + // important one — otherwise a question that fired on a prior install + // would just vanish, leaving the user wondering what happened. Seeded + // defaults (E) get a softer mention since the user still sees the value + // in the prompt. + if (!this.skipPrompts && (reusedFromGlobal.length > 0 || seededFromGlobal.length > 0)) { + const { globalUserConfigPath } = require('../global-config'); + const globalPath = globalUserConfigPath(); + const lines = []; + if (reusedFromGlobal.length > 0) { + lines.push('Using values from your global BMad config:'); + for (const { key, value } of reusedFromGlobal) { + lines.push(` ${key} = ${JSON.stringify(value)}`); + } + } + if (seededFromGlobal.length > 0) { + if (lines.length > 0) lines.push(''); + lines.push('Defaults pre-filled from your global BMad config:'); + for (const { key, value } of seededFromGlobal) { + lines.push(` ${key} = ${JSON.stringify(value)}`); + } + } + lines.push('', `To change these, edit ${globalPath}`, '(or unset them there to be prompted on the next install).'); + await prompts.log.info(lines.join('\n')); + } + // Collect all answers (static + prompted) let allAnswers = { ...staticAnswers }; @@ -1870,9 +1964,10 @@ class OfficialModules { existingValue = this.normalizeExistingValueForPrompt(existingValue, moduleName, item, moduleConfig); } - // Special handling for user_name: default to system user + // Special handling for user_name: prefer global identity (~/.bmad) over + // OS username so the prompt's default reflects what the user already chose. if (moduleName === 'core' && key === 'user_name' && !existingValue) { - item.default = this.getDefaultUsername(); + item.default = this._identityFallback('user_name'); } // Determine question type and default value diff --git a/tools/installer/set-overrides.js b/tools/installer/set-overrides.js index 9349ee2d6..4d254863f 100644 --- a/tools/installer/set-overrides.js +++ b/tools/installer/set-overrides.js @@ -25,6 +25,8 @@ const PROTOTYPE_POLLUTING_NAMES = new Set(['__proto__', 'prototype', 'constructo const path = require('node:path'); const fs = require('./fs-native'); const yaml = require('yaml'); +const { globalUserConfigPath } = require('./global-config'); +const { resolveInstalledModuleYaml } = require('./project-root'); /** * Parse a single `--set .=` entry. @@ -83,11 +85,25 @@ function parseSetEntries(entries) { } /** - * Encode a JS string as a TOML basic string (double-quoted with escapes). - * @param {string} value + * Encode a `--set` value as a TOML literal. Types are inferred from the value + * so `--set bmm.workers=4` writes `workers = 4` (integer), not `"4"` (string). + * + * Rules (mirror how TOML would interpret the literal hand-typed in a config): + * - `true` / `false` → boolean + * - `-?\d+` → integer + * - `-?\d+\.\d+` → float + * - everything else → quoted basic string + * + * To force a string that looks like a bool/number, wrap in literal quotes: + * --set foo.x='"true"' → x = "true" + * + * @param {string} value raw value as received from the --set flag */ function tomlString(value) { const s = String(value); + if (s === 'true' || s === 'false') return s; + if (/^-?\d+$/.test(s)) return s; + if (/^-?\d+\.\d+$/.test(s)) return s; // Per the TOML spec, basic strings escape `\`, `"`, and control characters. return ( '"' + @@ -142,8 +158,10 @@ function upsertTomlKey(content, section, key, valueToml) { const hadTrailingNewline = lines.length > 0 && lines.at(-1) === ''; if (hadTrailingNewline) lines.pop(); - // Locate the target section. - const sectionStart = lines.findIndex((line) => line.trim() === section); + // Locate the target section. Tolerates a trailing inline comment on the + // header (`[core] # personal`) and a header line with non-newline + // trailing whitespace — `line.trim() === section` would miss both. + const sectionStart = lines.findIndex((line) => isSectionHeader(line, section)); if (sectionStart === -1) { // Section doesn't exist — append a new block. Pad with a blank line if // the file is non-empty so sections stay visually separated. @@ -168,11 +186,12 @@ function upsertTomlKey(content, section, key, valueToml) { const match = lines[i].match(keyPattern); if (match) { const indent = match[1]; - // Preserve trailing comment if present. We split on the first `#` that - // is preceded by whitespace — TOML strings can't contain unescaped `#` - // in basic-string form so this is safe for the values we emit. + // Preserve trailing comment if present. `findInlineCommentStart` tracks + // double-quoted string state so a `#` inside a value like + // `"path with # hash"` isn't mistaken for a comment marker (per TOML + // spec, basic strings may contain unescaped `#`). const tail = match[2]; - const commentIdx = tail.search(/\s+#/); + const commentIdx = findInlineCommentStart(tail); const commentSuffix = commentIdx === -1 ? '' : tail.slice(commentIdx); lines[i] = `${indent}${key} = ${valueToml}${commentSuffix}`; return lines.join('\n') + (hadTrailingNewline ? '\n' : ''); @@ -194,6 +213,51 @@ function escapeRegExp(s) { return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); } +/** + * Match a TOML section header line against a target like `[core]`. Tolerates + * leading/trailing whitespace and a trailing inline comment, which the + * previous `line.trim() === section` check missed. + */ +function isSectionHeader(line, target) { + const trimmed = line.trimStart(); + if (!trimmed.startsWith(target)) return false; + const after = trimmed.slice(target.length); + return /^\s*(?:#.*)?$/.test(after); +} + +/** + * Find the start index of an inline `# comment` in a TOML value tail, + * tracking double-quoted string state so a `#` inside a string literal is + * not treated as a comment. Returns the index of the whitespace run that + * precedes the `#` (matching the contract of the old `/\s+#/` regex), or + * -1 if there's no inline comment. + */ +function findInlineCommentStart(text) { + let inString = false; + let escaped = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\') { + escaped = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (ch === '#' && !inString) { + let j = i; + while (j > 0 && /\s/.test(text[j - 1])) j--; + if (j < i) return j; + } + } + return -1; +} + /** * Look up `[section] key` in a TOML file. Returns true if the file exists, * the section is present, and `key` is set within it. Used by @@ -205,29 +269,66 @@ async function tomlHasKey(filePath, section, key) { if (!(await fs.pathExists(filePath))) return false; const content = await fs.readFile(filePath, 'utf8'); const lines = content.split('\n'); - const sectionStart = lines.findIndex((line) => line.trim() === section); - if (sectionStart === -1) return false; const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`); - for (let i = sectionStart + 1; i < lines.length; i++) { - if (/^\s*\[/.test(lines[i])) return false; - if (keyPattern.test(lines[i])) return true; + // Walk every line tracking whether we're inside the target section. This + // both tolerates inline-commented headers (`[core] # personal`) and + // handles the edge case where the same section appears more than once + // (legal in TOML — tomllib merges them — but the previous `findIndex` + // only checked the first block, misrouting `--set`). + let inTargetSection = false; + for (const line of lines) { + if (/^\s*\[/.test(line)) { + inTargetSection = isSectionHeader(line, section); + continue; + } + if (inTargetSection && keyPattern.test(line)) return true; } return false; } +/** + * Look up which prompt keys in `core/module.yaml` are declared `scope: user`. + * Used so `--set` routes core scope:user keys (user_name, communication_language) + * to the global identity file the installer's writeGlobalUserCore writes to, + * rather than polluting project config.toml as a team-scope key. + * Returns an empty set if core isn't installed or the schema can't be parsed. + */ +async function loadCoreUserScopeKeys() { + const result = new Set(); + try { + const corePath = await resolveInstalledModuleYaml('core'); + if (!corePath) return result; + const parsed = yaml.parse(await fs.readFile(corePath, 'utf8')); + if (!parsed || typeof parsed !== 'object') return result; + for (const [key, value] of Object.entries(parsed)) { + if (value && typeof value === 'object' && 'prompt' in value && value.scope === 'user') { + result.add(key); + } + } + } catch { + // Schema unavailable — fall back to two-tier routing. + } + return result; +} + /** * Apply parsed `--set` overrides to the central TOML files written by the * installer. Called at the end of an install / quick-update. * * Routing per (module, key): - * 1. If `_bmad/config.user.toml` already has `[section] key`, update there - * (user-scope key like `core.user_name`, `bmm.user_skill_level`). - * 2. Otherwise update `_bmad/config.toml` (team scope, the default). + * 1. If `~/.bmad/config.user.toml` already has `[section] key`, update there + * (global identity store — same place writeGlobalUserCore writes to). + * 2. Else if `_bmad/config.user.toml` already has `[section] key`, update + * there (project-scoped personal override). + * 3. Else if the key is a known core scope:user key (user_name, + * communication_language per core/module.yaml), route to global. Otherwise + * writeCentralConfig's next-install partition would strip the value out + * of project files. + * 4. Otherwise update `_bmad/config.toml` (team scope, the default). * - * The schema-correct user/team partition lives in `manifest-generator`. We - * intentionally don't re-read module schemas here — the only goal is to - * match the file the installer just wrote the key to. For brand-new keys - * (not in either file yet), team scope is the safe default. + * The schema-correct partition lives in `manifest-generator`. We only reach + * for the core schema (small, always present) so the first-run case for + * `--set core.user_name=...` doesn't land in the wrong file. * * @param {Object>} overrides * @param {string} bmadDir absolute path to `_bmad/` @@ -240,6 +341,8 @@ async function applySetOverrides(overrides, bmadDir) { const teamPath = path.join(bmadDir, 'config.toml'); const userPath = path.join(bmadDir, 'config.user.toml'); + const globalPath = globalUserConfigPath(); + const coreUserKeys = await loadCoreUserScopeKeys(); for (const moduleCode of Object.keys(overrides)) { // Skip overrides for modules not actually installed. The installer writes @@ -258,16 +361,35 @@ async function applySetOverrides(overrides, bmadDir) { const value = moduleOverrides[key]; const valueToml = tomlString(value); - const userOwnsIt = await tomlHasKey(userPath, section, key); - const targetPath = userOwnsIt ? userPath : teamPath; + // 3-tier routing: prefer the file that already owns the key; otherwise + // honor core's user-scope partition (so `--set core.user_name` lands in + // ~/.bmad on a fresh install, not in project team config). + const globalOwnsIt = moduleCode === 'core' && (await tomlHasKey(globalPath, section, key)); + const userOwnsIt = !globalOwnsIt && (await tomlHasKey(userPath, section, key)); + const isCoreUserScope = moduleCode === 'core' && coreUserKeys.has(key) && !userOwnsIt; + let targetPath; + let scope; + if (globalOwnsIt || isCoreUserScope) { + targetPath = globalPath; + scope = 'user'; + } else if (userOwnsIt) { + targetPath = userPath; + scope = 'user'; + } else { + targetPath = teamPath; + scope = 'team'; + } - // The team file always exists post-install; the user file only exists - // if the install wrote at least one user-scope key. If we're routing to - // it but it doesn't exist yet, create it with a minimal header so it - // has the same shape as installer-written user toml. + // The team file always exists post-install; the user/global files only + // exist once the installer has reason to write to them. If we're routing + // to one that doesn't exist yet, create it with a minimal header so it + // has the same shape as installer-written files. let content = ''; if (await fs.pathExists(targetPath)) { content = await fs.readFile(targetPath, 'utf8'); + } else if (targetPath === globalPath) { + await fs.ensureDir(path.dirname(globalPath)); + content = '# Global personal BMad config (see ~/.bmad).\n'; } else { content = '# Personal overrides for _bmad/config.toml.\n'; } @@ -277,8 +399,8 @@ async function applySetOverrides(overrides, bmadDir) { applied.push({ module: moduleCode, key, - scope: userOwnsIt ? 'user' : 'team', - file: path.basename(targetPath), + scope, + file: targetPath === globalPath ? '~/.bmad/config.user.toml' : path.basename(targetPath), }); }