custom agents and workflows can now also be installed with a simple custom.yaml designation

This commit is contained in:
Brian Madison
2025-12-06 22:45:02 -06:00
parent 903c7a4133
commit eacfba2e5b
32 changed files with 2653 additions and 8 deletions

View File

@@ -38,6 +38,7 @@ const { CLIUtils } = require('../../../lib/cli-utils');
const { ManifestGenerator } = require('./manifest-generator');
const { IdeConfigManager } = require('./ide-config-manager');
const { replaceAgentSidecarFolders } = require('./post-install-sidecar-replacement');
const { CustomHandler } = require('../custom/handler');
class Installer {
constructor() {
@@ -51,6 +52,7 @@ class Installer {
this.dependencyResolver = new DependencyResolver();
this.configCollector = new ConfigCollector();
this.ideConfigManager = new IdeConfigManager();
this.customHandler = new CustomHandler();
this.installedFiles = []; // Track all installed files
this.ttsInjectedFiles = []; // Track files with TTS injection applied
}
@@ -1026,6 +1028,9 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
}
}
// Update custom content (add new files, don't remove existing)
await this.updateCustomContent(projectDir, bmadDir);
// Replace {agent_sidecar_folder} placeholders in all agent files
console.log(chalk.dim('\n Configuring agent sidecar folders...'));
const sidecarResults = await replaceAgentSidecarFolders(bmadDir);
@@ -1163,6 +1168,133 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
return { success: true };
}
/**
* Update custom content (add new files without removing existing ones)
* @param {string} projectDir - Project directory
* @param {string} bmadDir - BMAD installation directory
*/
async updateCustomContent(projectDir, bmadDir) {
try {
// Find all custom content
const customContents = await this.customHandler.findCustomContent(projectDir);
if (customContents.length === 0) {
return; // No custom content to update
}
// Load core config
const coreConfigPath = path.join(bmadDir, 'bmb', 'config.yaml');
let coreConfig = {};
if (await fs.pathExists(coreConfigPath)) {
const yamlLib = require('yaml');
const coreConfigContent = await fs.readFile(coreConfigPath, 'utf8');
coreConfig = yamlLib.load(coreConfigContent);
}
console.log(chalk.dim('\nUpdating custom content...'));
for (const customPath of customContents) {
const customInfo = await this.customHandler.getCustomInfo(customPath);
if (customInfo) {
console.log(chalk.dim(` Checking: ${customInfo.name}`));
// Install only adds new files, doesn't remove existing ones
const results = await this.customHandler.install(
customPath,
bmadDir,
{
user_name: coreConfig.user_name,
communication_language: coreConfig.communication_language,
output_folder: coreConfig.output_folder,
bmad_folder: path.basename(bmadDir),
},
(filePath) => {
this.installedFiles.push(filePath);
},
);
// Only show if new files were added or preserved
if (results.filesCopied > 0 || results.preserved > 0) {
if (results.filesCopied > 0) {
console.log(chalk.dim(` Added ${results.filesCopied} new file(s)`));
}
if (results.preserved > 0) {
console.log(chalk.dim(` Preserved ${results.preserved} existing file(s)`));
}
}
}
}
} catch (error) {
console.warn(chalk.yellow(`Warning: Failed to update custom content: ${error.message}`));
}
}
/**
* Install custom content by ID
* @param {string} customId - Custom content ID
* @param {string} bmadDir - BMAD installation directory
* @param {Object} coreConfig - Core configuration
*/
async installCustomContent(customId, bmadDir, coreConfig) {
try {
// Find the custom content
const customContents = await this.customHandler.findCustomContent(process.cwd());
let customInfo = null;
let customPath = null;
for (const path of customContents) {
const info = await this.customHandler.getCustomInfo(path);
if (info && info.id === customId) {
customInfo = info;
customPath = path;
break;
}
}
if (!customInfo || !customPath) {
console.warn(chalk.yellow(`Warning: Custom content '${customId}' not found`));
return;
}
console.log(chalk.dim(` Installing: ${customInfo.name}`));
// Install the custom content
const results = await this.customHandler.install(
customPath,
bmadDir,
{
user_name: coreConfig.user_name,
communication_language: coreConfig.communication_language,
output_folder: coreConfig.output_folder,
bmad_folder: path.basename(bmadDir),
},
(filePath) => {
this.installedFiles.push(filePath);
},
);
// Show results
if (results.agentsInstalled > 0) {
console.log(chalk.dim(` ${results.agentsInstalled} agent(s) installed`));
}
if (results.workflowsInstalled > 0) {
console.log(chalk.dim(` ${results.workflowsInstalled} workflow(s) installed`));
}
if (results.filesCopied > 0) {
console.log(chalk.dim(` ${results.filesCopied} file(s) copied`));
}
if (results.preserved > 0) {
console.log(chalk.dim(` ${results.preserved} file(s) preserved`));
}
if (results.errors.length > 0) {
console.log(chalk.yellow(` ${results.errors.length} error(s)`));
for (const error of results.errors) console.log(chalk.dim(` - ${error}`));
}
} catch (error) {
console.error(chalk.red(`Failed to install custom content '${customId}':`, error.message));
}
}
/**
* Private: Create directory structure
*/

View File

@@ -0,0 +1,266 @@
const path = require('node:path');
const fs = require('fs-extra');
const chalk = require('chalk');
const yaml = require('js-yaml');
const { FileOps } = require('../../../lib/file-ops');
/**
* Handler for custom content (custom.yaml)
* Installs custom agents and workflows without requiring a full module structure
*/
class CustomHandler {
constructor() {
this.fileOps = new FileOps();
}
/**
* Find all custom.yaml files in the project
* @param {string} projectRoot - Project root directory
* @returns {Array} List of custom content paths
*/
async findCustomContent(projectRoot) {
const customPaths = [];
// Helper function to recursively scan directories
async function scanDirectory(dir, excludePaths = []) {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
// Skip hidden directories and common exclusions
if (
entry.name.startsWith('.') ||
entry.name === 'node_modules' ||
entry.name === 'dist' ||
entry.name === 'build' ||
entry.name === '.git' ||
entry.name === 'bmad'
) {
continue;
}
// Skip excluded paths
if (excludePaths.some((exclude) => fullPath.startsWith(exclude))) {
continue;
}
if (entry.isDirectory()) {
// Recursively scan subdirectories
await scanDirectory(fullPath, excludePaths);
} else if (entry.name === 'custom.yaml') {
// Found a custom.yaml file
customPaths.push(fullPath);
}
}
} catch {
// Ignore errors (e.g., permission denied)
}
}
// Scan the entire project, but exclude source directories
await scanDirectory(projectRoot, [path.join(projectRoot, 'src'), path.join(projectRoot, 'tools'), path.join(projectRoot, 'test')]);
return customPaths;
}
/**
* Get custom content info from a custom.yaml file
* @param {string} customYamlPath - Path to custom.yaml file
* @returns {Object|null} Custom content info
*/
async getCustomInfo(customYamlPath) {
try {
const configContent = await fs.readFile(customYamlPath, 'utf8');
// Try to parse YAML with error handling
let config;
try {
config = yaml.load(configContent);
} catch (parseError) {
console.warn(chalk.yellow(`Warning: YAML parse error in ${customYamlPath}:`, parseError.message));
return null;
}
const customDir = path.dirname(customYamlPath);
const relativePath = path.relative(process.cwd(), customDir);
return {
id: config.code || path.basename(customDir),
name: config.name || `Custom: ${path.basename(customDir)}`,
description: config.description || 'Custom agents and workflows',
path: customDir,
relativePath: relativePath,
defaultSelected: config.default_selected === true,
config: config,
};
} catch (error) {
console.warn(chalk.yellow(`Warning: Failed to read ${customYamlPath}:`, error.message));
return null;
}
}
/**
* Install custom content
* @param {string} customPath - Path to custom content directory
* @param {string} bmadDir - Target bmad directory
* @param {Object} config - Configuration from custom.yaml
* @param {Function} fileTrackingCallback - Optional callback to track installed files
* @returns {Object} Installation result
*/
async install(customPath, bmadDir, config, fileTrackingCallback = null) {
const results = {
agentsInstalled: 0,
workflowsInstalled: 0,
filesCopied: 0,
preserved: 0,
errors: [],
};
try {
// Create custom directories in bmad
const bmadCustomDir = path.join(bmadDir, 'custom');
const bmadAgentsDir = path.join(bmadCustomDir, 'agents');
const bmadWorkflowsDir = path.join(bmadCustomDir, 'workflows');
await fs.ensureDir(bmadCustomDir);
await fs.ensureDir(bmadAgentsDir);
await fs.ensureDir(bmadWorkflowsDir);
// Process agents - copy entire agents directory structure
const agentsDir = path.join(customPath, 'agents');
if (await fs.pathExists(agentsDir)) {
await this.copyDirectory(agentsDir, bmadAgentsDir, results, fileTrackingCallback, config);
// Count agent files
const agentFiles = await this.findFilesRecursively(agentsDir, ['.agent.yaml', '.md']);
results.agentsInstalled = agentFiles.length;
}
// Process workflows - copy entire workflows directory structure
const workflowsDir = path.join(customPath, 'workflows');
if (await fs.pathExists(workflowsDir)) {
await this.copyDirectory(workflowsDir, bmadWorkflowsDir, results, fileTrackingCallback, config);
// Count workflow files
const workflowFiles = await this.findFilesRecursively(workflowsDir, ['.md']);
results.workflowsInstalled = workflowFiles.length;
}
// Process any additional files at root
const entries = await fs.readdir(customPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name !== 'custom.yaml' && !entry.name.startsWith('.') && !entry.name.endsWith('.md')) {
// Skip .md files at root as they're likely docs
const sourcePath = path.join(customPath, entry.name);
const targetPath = path.join(bmadCustomDir, entry.name);
try {
// Check if file already exists
if (await fs.pathExists(targetPath)) {
// File already exists, preserve it
results.preserved = (results.preserved || 0) + 1;
} else {
await fs.copy(sourcePath, targetPath);
results.filesCopied++;
if (fileTrackingCallback) {
fileTrackingCallback(targetPath);
}
}
} catch (error) {
results.errors.push(`Failed to copy file ${entry.name}: ${error.message}`);
}
}
}
} catch (error) {
results.errors.push(`Installation failed: ${error.message}`);
}
return results;
}
/**
* Find all files with specific extensions recursively
* @param {string} dir - Directory to search
* @param {Array} extensions - File extensions to match
* @returns {Array} List of matching files
*/
async findFilesRecursively(dir, extensions) {
const files = [];
async function search(currentDir) {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await search(fullPath);
} else if (extensions.some((ext) => entry.name.endsWith(ext))) {
files.push(fullPath);
}
}
}
await search(dir);
return files;
}
/**
* Recursively copy a directory
* @param {string} sourceDir - Source directory
* @param {string} targetDir - Target directory
* @param {Object} results - Results object to update
* @param {Function} fileTrackingCallback - Optional callback
* @param {Object} config - Configuration for placeholder replacement
*/
async copyDirectory(sourceDir, targetDir, results, fileTrackingCallback, config) {
await fs.ensureDir(targetDir);
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
await this.copyDirectory(sourcePath, targetPath, results, fileTrackingCallback, config);
} else {
try {
// Check if file already exists
if (await fs.pathExists(targetPath)) {
// File already exists, preserve it
results.preserved = (results.preserved || 0) + 1;
} else {
// Copy with placeholder replacement for text files
const textExtensions = ['.md', '.yaml', '.yml', '.txt', '.json'];
if (textExtensions.some((ext) => entry.name.endsWith(ext))) {
await this.fileOps.copyFile(sourcePath, targetPath, {
bmadFolder: config.bmad_folder || 'bmad',
userName: config.user_name || 'User',
communicationLanguage: config.communication_language || 'English',
outputFolder: config.output_folder || 'docs',
});
} else {
await fs.copy(sourcePath, targetPath);
}
results.filesCopied++;
if (fileTrackingCallback) {
fileTrackingCallback(targetPath);
}
}
if (entry.name.endsWith('.md')) {
results.workflowsInstalled++;
}
} catch (error) {
results.errors.push(`Failed to copy ${entry.name}: ${error.message}`);
}
}
}
}
}
module.exports = { CustomHandler };

View File

@@ -137,10 +137,11 @@ class ModuleManager {
continue;
}
// Check if this directory contains a module (only install-config.yaml is valid now)
// Check if this directory contains a module (install-config.yaml OR custom.yaml)
const installerConfigPath = path.join(fullPath, '_module-installer', 'install-config.yaml');
const customConfigPath = path.join(fullPath, 'custom.yaml');
if (await fs.pathExists(installerConfigPath)) {
if ((await fs.pathExists(installerConfigPath)) || (await fs.pathExists(customConfigPath))) {
modulePaths.add(fullPath);
// Don't scan inside modules - they might have their own nested structures
continue;
@@ -225,11 +226,19 @@ class ModuleManager {
* @returns {Object|null} Module info or null if not a valid module
*/
async getModuleInfo(modulePath, defaultName, sourceDescription) {
// Check for module structure (only install-config.yaml is valid now)
// Check for module structure (install-config.yaml OR custom.yaml)
const installerConfigPath = path.join(modulePath, '_module-installer', 'install-config.yaml');
const customConfigPath = path.join(modulePath, 'custom.yaml');
let configPath = null;
if (await fs.pathExists(installerConfigPath)) {
configPath = installerConfigPath;
} else if (await fs.pathExists(customConfigPath)) {
configPath = customConfigPath;
}
// Skip if this doesn't look like a module
if (!(await fs.pathExists(installerConfigPath))) {
if (!configPath) {
return null;
}
@@ -243,11 +252,12 @@ class ModuleManager {
description: 'BMAD Module',
version: '5.0.0',
source: sourceDescription,
isCustom: configPath === customConfigPath,
};
// Read module config for metadata
try {
const configContent = await fs.readFile(installerConfigPath, 'utf8');
const configContent = await fs.readFile(configPath, 'utf8');
const config = yaml.load(configContent);
// Use the code property as the id if available
@@ -284,6 +294,12 @@ class ModuleManager {
if (await fs.pathExists(installerConfigPath)) {
return srcModulePath;
}
// Also check for custom.yaml in src/modules
const customConfigPath = path.join(srcModulePath, 'custom.yaml');
if (await fs.pathExists(customConfigPath)) {
return srcModulePath;
}
}
// If not found in src/modules, search the entire project
@@ -298,10 +314,18 @@ class ModuleManager {
// Need to read configs to match by ID
for (const modulePath of allModulePaths) {
const installerConfigPath = path.join(modulePath, '_module-installer', 'install-config.yaml');
const customConfigPath = path.join(modulePath, 'custom.yaml');
let configPath = null;
if (await fs.pathExists(installerConfigPath)) {
configPath = installerConfigPath;
} else if (await fs.pathExists(customConfigPath)) {
configPath = customConfigPath;
}
if (configPath) {
try {
const configContent = await fs.readFile(installerConfigPath, 'utf8');
const configContent = await fs.readFile(configPath, 'utf8');
const config = yaml.load(configContent);
if (config.code === moduleName) {
return modulePath;

View File

@@ -491,11 +491,13 @@ class UI {
const availableModules = await moduleManager.listAvailable();
const isNewInstallation = installedModuleIds.size === 0;
return availableModules.map((mod) => ({
name: mod.name,
const moduleChoices = availableModules.map((mod) => ({
name: mod.isCustom ? `${mod.name} ${chalk.red('(Custom)')}` : mod.name,
value: mod.id,
checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
}));
return moduleChoices;
}
/**