const fs = require("node:fs").promises; const path = require("node:path"); const DependencyResolver = require("../lib/dependency-resolver"); const yamlUtils = require("../lib/yaml-utils"); class WebBuilder { constructor(options = {}) { this.rootDir = options.rootDir || process.cwd(); this.outputDirs = options.outputDirs || [path.join(this.rootDir, "dist")]; this.resolver = new DependencyResolver(this.rootDir); this.templatePath = path.join( this.rootDir, "tools", "md-assets", "web-agent-startup-instructions.md" ); } parseYaml(content) { const yaml = require("js-yaml"); return yaml.load(content); } convertToWebPath(filePath, bundleRoot = 'bmad-core') { // Convert absolute paths to web bundle paths with dot prefix // All resources get installed under the bundle root, so use that path const relativePath = path.relative(this.rootDir, filePath); const pathParts = relativePath.split(path.sep); let resourcePath; if (pathParts[0] === 'expansion-packs') { // For expansion packs, remove 'expansion-packs/packname' and use the rest resourcePath = pathParts.slice(2).join('/'); } else { // For bmad-core, common, etc., remove the first part resourcePath = pathParts.slice(1).join('/'); } return `.${bundleRoot}/${resourcePath}`; } generateWebInstructions(bundleType, packName = null) { // Generate dynamic web instructions based on bundle type const rootExample = packName ? `.${packName}` : '.bmad-core'; const examplePath = packName ? `.${packName}/folder/filename.md` : '.bmad-core/folder/filename.md'; const personasExample = packName ? `.${packName}/personas/analyst.md` : '.bmad-core/personas/analyst.md'; const tasksExample = packName ? `.${packName}/tasks/create-story.md` : '.bmad-core/tasks/create-story.md'; const utilsExample = packName ? `.${packName}/utils/template-format.md` : '.bmad-core/utils/template-format.md'; const tasksRef = packName ? `.${packName}/tasks/create-story.md` : '.bmad-core/tasks/create-story.md'; return `# Web Agent Bundle Instructions You are now operating as a specialized AI agent from the BMad-Method framework. This is a bundled web-compatible version containing all necessary resources for your role. ## Important Instructions 1. **Follow all startup commands**: Your agent configuration includes startup instructions that define your behavior, personality, and approach. These MUST be followed exactly. 2. **Resource Navigation**: This bundle contains all resources you need. Resources are marked with tags like: - \`==================== START: ${examplePath} ====================\` - \`==================== END: ${examplePath} ====================\` When you need to reference a resource mentioned in your instructions: - Look for the corresponding START/END tags - The format is always the full path with dot prefix (e.g., \`${personasExample}\`, \`${tasksExample}\`) - If a section is specified (e.g., \`{root}/tasks/create-story.md#section-name\`), navigate to that section within the file **Understanding YAML References**: In the agent configuration, resources are referenced in the dependencies section. For example: \`\`\`yaml dependencies: utils: - template-format tasks: - create-story \`\`\` These references map directly to bundle sections: - \`utils: template-format\` → Look for \`==================== START: ${utilsExample} ====================\` - \`tasks: create-story\` → Look for \`==================== START: ${tasksRef} ====================\` 3. **Execution Context**: You are operating in a web environment. All your capabilities and knowledge are contained within this bundle. Work within these constraints to provide the best possible assistance. 4. **Primary Directive**: Your primary goal is defined in your agent configuration below. Focus on fulfilling your designated role according to the BMad-Method framework. --- `; } async cleanOutputDirs() { for (const dir of this.outputDirs) { try { await fs.rm(dir, { recursive: true, force: true }); console.log(`Cleaned: ${path.relative(this.rootDir, dir)}`); } catch (error) { console.debug(`Failed to clean directory ${dir}:`, error.message); // Directory might not exist, that's fine } } } async buildAgents() { const agents = await this.resolver.listAgents(); for (const agentId of agents) { console.log(` Building agent: ${agentId}`); const bundle = await this.buildAgentBundle(agentId); // Write to all output directories for (const outputDir of this.outputDirs) { const outputPath = path.join(outputDir, "agents"); await fs.mkdir(outputPath, { recursive: true }); const outputFile = path.join(outputPath, `${agentId}.txt`); await fs.writeFile(outputFile, bundle, "utf8"); } } console.log(`Built ${agents.length} agent bundles in ${this.outputDirs.length} locations`); } async buildTeams() { const teams = await this.resolver.listTeams(); for (const teamId of teams) { console.log(` Building team: ${teamId}`); const bundle = await this.buildTeamBundle(teamId); // Write to all output directories for (const outputDir of this.outputDirs) { const outputPath = path.join(outputDir, "teams"); await fs.mkdir(outputPath, { recursive: true }); const outputFile = path.join(outputPath, `${teamId}.txt`); await fs.writeFile(outputFile, bundle, "utf8"); } } console.log(`Built ${teams.length} team bundles in ${this.outputDirs.length} locations`); } async buildAgentBundle(agentId) { const dependencies = await this.resolver.resolveAgentDependencies(agentId); const template = this.generateWebInstructions('agent'); const sections = [template]; // Add agent configuration const agentPath = this.convertToWebPath(dependencies.agent.path, 'bmad-core'); sections.push(this.formatSection(agentPath, dependencies.agent.content, 'bmad-core')); // Add all dependencies for (const resource of dependencies.resources) { const resourcePath = this.convertToWebPath(resource.path, 'bmad-core'); sections.push(this.formatSection(resourcePath, resource.content, 'bmad-core')); } return sections.join("\n"); } async buildTeamBundle(teamId) { const dependencies = await this.resolver.resolveTeamDependencies(teamId); const template = this.generateWebInstructions('team'); const sections = [template]; // Add team configuration const teamPath = this.convertToWebPath(dependencies.team.path, 'bmad-core'); sections.push(this.formatSection(teamPath, dependencies.team.content, 'bmad-core')); // Add all agents for (const agent of dependencies.agents) { const agentPath = this.convertToWebPath(agent.path, 'bmad-core'); sections.push(this.formatSection(agentPath, agent.content, 'bmad-core')); } // Add all deduplicated resources for (const resource of dependencies.resources) { const resourcePath = this.convertToWebPath(resource.path, 'bmad-core'); sections.push(this.formatSection(resourcePath, resource.content, 'bmad-core')); } return sections.join("\n"); } processAgentContent(content) { // First, replace content before YAML with the template const yamlContent = yamlUtils.extractYamlFromAgent(content); if (!yamlContent) return content; const yamlMatch = content.match(/```ya?ml\n([\s\S]*?)\n```/); if (!yamlMatch) return content; const yamlStartIndex = content.indexOf(yamlMatch[0]); const yamlEndIndex = yamlStartIndex + yamlMatch[0].length; // Parse YAML and remove root and IDE-FILE-RESOLUTION properties try { const yaml = require("js-yaml"); const parsed = yaml.load(yamlContent); // Remove the properties if they exist at root level delete parsed.root; delete parsed["IDE-FILE-RESOLUTION"]; delete parsed["REQUEST-RESOLUTION"]; // Also remove from activation-instructions if they exist if (parsed["activation-instructions"] && Array.isArray(parsed["activation-instructions"])) { parsed["activation-instructions"] = parsed["activation-instructions"].filter( (instruction) => { return ( !instruction.startsWith("IDE-FILE-RESOLUTION:") && !instruction.startsWith("REQUEST-RESOLUTION:") ); } ); } // Reconstruct the YAML const cleanedYaml = yaml.dump(parsed, { lineWidth: -1 }); // Get the agent name from the YAML for the header const agentName = parsed.agent?.id || "agent"; // Build the new content with just the agent header and YAML const newHeader = `# ${agentName}\n\nCRITICAL: Read the full YAML, start activation to alter your state of being, follow startup section instructions, stay in this being until told to exit this mode:\n\n`; const afterYaml = content.substring(yamlEndIndex); return newHeader + "```yaml\n" + cleanedYaml.trim() + "\n```" + afterYaml; } catch (error) { console.warn("Failed to process agent YAML:", error.message); // If parsing fails, return original content return content; } } formatSection(path, content, bundleRoot = 'bmad-core') { const separator = "===================="; // Process agent content if this is an agent file if (path.includes("/agents/")) { content = this.processAgentContent(content); } // Replace {root} references with the actual bundle root content = this.replaceRootReferences(content, bundleRoot); return [ `${separator} START: ${path} ${separator}`, content.trim(), `${separator} END: ${path} ${separator}`, "", ].join("\n"); } replaceRootReferences(content, bundleRoot) { // Replace {root} with the appropriate bundle root path return content.replace(/\{root\}/g, `.${bundleRoot}`); } async validate() { console.log("Validating agent configurations..."); const agents = await this.resolver.listAgents(); for (const agentId of agents) { try { await this.resolver.resolveAgentDependencies(agentId); console.log(` ✓ ${agentId}`); } catch (error) { console.log(` ✗ ${agentId}: ${error.message}`); throw error; } } console.log("\nValidating team configurations..."); const teams = await this.resolver.listTeams(); for (const teamId of teams) { try { await this.resolver.resolveTeamDependencies(teamId); console.log(` ✓ ${teamId}`); } catch (error) { console.log(` ✗ ${teamId}: ${error.message}`); throw error; } } } async buildAllExpansionPacks(options = {}) { const expansionPacks = await this.listExpansionPacks(); for (const packName of expansionPacks) { console.log(` Building expansion pack: ${packName}`); await this.buildExpansionPack(packName, options); } console.log(`Built ${expansionPacks.length} expansion pack bundles`); } async buildExpansionPack(packName, options = {}) { const packDir = path.join(this.rootDir, "expansion-packs", packName); const outputDirs = [path.join(this.rootDir, "dist", "expansion-packs", packName)]; // Clean output directories if requested if (options.clean !== false) { for (const outputDir of outputDirs) { try { await fs.rm(outputDir, { recursive: true, force: true }); } catch (error) { // Directory might not exist, that's fine } } } // Build individual agents first const agentsDir = path.join(packDir, "agents"); try { const agentFiles = await fs.readdir(agentsDir); const agentMarkdownFiles = agentFiles.filter((f) => f.endsWith(".md")); if (agentMarkdownFiles.length > 0) { console.log(` Building individual agents for ${packName}:`); for (const agentFile of agentMarkdownFiles) { const agentName = agentFile.replace(".md", ""); console.log(` - ${agentName}`); // Build individual agent bundle const bundle = await this.buildExpansionAgentBundle(packName, packDir, agentName); // Write to all output directories for (const outputDir of outputDirs) { const agentsOutputDir = path.join(outputDir, "agents"); await fs.mkdir(agentsOutputDir, { recursive: true }); const outputFile = path.join(agentsOutputDir, `${agentName}.txt`); await fs.writeFile(outputFile, bundle, "utf8"); } } } } catch (error) { console.debug(` No agents directory found for ${packName}`); } // Build team bundle const agentTeamsDir = path.join(packDir, "agent-teams"); try { const teamFiles = await fs.readdir(agentTeamsDir); const teamFile = teamFiles.find((f) => f.endsWith(".yaml")); if (teamFile) { console.log(` Building team bundle for ${packName}`); const teamConfigPath = path.join(agentTeamsDir, teamFile); // Build expansion pack as a team bundle const bundle = await this.buildExpansionTeamBundle(packName, packDir, teamConfigPath); // Write to all output directories for (const outputDir of outputDirs) { const teamsOutputDir = path.join(outputDir, "teams"); await fs.mkdir(teamsOutputDir, { recursive: true }); const outputFile = path.join(teamsOutputDir, teamFile.replace(".yaml", ".txt")); await fs.writeFile(outputFile, bundle, "utf8"); console.log(` ✓ Created bundle: ${path.relative(this.rootDir, outputFile)}`); } } else { console.warn(` ⚠ No team configuration found in ${packName}/agent-teams/`); } } catch (error) { console.warn(` ⚠ No agent-teams directory found for ${packName}`); } } async buildExpansionAgentBundle(packName, packDir, agentName) { const template = this.generateWebInstructions('expansion-agent', packName); const sections = [template]; // Add agent configuration const agentPath = path.join(packDir, "agents", `${agentName}.md`); const agentContent = await fs.readFile(agentPath, "utf8"); const agentWebPath = this.convertToWebPath(agentPath, packName); sections.push(this.formatSection(agentWebPath, agentContent, packName)); // Resolve and add agent dependencies const yamlContent = yamlUtils.extractYamlFromAgent(agentContent); if (yamlContent) { try { const yaml = require("js-yaml"); const agentConfig = yaml.load(yamlContent); if (agentConfig.dependencies) { // Add resources, first try expansion pack, then core for (const [resourceType, resources] of Object.entries(agentConfig.dependencies)) { if (Array.isArray(resources)) { for (const resourceName of resources) { let found = false; const extensions = [".md", ".yaml"]; // Try expansion pack first for (const ext of extensions) { const resourcePath = path.join(packDir, resourceType, `${resourceName}${ext}`); try { const resourceContent = await fs.readFile(resourcePath, "utf8"); const resourceWebPath = this.convertToWebPath(resourcePath, packName); sections.push( this.formatSection(resourceWebPath, resourceContent, packName) ); found = true; break; } catch (error) { // Not in expansion pack, continue } } // If not found in expansion pack, try core if (!found) { for (const ext of extensions) { const corePath = path.join( this.rootDir, "bmad-core", resourceType, `${resourceName}${ext}` ); try { const coreContent = await fs.readFile(corePath, "utf8"); const coreWebPath = this.convertToWebPath(corePath, packName); sections.push( this.formatSection(coreWebPath, coreContent, packName) ); found = true; break; } catch (error) { // Not in core either, continue } } } // If not found in core, try common folder if (!found) { for (const ext of extensions) { const commonPath = path.join( this.rootDir, "common", resourceType, `${resourceName}${ext}` ); try { const commonContent = await fs.readFile(commonPath, "utf8"); const commonWebPath = this.convertToWebPath(commonPath, packName); sections.push( this.formatSection(commonWebPath, commonContent, packName) ); found = true; break; } catch (error) { // Not in common either, continue } } } if (!found) { console.warn( ` ⚠ Dependency ${resourceType}#${resourceName} not found in expansion pack or core` ); } } } } } } catch (error) { console.debug(`Failed to parse agent YAML for ${agentName}:`, error.message); } } return sections.join("\n"); } async buildExpansionTeamBundle(packName, packDir, teamConfigPath) { const template = this.generateWebInstructions('expansion-team', packName); const sections = [template]; // Add team configuration and parse to get agent list const teamContent = await fs.readFile(teamConfigPath, "utf8"); const teamFileName = path.basename(teamConfigPath, ".yaml"); const teamConfig = this.parseYaml(teamContent); const teamWebPath = this.convertToWebPath(teamConfigPath, packName); sections.push(this.formatSection(teamWebPath, teamContent, packName)); // Get list of expansion pack agents const expansionAgents = new Set(); const agentsDir = path.join(packDir, "agents"); try { const agentFiles = await fs.readdir(agentsDir); for (const agentFile of agentFiles.filter((f) => f.endsWith(".md"))) { const agentName = agentFile.replace(".md", ""); expansionAgents.add(agentName); } } catch (error) { console.warn(` ⚠ No agents directory found in ${packName}`); } // Build a map of all available expansion pack resources for override checking const expansionResources = new Map(); const resourceDirs = ["templates", "tasks", "checklists", "workflows", "data"]; for (const resourceDir of resourceDirs) { const resourcePath = path.join(packDir, resourceDir); try { const resourceFiles = await fs.readdir(resourcePath); for (const resourceFile of resourceFiles.filter( (f) => f.endsWith(".md") || f.endsWith(".yaml") )) { const fileName = resourceFile.replace(/\.(md|yaml)$/, ""); expansionResources.set(`${resourceDir}#${fileName}`, true); } } catch (error) { // Directory might not exist, that's fine } } // Process all agents listed in team configuration const agentsToProcess = teamConfig.agents || []; // Ensure bmad-orchestrator is always included for teams if (!agentsToProcess.includes("bmad-orchestrator")) { console.warn(` ⚠ Team ${teamFileName} missing bmad-orchestrator, adding automatically`); agentsToProcess.unshift("bmad-orchestrator"); } // Track all dependencies from all agents (deduplicated) const allDependencies = new Map(); for (const agentId of agentsToProcess) { if (expansionAgents.has(agentId)) { // Use expansion pack version (override) const agentPath = path.join(agentsDir, `${agentId}.md`); const agentContent = await fs.readFile(agentPath, "utf8"); const expansionAgentWebPath = this.convertToWebPath(agentPath, packName); sections.push(this.formatSection(expansionAgentWebPath, agentContent, packName)); // Parse and collect dependencies from expansion agent const agentYaml = agentContent.match(/```yaml\n([\s\S]*?)\n```/); if (agentYaml) { try { const agentConfig = this.parseYaml(agentYaml[1]); if (agentConfig.dependencies) { for (const [resourceType, resources] of Object.entries(agentConfig.dependencies)) { if (Array.isArray(resources)) { for (const resourceName of resources) { const key = `${resourceType}#${resourceName}`; if (!allDependencies.has(key)) { allDependencies.set(key, { type: resourceType, name: resourceName }); } } } } } } catch (error) { console.debug(`Failed to parse agent YAML for ${agentId}:`, error.message); } } } else { // Use core BMad version try { const coreAgentPath = path.join(this.rootDir, "bmad-core", "agents", `${agentId}.md`); const coreAgentContent = await fs.readFile(coreAgentPath, "utf8"); const coreAgentWebPath = this.convertToWebPath(coreAgentPath, packName); sections.push(this.formatSection(coreAgentWebPath, coreAgentContent, packName)); // Parse and collect dependencies from core agent const yamlContent = yamlUtils.extractYamlFromAgent(coreAgentContent, true); if (yamlContent) { try { const agentConfig = this.parseYaml(yamlContent); if (agentConfig.dependencies) { for (const [resourceType, resources] of Object.entries(agentConfig.dependencies)) { if (Array.isArray(resources)) { for (const resourceName of resources) { const key = `${resourceType}#${resourceName}`; if (!allDependencies.has(key)) { allDependencies.set(key, { type: resourceType, name: resourceName }); } } } } } } catch (error) { console.debug(`Failed to parse agent YAML for ${agentId}:`, error.message); } } } catch (error) { console.warn(` ⚠ Agent ${agentId} not found in core or expansion pack`); } } } // Add all collected dependencies from agents // Always prefer expansion pack versions if they exist for (const [key, dep] of allDependencies) { let found = false; const extensions = [".md", ".yaml"]; // Always check expansion pack first, even if the dependency came from a core agent if (expansionResources.has(key)) { // We know it exists in expansion pack, find and load it for (const ext of extensions) { const expansionPath = path.join(packDir, dep.type, `${dep.name}${ext}`); try { const content = await fs.readFile(expansionPath, "utf8"); const expansionWebPath = this.convertToWebPath(expansionPath, packName); sections.push(this.formatSection(expansionWebPath, content, packName)); console.log(` ✓ Using expansion override for ${key}`); found = true; break; } catch (error) { // Try next extension } } } // If not found in expansion pack (or doesn't exist there), try core if (!found) { for (const ext of extensions) { const corePath = path.join(this.rootDir, "bmad-core", dep.type, `${dep.name}${ext}`); try { const content = await fs.readFile(corePath, "utf8"); const coreWebPath = this.convertToWebPath(corePath, packName); sections.push(this.formatSection(coreWebPath, content, packName)); found = true; break; } catch (error) { // Not in core either, continue } } } // If not found in core, try common folder if (!found) { for (const ext of extensions) { const commonPath = path.join(this.rootDir, "common", dep.type, `${dep.name}${ext}`); try { const content = await fs.readFile(commonPath, "utf8"); const commonWebPath = this.convertToWebPath(commonPath, packName); sections.push(this.formatSection(commonWebPath, content, packName)); found = true; break; } catch (error) { // Not in common either, continue } } } if (!found) { console.warn(` ⚠ Dependency ${key} not found in expansion pack or core`); } } // Add remaining expansion pack resources not already included as dependencies for (const resourceDir of resourceDirs) { const resourcePath = path.join(packDir, resourceDir); try { const resourceFiles = await fs.readdir(resourcePath); for (const resourceFile of resourceFiles.filter( (f) => f.endsWith(".md") || f.endsWith(".yaml") )) { const filePath = path.join(resourcePath, resourceFile); const fileContent = await fs.readFile(filePath, "utf8"); const fileName = resourceFile.replace(/\.(md|yaml)$/, ""); // Only add if not already included as a dependency const resourceKey = `${resourceDir}#${fileName}`; if (!allDependencies.has(resourceKey)) { const fullResourcePath = path.join(resourcePath, resourceFile); const resourceWebPath = this.convertToWebPath(fullResourcePath, packName); sections.push(this.formatSection(resourceWebPath, fileContent, packName)); } } } catch (error) { // Directory might not exist, that's fine } } return sections.join("\n"); } async listExpansionPacks() { const expansionPacksDir = path.join(this.rootDir, "expansion-packs"); try { const entries = await fs.readdir(expansionPacksDir, { withFileTypes: true }); return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); } catch (error) { console.warn("No expansion-packs directory found"); return []; } } listAgents() { return this.resolver.listAgents(); } } module.exports = WebBuilder;