Compare commits
1 Commits
f7b3b5a2c6
...
e567326150
| Author | SHA1 | Date |
|---|---|---|
|
|
e567326150 |
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
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
|
||||
|
|
@ -21,7 +20,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`. 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
|
@ -30,19 +29,13 @@ 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`. 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`
|
||||
- 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`
|
||||
- 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.
|
||||
|
|
@ -52,7 +45,7 @@ If the spec is an epic story and `{sprint_status}` exists: find the `development
|
|||
|
||||
**A) Epic story path** — if the intent is clearly an epic story:
|
||||
|
||||
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.
|
||||
1. Identify the epic number and (if present) the story number. 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.
|
||||
|
|
@ -66,8 +59,6 @@ If the spec is an epic story and `{sprint_status}` exists: find the `development
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -48,25 +48,16 @@ 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.
|
||||
|
||||
### Mark Spec Done
|
||||
### Commit and Present
|
||||
|
||||
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:
|
||||
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:
|
||||
- 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.
|
||||
|
||||
### 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.
|
||||
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.
|
||||
|
||||
Workflow complete.
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ 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
|
||||
|
|
@ -41,8 +39,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -65,7 +65,6 @@ 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -1728,6 +1728,36 @@ 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');
|
||||
|
|
@ -1924,6 +1954,25 @@ 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('');
|
||||
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
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
|
||||
*/
|
||||
|
|
@ -38,6 +52,37 @@ 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)
|
||||
|
|
@ -48,6 +93,98 @@ 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 };
|
||||
|
|
|
|||
|
|
@ -107,6 +107,117 @@ 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
|
||||
|
|
@ -199,6 +310,62 @@ 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
|
||||
|
|
@ -236,6 +403,27 @@ 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
|
||||
|
|
@ -250,6 +438,354 @@ 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
|
||||
|
|
@ -450,6 +986,47 @@ 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 };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
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 };
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
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,
|
||||
};
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
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,
|
||||
};
|
||||
|
|
@ -15,6 +15,8 @@
|
|||
* - 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
|
||||
|
|
@ -192,6 +194,125 @@ 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,
|
||||
|
|
@ -207,13 +328,37 @@ 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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -0,0 +1 @@
|
|||
default-agent.md
|
||||
|
|
@ -0,0 +1 @@
|
|||
default-workflow.md
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
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!
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
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}}
|
||||
"""
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
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}}
|
||||
"""
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
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}}
|
||||
"""
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
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}}
|
||||
"""
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
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}}
|
||||
"""
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
inclusion: manual
|
||||
---
|
||||
|
||||
# {{name}}
|
||||
|
||||
Read the entire task file at: #[[file:{{bmadFolderName}}/{{path}}]]
|
||||
|
||||
Follow all instructions in the task file exactly as written.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
inclusion: manual
|
||||
---
|
||||
|
||||
# {{name}}
|
||||
|
||||
Read the entire tool file at: #[[file:{{bmadFolderName}}/{{path}}]]
|
||||
|
||||
Follow all instructions in the tool file exactly as written.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
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!
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
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}}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
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}}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
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}}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
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}}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# {{name}}
|
||||
|
||||
{{description}}
|
||||
|
||||
---
|
||||
|
||||
Read the entire workflow file at: {project-root}/_bmad/{{workflow_path}}
|
||||
|
||||
Follow all instructions in the workflow file exactly as written.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# {{name}}
|
||||
|
||||
{{description}}
|
||||
|
||||
## Instructions
|
||||
|
||||
Read the entire workflow file at: {project-root}/_bmad/{{workflow_path}}
|
||||
|
||||
Follow all instructions in the workflow file exactly as written.
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -155,6 +155,33 @@ 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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -109,6 +109,46 @@ 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
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ 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;
|
||||
}
|
||||
|
||||
|
|
@ -502,6 +500,32 @@ 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
|
||||
|
|
@ -675,6 +699,29 @@ 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
|
||||
|
|
@ -1044,6 +1091,7 @@ 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);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,17 @@ 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 };
|
||||
|
|
|
|||
|
|
@ -498,6 +498,26 @@ 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?}]
|
||||
|
|
@ -558,6 +578,42 @@ 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
|
||||
|
|
@ -575,6 +631,50 @@ 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)
|
||||
|
|
@ -690,14 +790,20 @@ module.exports = {
|
|||
note,
|
||||
box,
|
||||
spinner,
|
||||
progress,
|
||||
taskLog,
|
||||
select,
|
||||
multiselect,
|
||||
autocompleteMultiselect,
|
||||
autocomplete,
|
||||
selectKey,
|
||||
confirm,
|
||||
text,
|
||||
path: pathPrompt,
|
||||
password,
|
||||
group,
|
||||
tasks,
|
||||
log,
|
||||
stream,
|
||||
prompt,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue