Compare commits

...

6 Commits

Author SHA1 Message Date
don-petry 8716f88b4f
Merge 9924dc6344 into ea99b7ece5 2026-04-11 01:07:57 -04:00
Alex Verkhovsky ea99b7ece5
chore(installer): remove 1,683 lines of dead code (#2247)
* chore(installer): remove dead code across installer modules

Delete 3 entirely dead files (agent-command-generator, bmad-artifacts,
module-injections) and remove ~50 unused exports from manifest.js,
cli-utils.js, prompts.js, path-utils.js, official-modules.js,
external-manager.js, custom-module-manager.js, and registry-client.js.
Removes corresponding dead tests.

* fix(installer): restore currentProjectDir writes for placeholder expansion

The previous commit removed the three assignments to
OfficialModules.currentProjectDir as dead code, but buildQuestion()
still reads the property to resolve {directory_name} placeholders in
module config defaults during interactive collection. Without the
writes, any module default containing {directory_name} would surface
the literal placeholder to users.
2026-04-10 20:24:50 -07:00
Alex Verkhovsky eabcd03f65
chore(installer): remove dead template and agent-command pipeline (#2244)
The legacy agent-command-generator, bmad-artifacts helpers, and all 26
IDE template files (combined/ and split/) are unreachable dead code.
The installer now uses verbatim SKILL.md directory copying -- no template
rendering occurs. The files own TODO comments confirm retirement.
2026-04-10 10:06:57 -07:00
Alex Verkhovsky 17da5ca8ca
feat(quick-dev): sync sprint-status.yaml on epic-story implementation (#2234)
* feat(quick-dev): sync sprint-status.yaml on epic-story implementation

When quick-dev infers the intent is an epic story, resolve the full
sprint-status key during step-01's previous-story-continuity sub-step,
then sync sprint-status.yaml at the two workflow boundaries code-review
already owns the trailing half of:

- step-03 start: flip the story to in-progress and lift the parent
  epic out of backlog if needed.
- step-05 end: flip the story to review. Code-review keeps ownership
  of review -> done.

Resolution uses exact numeric-segment equality on the {epic}-{story}
prefix (never string-prefix match), so 1-1 no longer collides with
1-10. Both sync blocks are idempotent so step-04 loopbacks do not
clobber human edits or bump last_updated without cause. Skips silently
when sprint-status.yaml is missing or the intent is not an epic story.

* feat(quick-dev): add sprint-status sync to one-shot route

Epic stories do get implemented via one-shot in practice. Add the same
in-progress / review sync pair that step-03 and step-05 already have,
with identical idempotency guards and skip-on-missing behavior.

* refactor(quick-dev): extract sprint-status sync into shared file

Replace inline sync blocks in step-03, step-05, and step-oneshot with
one-line callouts to sync-sprint-status.md. The shared file owns all
edge-case handling (idempotency, epic lift, missing file/key) and is
parameterized by {target_status}. Any future route picks it up with a
single Follow line.

* fix(quick-dev): resolve story_key on early-exit resume paths

Extract story-key resolution into a shared subsection referenced by
all early-exit paths and INSTRUCTIONS, ensuring sprint-status sync
works for resumed epic stories.

* refactor(quick-dev): tighten story-key resolution prompt

Remove mechanical details the LLM can infer; keep only the
collision-prevention constraint.
2026-04-10 10:03:53 -07: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
45 changed files with 375 additions and 1952 deletions

View File

@ -1,6 +1,7 @@
---
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
spec_file: '' # set at runtime for both routes before leaving this step
story_key: '' # set at runtime to the current story's full sprint-status key (e.g. 3-2-digest-delivery) when the intent is an epic story and sprint-status resolution succeeds
---
# Step 1: Clarify and Route
@ -20,7 +21,7 @@ Before listing artifacts or prompting the user, check whether you already know t
1. Explicit argument
Did the user pass a specific file path, spec name, or clear instruction this message?
- If it points to a file that matches the spec template (has `status` frontmatter with a recognized value: draft, ready-for-dev, in-progress, in-review, or done) → set `spec_file` and **EARLY EXIT** to the appropriate step (step-02 for draft, step-03 for ready/in-progress, step-04 for review). For `done`, ingest as context and proceed to INSTRUCTIONS — do not resume.
- If it points to a file that matches the spec template (has `status` frontmatter with a recognized value: draft, ready-for-dev, in-progress, in-review, or done) → set `spec_file`. Before exiting, run **Story-key resolution** (below). Then **EARLY EXIT** to the appropriate step (step-02 for draft, step-03 for ready/in-progress, step-04 for review). For `done`, ingest as context and proceed to INSTRUCTIONS — do not resume.
- Anything else (intent files, external docs, plans, descriptions) → ingest it as starting intent and proceed to INSTRUCTIONS. Do not attempt to infer a workflow state from it.
2. Recent conversation
@ -29,13 +30,19 @@ Before listing artifacts or prompting the user, check whether you already know t
3. Otherwise — scan artifacts and ask
- Active specs (`draft`, `ready-for-dev`, `in-progress`, `in-review`) in `{implementation_artifacts}`? → List them and HALT. Ask user which to resume (or `[N]` for new).
- If `draft` selected: Set `spec_file`. **EARLY EXIT**`./step-02-plan.md` (resume planning from the draft)
- If `ready-for-dev` or `in-progress` selected: Set `spec_file`. **EARLY EXIT**`./step-03-implement.md`
- If `in-review` selected: Set `spec_file`. **EARLY EXIT**`./step-04-review.md`
- If `draft` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT**`./step-02-plan.md` (resume planning from the draft)
- If `ready-for-dev` or `in-progress` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT**`./step-03-implement.md`
- If `in-review` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT**`./step-04-review.md`
- Unformatted spec or intent file lacking `status` frontmatter? → Suggest treating its contents as the starting intent. Do NOT attempt to infer a state and resume it.
Never ask extra questions if you already understand what the user intends.
### Story-key resolution
This runs on ALL paths (early-exit and INSTRUCTIONS) whenever `spec_file` is set. Determine whether the spec is an epic story — use the spec's filename, frontmatter, and any loaded epics file to identify `{epic_num}` and `{story_num}`. If the spec is not an epic story, skip silently and leave `{story_key}` unset.
If the spec is an epic story and `{sprint_status}` exists: find the `development_status` key matching `{epic_num}-{story_num}` by exact numeric equality on the first two segments (so `1-1` never collides with `1-10`). Exactly one match → set `{story_key}` to that full key. Zero or multiple matches → leave `{story_key}` unset (warn on multiple).
## INSTRUCTIONS
1. Load context.
@ -45,7 +52,7 @@ Never ask extra questions if you already understand what the user intends.
**A) Epic story path** — if the intent is clearly an epic story:
1. Identify the epic number and (if present) the story number. If you can't identify an epic number, use path B.
1. Identify the epic number `{epic_num}` and (if present) the story number `{story_num}`. If you can't identify an epic number, use path B.
2. **Check for a valid cached epic context.** Look for `{implementation_artifacts}/epic-<N>-context.md` (where `<N>` is the epic number). A file is **valid** when it exists, is non-empty, starts with `# Epic <N> Context:` (with the correct epic number), and no file in `{planning_artifacts}` is newer.
- **If valid:** load it as the primary planning context. Do not load raw planning docs (PRD, architecture, UX, etc.). Skip to step 5.
@ -59,6 +66,8 @@ Never ask extra questions if you already understand what the user intends.
5. **Previous story continuity.** Regardless of which context source succeeded above, scan `{implementation_artifacts}` for specs from the same epic with `status: done` and a lower story number. Load the most recent one (highest story number below current). Extract its **Code Map**, **Design Notes**, **Spec Change Log**, and **task list** as continuity context for step-02 planning. If no `done` spec is found but an `in-review` spec exists for the same epic with a lower story number, note it to the user and ask whether to load it.
6. **Resolve `{story_key}`.** If not already set by an earlier early-exit path, run **Story-key resolution** (above) now.
**B) Freeform path** — if the intent is not an epic story:
- Planning artifacts are the output of BMAD phases 1-3. Typical files include:
- **PRD** (`*prd*`) — product requirements and success criteria

View File

@ -24,6 +24,8 @@ Capture `baseline_commit` (current HEAD, or `NO_VCS` if version control is unava
Change `{spec_file}` status to `in-progress` in the frontmatter before starting implementation.
Follow `./sync-sprint-status.md` with `{target_status}` = `in-progress`.
If `{spec_file}` has a non-empty `context:` list in its frontmatter, load those files before implementation begins. When handing to a sub-agent, include them in the sub-agent prompt so it has access to the referenced context.
Hand `{spec_file}` to a sub-agent/task and let it implement. If no sub-agents are available, implement directly.

View File

@ -48,16 +48,25 @@ Format each stop as framing first, link on the next indented line:
When there is only one concern, omit the bold label — just list the stops directly.
### Commit and Present
### Mark Spec Done
1. Change `{spec_file}` status to `done` in the frontmatter.
2. If version control is available and the tree is dirty, create a local commit with a conventional message derived from the spec title.
3. Open the spec in the user's editor so they can click through the Suggested Review Order:
Change `{spec_file}` status to `done` in the frontmatter.
Follow `./sync-sprint-status.md` with `{target_status}` = `review`.
### Commit and Open
1. If version control is available and the tree is dirty, create a local commit with a conventional message derived from the spec title.
2. Open the spec in the user's editor so they can click through the Suggested Review Order:
- Resolve two absolute paths: (1) the repository root (`git rev-parse --show-toplevel` — returns the worktree root when in a worktree, project root otherwise; if this fails, fall back to the current working directory), (2) `{spec_file}`. Run `code -r "{absolute-root}" "{absolute-spec-file}"` — the root first so VS Code opens in the right context, then the spec file. Always double-quote paths to handle spaces and special characters.
- If `code` is not available (command fails), skip gracefully and tell the user the spec file path instead.
4. Display summary of your work to the user, including the commit hash if one was created. Any file paths shown in conversation/terminal output must use CWD-relative format (no leading `/`) with `:line` notation (e.g., `src/path/file.ts:42`) for terminal clickability — the goal is to make paths clickable in terminal emulators. Include:
- A note that the spec is open in their editor (or the file path if it couldn't be opened). Mention that `{spec_file}` now contains a Suggested Review Order.
- **Navigation tip:** "Ctrl+click (Cmd+click on macOS) the links in the Suggested Review Order to jump to each stop."
- Offer to push and/or create a pull request.
### Display Summary
Display summary of your work to the user, including the commit hash if one was created. Any file paths shown in conversation/terminal output must use CWD-relative format (no leading `/`) with `:line` notation (e.g., `src/path/file.ts:42`) for terminal clickability — the goal is to make paths clickable in terminal emulators. Include:
- A note that the spec is open in their editor (or the file path if it couldn't be opened). Mention that `{spec_file}` now contains a Suggested Review Order.
- **Navigation tip:** "Ctrl+click (Cmd+click on macOS) the links in the Suggested Review Order to jump to each stop."
- Offer to push and/or create a pull request.
Workflow complete.

View File

@ -13,6 +13,8 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md'
### Implement
Follow `./sync-sprint-status.md` with `{target_status}` = `in-progress`.
Implement the clarified intent directly.
### Review
@ -39,6 +41,8 @@ Write `{spec_file}` using `./spec-template.md`. Fill only these sections — del
2. **Title and Intent**`# {title}` heading and `## Intent` with **Problem** and **Approach** lines. Reuse the summary you already generated for the terminal.
3. **Suggested Review Order** — append after Intent. Build using the same convention as `./step-05-present.md` § "Generate Suggested Review Order" (spec-file-relative links, concern-based ordering, ultra-concise framing).
Follow `./sync-sprint-status.md` with `{target_status}` = `review`.
### Commit
If version control is available and the tree is dirty, create a local commit with a conventional message derived from the intent. If VCS is unavailable, skip.

View File

@ -0,0 +1,19 @@
# Sync Sprint Status
Shared sub-step for updating `sprint-status.yaml` during quick-dev. Called from any route (plan-code-review, one-shot, future routes) with a `{target_status}` parameter.
## Preconditions
Skip this entire file (return to caller) if ANY of:
- `{story_key}` is unset
- `{sprint_status}` does not exist on disk
## Instructions
1. Load the FULL `{sprint_status}` file.
2. Find the `development_status` entry matching `{story_key}`. If not found, warn the user once (`"{story_key} not found in sprint-status; skipping sprint sync"`) and return to caller.
3. **Idempotency check.** If `development_status[{story_key}]` is already at `{target_status}` or a later state (`review` is later than `in-progress`; `done` is later than both), return to caller — no write needed. Never regress a story's status.
4. Set `development_status[{story_key}]` to `{target_status}`.
5. **Epic lift (only when `{target_status}` = `in-progress`).** Derive the parent epic key as `epic-{N}` from the leading numeric segment of `{story_key}` (e.g., `3-2-digest-delivery``epic-3`). If that entry exists and is `backlog`, set it to `in-progress`. Leave it alone otherwise. Skip this sub-step entirely when `{target_status}` is not `in-progress`.
6. Refresh `last_updated` to the current date.
7. Save the file, preserving ALL comments and structure including STATUS DEFINITIONS and WORKFLOW NOTES.

View File

@ -65,6 +65,7 @@ Load and read full config from `{main_config}` and resolve:
- `project_name`, `planning_artifacts`, `implementation_artifacts`, `user_name`
- `communication_language`, `document_output_language`, `user_skill_level`
- `date` as system-generated current datetime
- `sprint_status` = `{implementation_artifacts}/sprint-status.yaml`
- `project_context` = `**/project-context.md` (load if exists)
- CLAUDE.md / memory files (load if exist)

View File

@ -1728,36 +1728,6 @@ async function runTests() {
// ============================================================
console.log(`${colors.yellow}Test Suite 33: Community & Custom Module Managers${colors.reset}\n`);
// --- CustomModuleManager.validateGitHubUrl ---
{
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
const mgr = new CustomModuleManager();
const https1 = mgr.validateGitHubUrl('https://github.com/owner/repo');
assert(https1.isValid === true, 'validateGitHubUrl accepts HTTPS URL');
assert(https1.owner === 'owner' && https1.repo === 'repo', 'validateGitHubUrl extracts owner/repo from HTTPS');
const https2 = mgr.validateGitHubUrl('https://github.com/owner/repo.git');
assert(https2.isValid === true, 'validateGitHubUrl accepts HTTPS URL with .git');
assert(https2.repo === 'repo', 'validateGitHubUrl strips .git suffix');
const ssh1 = mgr.validateGitHubUrl('git@github.com:owner/repo.git');
assert(ssh1.isValid === true, 'validateGitHubUrl accepts SSH URL');
assert(ssh1.owner === 'owner' && ssh1.repo === 'repo', 'validateGitHubUrl extracts owner/repo from SSH');
const bad1 = mgr.validateGitHubUrl('https://gitlab.com/owner/repo');
assert(bad1.isValid === false, 'validateGitHubUrl rejects non-GitHub URL');
const bad2 = mgr.validateGitHubUrl('');
assert(bad2.isValid === false, 'validateGitHubUrl rejects empty string');
const bad3 = mgr.validateGitHubUrl(null);
assert(bad3.isValid === false, 'validateGitHubUrl rejects null');
const bad4 = mgr.validateGitHubUrl('https://github.com/owner');
assert(bad4.isValid === false, 'validateGitHubUrl rejects URL without repo');
}
// --- CustomModuleManager._normalizeCustomModule ---
{
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
@ -1954,25 +1924,6 @@ async function runTests() {
assert(notFound === null, 'getModuleByCode returns null for unknown code');
}
// --- CustomModuleManager URL edge cases ---
{
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
const mgr = new CustomModuleManager();
// HTTP (not HTTPS) should work
const http = mgr.validateGitHubUrl('http://github.com/owner/repo');
assert(http.isValid === true, 'validateGitHubUrl accepts HTTP URL');
// Trailing slash should be rejected (strict matching)
const trailing = mgr.validateGitHubUrl('https://github.com/owner/repo/');
assert(trailing.isValid === false, 'validateGitHubUrl rejects trailing slash');
// SSH without .git should work
const sshNoDotGit = mgr.validateGitHubUrl('git@github.com:owner/repo');
assert(sshNoDotGit.isValid === true, 'validateGitHubUrl accepts SSH without .git');
assert(sshNoDotGit.repo === 'repo', 'validateGitHubUrl extracts repo from SSH without .git');
}
console.log('');
// ============================================================

View File

@ -1,20 +1,6 @@
const path = require('node:path');
const os = require('node:os');
const prompts = require('./prompts');
const CLIUtils = {
/**
* Get version from package.json
*/
getVersion() {
try {
const packageJson = require(path.join(__dirname, '..', '..', 'package.json'));
return packageJson.version || 'Unknown';
} catch {
return 'Unknown';
}
},
/**
* Display BMAD logo and version using @clack intro + box
*/
@ -52,37 +38,6 @@ const CLIUtils = {
});
},
/**
* Display section header
* @param {string} title - Section title
* @param {string} subtitle - Optional subtitle
*/
async displaySection(title, subtitle = null) {
await prompts.note(subtitle || '', title);
},
/**
* Display info box
* @param {string|Array} content - Content to display
* @param {Object} options - Box options
*/
async displayBox(content, options = {}) {
let text = content;
if (Array.isArray(content)) {
text = content.join('\n\n');
}
const color = await prompts.getColor();
const borderColor = options.borderColor || 'cyan';
const colorMap = { green: color.green, red: color.red, yellow: color.yellow, cyan: color.cyan, blue: color.blue };
const formatBorder = colorMap[borderColor] || color.cyan;
await prompts.box(text, options.title, {
rounded: options.borderStyle === 'round' || options.borderStyle === undefined,
formatBorder,
});
},
/**
* Display module configuration header
* @param {string} moduleName - Module name (fallback if no custom header)
@ -93,98 +48,6 @@ const CLIUtils = {
const title = header || `Configuring ${moduleName.toUpperCase()} Module`;
await prompts.note(subheader || '', title);
},
/**
* Display module with no custom configuration
* @param {string} moduleName - Module name (fallback if no custom header)
* @param {string} header - Custom header from module.yaml
* @param {string} subheader - Custom subheader from module.yaml
*/
async displayModuleNoConfig(moduleName, header = null, subheader = null) {
const title = header || `${moduleName.toUpperCase()} Module - No Custom Configuration`;
await prompts.note(subheader || '', title);
},
/**
* Display step indicator
* @param {number} current - Current step
* @param {number} total - Total steps
* @param {string} description - Step description
*/
async displayStep(current, total, description) {
const progress = `[${current}/${total}]`;
await prompts.log.step(`${progress} ${description}`);
},
/**
* Display completion message
* @param {string} message - Completion message
*/
async displayComplete(message) {
const color = await prompts.getColor();
await prompts.box(`\u2728 ${message}`, 'Complete', {
rounded: true,
formatBorder: color.green,
});
},
/**
* Display error message
* @param {string} message - Error message
*/
async displayError(message) {
const color = await prompts.getColor();
await prompts.box(`\u2717 ${message}`, 'Error', {
rounded: true,
formatBorder: color.red,
});
},
/**
* Format list for display
* @param {Array} items - Items to display
* @param {string} prefix - Item prefix
*/
formatList(items, prefix = '\u2022') {
return items.map((item) => ` ${prefix} ${item}`).join('\n');
},
/**
* Clear previous lines
* @param {number} lines - Number of lines to clear
*/
clearLines(lines) {
for (let i = 0; i < lines; i++) {
process.stdout.moveCursor(0, -1);
process.stdout.clearLine(1);
}
},
/**
* Display module completion message
* @param {string} moduleName - Name of the completed module
* @param {boolean} clearScreen - Whether to clear the screen first (deprecated, always false now)
*/
displayModuleComplete(moduleName, clearScreen = false) {
// No longer clear screen or show boxes - just a simple completion message
// This is deprecated but kept for backwards compatibility
},
/**
* Expand path with ~ expansion
* @param {string} inputPath - Path to expand
* @returns {string} Expanded path
*/
expandPath(inputPath) {
if (!inputPath) return inputPath;
// Expand ~ to home directory
if (inputPath.startsWith('~')) {
return path.join(os.homedir(), inputPath.slice(1));
}
return inputPath;
},
};
module.exports = { CLIUtils };

View File

@ -107,117 +107,6 @@ class Manifest {
return null;
}
/**
* Update existing manifest
* @param {string} bmadDir - Path to bmad directory
* @param {Object} updates - Fields to update
* @param {Array} installedFiles - Updated list of installed files
*/
async update(bmadDir, updates, installedFiles = null) {
const yaml = require('yaml');
const manifest = (await this._readRaw(bmadDir)) || {
installation: {},
modules: [],
ides: [],
};
// Handle module updates
if (updates.modules) {
// If modules is being updated, we need to preserve detailed module info
const existingDetailed = manifest.modules || [];
const incomingNames = updates.modules;
// Build updated modules array
const updatedModules = [];
for (const name of incomingNames) {
const existing = existingDetailed.find((m) => m.name === name);
if (existing) {
// Preserve existing details, update lastUpdated if this module is being updated
updatedModules.push({
...existing,
lastUpdated: new Date().toISOString(),
});
} else {
// New module - add with minimal details
updatedModules.push({
name,
version: null,
installDate: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
source: 'unknown',
});
}
}
manifest.modules = updatedModules;
}
// Merge other updates
if (updates.version) {
manifest.installation.version = updates.version;
}
if (updates.installDate) {
manifest.installation.installDate = updates.installDate;
}
manifest.installation.lastUpdated = new Date().toISOString();
if (updates.ides) {
manifest.ides = updates.ides;
}
// Handle per-module version updates
if (updates.moduleVersions) {
for (const [moduleName, versionInfo] of Object.entries(updates.moduleVersions)) {
const moduleIndex = manifest.modules.findIndex((m) => m.name === moduleName);
if (moduleIndex !== -1) {
manifest.modules[moduleIndex] = {
...manifest.modules[moduleIndex],
...versionInfo,
lastUpdated: new Date().toISOString(),
};
}
}
}
// Handle adding a new module with version info
if (updates.addModule) {
const { name, version, source, npmPackage, repoUrl, localPath } = updates.addModule;
const existing = manifest.modules.find((m) => m.name === name);
if (!existing) {
const entry = {
name,
version: version || null,
installDate: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
source: source || 'external',
npmPackage: npmPackage || null,
repoUrl: repoUrl || null,
};
if (localPath) entry.localPath = localPath;
manifest.modules.push(entry);
}
}
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
await fs.ensureDir(path.dirname(manifestPath));
// Clean the manifest data to remove any non-serializable values
const cleanManifestData = structuredClone(manifest);
const yamlContent = yaml.stringify(cleanManifestData, {
indent: 2,
lineWidth: 0,
sortKeys: false,
});
// Ensure POSIX-compliant final newline
const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n';
await fs.writeFile(manifestPath, content, 'utf8');
// Return the flattened format for compatibility
return this._flattenManifest(manifest);
}
/**
* Read raw manifest data without flattening
* @param {string} bmadDir - Path to bmad directory
@ -310,62 +199,6 @@ class Manifest {
await this._writeRaw(bmadDir, manifest);
}
/**
* Remove a module from the manifest
* @param {string} bmadDir - Path to bmad directory
* @param {string} moduleName - Module name to remove
*/
async removeModule(bmadDir, moduleName) {
const manifest = await this._readRaw(bmadDir);
if (!manifest || !manifest.modules) {
return;
}
const index = manifest.modules.findIndex((m) => m.name === moduleName);
if (index !== -1) {
manifest.modules.splice(index, 1);
await this._writeRaw(bmadDir, manifest);
}
}
/**
* Update a single module's version info
* @param {string} bmadDir - Path to bmad directory
* @param {string} moduleName - Module name
* @param {Object} versionInfo - Version info to update
*/
async updateModuleVersion(bmadDir, moduleName, versionInfo) {
const manifest = await this._readRaw(bmadDir);
if (!manifest || !manifest.modules) {
return;
}
const index = manifest.modules.findIndex((m) => m.name === moduleName);
if (index !== -1) {
manifest.modules[index] = {
...manifest.modules[index],
...versionInfo,
lastUpdated: new Date().toISOString(),
};
await this._writeRaw(bmadDir, manifest);
}
}
/**
* Get version info for a specific module
* @param {string} bmadDir - Path to bmad directory
* @param {string} moduleName - Module name
* @returns {Object|null} Module version info or null
*/
async getModuleVersion(bmadDir, moduleName) {
const manifest = await this._readRaw(bmadDir);
if (!manifest || !manifest.modules) {
return null;
}
return manifest.modules.find((m) => m.name === moduleName) || null;
}
/**
* Get all modules with their version info
* @param {string} bmadDir - Path to bmad directory
@ -403,27 +236,6 @@ class Manifest {
await fs.writeFile(manifestPath, content, 'utf8');
}
/**
* Add an IDE configuration to the manifest
* @param {string} bmadDir - Path to bmad directory
* @param {string} ideName - IDE name to add
*/
async addIde(bmadDir, ideName) {
const manifest = await this.read(bmadDir);
if (!manifest) {
throw new Error('No manifest found');
}
if (!manifest.ides) {
manifest.ides = [];
}
if (!manifest.ides.includes(ideName)) {
manifest.ides.push(ideName);
await this.update(bmadDir, { ides: manifest.ides });
}
}
/**
* Calculate SHA256 hash of a file
* @param {string} filePath - Path to file
@ -438,354 +250,6 @@ class Manifest {
}
}
/**
* Parse installed files to extract metadata
* @param {Array} installedFiles - List of installed file paths
* @param {string} bmadDir - Path to bmad directory for relative paths
* @returns {Array} Array of file metadata objects
*/
async parseInstalledFiles(installedFiles, bmadDir) {
const fileMetadata = [];
for (const filePath of installedFiles) {
const fileExt = path.extname(filePath).toLowerCase();
// Make path relative to parent of bmad directory, starting with 'bmad/'
const relativePath = 'bmad' + filePath.replace(bmadDir, '').replaceAll('\\', '/');
// Calculate file hash
const hash = await this.calculateFileHash(filePath);
// Handle markdown files - extract XML metadata if present
if (fileExt === '.md') {
try {
if (await fs.pathExists(filePath)) {
const content = await fs.readFile(filePath, 'utf8');
const metadata = this.extractXmlNodeAttributes(content, filePath, relativePath);
if (metadata) {
// Has XML metadata
metadata.hash = hash;
fileMetadata.push(metadata);
} else {
// No XML metadata - still track the file
fileMetadata.push({
file: relativePath,
type: 'md',
name: path.basename(filePath, fileExt),
title: null,
hash: hash,
});
}
}
} catch (error) {
await prompts.log.warn(`Could not parse ${filePath}: ${error.message}`);
}
}
// Handle other file types (CSV, JSON, YAML, etc.)
else {
fileMetadata.push({
file: relativePath,
type: fileExt.slice(1), // Remove the dot
name: path.basename(filePath, fileExt),
title: null,
hash: hash,
});
}
}
return fileMetadata;
}
/**
* Extract XML node attributes from MD file content
* @param {string} content - File content
* @param {string} filePath - File path for context
* @param {string} relativePath - Relative path starting with 'bmad/'
* @returns {Object|null} Extracted metadata or null
*/
extractXmlNodeAttributes(content, filePath, relativePath) {
// Look for XML blocks in code fences
const xmlBlockMatch = content.match(/```xml\s*([\s\S]*?)```/);
if (!xmlBlockMatch) {
return null;
}
const xmlContent = xmlBlockMatch[1];
// Extract root XML node (agent, task, template, etc.)
const rootNodeMatch = xmlContent.match(/<(\w+)([^>]*)>/);
if (!rootNodeMatch) {
return null;
}
const nodeType = rootNodeMatch[1];
const attributes = rootNodeMatch[2];
// Extract name and title attributes (id not needed since we have path)
const nameMatch = attributes.match(/name="([^"]*)"/);
const titleMatch = attributes.match(/title="([^"]*)"/);
return {
file: relativePath,
type: nodeType,
name: nameMatch ? nameMatch[1] : null,
title: titleMatch ? titleMatch[1] : null,
};
}
/**
* Generate CSV manifest content
* @param {Object} data - Manifest data
* @param {Array} fileMetadata - File metadata array
* @param {Object} moduleConfigs - Module configuration data
* @returns {string} CSV content
*/
generateManifestCsv(data, fileMetadata, moduleConfigs = {}) {
const timestamp = new Date().toISOString();
let csv = [];
// Header section
csv.push(
'# BMAD Manifest',
`# Generated: ${timestamp}`,
'',
'## Installation Info',
'Property,Value',
`Version,${data.version}`,
`InstallDate,${data.installDate || timestamp}`,
`LastUpdated,${data.lastUpdated || timestamp}`,
);
if (data.language) {
csv.push(`Language,${data.language}`);
}
csv.push('');
// Modules section
if (data.modules && data.modules.length > 0) {
csv.push('## Modules', 'Name,Version,ShortTitle');
for (const moduleName of data.modules) {
const config = moduleConfigs[moduleName] || {};
csv.push([moduleName, config.version || '', config['short-title'] || ''].map((v) => this.escapeCsv(v)).join(','));
}
csv.push('');
}
// IDEs section
if (data.ides && data.ides.length > 0) {
csv.push('## IDEs', 'IDE');
for (const ide of data.ides) {
csv.push(this.escapeCsv(ide));
}
csv.push('');
}
// Files section - NO LONGER USED
// Files are now tracked in files-manifest.csv by ManifestGenerator
return csv.join('\n');
}
/**
* Parse CSV manifest content back to object
* @param {string} csvContent - CSV content to parse
* @returns {Object} Parsed manifest data
*/
parseManifestCsv(csvContent) {
const result = {
modules: [],
ides: [],
files: [],
};
const lines = csvContent.split('\n');
let section = '';
for (const line_ of lines) {
const line = line_.trim();
// Skip empty lines and comments
if (!line || line.startsWith('#')) {
// Check for section headers
if (line.startsWith('## ')) {
section = line.slice(3).toLowerCase();
}
continue;
}
// Parse based on current section
switch (section) {
case 'installation info': {
// Skip header row
if (line === 'Property,Value') continue;
const [property, ...valueParts] = line.split(',');
const value = this.unescapeCsv(valueParts.join(','));
switch (property) {
// Path no longer stored in manifest
case 'Version': {
result.version = value;
break;
}
case 'InstallDate': {
result.installDate = value;
break;
}
case 'LastUpdated': {
result.lastUpdated = value;
break;
}
case 'Language': {
result.language = value;
break;
}
}
break;
}
case 'modules': {
// Skip header row
if (line === 'Name,Version,ShortTitle') continue;
const parts = this.parseCsvLine(line);
if (parts[0]) {
result.modules.push(parts[0]);
}
break;
}
case 'ides': {
// Skip header row
if (line === 'IDE') continue;
result.ides.push(this.unescapeCsv(line));
break;
}
case 'files': {
// Skip header rows (support both old and new format)
if (line === 'Type,Path,Name,Title' || line === 'Type,Path,Name,Title,Hash') continue;
const parts = this.parseCsvLine(line);
if (parts.length >= 2) {
result.files.push({
type: parts[0] || '',
file: parts[1] || '',
name: parts[2] || null,
title: parts[3] || null,
hash: parts[4] || null, // Hash column (may not exist in old manifests)
});
}
break;
}
// No default
}
}
return result;
}
/**
* Parse a CSV line handling quotes and commas
* @param {string} line - CSV line to parse
* @returns {Array} Array of values
*/
parseCsvLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
// Escaped quote
current += '"';
i++;
} else {
// Toggle quote state
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
// Field separator
result.push(this.unescapeCsv(current));
current = '';
} else {
current += char;
}
}
// Add the last field
result.push(this.unescapeCsv(current));
return result;
}
/**
* Escape CSV special characters
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
escapeCsv(text) {
if (!text) return '';
const str = String(text);
// If contains comma, newline, or quote, wrap in quotes and escape quotes
if (str.includes(',') || str.includes('\n') || str.includes('"')) {
return '"' + str.replaceAll('"', '""') + '"';
}
return str;
}
/**
* Unescape CSV field
* @param {string} text - Text to unescape
* @returns {string} Unescaped text
*/
unescapeCsv(text) {
if (!text) return '';
// Remove surrounding quotes if present
if (text.startsWith('"') && text.endsWith('"')) {
text = text.slice(1, -1);
// Unescape doubled quotes
text = text.replaceAll('""', '"');
}
return text;
}
/**
* Load module configuration files
* @param {Array} modules - List of module names
* @returns {Object} Module configurations indexed by name
*/
async loadModuleConfigs(modules) {
const configs = {};
for (const moduleName of modules) {
// Handle core module differently - it's in src/core-skills not src/modules/core
const configPath =
moduleName === 'core'
? path.join(process.cwd(), 'src', 'core-skills', 'config.yaml')
: path.join(process.cwd(), 'src', 'modules', moduleName, 'config.yaml');
try {
if (await fs.pathExists(configPath)) {
const yaml = require('yaml');
const content = await fs.readFile(configPath, 'utf8');
configs[moduleName] = yaml.parse(content);
}
} catch (error) {
await prompts.log.warn(`Could not load config for module ${moduleName}: ${error.message}`);
}
}
return configs;
}
/**
* Get module version info from source
* @param {string} moduleName - Module name/code
@ -986,47 +450,6 @@ class Manifest {
return updates;
}
/**
* Compare two semantic versions
* @param {string} v1 - First version
* @param {string} v2 - Second version
* @returns {number} -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
*/
compareVersions(v1, v2) {
if (!v1 || !v2) return 0;
const normalize = (v) => {
// Remove leading 'v' if present
v = v.replace(/^v/, '');
// Handle prerelease tags
const parts = v.split('-');
const main = parts[0].split('.');
const prerelease = parts[1];
return { main, prerelease };
};
const n1 = normalize(v1);
const n2 = normalize(v2);
// Compare main version parts
for (let i = 0; i < 3; i++) {
const num1 = parseInt(n1.main[i] || '0', 10);
const num2 = parseInt(n2.main[i] || '0', 10);
if (num1 !== num2) {
return num1 < num2 ? -1 : 1;
}
}
// If main versions are equal, compare prerelease
if (n1.prerelease && n2.prerelease) {
return n1.prerelease < n2.prerelease ? -1 : n1.prerelease > n2.prerelease ? 1 : 0;
}
if (n1.prerelease) return -1; // Prerelease is older than stable
if (n2.prerelease) return 1; // Stable is newer than prerelease
return 0;
}
}
module.exports = { Manifest };

View File

@ -1,180 +0,0 @@
const path = require('node:path');
const fs = require('fs-extra');
const { toColonPath, toDashPath, customAgentColonName, customAgentDashName, BMAD_FOLDER_NAME } = require('./path-utils');
/**
* Generates launcher command files for each agent
*/
class AgentCommandGenerator {
constructor(bmadFolderName = BMAD_FOLDER_NAME) {
this.templatePath = path.join(__dirname, '../templates/agent-command-template.md');
this.bmadFolderName = bmadFolderName;
}
/**
* Collect agent artifacts for IDE installation
* @param {string} bmadDir - BMAD installation directory
* @param {Array} selectedModules - Modules to include
* @returns {Object} Artifacts array with metadata
*/
async collectAgentArtifacts(bmadDir, selectedModules = []) {
const { getAgentsFromBmad } = require('./bmad-artifacts');
// Get agents from INSTALLED bmad/ directory
const agents = await getAgentsFromBmad(bmadDir, selectedModules);
const artifacts = [];
for (const agent of agents) {
const launcherContent = await this.generateLauncherContent(agent);
// Use relativePath if available (for nested agents), otherwise just name with .md
const agentPathInModule = agent.relativePath || `${agent.name}.md`;
// Calculate the relative agent path (e.g., bmm/agents/pm.md)
let agentRelPath = agent.path || '';
// Normalize path separators for cross-platform compatibility
agentRelPath = agentRelPath.replaceAll('\\', '/');
// Remove _bmad/ prefix if present to get relative path from project root
// Handle both absolute paths (/path/to/_bmad/...) and relative paths (_bmad/...)
if (agentRelPath.includes('_bmad/')) {
const parts = agentRelPath.split(/_bmad\//);
if (parts.length > 1) {
agentRelPath = parts.slice(1).join('/');
}
}
artifacts.push({
type: 'agent-launcher',
name: agent.name,
description: agent.description || `${agent.name} agent`,
module: agent.module,
canonicalId: agent.canonicalId || '',
relativePath: path.join(agent.module, 'agents', agentPathInModule), // For command filename
agentPath: agentRelPath, // Relative path to actual agent file
content: launcherContent,
sourcePath: agent.path,
});
}
return {
artifacts,
counts: {
agents: agents.length,
},
};
}
/**
* Generate launcher content for an agent
* @param {Object} agent - Agent metadata
* @returns {string} Launcher file content
*/
async generateLauncherContent(agent) {
// Load the template
const template = await fs.readFile(this.templatePath, 'utf8');
// Replace template variables
// Use relativePath if available (for nested agents), otherwise just name with .md
const agentPathInModule = agent.relativePath || `${agent.name}.md`;
return template
.replaceAll('{{name}}', agent.name)
.replaceAll('{{module}}', agent.module)
.replaceAll('{{path}}', agentPathInModule)
.replaceAll('{{description}}', agent.description || `${agent.name} agent`)
.replaceAll('_bmad', this.bmadFolderName)
.replaceAll('_bmad', '_bmad');
}
/**
* Write agent launcher artifacts to IDE commands directory
* @param {string} baseCommandsDir - Base commands directory for the IDE
* @param {Array} artifacts - Agent launcher artifacts
* @returns {number} Count of launchers written
*/
async writeAgentLaunchers(baseCommandsDir, artifacts) {
let writtenCount = 0;
for (const artifact of artifacts) {
if (artifact.type === 'agent-launcher') {
const moduleAgentsDir = path.join(baseCommandsDir, artifact.module, 'agents');
await fs.ensureDir(moduleAgentsDir);
const launcherPath = path.join(moduleAgentsDir, `${artifact.name}.md`);
await fs.writeFile(launcherPath, artifact.content);
writtenCount++;
}
}
return writtenCount;
}
/**
* Write agent launcher artifacts using underscore format (Windows-compatible)
* Creates flat files like: bmad_bmm_pm.md
*
* @param {string} baseCommandsDir - Base commands directory for the IDE
* @param {Array} artifacts - Agent launcher artifacts
* @returns {number} Count of launchers written
*/
async writeColonArtifacts(baseCommandsDir, artifacts) {
let writtenCount = 0;
for (const artifact of artifacts) {
if (artifact.type === 'agent-launcher') {
// Convert relativePath to underscore format: bmm/agents/pm.md → bmad_bmm_pm.md
const flatName = toColonPath(artifact.relativePath);
const launcherPath = path.join(baseCommandsDir, flatName);
await fs.ensureDir(path.dirname(launcherPath));
await fs.writeFile(launcherPath, artifact.content);
writtenCount++;
}
}
return writtenCount;
}
/**
* Write agent launcher artifacts using dash format (NEW STANDARD)
* Creates flat files like: bmad-agent-bmm-pm.md
*
* The bmad-agent- prefix distinguishes agents from workflows/tasks/tools.
*
* @param {string} baseCommandsDir - Base commands directory for the IDE
* @param {Array} artifacts - Agent launcher artifacts
* @returns {number} Count of launchers written
*/
async writeDashArtifacts(baseCommandsDir, artifacts) {
let writtenCount = 0;
for (const artifact of artifacts) {
if (artifact.type === 'agent-launcher') {
// Convert relativePath to dash format: bmm/agents/pm.md → bmad-agent-bmm-pm.md
const flatName = toDashPath(artifact.relativePath);
const launcherPath = path.join(baseCommandsDir, flatName);
await fs.ensureDir(path.dirname(launcherPath));
await fs.writeFile(launcherPath, artifact.content);
writtenCount++;
}
}
return writtenCount;
}
/**
* Get the custom agent name in underscore format (Windows-compatible)
* @param {string} agentName - Custom agent name
* @returns {string} Underscore-formatted filename
*/
getCustomAgentColonName(agentName) {
return customAgentColonName(agentName);
}
/**
* Get the custom agent name in underscore format (Windows-compatible)
* @param {string} agentName - Custom agent name
* @returns {string} Underscore-formatted filename
*/
getCustomAgentDashName(agentName) {
return customAgentDashName(agentName);
}
}
module.exports = { AgentCommandGenerator };

View File

@ -1,208 +0,0 @@
const path = require('node:path');
const fs = require('fs-extra');
const { loadSkillManifest, getCanonicalId } = require('./skill-manifest');
/**
* Helpers for gathering BMAD agents/tasks from the installed tree.
* Shared by installers that need Claude-style exports.
*
* TODO: Dead code cleanup compiled XML agents are retired.
*
* All agents now use the SKILL.md directory format with bmad-skill-manifest.yaml
* (type: agent). The legacy pipeline below only discovers compiled .md files
* containing <agent> XML tags, which no longer exist. The following are dead:
*
* - getAgentsFromBmad() scans {module}/agents/ for .md files with <agent> tags
* - getAgentsFromDir() recursive helper for the above
* - AgentCommandGenerator (agent-command-generator.js) generates launcher .md files
* that tell the LLM to load a compiled agent .md file
* - agent-command-template.md (templates/) the launcher template with hardcoded
* {module}/agents/{{path}} reference
*
* Agent metadata for agent-manifest.csv is now handled entirely by
* ManifestGenerator.getAgentsFromDirRecursive() in manifest-generator.js,
* which walks the full module tree and finds type:agent directories.
*
* IDE installation of agents is handled by the native skill pipeline
* each agent's SKILL.md directory is installed directly to the IDE's
* skills path, so no launcher intermediary is needed.
*
* Cleanup: remove getAgentsFromBmad, getAgentsFromDir, their exports,
* AgentCommandGenerator, agent-command-template.md, and all call sites
* in IDE installers that invoke collectAgentArtifacts / writeAgentLaunchers /
* writeColonArtifacts / writeDashArtifacts.
* getTasksFromBmad and getTasksFromDir may still be live verify before removing.
*/
async function getAgentsFromBmad(bmadDir, selectedModules = []) {
const agents = [];
// Get core agents
if (await fs.pathExists(path.join(bmadDir, 'core', 'agents'))) {
const coreAgents = await getAgentsFromDir(path.join(bmadDir, 'core', 'agents'), 'core');
agents.push(...coreAgents);
}
// Get module agents
for (const moduleName of selectedModules) {
const agentsPath = path.join(bmadDir, moduleName, 'agents');
if (await fs.pathExists(agentsPath)) {
const moduleAgents = await getAgentsFromDir(agentsPath, moduleName);
agents.push(...moduleAgents);
}
}
// Get standalone agents from bmad/agents/ directory
const standaloneAgentsDir = path.join(bmadDir, 'agents');
if (await fs.pathExists(standaloneAgentsDir)) {
const agentDirs = await fs.readdir(standaloneAgentsDir, { withFileTypes: true });
for (const agentDir of agentDirs) {
if (!agentDir.isDirectory()) continue;
const agentDirPath = path.join(standaloneAgentsDir, agentDir.name);
const agentFiles = await fs.readdir(agentDirPath);
const skillManifest = await loadSkillManifest(agentDirPath);
for (const file of agentFiles) {
if (!file.endsWith('.md')) continue;
if (file.includes('.customize.')) continue;
const filePath = path.join(agentDirPath, file);
const content = await fs.readFile(filePath, 'utf8');
if (content.includes('localskip="true"')) continue;
agents.push({
path: filePath,
name: file.replace('.md', ''),
module: 'standalone', // Mark as standalone agent
canonicalId: getCanonicalId(skillManifest, file),
});
}
}
}
return agents;
}
async function getTasksFromBmad(bmadDir, selectedModules = []) {
const tasks = [];
if (await fs.pathExists(path.join(bmadDir, 'core', 'tasks'))) {
const coreTasks = await getTasksFromDir(path.join(bmadDir, 'core', 'tasks'), 'core');
tasks.push(...coreTasks);
}
for (const moduleName of selectedModules) {
const tasksPath = path.join(bmadDir, moduleName, 'tasks');
if (await fs.pathExists(tasksPath)) {
const moduleTasks = await getTasksFromDir(tasksPath, moduleName);
tasks.push(...moduleTasks);
}
}
return tasks;
}
async function getAgentsFromDir(dirPath, moduleName, relativePath = '') {
const agents = [];
if (!(await fs.pathExists(dirPath))) {
return agents;
}
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const skillManifest = await loadSkillManifest(dirPath);
for (const entry of entries) {
// Skip if entry.name is undefined or not a string
if (!entry.name || typeof entry.name !== 'string') {
continue;
}
const fullPath = path.join(dirPath, entry.name);
const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
// Recurse into subdirectories
const subDirAgents = await getAgentsFromDir(fullPath, moduleName, newRelativePath);
agents.push(...subDirAgents);
} else if (entry.name.endsWith('.md')) {
// Skip README files and other non-agent files
if (entry.name.toLowerCase() === 'readme.md' || entry.name.toLowerCase().startsWith('readme-')) {
continue;
}
if (entry.name.includes('.customize.')) {
continue;
}
const content = await fs.readFile(fullPath, 'utf8');
if (content.includes('localskip="true"')) {
continue;
}
// Only include files that have agent-specific content (compiled agents have <agent> tag)
if (!content.includes('<agent')) {
continue;
}
agents.push({
path: fullPath,
name: entry.name.replace('.md', ''),
module: moduleName,
relativePath: newRelativePath, // Keep the .md extension for the full path
canonicalId: getCanonicalId(skillManifest, entry.name),
});
}
}
return agents;
}
async function getTasksFromDir(dirPath, moduleName) {
const tasks = [];
if (!(await fs.pathExists(dirPath))) {
return tasks;
}
const files = await fs.readdir(dirPath);
const skillManifest = await loadSkillManifest(dirPath);
for (const file of files) {
// Include both .md and .xml task files
if (!file.endsWith('.md') && !file.endsWith('.xml')) {
continue;
}
const filePath = path.join(dirPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Skip internal/engine files (not user-facing tasks)
if (content.includes('internal="true"')) {
continue;
}
// Remove extension to get task name
const ext = file.endsWith('.xml') ? '.xml' : '.md';
tasks.push({
path: filePath,
name: file.replace(ext, ''),
module: moduleName,
canonicalId: getCanonicalId(skillManifest, file),
});
}
return tasks;
}
module.exports = {
getAgentsFromBmad,
getTasksFromBmad,
getAgentsFromDir,
getTasksFromDir,
};

View File

@ -1,136 +0,0 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const { glob } = require('glob');
const { getSourcePath } = require('../../project-root');
async function loadModuleInjectionConfig(handler, moduleName) {
const sourceModulesPath = getSourcePath('modules');
const handlerBaseDir = path.join(sourceModulesPath, moduleName, 'sub-modules', handler);
const configPath = path.join(handlerBaseDir, 'injections.yaml');
if (!(await fs.pathExists(configPath))) {
return null;
}
const configContent = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(configContent) || {};
return {
config,
handlerBaseDir,
configPath,
};
}
function shouldApplyInjection(injection, subagentChoices) {
if (!subagentChoices || subagentChoices.install === 'none') {
return false;
}
if (subagentChoices.install === 'all') {
return true;
}
if (subagentChoices.install === 'selective') {
const selected = subagentChoices.selected || [];
if (injection.requires === 'any' && selected.length > 0) {
return true;
}
if (injection.requires) {
const required = `${injection.requires}.md`;
return selected.includes(required);
}
if (injection.point) {
const selectedNames = selected.map((file) => file.replace('.md', ''));
return selectedNames.some((name) => injection.point.includes(name));
}
}
return false;
}
function filterAgentInstructions(content, selectedFiles) {
if (!selectedFiles || selectedFiles.length === 0) {
return '';
}
const selectedAgents = selectedFiles.map((file) => file.replace('.md', ''));
const lines = content.split('\n');
const filteredLines = [];
for (const line of lines) {
if (line.includes('<llm') || line.includes('</llm>')) {
filteredLines.push(line);
} else if (line.includes('subagent')) {
let shouldInclude = false;
for (const agent of selectedAgents) {
if (line.includes(agent)) {
shouldInclude = true;
break;
}
}
if (shouldInclude) {
filteredLines.push(line);
}
} else if (line.includes('When creating PRDs') || line.includes('ACTIVELY delegate')) {
filteredLines.push(line);
}
}
if (filteredLines.length > 2) {
return filteredLines.join('\n');
}
return '';
}
async function resolveSubagentFiles(handlerBaseDir, subagentConfig, subagentChoices) {
if (!subagentConfig || !subagentConfig.files) {
return [];
}
if (!subagentChoices || subagentChoices.install === 'none') {
return [];
}
let filesToCopy = subagentConfig.files;
if (subagentChoices.install === 'selective') {
filesToCopy = subagentChoices.selected || [];
}
const sourceDir = path.join(handlerBaseDir, subagentConfig.source || '');
const resolved = [];
for (const file of filesToCopy) {
// Use forward slashes for glob pattern (works on both Windows and Unix)
// Convert backslashes to forward slashes for glob compatibility
const normalizedSourceDir = sourceDir.replaceAll('\\', '/');
const pattern = `${normalizedSourceDir}/**/${file}`;
const matches = await glob(pattern);
if (matches.length > 0) {
const absolutePath = matches[0];
resolved.push({
file,
absolutePath,
relativePath: path.relative(sourceDir, absolutePath),
sourceDir,
});
}
}
return resolved;
}
module.exports = {
loadModuleInjectionConfig,
shouldApplyInjection,
filterAgentInstructions,
resolveSubagentFiles,
};

View File

@ -15,8 +15,6 @@
* - standalone/agents/fred.md bmad-agent-standalone-fred.md
*/
// Type segments - agents are included in naming, others are filtered out
const TYPE_SEGMENTS = ['workflows', 'tasks', 'tools'];
const AGENT_SEGMENT = 'agents';
// BMAD installation folder name - centralized constant for all installers
@ -194,125 +192,6 @@ function parseDashName(filename) {
};
}
// ============================================================================
// LEGACY FUNCTIONS (underscore format) - kept for backward compatibility
// ============================================================================
/**
* Convert hierarchical path to flat underscore-separated name (LEGACY)
* @deprecated Use toDashName instead
*/
function toUnderscoreName(module, type, name) {
const isAgent = type === AGENT_SEGMENT;
if (module === 'core') {
return isAgent ? `bmad_agent_${name}.md` : `bmad_${name}.md`;
}
if (module === 'standalone') {
return isAgent ? `bmad_agent_standalone_${name}.md` : `bmad_standalone_${name}.md`;
}
return isAgent ? `bmad_${module}_agent_${name}.md` : `bmad_${module}_${name}.md`;
}
/**
* Convert relative path to flat underscore-separated name (LEGACY)
* @deprecated Use toDashPath instead
*/
function toUnderscorePath(relativePath) {
// Strip common file extensions (same as toDashPath for consistency)
const withoutExt = relativePath.replace(/\.(md|yaml|yml|json|xml|toml)$/i, '');
const parts = withoutExt.split(/[/\\]/);
const module = parts[0];
const type = parts[1];
const name = parts.slice(2).join('_');
return toUnderscoreName(module, type, name);
}
/**
* Create custom agent underscore name (LEGACY)
* @deprecated Use customAgentDashName instead
*/
function customAgentUnderscoreName(agentName) {
return `bmad_custom_${agentName}.md`;
}
/**
* Check if a filename uses underscore format (LEGACY)
* @deprecated Use isDashFormat instead
*/
function isUnderscoreFormat(filename) {
return filename.startsWith('bmad_') && filename.includes('_');
}
/**
* Extract parts from an underscore-formatted filename (LEGACY)
* @deprecated Use parseDashName instead
*/
function parseUnderscoreName(filename) {
const withoutExt = filename.replace('.md', '');
const parts = withoutExt.split('_');
if (parts.length < 2 || parts[0] !== 'bmad') {
return null;
}
const agentIndex = parts.indexOf('agent');
if (agentIndex !== -1) {
if (agentIndex === 1) {
// bmad_agent_... - check for standalone
if (parts.length >= 4 && parts[2] === 'standalone') {
return {
prefix: parts[0],
module: 'standalone',
type: 'agents',
name: parts.slice(3).join('_'),
};
}
return {
prefix: parts[0],
module: 'core',
type: 'agents',
name: parts.slice(agentIndex + 1).join('_'),
};
} else {
return {
prefix: parts[0],
module: parts[1],
type: 'agents',
name: parts.slice(agentIndex + 1).join('_'),
};
}
}
if (parts.length === 2) {
return {
prefix: parts[0],
module: 'core',
type: 'workflows',
name: parts[1],
};
}
// Check for standalone non-agent: bmad_standalone_name
if (parts[1] === 'standalone') {
return {
prefix: parts[0],
module: 'standalone',
type: 'workflows',
name: parts.slice(2).join('_'),
};
}
return {
prefix: parts[0],
module: parts[1],
type: 'workflows',
name: parts.slice(2).join('_'),
};
}
/**
* Resolve the skill name for an artifact.
* Prefers canonicalId from a bmad-skill-manifest.yaml sidecar when available,
@ -328,37 +207,13 @@ function resolveSkillName(artifact) {
return toDashPath(artifact.relativePath);
}
// Backward compatibility aliases (colon format was same as underscore)
const toColonName = toUnderscoreName;
const toColonPath = toUnderscorePath;
const customAgentColonName = customAgentUnderscoreName;
const isColonFormat = isUnderscoreFormat;
const parseColonName = parseUnderscoreName;
module.exports = {
// New standard (dash-based)
toDashName,
toDashPath,
resolveSkillName,
customAgentDashName,
isDashFormat,
parseDashName,
// Legacy (underscore-based) - kept for backward compatibility
toUnderscoreName,
toUnderscorePath,
customAgentUnderscoreName,
isUnderscoreFormat,
parseUnderscoreName,
// Backward compatibility aliases
toColonName,
toColonPath,
customAgentColonName,
isColonFormat,
parseColonName,
TYPE_SEGMENTS,
AGENT_SEGMENT,
BMAD_FOLDER_NAME,
};

View File

@ -1,14 +0,0 @@
---
name: '{{name}}'
description: '{{description}}'
---
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
<agent-activation CRITICAL="TRUE">
1. LOAD the FULL agent file from {project-root}/_bmad/{{module}}/agents/{{path}}
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
3. Execute ALL activation steps exactly as written in the agent file
4. Follow the agent's persona and menu system precisely
5. Stay in character throughout the session
</agent-activation>

View File

@ -1,8 +0,0 @@
---
name: '{{name}}'
description: '{{description}}'
---
Read the entire workflow file at: {project-root}/_bmad/{{workflow_path}}
Follow all instructions in the workflow file exactly as written.

View File

@ -1 +0,0 @@
default-agent.md

View File

@ -1 +0,0 @@
default-workflow.md

View File

@ -1,15 +0,0 @@
---
name: '{{name}}'
description: '{{description}}'
---
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
<agent-activation CRITICAL="TRUE">
1. LOAD the FULL agent file from {project-root}/_bmad/{{path}}
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
3. FOLLOW every step in the <activation> section precisely
4. DISPLAY the welcome/greeting as instructed
5. PRESENT the numbered menu
6. WAIT for user input before proceeding
</agent-activation>

View File

@ -1,10 +0,0 @@
---
name: '{{name}}'
description: '{{description}}'
---
# {{name}}
Read the entire task file at: {project-root}/{{bmadFolderName}}/{{path}}
Follow all instructions in the task file exactly as written.

View File

@ -1,10 +0,0 @@
---
name: '{{name}}'
description: '{{description}}'
---
# {{name}}
Read the entire tool file at: {project-root}/{{bmadFolderName}}/{{path}}
Follow all instructions in the tool file exactly as written.

View File

@ -1,6 +0,0 @@
---
name: '{{name}}'
description: '{{description}}'
---
IT IS CRITICAL THAT YOU FOLLOW THIS COMMAND: LOAD the FULL {project-root}/{{bmadFolderName}}/{{path}}, READ its entire contents and follow its directions exactly!

View File

@ -1,14 +0,0 @@
description = "Activates the {{name}} agent from the BMad Method."
prompt = """
CRITICAL: You are now the BMad '{{name}}' agent.
PRE-FLIGHT CHECKLIST:
1. [ ] IMMEDIATE ACTION: Load and parse {project-root}/{{bmadFolderName}}/{{module}}/config.yaml - store ALL config values in memory for use throughout the session.
2. [ ] IMMEDIATE ACTION: Read and internalize the full agent definition at {project-root}/{{bmadFolderName}}/{{path}}.
3. [ ] CONFIRM: The user's name from config is {user_name}.
Only after all checks are complete, greet the user by name and display the menu.
Acknowledge this checklist is complete in your first response.
AGENT DEFINITION: {project-root}/{{bmadFolderName}}/{{path}}
"""

View File

@ -1,11 +0,0 @@
description = "Executes the {{name}} task from the BMAD Method."
prompt = """
Execute the BMAD '{{name}}' task.
TASK INSTRUCTIONS:
1. LOAD the task file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents
3. FOLLOW every instruction precisely as specified
TASK FILE: {project-root}/{{bmadFolderName}}/{{path}}
"""

View File

@ -1,11 +0,0 @@
description = "Executes the {{name}} tool from the BMAD Method."
prompt = """
Execute the BMAD '{{name}}' tool.
TOOL INSTRUCTIONS:
1. LOAD the tool file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents
3. FOLLOW every instruction precisely as specified
TOOL FILE: {project-root}/{{bmadFolderName}}/{{path}}
"""

View File

@ -1,16 +0,0 @@
description = '{{description}}'
prompt = """
Execute the BMAD '{{name}}' workflow.
CRITICAL: This is a structured YAML workflow. Follow these steps precisely:
1. LOAD the workflow definition from {project-root}/{{bmadFolderName}}/{{workflow_path}}
2. PARSE the YAML structure to understand:
- Workflow phases and steps
- Required inputs and outputs
- Dependencies between steps
3. EXECUTE each step in order
4. VALIDATE outputs before proceeding to next step
WORKFLOW FILE: {project-root}/{{bmadFolderName}}/{{workflow_path}}
"""

View File

@ -1,14 +0,0 @@
description = '{{description}}'
prompt = """
Execute the BMAD '{{name}}' workflow.
CRITICAL: You must load and follow the workflow definition exactly.
WORKFLOW INSTRUCTIONS:
1. LOAD the workflow file from {project-root}/{{bmadFolderName}}/{{workflow_path}}
2. READ its entire contents
3. FOLLOW every step precisely as specified
4. DO NOT skip or modify any steps
WORKFLOW FILE: {project-root}/{{bmadFolderName}}/{{workflow_path}}
"""

View File

@ -1,16 +0,0 @@
---
inclusion: manual
---
# {{name}}
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
<agent-activation CRITICAL="TRUE">
1. LOAD the FULL agent file from #[[file:{{bmadFolderName}}/{{path}}]]
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
3. FOLLOW every step in the <activation> section precisely
4. DISPLAY the welcome/greeting as instructed
5. PRESENT the numbered menu
6. WAIT for user input before proceeding
</agent-activation>

View File

@ -1,9 +0,0 @@
---
inclusion: manual
---
# {{name}}
Read the entire task file at: #[[file:{{bmadFolderName}}/{{path}}]]
Follow all instructions in the task file exactly as written.

View File

@ -1,9 +0,0 @@
---
inclusion: manual
---
# {{name}}
Read the entire tool file at: #[[file:{{bmadFolderName}}/{{path}}]]
Follow all instructions in the tool file exactly as written.

View File

@ -1,7 +0,0 @@
---
inclusion: manual
---
# {{name}}
IT IS CRITICAL THAT YOU FOLLOW THIS COMMAND: LOAD the FULL #[[file:{{bmadFolderName}}/{{path}}]], READ its entire contents and follow its directions exactly!

View File

@ -1,15 +0,0 @@
---
mode: all
description: '{{description}}'
---
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
<agent-activation CRITICAL="TRUE">
1. LOAD the FULL agent file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
3. FOLLOW every step in the <activation> section precisely
4. DISPLAY the welcome/greeting as instructed
5. PRESENT the numbered menu
6. WAIT for user input before proceeding
</agent-activation>

View File

@ -1,13 +0,0 @@
---
description: '{{description}}'
---
Execute the BMAD '{{name}}' task.
TASK INSTRUCTIONS:
1. LOAD the task file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents
3. FOLLOW every instruction precisely as specified
TASK FILE: {project-root}/{{bmadFolderName}}/{{path}}

View File

@ -1,13 +0,0 @@
---
description: '{{description}}'
---
Execute the BMAD '{{name}}' tool.
TOOL INSTRUCTIONS:
1. LOAD the tool file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents
3. FOLLOW every instruction precisely as specified
TOOL FILE: {project-root}/{{bmadFolderName}}/{{path}}

View File

@ -1,16 +0,0 @@
---
description: '{{description}}'
---
Execute the BMAD '{{name}}' workflow.
CRITICAL: You must load and follow the workflow definition exactly.
WORKFLOW INSTRUCTIONS:
1. LOAD the workflow file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents
3. FOLLOW every step precisely as specified
4. DO NOT skip or modify any steps
WORKFLOW FILE: {project-root}/{{bmadFolderName}}/{{path}}

View File

@ -1,16 +0,0 @@
---
description: '{{description}}'
---
Execute the BMAD '{{name}}' workflow.
CRITICAL: You must load and follow the workflow definition exactly.
WORKFLOW INSTRUCTIONS:
1. LOAD the workflow file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents
3. FOLLOW every step precisely as specified
4. DO NOT skip or modify any steps
WORKFLOW FILE: {project-root}/{{bmadFolderName}}/{{path}}

View File

@ -1,9 +0,0 @@
# {{name}}
{{description}}
---
Read the entire workflow file at: {project-root}/_bmad/{{workflow_path}}
Follow all instructions in the workflow file exactly as written.

View File

@ -1,9 +0,0 @@
# {{name}}
{{description}}
## Instructions
Read the entire workflow file at: {project-root}/_bmad/{{workflow_path}}
Follow all instructions in the workflow file exactly as written.

View File

@ -1,10 +0,0 @@
---
description: '{{description}}'
auto_execution_mode: "iterate"
---
# {{name}}
Read the entire workflow file at {project-root}/_bmad/{{workflow_path}}
Follow all instructions in the workflow file exactly as written.

View File

@ -155,33 +155,6 @@ class CustomModuleManager {
};
}
/**
* @deprecated Use parseSource() instead. Kept for backward compatibility.
* Parse and validate a GitHub repository URL.
* @param {string} url - GitHub URL to validate
* @returns {Object} { owner, repo, isValid, error }
*/
validateGitHubUrl(url) {
if (!url || typeof url !== 'string') {
return { owner: null, repo: null, isValid: false, error: 'URL is required' };
}
const trimmed = url.trim();
// HTTPS format: https://github.com/owner/repo[.git] (strict, no trailing path)
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
if (httpsMatch) {
return { owner: httpsMatch[1], repo: httpsMatch[2], isValid: true, error: null };
}
// SSH format: git@github.com:owner/repo[.git]
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
if (sshMatch) {
return { owner: sshMatch[1], repo: sshMatch[2], isValid: true, error: null };
}
return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' };
}
// ─── Marketplace JSON ─────────────────────────────────────────────────────
/**

View File

@ -109,46 +109,6 @@ class ExternalModuleManager {
return modules.find((m) => m.code === code) || null;
}
/**
* Get module info by key
* @param {string} key - The module key (e.g., 'bmad-creative-intelligence-suite')
* @returns {Object|null} Module info or null if not found
*/
async getModuleByKey(key) {
const modules = await this.listAvailable();
return modules.find((m) => m.key === key) || null;
}
/**
* Check if a module code exists in external modules
* @param {string} code - The module code to check
* @returns {boolean} True if the module exists
*/
async hasModule(code) {
const module = await this.getModuleByCode(code);
return module !== null;
}
/**
* Get the URL for a module by code
* @param {string} code - The module code
* @returns {string|null} The URL or null if not found
*/
async getModuleUrl(code) {
const module = await this.getModuleByCode(code);
return module ? module.url : null;
}
/**
* Get the module definition path for a module by code
* @param {string} code - The module code
* @returns {string|null} The module definition path or null if not found
*/
async getModuleDefinition(code) {
const module = await this.getModuleByCode(code);
return module ? module.moduleDefinition : null;
}
/**
* Get the cache directory for external modules
* @returns {string} Path to the external modules cache directory

View File

@ -12,6 +12,8 @@ class OfficialModules {
// Config collection state (merged from ConfigCollector)
this.collectedConfig = {};
this._existingConfig = null;
// Tracked during interactive config collection so {directory_name}
// placeholder defaults can be resolved in buildQuestion().
this.currentProjectDir = null;
}
@ -500,32 +502,6 @@ class OfficialModules {
}
}
/**
* Find all .md agent files recursively in a directory
* @param {string} dir - Directory to search
* @returns {Array} List of .md agent file paths
*/
async findAgentMdFiles(dir) {
const agentFiles = [];
async function searchDirectory(searchDir) {
const entries = await fs.readdir(searchDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(searchDir, entry.name);
if (entry.isFile() && entry.name.endsWith('.md')) {
agentFiles.push(fullPath);
} else if (entry.isDirectory()) {
await searchDirectory(fullPath);
}
}
}
await searchDirectory(dir);
return agentFiles;
}
/**
* Create directories declared in module.yaml's `directories` key
* This replaces the security-risky module installer pattern with declarative config
@ -699,29 +675,6 @@ class OfficialModules {
return { createdDirs, movedDirs, createdWdsFolders };
}
/**
* Private: Process module configuration
* @param {string} modulePath - Path to installed module
* @param {string} moduleName - Module name
*/
async processModuleConfig(modulePath, moduleName) {
const configPath = path.join(modulePath, 'config.yaml');
if (await fs.pathExists(configPath)) {
try {
let configContent = await fs.readFile(configPath, 'utf8');
// Replace path placeholders
configContent = configContent.replaceAll('{project-root}', `bmad/${moduleName}`);
configContent = configContent.replaceAll('{module}', moduleName);
await fs.writeFile(configPath, configContent, 'utf8');
} catch (error) {
await prompts.log.warn(`Failed to process module config: ${error.message}`);
}
}
}
/**
* Private: Sync module files (preserving user modifications)
* @param {string} sourcePath - Source module path
@ -1091,7 +1044,6 @@ class OfficialModules {
*/
async collectModuleConfigQuick(moduleName, projectDir, silentMode = true) {
this.currentProjectDir = projectDir;
// Load existing config if not already loaded
if (!this._existingConfig) {
await this.loadExistingConfig(projectDir);

View File

@ -50,17 +50,6 @@ class RegistryClient {
const content = await this.fetch(url, timeout);
return yaml.parse(content);
}
/**
* Fetch a URL and parse the response as JSON.
* @param {string} url - URL to fetch
* @param {number} [timeout] - Timeout in ms
* @returns {Promise<Object>} Parsed JSON content
*/
async fetchJson(url, timeout) {
const content = await this.fetch(url, timeout);
return JSON.parse(content);
}
}
module.exports = { RegistryClient };

View File

@ -498,26 +498,6 @@ async function password(options) {
return result;
}
/**
* Group multiple prompts together
* @param {Object} prompts - Object of prompt functions
* @param {Object} [options] - Group options
* @returns {Promise<Object>} Object with all answers
*/
async function group(prompts, options = {}) {
const clack = await getClack();
const result = await clack.group(prompts, {
onCancel: () => {
clack.cancel('Operation cancelled');
process.exit(0);
},
...options,
});
return result;
}
/**
* Run tasks with spinner feedback
* @param {Array} tasks - Array of task objects [{title, task, enabled?}]
@ -578,42 +558,6 @@ async function box(content, title, options) {
clack.box(content, title, options);
}
/**
* Create a progress bar for visualizing task completion
* @param {Object} [options] - Progress options (max, style, etc.)
* @returns {Promise<Object>} Progress controller with start, advance, stop methods
*/
async function progress(options) {
const clack = await getClack();
return clack.progress(options);
}
/**
* Create a task log for displaying scrolling subprocess output
* @param {Object} options - TaskLog options (title, limit, retainLog)
* @returns {Promise<Object>} TaskLog controller with message, success, error methods
*/
async function taskLog(options) {
const clack = await getClack();
return clack.taskLog(options);
}
/**
* File system path prompt with autocomplete
* @param {Object} options - Path options
* @param {string} options.message - The prompt message
* @param {string} [options.initialValue] - Initial path value
* @param {boolean} [options.directory=false] - Only allow directories
* @param {Function} [options.validate] - Validation function
* @returns {Promise<string>} Selected path
*/
async function pathPrompt(options) {
const clack = await getClack();
const result = await clack.path(options);
await handleCancel(result);
return result;
}
/**
* Autocomplete single-select prompt with type-ahead filtering
* @param {Object} options - Autocomplete options
@ -631,50 +575,6 @@ async function autocomplete(options) {
return result;
}
/**
* Key-based instant selection prompt
* @param {Object} options - SelectKey options
* @param {string} options.message - The prompt message
* @param {Array} options.options - Array of choices [{value, label, hint?}]
* @returns {Promise<any>} Selected value
*/
async function selectKey(options) {
const clack = await getClack();
const result = await clack.selectKey(options);
await handleCancel(result);
return result;
}
/**
* Stream messages with dynamic content (for LLMs, generators, etc.)
*/
const stream = {
async info(generator) {
const clack = await getClack();
return clack.stream.info(generator);
},
async success(generator) {
const clack = await getClack();
return clack.stream.success(generator);
},
async step(generator) {
const clack = await getClack();
return clack.stream.step(generator);
},
async warn(generator) {
const clack = await getClack();
return clack.stream.warn(generator);
},
async error(generator) {
const clack = await getClack();
return clack.stream.error(generator);
},
async message(generator, options) {
const clack = await getClack();
return clack.stream.message(generator, options);
},
};
/**
* Get the color utility (picocolors instance from @clack/prompts)
* @returns {Promise<Object>} The color utility (picocolors)
@ -790,20 +690,14 @@ module.exports = {
note,
box,
spinner,
progress,
taskLog,
select,
multiselect,
autocompleteMultiselect,
autocomplete,
selectKey,
confirm,
text,
path: pathPrompt,
password,
group,
tasks,
log,
stream,
prompt,
};

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