Files
Entropyk/.opencode/skills/bmad-init/scripts/bmad_init.py
Sepehr ab5dc7e568 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>
2026-04-25 15:01:09 +02:00

594 lines
21 KiB
Python

# /// 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()