chore: remove BMAD framework files and IDE configuration artifacts
Clean up unused BMAD workflow, agent, and command files across all IDE configurations (.agent, .clinerules, .cursor, .gemini, .github, .kilocode, .opencode) and internal module files (_bmad/bmb, _bmad/bmm). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
593
_bmad/core/bmad-init/scripts/bmad_init.py
Normal file
593
_bmad/core/bmad-init/scripts/bmad_init.py
Normal file
@@ -0,0 +1,593 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pyyaml"]
|
||||
# ///
|
||||
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
BMad Init — Project configuration bootstrap and config loader.
|
||||
|
||||
Config files (flat YAML per module):
|
||||
- _bmad/core/config.yaml (core settings — user_name, language, output_folder, etc.)
|
||||
- _bmad/{module}/config.yaml (module settings + core values merged in)
|
||||
|
||||
Usage:
|
||||
# Fast path — load all vars for a module (includes core vars)
|
||||
python bmad_init.py load --module bmb --all --project-root /path
|
||||
|
||||
# Load specific vars with optional defaults
|
||||
python bmad_init.py load --module bmb --vars var1:default1,var2 --project-root /path
|
||||
|
||||
# Load core only
|
||||
python bmad_init.py load --all --project-root /path
|
||||
|
||||
# Check if init is needed
|
||||
python bmad_init.py check --project-root /path
|
||||
python bmad_init.py check --module bmb --skill-path /path/to/skill --project-root /path
|
||||
|
||||
# Resolve module defaults given core answers
|
||||
python bmad_init.py resolve-defaults --module bmb --core-answers '{"output_folder":"..."}' --project-root /path
|
||||
|
||||
# Write config from answered questions
|
||||
python bmad_init.py write --answers '{"core": {...}, "bmb": {...}}' --project-root /path
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Project Root Detection
|
||||
# =============================================================================
|
||||
|
||||
def find_project_root(llm_provided=None):
|
||||
"""
|
||||
Find project root by looking for _bmad folder.
|
||||
|
||||
Args:
|
||||
llm_provided: Path explicitly provided via --project-root.
|
||||
|
||||
Returns:
|
||||
Path to project root, or None if not found.
|
||||
"""
|
||||
if llm_provided:
|
||||
candidate = Path(llm_provided)
|
||||
if (candidate / '_bmad').exists():
|
||||
return candidate
|
||||
# First run — _bmad won't exist yet but LLM path is still valid
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
|
||||
for start_dir in [Path.cwd(), Path(__file__).resolve().parent]:
|
||||
current_dir = start_dir
|
||||
while current_dir != current_dir.parent:
|
||||
if (current_dir / '_bmad').exists():
|
||||
return current_dir
|
||||
current_dir = current_dir.parent
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Module YAML Loading
|
||||
# =============================================================================
|
||||
|
||||
def load_module_yaml(path):
|
||||
"""
|
||||
Load and parse a module.yaml file, separating metadata from variable definitions.
|
||||
|
||||
Returns:
|
||||
Dict with 'meta' (code, name, etc.) and 'variables' (var definitions)
|
||||
and 'directories' (list of dir templates), or None on failure.
|
||||
"""
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
raw = yaml.safe_load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not raw or not isinstance(raw, dict):
|
||||
return None
|
||||
|
||||
meta_keys = {'code', 'name', 'description', 'default_selected', 'header', 'subheader'}
|
||||
meta = {}
|
||||
variables = {}
|
||||
directories = []
|
||||
|
||||
for key, value in raw.items():
|
||||
if key == 'directories':
|
||||
directories = value if isinstance(value, list) else []
|
||||
elif key in meta_keys:
|
||||
meta[key] = value
|
||||
elif isinstance(value, dict) and 'prompt' in value:
|
||||
variables[key] = value
|
||||
# Skip comment-only entries (## var_name lines become None values)
|
||||
|
||||
return {'meta': meta, 'variables': variables, 'directories': directories}
|
||||
|
||||
|
||||
def find_core_module_yaml():
|
||||
"""Find the core module.yaml bundled with this skill."""
|
||||
return Path(__file__).resolve().parent.parent / 'resources' / 'core-module.yaml'
|
||||
|
||||
|
||||
def find_target_module_yaml(module_code, project_root, skill_path=None):
|
||||
"""
|
||||
Find module.yaml for a given module code.
|
||||
|
||||
Search order:
|
||||
1. skill_path/assets/module.yaml (calling skill's assets)
|
||||
2. skill_path/module.yaml (calling skill's root)
|
||||
3. _bmad/{module_code}/module.yaml (installed module location)
|
||||
"""
|
||||
search_paths = []
|
||||
|
||||
if skill_path:
|
||||
sp = Path(skill_path)
|
||||
search_paths.append(sp / 'assets' / 'module.yaml')
|
||||
search_paths.append(sp / 'module.yaml')
|
||||
|
||||
if project_root and module_code:
|
||||
search_paths.append(Path(project_root) / '_bmad' / module_code / 'module.yaml')
|
||||
|
||||
for path in search_paths:
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Config Loading (Flat per-module files)
|
||||
# =============================================================================
|
||||
|
||||
def load_config_file(path):
|
||||
"""Load a flat YAML config file. Returns dict or None."""
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def load_module_config(module_code, project_root):
|
||||
"""Load config for a specific module from _bmad/{module}/config.yaml."""
|
||||
config_path = Path(project_root) / '_bmad' / module_code / 'config.yaml'
|
||||
return load_config_file(config_path)
|
||||
|
||||
|
||||
def resolve_project_root_placeholder(value, project_root):
|
||||
"""Replace {project-root} placeholder with actual path."""
|
||||
if not value or not isinstance(value, str):
|
||||
return value
|
||||
if '{project-root}' in value:
|
||||
return value.replace('{project-root}', str(project_root))
|
||||
return value
|
||||
|
||||
|
||||
def parse_var_specs(vars_string):
|
||||
"""
|
||||
Parse variable specs: var_name:default_value,var_name2:default_value2
|
||||
No default = returns null if missing.
|
||||
"""
|
||||
if not vars_string:
|
||||
return []
|
||||
specs = []
|
||||
for spec in vars_string.split(','):
|
||||
spec = spec.strip()
|
||||
if not spec:
|
||||
continue
|
||||
if ':' in spec:
|
||||
parts = spec.split(':', 1)
|
||||
specs.append({'name': parts[0].strip(), 'default': parts[1].strip()})
|
||||
else:
|
||||
specs.append({'name': spec, 'default': None})
|
||||
return specs
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Template Expansion
|
||||
# =============================================================================
|
||||
|
||||
def expand_template(value, context):
|
||||
"""
|
||||
Expand {placeholder} references in a string using context dict.
|
||||
|
||||
Supports: {project-root}, {value}, {output_folder}, {directory_name}, etc.
|
||||
"""
|
||||
if not value or not isinstance(value, str):
|
||||
return value
|
||||
result = value
|
||||
for key, val in context.items():
|
||||
placeholder = '{' + key + '}'
|
||||
if placeholder in result and val is not None:
|
||||
result = result.replace(placeholder, str(val))
|
||||
return result
|
||||
|
||||
|
||||
def apply_result_template(var_def, raw_value, context):
|
||||
"""
|
||||
Apply a variable's result template to transform the raw user answer.
|
||||
|
||||
E.g., result: "{project-root}/{value}" with value="_bmad-output"
|
||||
becomes "/Users/foo/project/_bmad-output"
|
||||
"""
|
||||
result_template = var_def.get('result')
|
||||
if not result_template:
|
||||
return raw_value
|
||||
|
||||
ctx = dict(context)
|
||||
ctx['value'] = raw_value
|
||||
return expand_template(result_template, ctx)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Load Command (Fast Path)
|
||||
# =============================================================================
|
||||
|
||||
def cmd_load(args):
|
||||
"""Load config vars — the fast path."""
|
||||
project_root = find_project_root(llm_provided=args.project_root)
|
||||
if not project_root:
|
||||
print(json.dumps({'error': 'Project root not found (_bmad folder not detected)'}),
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
module_code = args.module or 'core'
|
||||
|
||||
# Load the module's config (which includes core vars)
|
||||
config = load_module_config(module_code, project_root)
|
||||
if config is None:
|
||||
print(json.dumps({
|
||||
'init_required': True,
|
||||
'missing_module': module_code,
|
||||
}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Resolve {project-root} in all values
|
||||
for key in config:
|
||||
config[key] = resolve_project_root_placeholder(config[key], project_root)
|
||||
|
||||
if args.all:
|
||||
print(json.dumps(config, indent=2))
|
||||
else:
|
||||
var_specs = parse_var_specs(args.vars)
|
||||
if not var_specs:
|
||||
print(json.dumps({'error': 'Either --vars or --all must be specified'}),
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
result = {}
|
||||
for spec in var_specs:
|
||||
val = config.get(spec['name'])
|
||||
if val is not None and val != '':
|
||||
result[spec['name']] = val
|
||||
elif spec['default'] is not None:
|
||||
result[spec['name']] = spec['default']
|
||||
else:
|
||||
result[spec['name']] = None
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Check Command
|
||||
# =============================================================================
|
||||
|
||||
def cmd_check(args):
|
||||
"""Check if config exists and return status with module.yaml questions if needed."""
|
||||
project_root = find_project_root(llm_provided=args.project_root)
|
||||
if not project_root:
|
||||
print(json.dumps({
|
||||
'status': 'no_project',
|
||||
'message': 'No project root found. Provide --project-root to bootstrap.',
|
||||
}, indent=2))
|
||||
return
|
||||
|
||||
project_root = Path(project_root)
|
||||
module_code = args.module
|
||||
|
||||
# Check core config
|
||||
core_config = load_module_config('core', project_root)
|
||||
core_exists = core_config is not None
|
||||
|
||||
# If no module requested, just check core
|
||||
if not module_code or module_code == 'core':
|
||||
if core_exists:
|
||||
print(json.dumps({'status': 'ready', 'project_root': str(project_root)}, indent=2))
|
||||
else:
|
||||
core_yaml_path = find_core_module_yaml()
|
||||
core_module = load_module_yaml(core_yaml_path) if core_yaml_path.exists() else None
|
||||
print(json.dumps({
|
||||
'status': 'core_missing',
|
||||
'project_root': str(project_root),
|
||||
'core_module': core_module,
|
||||
}, indent=2))
|
||||
return
|
||||
|
||||
# Module requested — check if its config exists
|
||||
module_config = load_module_config(module_code, project_root)
|
||||
if module_config is not None:
|
||||
print(json.dumps({'status': 'ready', 'project_root': str(project_root)}, indent=2))
|
||||
return
|
||||
|
||||
# Module config missing — find its module.yaml for questions
|
||||
target_yaml_path = find_target_module_yaml(
|
||||
module_code, project_root, skill_path=args.skill_path
|
||||
)
|
||||
target_module = load_module_yaml(target_yaml_path) if target_yaml_path else None
|
||||
|
||||
result = {
|
||||
'project_root': str(project_root),
|
||||
}
|
||||
|
||||
if not core_exists:
|
||||
result['status'] = 'core_missing'
|
||||
core_yaml_path = find_core_module_yaml()
|
||||
result['core_module'] = load_module_yaml(core_yaml_path) if core_yaml_path.exists() else None
|
||||
else:
|
||||
result['status'] = 'module_missing'
|
||||
result['core_vars'] = core_config
|
||||
|
||||
result['target_module'] = target_module
|
||||
if target_yaml_path:
|
||||
result['target_module_yaml_path'] = str(target_yaml_path)
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Resolve Defaults Command
|
||||
# =============================================================================
|
||||
|
||||
def cmd_resolve_defaults(args):
|
||||
"""Given core answers, resolve a module's variable defaults."""
|
||||
project_root = find_project_root(llm_provided=args.project_root)
|
||||
if not project_root:
|
||||
print(json.dumps({'error': 'Project root not found'}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
core_answers = json.loads(args.core_answers)
|
||||
except json.JSONDecodeError as e:
|
||||
print(json.dumps({'error': f'Invalid JSON in --core-answers: {e}'}),
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Build context for template expansion
|
||||
context = {
|
||||
'project-root': str(project_root),
|
||||
'directory_name': Path(project_root).name,
|
||||
}
|
||||
context.update(core_answers)
|
||||
|
||||
# Find and load the module's module.yaml
|
||||
module_code = args.module
|
||||
target_yaml_path = find_target_module_yaml(
|
||||
module_code, project_root, skill_path=args.skill_path
|
||||
)
|
||||
if not target_yaml_path:
|
||||
print(json.dumps({'error': f'No module.yaml found for module: {module_code}'}),
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
module_def = load_module_yaml(target_yaml_path)
|
||||
if not module_def:
|
||||
print(json.dumps({'error': f'Failed to parse module.yaml at: {target_yaml_path}'}),
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Resolve defaults in each variable
|
||||
resolved_vars = {}
|
||||
for var_name, var_def in module_def['variables'].items():
|
||||
default = var_def.get('default', '')
|
||||
resolved_default = expand_template(str(default), context)
|
||||
resolved_vars[var_name] = dict(var_def)
|
||||
resolved_vars[var_name]['default'] = resolved_default
|
||||
|
||||
result = {
|
||||
'module_code': module_code,
|
||||
'meta': module_def['meta'],
|
||||
'variables': resolved_vars,
|
||||
'directories': module_def['directories'],
|
||||
}
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Write Command
|
||||
# =============================================================================
|
||||
|
||||
def cmd_write(args):
|
||||
"""Write config files from answered questions."""
|
||||
project_root = find_project_root(llm_provided=args.project_root)
|
||||
if not project_root:
|
||||
if args.project_root:
|
||||
project_root = Path(args.project_root)
|
||||
else:
|
||||
print(json.dumps({'error': 'Project root not found and --project-root not provided'}),
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
project_root = Path(project_root)
|
||||
|
||||
try:
|
||||
answers = json.loads(args.answers)
|
||||
except json.JSONDecodeError as e:
|
||||
print(json.dumps({'error': f'Invalid JSON in --answers: {e}'}),
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
context = {
|
||||
'project-root': str(project_root),
|
||||
'directory_name': project_root.name,
|
||||
}
|
||||
|
||||
# Load module.yaml definitions to get result templates
|
||||
core_yaml_path = find_core_module_yaml()
|
||||
core_def = load_module_yaml(core_yaml_path) if core_yaml_path.exists() else None
|
||||
|
||||
files_written = []
|
||||
dirs_created = []
|
||||
|
||||
# Process core answers first (needed for module config expansion)
|
||||
core_answers_raw = answers.get('core', {})
|
||||
core_config = {}
|
||||
|
||||
if core_answers_raw and core_def:
|
||||
for var_name, raw_value in core_answers_raw.items():
|
||||
var_def = core_def['variables'].get(var_name, {})
|
||||
expanded = apply_result_template(var_def, raw_value, context)
|
||||
core_config[var_name] = expanded
|
||||
|
||||
# Write core config
|
||||
core_dir = project_root / '_bmad' / 'core'
|
||||
core_dir.mkdir(parents=True, exist_ok=True)
|
||||
core_config_path = core_dir / 'config.yaml'
|
||||
|
||||
# Merge with existing if present
|
||||
existing = load_config_file(core_config_path) or {}
|
||||
existing.update(core_config)
|
||||
|
||||
_write_config_file(core_config_path, existing, 'CORE')
|
||||
files_written.append(str(core_config_path))
|
||||
elif core_answers_raw:
|
||||
# No core_def available — write raw values
|
||||
core_config = dict(core_answers_raw)
|
||||
core_dir = project_root / '_bmad' / 'core'
|
||||
core_dir.mkdir(parents=True, exist_ok=True)
|
||||
core_config_path = core_dir / 'config.yaml'
|
||||
existing = load_config_file(core_config_path) or {}
|
||||
existing.update(core_config)
|
||||
_write_config_file(core_config_path, existing, 'CORE')
|
||||
files_written.append(str(core_config_path))
|
||||
|
||||
# Update context with resolved core values for module expansion
|
||||
context.update(core_config)
|
||||
|
||||
# Process module answers
|
||||
for module_code, module_answers_raw in answers.items():
|
||||
if module_code == 'core':
|
||||
continue
|
||||
|
||||
# Find module.yaml for result templates
|
||||
target_yaml_path = find_target_module_yaml(
|
||||
module_code, project_root, skill_path=args.skill_path
|
||||
)
|
||||
module_def = load_module_yaml(target_yaml_path) if target_yaml_path else None
|
||||
|
||||
# Build module config: start with core values, then add module values
|
||||
# Re-read core config to get the latest (may have been updated above)
|
||||
latest_core = load_module_config('core', project_root) or core_config
|
||||
module_config = dict(latest_core)
|
||||
|
||||
for var_name, raw_value in module_answers_raw.items():
|
||||
if module_def:
|
||||
var_def = module_def['variables'].get(var_name, {})
|
||||
expanded = apply_result_template(var_def, raw_value, context)
|
||||
else:
|
||||
expanded = raw_value
|
||||
module_config[var_name] = expanded
|
||||
context[var_name] = expanded # Available for subsequent template expansion
|
||||
|
||||
# Write module config
|
||||
module_dir = project_root / '_bmad' / module_code
|
||||
module_dir.mkdir(parents=True, exist_ok=True)
|
||||
module_config_path = module_dir / 'config.yaml'
|
||||
|
||||
existing = load_config_file(module_config_path) or {}
|
||||
existing.update(module_config)
|
||||
|
||||
module_name = module_def['meta'].get('name', module_code.upper()) if module_def else module_code.upper()
|
||||
_write_config_file(module_config_path, existing, module_name)
|
||||
files_written.append(str(module_config_path))
|
||||
|
||||
# Create directories declared in module.yaml
|
||||
if module_def and module_def.get('directories'):
|
||||
for dir_template in module_def['directories']:
|
||||
dir_path = expand_template(dir_template, context)
|
||||
if dir_path:
|
||||
Path(dir_path).mkdir(parents=True, exist_ok=True)
|
||||
dirs_created.append(dir_path)
|
||||
|
||||
result = {
|
||||
'status': 'written',
|
||||
'files_written': files_written,
|
||||
'dirs_created': dirs_created,
|
||||
}
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
def _write_config_file(path, data, module_label):
|
||||
"""Write a config YAML file with a header comment."""
|
||||
from datetime import datetime, timezone
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.write(f'# {module_label} Module Configuration\n')
|
||||
f.write(f'# Generated by bmad-init\n')
|
||||
f.write(f'# Date: {datetime.now(timezone.utc).isoformat()}\n\n')
|
||||
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI Entry Point
|
||||
# =============================================================================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='BMad Init — Project configuration bootstrap and config loader.'
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest='command')
|
||||
|
||||
# --- load ---
|
||||
load_parser = subparsers.add_parser('load', help='Load config vars (fast path)')
|
||||
load_parser.add_argument('--module', help='Module code (omit for core only)')
|
||||
load_parser.add_argument('--vars', help='Comma-separated vars with optional defaults')
|
||||
load_parser.add_argument('--all', action='store_true', help='Return all config vars')
|
||||
load_parser.add_argument('--project-root', help='Project root path')
|
||||
|
||||
# --- check ---
|
||||
check_parser = subparsers.add_parser('check', help='Check if init is needed')
|
||||
check_parser.add_argument('--module', help='Module code to check (optional)')
|
||||
check_parser.add_argument('--skill-path', help='Path to the calling skill folder')
|
||||
check_parser.add_argument('--project-root', help='Project root path')
|
||||
|
||||
# --- resolve-defaults ---
|
||||
resolve_parser = subparsers.add_parser('resolve-defaults',
|
||||
help='Resolve module defaults given core answers')
|
||||
resolve_parser.add_argument('--module', required=True, help='Module code')
|
||||
resolve_parser.add_argument('--core-answers', required=True, help='JSON string of core answers')
|
||||
resolve_parser.add_argument('--skill-path', help='Path to calling skill folder')
|
||||
resolve_parser.add_argument('--project-root', help='Project root path')
|
||||
|
||||
# --- write ---
|
||||
write_parser = subparsers.add_parser('write', help='Write config files')
|
||||
write_parser.add_argument('--answers', required=True, help='JSON string of all answers')
|
||||
write_parser.add_argument('--skill-path', help='Path to calling skill (for module.yaml lookup)')
|
||||
write_parser.add_argument('--project-root', help='Project root path')
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.command is None:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
commands = {
|
||||
'load': cmd_load,
|
||||
'check': cmd_check,
|
||||
'resolve-defaults': cmd_resolve_defaults,
|
||||
'write': cmd_write,
|
||||
}
|
||||
|
||||
handler = commands.get(args.command)
|
||||
if handler:
|
||||
handler(args)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
329
_bmad/core/bmad-init/scripts/tests/test_bmad_init.py
Normal file
329
_bmad/core/bmad-init/scripts/tests/test_bmad_init.py
Normal file
@@ -0,0 +1,329 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pyyaml"]
|
||||
# ///
|
||||
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for bmad_init.py"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from bmad_init import (
|
||||
find_project_root,
|
||||
parse_var_specs,
|
||||
resolve_project_root_placeholder,
|
||||
expand_template,
|
||||
apply_result_template,
|
||||
load_module_yaml,
|
||||
find_core_module_yaml,
|
||||
find_target_module_yaml,
|
||||
load_config_file,
|
||||
load_module_config,
|
||||
)
|
||||
|
||||
|
||||
class TestFindProjectRoot(unittest.TestCase):
|
||||
|
||||
def test_finds_bmad_folder(self):
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
(Path(temp_dir) / '_bmad').mkdir()
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(temp_dir)
|
||||
result = find_project_root()
|
||||
self.assertEqual(result.resolve(), Path(temp_dir).resolve())
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
finally:
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
def test_llm_provided_with_bmad(self):
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
(Path(temp_dir) / '_bmad').mkdir()
|
||||
result = find_project_root(llm_provided=temp_dir)
|
||||
self.assertEqual(result.resolve(), Path(temp_dir).resolve())
|
||||
finally:
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
def test_llm_provided_without_bmad_still_returns_dir(self):
|
||||
"""First-run case: LLM provides path but _bmad doesn't exist yet."""
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
result = find_project_root(llm_provided=temp_dir)
|
||||
self.assertEqual(result.resolve(), Path(temp_dir).resolve())
|
||||
finally:
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
class TestParseVarSpecs(unittest.TestCase):
|
||||
|
||||
def test_vars_with_defaults(self):
|
||||
specs = parse_var_specs('var1:value1,var2:value2')
|
||||
self.assertEqual(len(specs), 2)
|
||||
self.assertEqual(specs[0]['name'], 'var1')
|
||||
self.assertEqual(specs[0]['default'], 'value1')
|
||||
|
||||
def test_vars_without_defaults(self):
|
||||
specs = parse_var_specs('var1,var2')
|
||||
self.assertEqual(len(specs), 2)
|
||||
self.assertIsNone(specs[0]['default'])
|
||||
|
||||
def test_mixed_vars(self):
|
||||
specs = parse_var_specs('required_var,var2:default2')
|
||||
self.assertIsNone(specs[0]['default'])
|
||||
self.assertEqual(specs[1]['default'], 'default2')
|
||||
|
||||
def test_colon_in_default(self):
|
||||
specs = parse_var_specs('path:{project-root}/some/path')
|
||||
self.assertEqual(specs[0]['default'], '{project-root}/some/path')
|
||||
|
||||
def test_empty_string(self):
|
||||
self.assertEqual(parse_var_specs(''), [])
|
||||
|
||||
def test_none(self):
|
||||
self.assertEqual(parse_var_specs(None), [])
|
||||
|
||||
|
||||
class TestResolveProjectRootPlaceholder(unittest.TestCase):
|
||||
|
||||
def test_resolve_placeholder(self):
|
||||
result = resolve_project_root_placeholder('{project-root}/output', Path('/test'))
|
||||
self.assertEqual(result, '/test/output')
|
||||
|
||||
def test_no_placeholder(self):
|
||||
result = resolve_project_root_placeholder('/absolute/path', Path('/test'))
|
||||
self.assertEqual(result, '/absolute/path')
|
||||
|
||||
def test_none(self):
|
||||
self.assertIsNone(resolve_project_root_placeholder(None, Path('/test')))
|
||||
|
||||
def test_non_string(self):
|
||||
self.assertEqual(resolve_project_root_placeholder(42, Path('/test')), 42)
|
||||
|
||||
|
||||
class TestExpandTemplate(unittest.TestCase):
|
||||
|
||||
def test_basic_expansion(self):
|
||||
result = expand_template('{project-root}/output', {'project-root': '/test'})
|
||||
self.assertEqual(result, '/test/output')
|
||||
|
||||
def test_multiple_placeholders(self):
|
||||
result = expand_template(
|
||||
'{output_folder}/planning',
|
||||
{'output_folder': '_bmad-output', 'project-root': '/test'}
|
||||
)
|
||||
self.assertEqual(result, '_bmad-output/planning')
|
||||
|
||||
def test_none_value(self):
|
||||
self.assertIsNone(expand_template(None, {}))
|
||||
|
||||
def test_non_string(self):
|
||||
self.assertEqual(expand_template(42, {}), 42)
|
||||
|
||||
|
||||
class TestApplyResultTemplate(unittest.TestCase):
|
||||
|
||||
def test_with_result_template(self):
|
||||
var_def = {'result': '{project-root}/{value}'}
|
||||
result = apply_result_template(var_def, '_bmad-output', {'project-root': '/test'})
|
||||
self.assertEqual(result, '/test/_bmad-output')
|
||||
|
||||
def test_without_result_template(self):
|
||||
result = apply_result_template({}, 'raw_value', {})
|
||||
self.assertEqual(result, 'raw_value')
|
||||
|
||||
def test_value_only_template(self):
|
||||
var_def = {'result': '{value}'}
|
||||
result = apply_result_template(var_def, 'English', {})
|
||||
self.assertEqual(result, 'English')
|
||||
|
||||
|
||||
class TestLoadModuleYaml(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_loads_core_module_yaml(self):
|
||||
path = Path(self.temp_dir) / 'module.yaml'
|
||||
path.write_text(
|
||||
'code: core\n'
|
||||
'name: "BMad Core Module"\n'
|
||||
'header: "Core Config"\n'
|
||||
'user_name:\n'
|
||||
' prompt: "What should agents call you?"\n'
|
||||
' default: "BMad"\n'
|
||||
' result: "{value}"\n'
|
||||
)
|
||||
result = load_module_yaml(path)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result['meta']['code'], 'core')
|
||||
self.assertEqual(result['meta']['name'], 'BMad Core Module')
|
||||
self.assertIn('user_name', result['variables'])
|
||||
self.assertEqual(result['variables']['user_name']['prompt'], 'What should agents call you?')
|
||||
|
||||
def test_loads_module_with_directories(self):
|
||||
path = Path(self.temp_dir) / 'module.yaml'
|
||||
path.write_text(
|
||||
'code: bmm\n'
|
||||
'name: "BMad Method"\n'
|
||||
'project_name:\n'
|
||||
' prompt: "Project name?"\n'
|
||||
' default: "{directory_name}"\n'
|
||||
' result: "{value}"\n'
|
||||
'directories:\n'
|
||||
' - "{planning_artifacts}"\n'
|
||||
)
|
||||
result = load_module_yaml(path)
|
||||
self.assertEqual(result['directories'], ['{planning_artifacts}'])
|
||||
|
||||
def test_returns_none_for_missing(self):
|
||||
result = load_module_yaml(Path(self.temp_dir) / 'nonexistent.yaml')
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_for_empty(self):
|
||||
path = Path(self.temp_dir) / 'empty.yaml'
|
||||
path.write_text('')
|
||||
result = load_module_yaml(path)
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestFindCoreModuleYaml(unittest.TestCase):
|
||||
|
||||
def test_returns_path_to_resources(self):
|
||||
path = find_core_module_yaml()
|
||||
self.assertTrue(str(path).endswith('resources/core-module.yaml'))
|
||||
|
||||
|
||||
class TestFindTargetModuleYaml(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.project_root = Path(self.temp_dir)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_finds_in_skill_assets(self):
|
||||
skill_path = self.project_root / 'skills' / 'test-skill'
|
||||
assets = skill_path / 'assets'
|
||||
assets.mkdir(parents=True)
|
||||
(assets / 'module.yaml').write_text('code: test\n')
|
||||
|
||||
result = find_target_module_yaml('test', self.project_root, str(skill_path))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertTrue(str(result).endswith('assets/module.yaml'))
|
||||
|
||||
def test_finds_in_skill_root(self):
|
||||
skill_path = self.project_root / 'skills' / 'test-skill'
|
||||
skill_path.mkdir(parents=True)
|
||||
(skill_path / 'module.yaml').write_text('code: test\n')
|
||||
|
||||
result = find_target_module_yaml('test', self.project_root, str(skill_path))
|
||||
self.assertIsNotNone(result)
|
||||
|
||||
def test_finds_in_bmad_module_dir(self):
|
||||
module_dir = self.project_root / '_bmad' / 'mymod'
|
||||
module_dir.mkdir(parents=True)
|
||||
(module_dir / 'module.yaml').write_text('code: mymod\n')
|
||||
|
||||
result = find_target_module_yaml('mymod', self.project_root)
|
||||
self.assertIsNotNone(result)
|
||||
|
||||
def test_returns_none_when_not_found(self):
|
||||
result = find_target_module_yaml('missing', self.project_root)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_skill_path_takes_priority(self):
|
||||
"""Skill assets module.yaml takes priority over _bmad/{module}/."""
|
||||
skill_path = self.project_root / 'skills' / 'test-skill'
|
||||
assets = skill_path / 'assets'
|
||||
assets.mkdir(parents=True)
|
||||
(assets / 'module.yaml').write_text('code: test\nname: from-skill\n')
|
||||
|
||||
module_dir = self.project_root / '_bmad' / 'test'
|
||||
module_dir.mkdir(parents=True)
|
||||
(module_dir / 'module.yaml').write_text('code: test\nname: from-bmad\n')
|
||||
|
||||
result = find_target_module_yaml('test', self.project_root, str(skill_path))
|
||||
self.assertTrue('assets' in str(result))
|
||||
|
||||
|
||||
class TestLoadConfigFile(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_loads_flat_yaml(self):
|
||||
path = Path(self.temp_dir) / 'config.yaml'
|
||||
path.write_text('user_name: Test\ncommunication_language: English\n')
|
||||
result = load_config_file(path)
|
||||
self.assertEqual(result['user_name'], 'Test')
|
||||
|
||||
def test_returns_none_for_missing(self):
|
||||
result = load_config_file(Path(self.temp_dir) / 'missing.yaml')
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestLoadModuleConfig(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.project_root = Path(self.temp_dir)
|
||||
bmad_core = self.project_root / '_bmad' / 'core'
|
||||
bmad_core.mkdir(parents=True)
|
||||
(bmad_core / 'config.yaml').write_text(
|
||||
'user_name: TestUser\n'
|
||||
'communication_language: English\n'
|
||||
'document_output_language: English\n'
|
||||
'output_folder: "{project-root}/_bmad-output"\n'
|
||||
)
|
||||
bmad_bmb = self.project_root / '_bmad' / 'bmb'
|
||||
bmad_bmb.mkdir(parents=True)
|
||||
(bmad_bmb / 'config.yaml').write_text(
|
||||
'user_name: TestUser\n'
|
||||
'communication_language: English\n'
|
||||
'document_output_language: English\n'
|
||||
'output_folder: "{project-root}/_bmad-output"\n'
|
||||
'bmad_builder_output_folder: "{project-root}/_bmad-output/skills"\n'
|
||||
'bmad_builder_reports: "{project-root}/_bmad-output/reports"\n'
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_load_core(self):
|
||||
result = load_module_config('core', self.project_root)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result['user_name'], 'TestUser')
|
||||
|
||||
def test_load_module_includes_core_vars(self):
|
||||
result = load_module_config('bmb', self.project_root)
|
||||
self.assertIsNotNone(result)
|
||||
# Module-specific var
|
||||
self.assertIn('bmad_builder_output_folder', result)
|
||||
# Core vars also present
|
||||
self.assertEqual(result['user_name'], 'TestUser')
|
||||
|
||||
def test_missing_module(self):
|
||||
result = load_module_config('nonexistent', self.project_root)
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user