All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
458 lines
16 KiB
Python
Executable File
458 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["pyyaml>=6.0"]
|
|
# ///
|
|
"""Merge module configuration into shared _bmad/config.yaml and config.user.yaml.
|
|
|
|
Reads a module.yaml definition and a JSON answers file, then writes or updates
|
|
the shared config.yaml (core values at root + module section) and config.user.yaml
|
|
(user_name, communication_language, plus any module variable with user_setting: true).
|
|
Uses an anti-zombie pattern for the module section in config.yaml.
|
|
|
|
Legacy migration: when --legacy-dir is provided, reads old per-module config files
|
|
from {legacy-dir}/{module-code}/config.yaml and {legacy-dir}/core/config.yaml.
|
|
Matching values serve as fallback defaults (answers override them). After a
|
|
successful merge, the legacy config.yaml files are deleted. Only the current
|
|
module and core directories are touched — other module directories are left alone.
|
|
|
|
Exit codes: 0=success, 1=validation error, 2=runtime error
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
print("Error: pyyaml is required (PEP 723 dependency)", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(
|
|
description="Merge module config into shared _bmad/config.yaml with anti-zombie pattern."
|
|
)
|
|
parser.add_argument(
|
|
"--config-path",
|
|
required=True,
|
|
help="Path to the target _bmad/config.yaml file",
|
|
)
|
|
parser.add_argument(
|
|
"--module-yaml",
|
|
required=True,
|
|
help="Path to the module.yaml definition file",
|
|
)
|
|
parser.add_argument(
|
|
"--answers",
|
|
required=True,
|
|
help="Path to JSON file with collected answers",
|
|
)
|
|
parser.add_argument(
|
|
"--user-config-path",
|
|
required=True,
|
|
help="Path to the target _bmad/config.user.yaml file",
|
|
)
|
|
parser.add_argument(
|
|
"--legacy-dir",
|
|
help="Path to _bmad/ directory to check for legacy per-module config files. "
|
|
"Matching values are used as fallback defaults, then legacy files are deleted.",
|
|
)
|
|
parser.add_argument(
|
|
"--verbose",
|
|
action="store_true",
|
|
help="Print detailed progress to stderr",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def load_yaml_file(path: str) -> dict:
|
|
"""Load a YAML file, returning empty dict if file doesn't exist."""
|
|
file_path = Path(path)
|
|
if not file_path.exists():
|
|
return {}
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
content = yaml.safe_load(f)
|
|
return content if content else {}
|
|
|
|
|
|
def load_json_file(path: str) -> dict:
|
|
"""Load a JSON file."""
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
# Keys that live at config root (shared across all modules)
|
|
_CORE_KEYS = frozenset(
|
|
{"user_name", "communication_language", "document_output_language", "output_folder"}
|
|
)
|
|
|
|
|
|
def load_legacy_values(
|
|
legacy_dir: str, module_code: str, module_yaml: dict, verbose: bool = False
|
|
) -> tuple[dict, dict, list]:
|
|
"""Read legacy per-module config files and return core/module value dicts.
|
|
|
|
Reads {legacy_dir}/core/config.yaml and {legacy_dir}/{module_code}/config.yaml.
|
|
Only returns values whose keys match the current schema (core keys or module.yaml
|
|
variable definitions). Other modules' directories are not touched.
|
|
|
|
Returns:
|
|
(legacy_core, legacy_module, files_found) where files_found lists paths read.
|
|
"""
|
|
legacy_core: dict = {}
|
|
legacy_module: dict = {}
|
|
files_found: list = []
|
|
|
|
# Read core legacy config
|
|
core_path = Path(legacy_dir) / "core" / "config.yaml"
|
|
if core_path.exists():
|
|
core_data = load_yaml_file(str(core_path))
|
|
files_found.append(str(core_path))
|
|
for k, v in core_data.items():
|
|
if k in _CORE_KEYS:
|
|
legacy_core[k] = v
|
|
if verbose:
|
|
print(f"Legacy core config: {list(legacy_core.keys())}", file=sys.stderr)
|
|
|
|
# Read module legacy config
|
|
mod_path = Path(legacy_dir) / module_code / "config.yaml"
|
|
if mod_path.exists():
|
|
mod_data = load_yaml_file(str(mod_path))
|
|
files_found.append(str(mod_path))
|
|
for k, v in mod_data.items():
|
|
if k in _CORE_KEYS:
|
|
# Core keys duplicated in module config — only use if not already set
|
|
if k not in legacy_core:
|
|
legacy_core[k] = v
|
|
elif k in module_yaml and isinstance(module_yaml[k], dict):
|
|
# Module-specific key that matches a current variable definition
|
|
legacy_module[k] = v
|
|
if verbose:
|
|
print(
|
|
f"Legacy module config: {list(legacy_module.keys())}", file=sys.stderr
|
|
)
|
|
|
|
return legacy_core, legacy_module, files_found
|
|
|
|
|
|
def apply_legacy_defaults(answers: dict, legacy_core: dict, legacy_module: dict) -> dict:
|
|
"""Apply legacy values as fallback defaults under the answers.
|
|
|
|
Legacy values fill in any key not already present in answers.
|
|
Explicit answers always win.
|
|
"""
|
|
merged = dict(answers)
|
|
|
|
if legacy_core:
|
|
core = merged.get("core", {})
|
|
filled_core = dict(legacy_core) # legacy as base
|
|
filled_core.update(core) # answers override
|
|
merged["core"] = filled_core
|
|
|
|
if legacy_module:
|
|
mod = merged.get("module", {})
|
|
filled_mod = dict(legacy_module) # legacy as base
|
|
filled_mod.update(mod) # answers override
|
|
merged["module"] = filled_mod
|
|
|
|
return merged
|
|
|
|
|
|
def cleanup_legacy_configs(
|
|
legacy_dir: str, module_code: str, verbose: bool = False
|
|
) -> list:
|
|
"""Delete legacy config.yaml files for this module and core only.
|
|
|
|
Returns list of deleted file paths.
|
|
"""
|
|
deleted = []
|
|
for subdir in (module_code, "core"):
|
|
legacy_path = Path(legacy_dir) / subdir / "config.yaml"
|
|
if legacy_path.exists():
|
|
if verbose:
|
|
print(f"Deleting legacy config: {legacy_path}", file=sys.stderr)
|
|
legacy_path.unlink()
|
|
deleted.append(str(legacy_path))
|
|
return deleted
|
|
|
|
|
|
def extract_module_metadata(module_yaml: dict) -> dict:
|
|
"""Extract non-variable metadata fields from module.yaml."""
|
|
meta = {}
|
|
for k in ("name", "description"):
|
|
if k in module_yaml:
|
|
meta[k] = module_yaml[k]
|
|
meta["version"] = module_yaml.get("module_version") # null if absent
|
|
if "default_selected" in module_yaml:
|
|
meta["default_selected"] = module_yaml["default_selected"]
|
|
return meta
|
|
|
|
|
|
def apply_result_templates(
|
|
module_yaml: dict, module_answers: dict, verbose: bool = False
|
|
) -> dict:
|
|
"""Apply result templates from module.yaml to transform raw answer values.
|
|
|
|
For each answer, if the corresponding variable definition in module.yaml has
|
|
a 'result' field, replaces {value} in that template with the answer. Skips
|
|
the template if the answer already contains '{project-root}' to prevent
|
|
double-prefixing.
|
|
"""
|
|
transformed = {}
|
|
for key, value in module_answers.items():
|
|
var_def = module_yaml.get(key)
|
|
if (
|
|
isinstance(var_def, dict)
|
|
and "result" in var_def
|
|
and "{project-root}" not in str(value)
|
|
):
|
|
template = var_def["result"]
|
|
transformed[key] = template.replace("{value}", str(value))
|
|
if verbose:
|
|
print(
|
|
f"Applied result template for '{key}': {value} → {transformed[key]}",
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
transformed[key] = value
|
|
return transformed
|
|
|
|
|
|
def merge_config(
|
|
existing_config: dict,
|
|
module_yaml: dict,
|
|
answers: dict,
|
|
verbose: bool = False,
|
|
) -> dict:
|
|
"""Merge answers into config, applying anti-zombie pattern.
|
|
|
|
Args:
|
|
existing_config: Current config.yaml contents (may be empty)
|
|
module_yaml: The module definition
|
|
answers: JSON with 'core' and/or 'module' keys
|
|
verbose: Print progress to stderr
|
|
|
|
Returns:
|
|
Updated config dict ready to write
|
|
"""
|
|
config = dict(existing_config)
|
|
module_code = module_yaml.get("code")
|
|
|
|
if not module_code:
|
|
print("Error: module.yaml must have a 'code' field", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Migrate legacy core: section to root
|
|
if "core" in config and isinstance(config["core"], dict):
|
|
if verbose:
|
|
print("Migrating legacy 'core' section to root", file=sys.stderr)
|
|
config.update(config.pop("core"))
|
|
|
|
# Strip user-only keys from config — they belong exclusively in config.user.yaml
|
|
for key in _CORE_USER_KEYS:
|
|
if key in config:
|
|
if verbose:
|
|
print(f"Removing user-only key '{key}' from config (belongs in config.user.yaml)", file=sys.stderr)
|
|
del config[key]
|
|
|
|
# Write core values at root (global properties, not nested under "core")
|
|
# Exclude user-only keys — those belong exclusively in config.user.yaml
|
|
core_answers = answers.get("core")
|
|
if core_answers:
|
|
shared_core = {k: v for k, v in core_answers.items() if k not in _CORE_USER_KEYS}
|
|
if shared_core:
|
|
if verbose:
|
|
print(f"Writing core config at root: {list(shared_core.keys())}", file=sys.stderr)
|
|
config.update(shared_core)
|
|
|
|
# Anti-zombie: remove existing module section
|
|
if module_code in config:
|
|
if verbose:
|
|
print(
|
|
f"Removing existing '{module_code}' section (anti-zombie)",
|
|
file=sys.stderr,
|
|
)
|
|
del config[module_code]
|
|
|
|
# Build module section: metadata + variable values
|
|
module_section = extract_module_metadata(module_yaml)
|
|
module_answers = apply_result_templates(
|
|
module_yaml, answers.get("module", {}), verbose
|
|
)
|
|
module_section.update(module_answers)
|
|
|
|
if verbose:
|
|
print(
|
|
f"Writing '{module_code}' section with keys: {list(module_section.keys())}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
config[module_code] = module_section
|
|
|
|
return config
|
|
|
|
|
|
# Core keys that are always written to config.user.yaml
|
|
_CORE_USER_KEYS = ("user_name", "communication_language")
|
|
|
|
|
|
def extract_user_settings(module_yaml: dict, answers: dict) -> dict:
|
|
"""Collect settings that belong in config.user.yaml.
|
|
|
|
Includes user_name and communication_language from core answers, plus any
|
|
module variable whose definition contains user_setting: true.
|
|
"""
|
|
user_settings = {}
|
|
|
|
core_answers = answers.get("core", {})
|
|
for key in _CORE_USER_KEYS:
|
|
if key in core_answers:
|
|
user_settings[key] = core_answers[key]
|
|
|
|
module_answers = answers.get("module", {})
|
|
for var_name, var_def in module_yaml.items():
|
|
if isinstance(var_def, dict) and var_def.get("user_setting") is True:
|
|
if var_name in module_answers:
|
|
user_settings[var_name] = module_answers[var_name]
|
|
|
|
return user_settings
|
|
|
|
|
|
def write_config(config: dict, config_path: str, verbose: bool = False) -> None:
|
|
"""Write config dict to YAML file, creating parent dirs as needed."""
|
|
path = Path(config_path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if verbose:
|
|
print(f"Writing config to {path}", file=sys.stderr)
|
|
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
yaml.dump(
|
|
config,
|
|
f,
|
|
default_flow_style=False,
|
|
allow_unicode=True,
|
|
sort_keys=False,
|
|
)
|
|
|
|
|
|
def write_init_compatible_configs(config, user_config, module_code, bmad_dir, verbose=False):
|
|
"""Write per-module config files in the format bmad-init expects.
|
|
|
|
bmad-init reads:
|
|
- _bmad/core/config.yaml (core settings as flat YAML)
|
|
- _bmad/{module}/config.yaml (core + module settings as flat YAML)
|
|
|
|
This bridges the setup skill's shared config format with bmad-init's
|
|
per-module config format used at runtime by all skills.
|
|
"""
|
|
_META_KEYS = frozenset({"name", "description", "version", "default_selected"})
|
|
written = []
|
|
|
|
# Assemble core values from flat config root + user config
|
|
core_values = {}
|
|
for key in _CORE_KEYS:
|
|
if key in config:
|
|
core_values[key] = config[key]
|
|
for key in _CORE_USER_KEYS:
|
|
if key in user_config:
|
|
core_values[key] = user_config[key]
|
|
|
|
# Write _bmad/core/config.yaml
|
|
core_path = str(Path(bmad_dir) / "core" / "config.yaml")
|
|
write_config(core_values, core_path, verbose)
|
|
written.append(core_path)
|
|
|
|
# Assemble module values: core + module-specific (flat, no metadata)
|
|
module_section = config.get(module_code, {})
|
|
module_values = dict(core_values)
|
|
for key, value in module_section.items():
|
|
if key not in _META_KEYS:
|
|
module_values[key] = value
|
|
|
|
# Write _bmad/{module}/config.yaml
|
|
module_path = str(Path(bmad_dir) / module_code / "config.yaml")
|
|
write_config(module_values, module_path, verbose)
|
|
written.append(module_path)
|
|
|
|
return written
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
|
|
# Load inputs
|
|
module_yaml = load_yaml_file(args.module_yaml)
|
|
if not module_yaml:
|
|
print(f"Error: Could not load module.yaml from {args.module_yaml}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
answers = load_json_file(args.answers)
|
|
existing_config = load_yaml_file(args.config_path)
|
|
|
|
if args.verbose:
|
|
exists = Path(args.config_path).exists()
|
|
print(f"Config file exists: {exists}", file=sys.stderr)
|
|
if exists:
|
|
print(f"Existing sections: {list(existing_config.keys())}", file=sys.stderr)
|
|
|
|
# Legacy migration: read old per-module configs as fallback defaults
|
|
legacy_files_found = []
|
|
if args.legacy_dir:
|
|
module_code = module_yaml.get("code", "")
|
|
legacy_core, legacy_module, legacy_files_found = load_legacy_values(
|
|
args.legacy_dir, module_code, module_yaml, args.verbose
|
|
)
|
|
if legacy_core or legacy_module:
|
|
answers = apply_legacy_defaults(answers, legacy_core, legacy_module)
|
|
if args.verbose:
|
|
print("Applied legacy values as fallback defaults", file=sys.stderr)
|
|
|
|
# Merge and write config.yaml
|
|
updated_config = merge_config(existing_config, module_yaml, answers, args.verbose)
|
|
write_config(updated_config, args.config_path, args.verbose)
|
|
|
|
# Merge and write config.user.yaml
|
|
user_settings = extract_user_settings(module_yaml, answers)
|
|
existing_user_config = load_yaml_file(args.user_config_path)
|
|
updated_user_config = dict(existing_user_config)
|
|
updated_user_config.update(user_settings)
|
|
if user_settings:
|
|
write_config(updated_user_config, args.user_config_path, args.verbose)
|
|
|
|
# Legacy cleanup: delete old per-module config files
|
|
legacy_deleted = []
|
|
if args.legacy_dir:
|
|
legacy_deleted = cleanup_legacy_configs(
|
|
args.legacy_dir, module_yaml["code"], args.verbose
|
|
)
|
|
|
|
# Write init-compatible per-module configs for bmad-init runtime loading
|
|
bmad_dir = str(Path(args.config_path).parent)
|
|
init_configs = write_init_compatible_configs(
|
|
updated_config, updated_user_config, module_yaml["code"], bmad_dir, args.verbose
|
|
)
|
|
|
|
# Output result summary as JSON
|
|
module_code = module_yaml["code"]
|
|
result = {
|
|
"status": "success",
|
|
"config_path": str(Path(args.config_path).resolve()),
|
|
"user_config_path": str(Path(args.user_config_path).resolve()),
|
|
"module_code": module_code,
|
|
"core_updated": bool(answers.get("core")),
|
|
"module_keys": list(updated_config.get(module_code, {}).keys()),
|
|
"user_keys": list(user_settings.keys()),
|
|
"legacy_configs_found": legacy_files_found,
|
|
"legacy_configs_deleted": legacy_deleted,
|
|
"init_configs_written": init_configs,
|
|
}
|
|
print(json.dumps(result, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|