Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
type: skill
type: agent
name: bmad-agent-analyst
displayName: Mary
title: Business Analyst
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
type: skill
type: agent
name: bmad-agent-tech-writer
displayName: Paige
title: Technical Writer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
type: skill
type: agent
name: bmad-agent-pm
displayName: John
title: Product Manager
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
type: skill
type: agent
name: bmad-agent-ux-designer
displayName: Sally
title: UX Designer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
type: skill
type: agent
name: bmad-agent-architect
displayName: Winston
title: Architect
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
type: skill
type: agent
name: bmad-agent-dev
displayName: Amelia
title: Developer Agent
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
type: skill
type: agent
name: bmad-agent-qa
displayName: Quinn
title: QA Engineer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
type: skill
type: agent
name: bmad-agent-quick-flow-solo-dev
displayName: Barry
title: Quick Flow Solo Dev
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
type: skill
type: agent
name: bmad-agent-sm
displayName: Bob
title: Scrum Master
Expand Down
87 changes: 87 additions & 0 deletions test/test-installation-components.js
Original file line number Diff line number Diff line change
Expand Up @@ -1648,6 +1648,93 @@ async function runTests() {
// skill-manifest.csv should include the native agent entrypoint
const skillManifestCsv29 = await fs.readFile(path.join(tempFixture29, '_config', 'skill-manifest.csv'), 'utf8');
assert(skillManifestCsv29.includes('bmad-tea'), 'skill-manifest.csv includes native type:agent SKILL.md entrypoint');

// --- Agents at non-agents/ paths (regression test for BMM/CIS layouts) ---
// Create a second fixture with agents at paths like bmm/1-analysis/bmad-agent-analyst/
const tempFixture29b = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-agent-paths-'));
await fs.ensureDir(path.join(tempFixture29b, '_config'));

// Agent at bmm-style path: bmm/1-analysis/bmad-agent-analyst/
const bmmAgentDir = path.join(tempFixture29b, 'bmm', '1-analysis', 'bmad-agent-analyst');
await fs.ensureDir(bmmAgentDir);
await fs.writeFile(
path.join(bmmAgentDir, 'bmad-skill-manifest.yaml'),
[
'type: agent',
'name: bmad-agent-analyst',
'displayName: Mary',
'title: Business Analyst',
'role: Strategic Business Analyst',
'module: bmm',
].join('\n') + '\n',
);
await fs.writeFile(
path.join(bmmAgentDir, 'SKILL.md'),
'---\nname: bmad-agent-analyst\ndescription: Business Analyst agent\n---\n\nAnalyst agent.\n',
);

// Agent at cis-style path: cis/skills/bmad-cis-agent-brainstorming-coach/
const cisAgentDir = path.join(tempFixture29b, 'cis', 'skills', 'bmad-cis-agent-brainstorming-coach');
await fs.ensureDir(cisAgentDir);
await fs.writeFile(
path.join(cisAgentDir, 'bmad-skill-manifest.yaml'),
[
'type: agent',
'name: bmad-cis-agent-brainstorming-coach',
'displayName: Carson',
'title: Brainstorming Specialist',
'role: Master Facilitator',
'module: cis',
].join('\n') + '\n',
);
await fs.writeFile(
path.join(cisAgentDir, 'SKILL.md'),
'---\nname: bmad-cis-agent-brainstorming-coach\ndescription: Brainstorming coach\n---\n\nCoach.\n',
);

// Agent at standard agents/ path (GDS-style): gds/agents/gds-agent-game-dev/
const gdsAgentDir = path.join(tempFixture29b, 'gds', 'agents', 'gds-agent-game-dev');
await fs.ensureDir(gdsAgentDir);
await fs.writeFile(
path.join(gdsAgentDir, 'bmad-skill-manifest.yaml'),
[
'type: agent',
'name: gds-agent-game-dev',
'displayName: Link',
'title: Game Developer',
'role: Senior Game Dev',
'module: gds',
].join('\n') + '\n',
);
await fs.writeFile(
path.join(gdsAgentDir, 'SKILL.md'),
'---\nname: gds-agent-game-dev\ndescription: Game developer agent\n---\n\nGame dev.\n',
);

const generator29b = new ManifestGenerator();
await generator29b.generateManifests(tempFixture29b, ['bmm', 'cis', 'gds'], [], { ides: [] });

// All three agents should appear in agents[] regardless of directory layout
const bmmAgent = generator29b.agents.find((a) => a.name === 'bmad-agent-analyst');
assert(bmmAgent !== undefined, 'Agent at bmm/1-analysis/ path appears in agents[]');
assert(bmmAgent && bmmAgent.module === 'bmm', 'BMM agent module field comes from manifest file');
assert(bmmAgent && bmmAgent.path.includes('bmm/1-analysis/bmad-agent-analyst'), 'BMM agent path reflects actual directory layout');

const cisAgent = generator29b.agents.find((a) => a.name === 'bmad-cis-agent-brainstorming-coach');
assert(cisAgent !== undefined, 'Agent at cis/skills/ path appears in agents[]');
assert(cisAgent && cisAgent.module === 'cis', 'CIS agent module field comes from manifest file');

const gdsAgent = generator29b.agents.find((a) => a.name === 'gds-agent-game-dev');
assert(gdsAgent !== undefined, 'Agent at gds/agents/ path appears in agents[]');
assert(gdsAgent && gdsAgent.module === 'gds', 'GDS agent module field comes from manifest file');

// agent-manifest.csv should contain all three
const agentCsv29b = await fs.readFile(path.join(tempFixture29b, '_config', 'agent-manifest.csv'), 'utf8');
assert(agentCsv29b.includes('bmad-agent-analyst'), 'agent-manifest.csv includes BMM-layout agent');
assert(agentCsv29b.includes('bmad-cis-agent-brainstorming-coach'), 'agent-manifest.csv includes CIS-layout agent');
assert(agentCsv29b.includes('gds-agent-game-dev'), 'agent-manifest.csv includes GDS-layout agent');

await fs.remove(tempFixture29b).catch(() => {});
} catch (error) {
assert(false, 'Unified skill scanner test succeeds', error.message);
} finally {
Expand Down
180 changes: 65 additions & 115 deletions tools/cli/installers/lib/core/manifest-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -268,153 +268,103 @@ class ManifestGenerator {
}

/**
* Collect all agents from core and selected modules
* Scans the INSTALLED bmad directory, not the source
* Collect all agents from selected modules by walking their directory trees.
*/
async collectAgents(selectedModules) {
this.agents = [];
const debug = process.env.BMAD_DEBUG_MANIFEST === 'true';

// Use updatedModules which already includes deduplicated 'core' + selectedModules
// Walk each module's full directory tree looking for type:agent manifests
for (const moduleName of this.updatedModules) {
const agentsPath = path.join(this.bmadDir, moduleName, 'agents');
const modulePath = path.join(this.bmadDir, moduleName);
if (!(await fs.pathExists(modulePath))) continue;

if (await fs.pathExists(agentsPath)) {
const moduleAgents = await this.getAgentsFromDir(agentsPath, moduleName);
this.agents.push(...moduleAgents);
}
const moduleAgents = await this.getAgentsFromDirRecursive(modulePath, moduleName, '', debug);
this.agents.push(...moduleAgents);
}

// Get standalone agents from bmad/agents/ directory
const standaloneAgentsDir = path.join(this.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 standaloneAgents = await this.getAgentsFromDirRecursive(standaloneAgentsDir, 'standalone', '', debug);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

standaloneAgentsDir is _bmad/agents, but passing moduleName='standalone' makes downstream installPath/agent.path look like _bmad/standalone/..., which won’t match the on-disk location for standalone agents.

Severity: high

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

this.agents.push(...standaloneAgents);
}

const agentDirPath = path.join(standaloneAgentsDir, agentDir.name);
const standaloneAgents = await this.getAgentsFromDir(agentDirPath, 'standalone');
this.agents.push(...standaloneAgents);
}
if (debug) {
console.log(`[DEBUG] collectAgents: total agents found: ${this.agents.length}`);
}
}

/**
* Get agents from a directory recursively
* Only includes .md files with agent content
* Recursively walk a directory tree collecting agents.
* Discovers agents via directory with bmad-skill-manifest.yaml containing type: agent
*
* @param {string} dirPath - Current directory being scanned
* @param {string} moduleName - Module this directory belongs to
* @param {string} relativePath - Path relative to the module root (for install path construction)
* @param {boolean} debug - Emit debug messages
*/
async getAgentsFromDir(dirPath, moduleName, relativePath = '') {
// Skip directories claimed by collectSkills
if (this.skillClaimedDirs && this.skillClaimedDirs.has(dirPath)) return [];
async getAgentsFromDirRecursive(dirPath, moduleName, relativePath = '', debug = false) {
const agents = [];
const entries = await fs.readdir(dirPath, { withFileTypes: true });
// Load skill manifest for this directory (if present)
const skillManifest = await this.loadSkillManifest(dirPath);
let entries;
try {
entries = await fs.readdir(dirPath, { withFileTypes: true });
} catch {
return agents;
}

for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);

if (entry.isDirectory()) {
// Check for new-format agent: bmad-skill-manifest.yaml with type: agent
// Note: type:agent dirs may also be claimed by collectSkills for IDE installation,
// but we still need to process them here for agent-manifest.csv
const dirManifest = await this.loadSkillManifest(fullPath);
if (dirManifest && dirManifest.__single && dirManifest.__single.type === 'agent') {
const m = dirManifest.__single;
const dirRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
const installPath =
moduleName === 'core'
? `${this.bmadFolderName}/core/agents/${dirRelativePath}`
: `${this.bmadFolderName}/${moduleName}/agents/${dirRelativePath}`;

agents.push({
name: m.name || entry.name,
displayName: m.displayName || m.name || entry.name,
title: m.title || '',
icon: m.icon || '',
capabilities: m.capabilities ? this.cleanForCSV(m.capabilities) : '',
role: m.role ? this.cleanForCSV(m.role) : '',
identity: m.identity ? this.cleanForCSV(m.identity) : '',
communicationStyle: m.communicationStyle ? this.cleanForCSV(m.communicationStyle) : '',
principles: m.principles ? this.cleanForCSV(m.principles) : '',
module: m.module || moduleName,
path: installPath,
canonicalId: m.canonicalId || '',
});

this.files.push({
type: 'agent',
name: m.name || entry.name,
module: moduleName,
path: installPath,
});
continue;
}

// Skip directories claimed by collectSkills (non-agent type skills)
if (this.skillClaimedDirs && this.skillClaimedDirs.has(fullPath)) continue;

// Recurse into subdirectories
const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
const subDirAgents = await this.getAgentsFromDir(fullPath, moduleName, newRelativePath);
agents.push(...subDirAgents);
} else if (entry.name.endsWith('.md') && entry.name.toLowerCase() !== 'readme.md') {
const content = await fs.readFile(fullPath, 'utf8');

// Skip files that don't contain <agent> tag (e.g., README files)
if (!content.includes('<agent')) {
continue;
}

// Skip web-only agents
if (content.includes('localskip="true"')) {
continue;
}

// Extract agent metadata from the XML structure
const nameMatch = content.match(/name="([^"]+)"/);
const titleMatch = content.match(/title="([^"]+)"/);
const iconMatch = content.match(/icon="([^"]+)"/);
const capabilitiesMatch = content.match(/capabilities="([^"]+)"/);

// Extract persona fields
const roleMatch = content.match(/<role>([^<]+)<\/role>/);
const identityMatch = content.match(/<identity>([\s\S]*?)<\/identity>/);
const styleMatch = content.match(/<communication_style>([\s\S]*?)<\/communication_style>/);
const principlesMatch = content.match(/<principles>([\s\S]*?)<\/principles>/);
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;

// Build relative path for installation
const fileRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
const installPath =
moduleName === 'core'
? `${this.bmadFolderName}/core/agents/${fileRelativePath}`
: `${this.bmadFolderName}/${moduleName}/agents/${fileRelativePath}`;
const fullPath = path.join(dirPath, entry.name);

const agentName = entry.name.replace('.md', '');
// Check for type:agent manifest BEFORE checking skillClaimedDirs —
// agent dirs may be claimed by collectSkills for IDE installation,
// but we still need them in agent-manifest.csv.
const dirManifest = await this.loadSkillManifest(fullPath);
if (dirManifest && dirManifest.__single && dirManifest.__single.type === 'agent') {
const m = dirManifest.__single;
const dirRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
const agentModule = m.module || moduleName;
const installPath = `${this.bmadFolderName}/${agentModule}/${dirRelativePath}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

installPath is built using agentModule (from manifest), so if a manifest declares a different module than the directory being scanned, the path written to agent-manifest.csv can point at a non-existent location (breaking consumers that treat path as a filesystem reference).

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.


agents.push({
name: agentName,
displayName: nameMatch ? nameMatch[1] : agentName,
title: titleMatch ? titleMatch[1] : '',
icon: iconMatch ? iconMatch[1] : '',
capabilities: capabilitiesMatch ? this.cleanForCSV(capabilitiesMatch[1]) : '',
role: roleMatch ? this.cleanForCSV(roleMatch[1]) : '',
identity: identityMatch ? this.cleanForCSV(identityMatch[1]) : '',
communicationStyle: styleMatch ? this.cleanForCSV(styleMatch[1]) : '',
principles: principlesMatch ? this.cleanForCSV(principlesMatch[1]) : '',
module: moduleName,
name: m.name || entry.name,
displayName: m.displayName || m.name || entry.name,
title: m.title || '',
icon: m.icon || '',
capabilities: m.capabilities ? this.cleanForCSV(m.capabilities) : '',
role: m.role ? this.cleanForCSV(m.role) : '',
identity: m.identity ? this.cleanForCSV(m.identity) : '',
communicationStyle: m.communicationStyle ? this.cleanForCSV(m.communicationStyle) : '',
principles: m.principles ? this.cleanForCSV(m.principles) : '',
module: agentModule,
path: installPath,
canonicalId: this.getCanonicalId(skillManifest, entry.name),
canonicalId: m.canonicalId || '',
});

// Add to files list
this.files.push({
type: 'agent',
name: agentName,
module: moduleName,
name: m.name || entry.name,
module: agentModule,
path: installPath,
});

if (debug) {
console.log(`[DEBUG] collectAgents: found type:agent "${m.name || entry.name}" at ${fullPath}`);
}
continue;
}

// Skip directories claimed by collectSkills (non-agent type skills) —
// avoids recursing into skill trees that can't contain agents.
if (this.skillClaimedDirs && this.skillClaimedDirs.has(fullPath)) continue;

// Recurse into subdirectories
const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
const subDirAgents = await this.getAgentsFromDirRecursive(fullPath, moduleName, newRelativePath, debug);
agents.push(...subDirAgents);
}

return agents;
Expand Down
Loading
Loading