Compare commits

...

8 Commits

Author SHA1 Message Date
don-petry b152cbd602
Merge 9924dc6344 into d401afd3f3 2026-04-12 23:57:39 -05:00
Brian d401afd3f3
Merge pull request #2252 from bmad-code-org/fix-workflow-diagram-bob
docs: remove Bob from workflow map diagrams
2026-04-12 23:13:57 -05:00
Brian b4d6a92e65
Merge branch 'main' into fix-workflow-diagram-bob 2026-04-12 23:13:47 -05:00
Brian Madison 246270bef2 docs: remove Bob from workflow map diagrams
Bob (Scrum Master) was consolidated into Amelia (Developer) in v6.3.0
(#2186) but still appeared in the workflow map diagrams for
sprint-planning, create-story, and retrospective. Updated both English
and French versions to show Amelia and removed the unused Bob CSS class.

Closes #2249
2026-04-12 23:12:32 -05:00
Brian 79a6876a65
Merge pull request #2251 from bmad-code-org/fix-installer-builtin-modules
fix(installer): source built-in modules locally instead of from registry
2026-04-12 23:01:42 -05:00
Brian Madison 83f374c254 fix(installer): source built-in modules locally instead of from registry
Core and BMM modules live in this repo (src/core-skills, src/bmm-skills)
but the installer UI sourced them from the remote registry. When the
registry was unreachable (VPN, proxy, firewall), the fallback YAML only
had the 4 external modules, so core and bmm disappeared from the install
list entirely.

Now _selectOfficialModules and getDefaultModules always read built-in
modules from the local source via OfficialModules.listAvailable(), then
append external modules from the registry. Network failures only affect
external modules.

Closes #2239
2026-04-12 22:41:40 -05:00
DJ 9924dc6344 fix: address CodeRabbit review feedback on cleanup-legacy.py
- Fix config restoration for zero-byte files by using `is not None`
  instead of truthiness checks on `config_backup` (empty bytes `b""` is
  falsy, causing silent loss of empty config.yaml files)
- Move config restore into the try/except block so mkdir/write_bytes
  errors are caught and reported as structured JSON instead of tracebacks
- Add logging.error() call on failure for observability
- Replace rglob("SKILL.md") with targeted glob() calls to avoid
  unnecessary deep traversal — only the two canonical installable
  layouts are checked
- Add docstring note explaining why find_skill_dirs() is intentionally
  stricter than the installer's recursive collectSkills()
- Add path traversal validation rejecting "..", "/", "\\" in dir names

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 04:24:27 -07:00
DJ db7b497eeb fix: scope find_skill_dirs to installable positions and preserve config.yaml
The cleanup-legacy.py script used an overly broad rglob("SKILL.md") that
matched template and asset files nested deep in the directory tree (e.g.
bmad-module-builder/assets/setup-skill-template/SKILL.md). This caused
cleanup to abort when it couldn't verify non-installable templates at the
skills directory.

Scopes find_skill_dirs() to only match SKILL.md at recognized installable
positions: direct children ({name}/SKILL.md) and skills subfolder
(skills/{name}/SKILL.md). Also adds config.yaml backup/restore around
shutil.rmtree() so per-module configs needed by bmad-init are preserved.

Fixes #2175

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:31:04 -07:00
5 changed files with 367 additions and 19 deletions

View File

@ -1,5 +1,6 @@
code: core
name: "BMad Core Module"
description: "Core configuration and shared resources"
header: "BMad Core Configuration"
subheader: "Configure the core settings for your BMad installation.\nThese settings will be used across all installed bmad skills, workflows, and agents."

View File

@ -598,7 +598,7 @@ class UI {
const officialCodes = new Set(officialSelected);
const externalManager = new ExternalModuleManager();
const registryModules = await externalManager.listAvailable();
const officialRegistryCodes = new Set(registryModules.map((m) => m.code));
const officialRegistryCodes = new Set(['core', 'bmm', ...registryModules.map((m) => m.code)]);
const installedNonOfficial = [...installedModuleIds].filter((id) => !officialRegistryCodes.has(id));
// Phase 2: Community modules (category drill-down)
@ -630,6 +630,11 @@ class UI {
* @returns {Array} Selected official module codes
*/
async _selectOfficialModules(installedModuleIds = new Set()) {
// Built-in modules (core, bmm) come from local source, not the registry
const { OfficialModules } = require('./modules/official-modules');
const builtInModules = (await new OfficialModules().listAvailable()).modules || [];
// External modules come from the registry (with fallback)
const externalManager = new ExternalModuleManager();
const registryModules = await externalManager.listAvailable();
@ -637,20 +642,34 @@ class UI {
const initialValues = [];
const lockedValues = ['core'];
const buildModuleEntry = async (mod) => {
const isInstalled = installedModuleIds.has(mod.code);
const version = await getMarketplaceVersion(mod.code);
const label = version ? `${mod.name} (v${version})` : mod.name;
const buildModuleEntry = async (code, name, description, isDefault) => {
const isInstalled = installedModuleIds.has(code);
const version = await getMarketplaceVersion(code);
const label = version ? `${name} (v${version})` : name;
return {
label,
value: mod.code,
hint: mod.description,
selected: isInstalled,
value: code,
hint: description,
selected: isInstalled || isDefault,
};
};
// Add built-in modules first (always available regardless of network)
const builtInCodes = new Set();
for (const mod of builtInModules) {
const code = mod.id;
builtInCodes.add(code);
const entry = await buildModuleEntry(code, mod.name, mod.description, mod.defaultSelected);
allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
if (entry.selected) {
initialValues.push(code);
}
}
// Add external registry modules (skip built-in duplicates)
for (const mod of registryModules) {
const entry = await buildModuleEntry(mod);
if (mod.builtIn || builtInCodes.has(mod.code)) continue;
const entry = await buildModuleEntry(mod.code, mod.name, mod.description, mod.defaultSelected);
allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
if (entry.selected) {
initialValues.push(mod.code);
@ -1122,12 +1141,26 @@ class UI {
* @returns {Array} Default module codes
*/
async getDefaultModules(installedModuleIds = new Set()) {
// Built-in modules with default_selected come from local source
const { OfficialModules } = require('./modules/official-modules');
const builtInModules = (await new OfficialModules().listAvailable()).modules || [];
const defaultModules = [];
const seen = new Set();
for (const mod of builtInModules) {
if (mod.defaultSelected || installedModuleIds.has(mod.id)) {
defaultModules.push(mod.id);
seen.add(mod.id);
}
}
// Add external registry defaults
const externalManager = new ExternalModuleManager();
const registryModules = await externalManager.listAvailable();
const defaultModules = [];
for (const mod of registryModules) {
if (mod.builtIn || seen.has(mod.code)) continue;
if (mod.defaultSelected || installedModuleIds.has(mod.code)) {
defaultModules.push(mod.code);
}

View File

@ -0,0 +1,316 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = []
# ///
"""Remove legacy module directories from _bmad/ after config migration.
After merge-config.py and merge-help-csv.py have migrated config data and
deleted individual legacy files, this script removes the now-redundant
directory trees. These directories contain skill files that are already
installed at .claude/skills/ (or equivalent) only the config files at
_bmad/ root need to persist.
When --skills-dir is provided, the script verifies that every skill found
in the legacy directories exists at the installed location before removing
anything. Directories without skills (like _config/) are removed directly.
Exit codes: 0=success (including nothing to remove), 1=validation error, 2=runtime error
"""
import argparse
import json
import logging
import shutil
import sys
from pathlib import Path
logger = logging.getLogger(__name__)
def parse_args():
parser = argparse.ArgumentParser(
description="Remove legacy module directories from _bmad/ after config migration."
)
parser.add_argument(
"--bmad-dir",
required=True,
help="Path to the _bmad/ directory",
)
parser.add_argument(
"--module-code",
required=True,
help="Module code being cleaned up (e.g. 'bmb')",
)
parser.add_argument(
"--also-remove",
action="append",
default=[],
help="Additional directory names under _bmad/ to remove (repeatable)",
)
parser.add_argument(
"--skills-dir",
help="Path to .claude/skills/ — enables safety verification that skills "
"are installed before removing legacy copies",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Print detailed progress to stderr",
)
return parser.parse_args()
def find_skill_dirs(base_path: str) -> list:
"""Find installable skill directories under base_path.
Only considers SKILL.md files at recognized installable positions:
- Direct children: base_path/{name}/SKILL.md (legacy flat layout)
- Skills subfolder: base_path/skills/{name}/SKILL.md (current layout)
SKILL.md files nested deeper (e.g. in tasks/, assets/, or within a
skill's own subdirectories) are not installable skills and are skipped.
NOTE: These discovery rules are intentionally stricter than the installer's
recursive collectSkills() behavior. The installer is permissive it walks
the entire tree to find all SKILL.md files for installation. Cleanup must
be conservative: we only match the two canonical installable layouts so we
never accidentally validate a SKILL.md buried in tasks/, assets/, or other
non-installable subdirectories as proof that a skill is present.
Returns:
List of skill directory names (e.g. ['bmad-agent-builder', 'bmad-builder-setup'])
"""
skills = []
root = Path(base_path)
if not root.exists():
return skills
# Direct child: {name}/SKILL.md
for skill_md in root.glob("*/SKILL.md"):
skills.append(skill_md.parent.name)
# Skills subfolder: skills/{name}/SKILL.md
skills_root = root / "skills"
if skills_root.exists():
for skill_md in skills_root.glob("*/SKILL.md"):
skills.append(skill_md.parent.name)
return sorted(set(skills))
def verify_skills_installed(
bmad_dir: str, dirs_to_check: list, skills_dir: str, verbose: bool = False
) -> list:
"""Verify that skills in legacy directories exist at the installed location.
Scans each directory in dirs_to_check for skill folders (containing SKILL.md),
then checks that a matching directory exists under skills_dir. Directories
that contain no skills (like _config/) are silently skipped.
Returns:
List of verified skill names.
Raises SystemExit(1) if any skills are missing from skills_dir.
"""
all_verified = []
missing = []
for dirname in dirs_to_check:
legacy_path = Path(bmad_dir) / dirname
if not legacy_path.exists():
continue
skill_names = find_skill_dirs(str(legacy_path))
if not skill_names:
if verbose:
print(
f"No skills found in {dirname}/ — skipping verification",
file=sys.stderr,
)
continue
for skill_name in skill_names:
installed_path = Path(skills_dir) / skill_name
if installed_path.is_dir():
all_verified.append(skill_name)
if verbose:
print(
f"Verified: {skill_name} exists at {installed_path}",
file=sys.stderr,
)
else:
missing.append(skill_name)
if verbose:
print(
f"MISSING: {skill_name} not found at {installed_path}",
file=sys.stderr,
)
if missing:
error_result = {
"status": "error",
"error": "Skills not found at installed location",
"missing_skills": missing,
"skills_dir": str(Path(skills_dir).resolve()),
}
print(json.dumps(error_result, indent=2))
sys.exit(1)
return sorted(set(all_verified))
def count_files(path: Path) -> int:
"""Count all files recursively in a directory."""
count = 0
for item in path.rglob("*"):
if item.is_file():
count += 1
return count
def cleanup_directories(
bmad_dir: str, dirs_to_remove: list, verbose: bool = False
) -> tuple:
"""Remove specified directories under bmad_dir.
Preserves config.yaml files if present (needed by bmad-init at runtime).
Returns:
(removed, not_found, total_files_removed) tuple
"""
removed = []
not_found = []
total_files = 0
for dirname in dirs_to_remove:
target = Path(bmad_dir) / dirname
if not target.exists():
not_found.append(dirname)
if verbose:
print(f"Not found (skipping): {target}", file=sys.stderr)
continue
if not target.is_dir():
if verbose:
print(f"Not a directory (skipping): {target}", file=sys.stderr)
not_found.append(dirname)
continue
# Validate directory name to prevent path traversal
if ".." in dirname or "/" in dirname or "\\" in dirname:
error_result = {
"status": "error",
"error": f"Invalid directory name (path traversal rejected): {dirname}",
"directories_removed": removed,
"directories_failed": dirname,
}
print(json.dumps(error_result, indent=2))
sys.exit(2)
# Preserve config.yaml if present (bmad-init needs per-module configs)
config_path = target / "config.yaml"
config_backup = None
if config_path.exists():
config_backup = config_path.read_bytes()
if verbose:
print(f"Preserving config.yaml in {dirname}/", file=sys.stderr)
file_count = count_files(target)
if config_backup is not None:
file_count -= 1 # Don't count the preserved file
if verbose:
print(
f"Removing {target} ({file_count} files)",
file=sys.stderr,
)
try:
shutil.rmtree(target)
# Restore preserved config.yaml
if config_backup is not None:
target.mkdir(parents=True, exist_ok=True)
config_path.write_bytes(config_backup)
if verbose:
print(
f"Restored config.yaml in {dirname}/",
file=sys.stderr,
)
except OSError as e:
logger.error("Failed during cleanup of %s: %s", target, e)
error_result = {
"status": "error",
"error": f"Failed to remove {target}: {e}",
"directories_removed": removed,
"directories_failed": dirname,
}
print(json.dumps(error_result, indent=2))
sys.exit(2)
removed.append(dirname)
total_files += file_count
return removed, not_found, total_files
def main():
args = parse_args()
bmad_dir = args.bmad_dir
module_code = args.module_code
# Build the list of directories to remove
dirs_to_remove = [module_code, "core"] + args.also_remove
# Deduplicate while preserving order
seen = set()
unique_dirs = []
for d in dirs_to_remove:
if d not in seen:
seen.add(d)
unique_dirs.append(d)
dirs_to_remove = unique_dirs
if args.verbose:
print(f"Directories to remove: {dirs_to_remove}", file=sys.stderr)
# Safety check: verify skills are installed before removing
verified_skills = None
if args.skills_dir:
if args.verbose:
print(
f"Verifying skills installed at {args.skills_dir}",
file=sys.stderr,
)
verified_skills = verify_skills_installed(
bmad_dir, dirs_to_remove, args.skills_dir, args.verbose
)
# Remove directories
removed, not_found, total_files = cleanup_directories(
bmad_dir, dirs_to_remove, args.verbose
)
# Build result
result = {
"status": "success",
"bmad_dir": str(Path(bmad_dir).resolve()),
"directories_removed": removed,
"directories_not_found": not_found,
"files_removed_count": total_files,
}
if args.skills_dir:
result["safety_checks"] = {
"skills_verified": True,
"skills_dir": str(Path(args.skills_dir).resolve()),
"verified_skills": verified_skills,
}
else:
result["safety_checks"] = None
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()

View File

@ -93,7 +93,6 @@
.agent-icon.john { background: linear-gradient(135deg, #60a5fa, #3b82f6); }
.agent-icon.sally { background: linear-gradient(135deg, #fbbf24, #f59e0b); color: #000; }
.agent-icon.winston { background: linear-gradient(135deg, #a78bfa, #8b5cf6); }
.agent-icon.bob { background: linear-gradient(135deg, #34d399, #10b981); color: #000; }
.agent-icon.amelia { background: linear-gradient(135deg, #fb7185, #ef4444); }
.agent-name { font-size: 0.65rem; }
@ -261,7 +260,7 @@
<span class="workflow-name">sprint-planning</span>
</div>
<div class="workflow-meta">
<div class="agent"><div class="agent-icon bob">B</div><span class="agent-name">Bob</span></div>
<div class="agent"><div class="agent-icon amelia">A</div><span class="agent-name">Amelia</span></div>
<span class="output">sprint-status.yaml →</span>
</div>
</div>
@ -270,7 +269,7 @@
<span class="workflow-name">create-story</span>
</div>
<div class="workflow-meta">
<div class="agent"><div class="agent-icon bob">B</div><span class="agent-name">Bob</span></div>
<div class="agent"><div class="agent-icon amelia">A</div><span class="agent-name">Amelia</span></div>
<span class="output">story-[slug].md →</span>
</div>
</div>
@ -308,7 +307,7 @@
<span class="badge adhoc">par Epic</span>
</div>
<div class="workflow-meta">
<div class="agent"><div class="agent-icon bob">B</div><span class="agent-name">Bob</span></div>
<div class="agent"><div class="agent-icon amelia">A</div><span class="agent-name">Amelia</span></div>
<span class="output">leçons</span>
</div>
</div>

View File

@ -93,7 +93,6 @@
.agent-icon.john { background: linear-gradient(135deg, #60a5fa, #3b82f6); }
.agent-icon.sally { background: linear-gradient(135deg, #fbbf24, #f59e0b); color: #000; }
.agent-icon.winston { background: linear-gradient(135deg, #a78bfa, #8b5cf6); }
.agent-icon.bob { background: linear-gradient(135deg, #34d399, #10b981); color: #000; }
.agent-icon.amelia { background: linear-gradient(135deg, #fb7185, #ef4444); }
.agent-name { font-size: 0.65rem; }
@ -272,7 +271,7 @@
<span class="workflow-name">sprint-planning</span>
</div>
<div class="workflow-meta">
<div class="agent"><div class="agent-icon bob">B</div><span class="agent-name">Bob</span></div>
<div class="agent"><div class="agent-icon amelia">A</div><span class="agent-name">Amelia</span></div>
<span class="output">sprint-status.yaml →</span>
</div>
</div>
@ -281,7 +280,7 @@
<span class="workflow-name">create-story</span>
</div>
<div class="workflow-meta">
<div class="agent"><div class="agent-icon bob">B</div><span class="agent-name">Bob</span></div>
<div class="agent"><div class="agent-icon amelia">A</div><span class="agent-name">Amelia</span></div>
<span class="output">story-[slug].md →</span>
</div>
</div>
@ -319,7 +318,7 @@
<span class="badge adhoc">per epic</span>
</div>
<div class="workflow-meta">
<div class="agent"><div class="agent-icon bob">B</div><span class="agent-name">Bob</span></div>
<div class="agent"><div class="agent-icon amelia">A</div><span class="agent-name">Amelia</span></div>
<span class="output">lessons</span>
</div>
</div>