custom install module cached

This commit is contained in:
Brian Madison 2025-12-07 20:46:09 -06:00
parent 6430173738
commit 738237b4ae
9 changed files with 1549 additions and 70 deletions

View File

@ -0,0 +1,239 @@
/**
* Custom Module Source Cache
* Caches custom module sources under _cfg/custom/ to ensure they're never lost
* and can be checked into source control
*/
const fs = require('fs-extra');
const path = require('node:path');
const crypto = require('node:crypto');
class CustomModuleCache {
constructor(bmadDir) {
this.bmadDir = bmadDir;
this.customCacheDir = path.join(bmadDir, '_cfg', 'custom');
this.manifestPath = path.join(this.customCacheDir, 'cache-manifest.yaml');
}
/**
* Ensure the custom cache directory exists
*/
async ensureCacheDir() {
await fs.ensureDir(this.customCacheDir);
}
/**
* Get cache manifest
*/
async getCacheManifest() {
if (!(await fs.pathExists(this.manifestPath))) {
return {};
}
const content = await fs.readFile(this.manifestPath, 'utf8');
const yaml = require('js-yaml');
return yaml.load(content) || {};
}
/**
* Update cache manifest
*/
async updateCacheManifest(manifest) {
const yaml = require('js-yaml');
const content = yaml.dump(manifest, {
indent: 2,
lineWidth: -1,
noRefs: true,
sortKeys: false,
});
await fs.writeFile(this.manifestPath, content);
}
/**
* Calculate hash of a file or directory
*/
async calculateHash(sourcePath) {
const hash = crypto.createHash('sha256');
const isDir = (await fs.stat(sourcePath)).isDirectory();
if (isDir) {
// For directories, hash all files
const files = [];
async function collectFiles(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile()) {
files.push(path.join(dir, entry.name));
} else if (entry.isDirectory() && !entry.name.startsWith('.')) {
await collectFiles(path.join(dir, entry.name));
}
}
}
await collectFiles(sourcePath);
files.sort(); // Ensure consistent order
for (const file of files) {
const content = await fs.readFile(file);
const relativePath = path.relative(sourcePath, file);
hash.update(relativePath + '|' + content.toString('base64'));
}
} else {
// For single files
const content = await fs.readFile(sourcePath);
hash.update(content);
}
return hash.digest('hex');
}
/**
* Cache a custom module source
* @param {string} moduleId - Module ID
* @param {string} sourcePath - Original source path
* @param {Object} metadata - Additional metadata to store
* @returns {Object} Cached module info
*/
async cacheModule(moduleId, sourcePath, metadata = {}) {
await this.ensureCacheDir();
const cacheDir = path.join(this.customCacheDir, moduleId);
const cacheManifest = await this.getCacheManifest();
// Check if already cached and unchanged
if (cacheManifest[moduleId]) {
const cached = cacheManifest[moduleId];
if (cached.originalHash && cached.originalHash === (await this.calculateHash(sourcePath))) {
// Source unchanged, return existing cache info
return {
moduleId,
cachePath: cacheDir,
...cached,
};
}
}
// Remove existing cache if it exists
if (await fs.pathExists(cacheDir)) {
await fs.remove(cacheDir);
}
// Copy module to cache
await fs.copy(sourcePath, cacheDir, {
filter: (src) => {
const relative = path.relative(sourcePath, src);
// Skip node_modules, .git, and other common ignore patterns
return !relative.includes('node_modules') && !relative.startsWith('.git') && !relative.startsWith('.DS_Store');
},
});
// Calculate hash of the source
const sourceHash = await this.calculateHash(sourcePath);
const cacheHash = await this.calculateHash(cacheDir);
// Update manifest - don't store originalPath for source control friendliness
cacheManifest[moduleId] = {
originalHash: sourceHash,
cacheHash: cacheHash,
cachedAt: new Date().toISOString(),
...metadata,
};
await this.updateCacheManifest(cacheManifest);
return {
moduleId,
cachePath: cacheDir,
...cacheManifest[moduleId],
};
}
/**
* Get cached module info
* @param {string} moduleId - Module ID
* @returns {Object|null} Cached module info or null
*/
async getCachedModule(moduleId) {
const cacheManifest = await this.getCacheManifest();
const cached = cacheManifest[moduleId];
if (!cached) {
return null;
}
const cacheDir = path.join(this.customCacheDir, moduleId);
if (!(await fs.pathExists(cacheDir))) {
// Cache dir missing, remove from manifest
delete cacheManifest[moduleId];
await this.updateCacheManifest(cacheManifest);
return null;
}
// Verify cache integrity
const currentCacheHash = await this.calculateHash(cacheDir);
if (currentCacheHash !== cached.cacheHash) {
console.warn(`Warning: Cache integrity check failed for ${moduleId}`);
}
return {
moduleId,
cachePath: cacheDir,
...cached,
};
}
/**
* Get all cached modules
* @returns {Array} Array of cached module info
*/
async getAllCachedModules() {
const cacheManifest = await this.getCacheManifest();
const cached = [];
for (const [moduleId, info] of Object.entries(cacheManifest)) {
const cachedModule = await this.getCachedModule(moduleId);
if (cachedModule) {
cached.push(cachedModule);
}
}
return cached;
}
/**
* Remove a cached module
* @param {string} moduleId - Module ID to remove
*/
async removeCachedModule(moduleId) {
const cacheManifest = await this.getCacheManifest();
const cacheDir = path.join(this.customCacheDir, moduleId);
// Remove cache directory
if (await fs.pathExists(cacheDir)) {
await fs.remove(cacheDir);
}
// Remove from manifest
delete cacheManifest[moduleId];
await this.updateCacheManifest(cacheManifest);
}
/**
* Sync cached modules with a list of module IDs
* @param {Array<string>} moduleIds - Module IDs to keep
*/
async syncCache(moduleIds) {
const cached = await this.getAllCachedModules();
for (const cachedModule of cached) {
if (!moduleIds.includes(cachedModule.moduleId)) {
await this.removeCachedModule(cachedModule.moduleId);
}
}
}
}
module.exports = { CustomModuleCache };

View File

@ -17,6 +17,7 @@ class Detector {
hasCore: false,
modules: [],
ides: [],
customModules: [],
manifest: null,
};
@ -32,6 +33,10 @@ class Detector {
result.manifest = manifestData;
result.version = manifestData.version;
result.installed = true;
// Copy custom modules if they exist
if (manifestData.customModules) {
result.customModules = manifestData.customModules;
}
}
// Check for core

View File

@ -22,6 +22,7 @@ const path = require('node:path');
const fs = require('fs-extra');
const chalk = require('chalk');
const ora = require('ora');
const inquirer = require('inquirer');
const { Detector } = require('./detector');
const { Manifest } = require('./manifest');
const { ModuleManager } = require('../modules/manager');
@ -750,15 +751,48 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
spinner.text = 'Creating directory structure...';
await this.createDirectoryStructure(bmadDir);
// Resolve dependencies for selected modules
spinner.text = 'Resolving dependencies...';
// Get project root
const projectRoot = getProjectRoot();
// Add custom content modules to the modules list for installation
// Step 1: Install core module first (if requested)
if (config.installCore) {
spinner.start('Installing BMAD core...');
await this.installCoreWithDependencies(bmadDir, { core: {} });
spinner.succeed('Core installed');
// Generate core config file
await this.generateModuleConfigs(bmadDir, { core: config.coreConfig || {} });
}
// Custom content is already handled in UI before module selection
let finalCustomContent = config.customContent;
// Step 3: Prepare modules list including cached custom modules
let allModules = [...(config.modules || [])];
if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) {
// During quick update, we might have custom module sources from the manifest
if (config._customModuleSources) {
// Add custom modules from stored sources
for (const [moduleId, customInfo] of config._customModuleSources) {
if (!allModules.includes(moduleId) && (await fs.pathExists(customInfo.sourcePath))) {
allModules.push(moduleId);
}
}
}
// Add cached custom modules
if (finalCustomContent && finalCustomContent.cachedModules) {
for (const cachedModule of finalCustomContent.cachedModules) {
if (!allModules.includes(cachedModule.id)) {
allModules.push(cachedModule.id);
}
}
}
// Regular custom content from user input (non-cached)
if (finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) {
// Add custom modules to the installation list
for (const customFile of config.customContent.selectedFiles) {
for (const customFile of finalCustomContent.selectedFiles) {
const { CustomHandler } = require('../custom/handler');
const customHandler = new CustomHandler();
const customInfo = await customHandler.getCustomInfo(customFile, projectDir);
@ -768,12 +802,18 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
}
}
const modulesToInstall = config.installCore ? ['core', ...allModules] : allModules;
// Don't include core again if already installed
if (config.installCore) {
allModules = allModules.filter((m) => m !== 'core');
}
const modulesToInstall = allModules;
// For dependency resolution, we need to pass the project root
// Create a temporary module manager that knows about custom content locations
const tempModuleManager = new ModuleManager({
scanProjectForModules: true,
bmadDir: bmadDir, // Pass bmadDir so we can check cache
});
// Make sure custom modules are discoverable
@ -793,12 +833,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
spinner.succeed('Dependencies resolved');
}
// Install core if requested or if dependencies require it
if (config.installCore || resolution.byModule.core) {
spinner.start('Installing BMAD core...');
await this.installCoreWithDependencies(bmadDir, resolution.byModule.core);
spinner.succeed('Core installed');
}
// Core is already installed above, skip if included in resolution
// Install modules with their dependencies
if (allModules && allModules.length > 0) {
@ -816,10 +851,42 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// Check if this is a custom module
let isCustomModule = false;
let customInfo = null;
if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) {
let useCache = false;
// First check if we have a cached version
if (finalCustomContent && finalCustomContent.cachedModules) {
const cachedModule = finalCustomContent.cachedModules.find((m) => m.id === moduleName);
if (cachedModule) {
isCustomModule = true;
customInfo = {
id: moduleName,
path: cachedModule.cachePath,
config: {},
};
useCache = true;
}
}
// Then check if we have custom module sources from the manifest (for quick update)
if (!isCustomModule && config._customModuleSources && config._customModuleSources.has(moduleName)) {
customInfo = config._customModuleSources.get(moduleName);
isCustomModule = true;
// Check if this is a cached module (source path starts with _cfg)
if (customInfo.sourcePath && (customInfo.sourcePath.startsWith('_cfg') || customInfo.sourcePath.includes('_cfg/custom'))) {
useCache = true;
// Make sure we have the right path structure
if (!customInfo.path) {
customInfo.path = customInfo.sourcePath;
}
}
}
// Finally check regular custom content
if (!isCustomModule && finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) {
const { CustomHandler } = require('../custom/handler');
const customHandler = new CustomHandler();
for (const customFile of config.customContent.selectedFiles) {
for (const customFile of finalCustomContent.selectedFiles) {
const info = await customHandler.getCustomInfo(customFile, projectDir);
if (info && info.id === moduleName) {
isCustomModule = true;
@ -874,10 +941,44 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// Create module config
await this.generateModuleConfigs(bmadDir, { [moduleName]: { ...config.coreConfig, ...customInfo.config } });
// Store custom module info for later manifest update
if (!config._customModulesToTrack) {
config._customModulesToTrack = [];
}
// For cached modules, use appropriate path handling
let sourcePath;
if (useCache) {
// Check if we have cached modules info (from initial install)
if (finalCustomContent && finalCustomContent.cachedModules) {
sourcePath = finalCustomContent.cachedModules.find((m) => m.id === moduleName)?.relativePath;
} else {
// During update, the sourcePath is already cache-relative if it starts with _cfg
sourcePath =
customInfo.sourcePath && customInfo.sourcePath.startsWith('_cfg')
? customInfo.sourcePath
: path.relative(bmadDir, customInfo.path || customInfo.sourcePath);
}
} else {
sourcePath = path.resolve(customInfo.path || customInfo.sourcePath);
}
config._customModulesToTrack.push({
id: customInfo.id,
name: customInfo.name,
sourcePath: sourcePath,
installDate: new Date().toISOString(),
});
} else {
// Regular module installation
// Special case for core module
if (moduleName === 'core') {
await this.installCoreWithDependencies(bmadDir, resolution.byModule[moduleName]);
} else {
await this.installModuleWithDependencies(moduleName, bmadDir, resolution.byModule[moduleName]);
}
}
spinner.succeed(`Module installed: ${moduleName}`);
}
@ -989,14 +1090,37 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
spinner.start('Generating workflow and agent manifests...');
const manifestGen = new ManifestGenerator();
// Include preserved modules (from quick update) and custom modules in the manifest
const allModulesToList = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules || [];
// For quick update, we need ALL installed modules in the manifest
// Not just the ones being updated
const allModulesForManifest = config._quickUpdate
? config._existingModules || allModules || []
: config._preserveModules
? [...allModules, ...config._preserveModules]
: allModules || [];
const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesToList, this.installedFiles, {
// For regular installs (including when called from quick update), use what we have
let modulesForCsvPreserve;
if (config._quickUpdate) {
// Quick update - use existing modules or fall back to modules being updated
modulesForCsvPreserve = config._existingModules || allModules || [];
} else {
// Regular install - use the modules we're installing plus any preserved ones
modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules;
}
const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, this.installedFiles, {
ides: config.ides || [],
preservedModules: config._preserveModules || [], // Scan these from installed bmad/ dir
preservedModules: modulesForCsvPreserve, // Scan these from installed bmad/ dir
});
// Add custom modules to manifest (now that it exists)
if (config._customModulesToTrack && config._customModulesToTrack.length > 0) {
spinner.text = 'Storing custom module sources...';
for (const customModule of config._customModulesToTrack) {
await this.manifest.addCustomModule(bmadDir, customModule);
}
}
spinner.succeed(
`Manifests generated: ${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools, ${manifestStats.files} files`,
);
@ -1259,6 +1383,30 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
const currentVersion = existingInstall.version;
const newVersion = require(path.join(getProjectRoot(), 'package.json')).version;
// Check for custom modules with missing sources before update
const customModuleSources = new Map();
if (existingInstall.customModules) {
for (const customModule of existingInstall.customModules) {
customModuleSources.set(customModule.id, customModule);
}
}
if (customModuleSources.size > 0) {
spinner.stop();
console.log(chalk.yellow('\nChecking custom module sources before update...'));
const projectRoot = getProjectRoot();
await this.handleMissingCustomSources(
customModuleSources,
bmadDir,
projectRoot,
'update',
existingInstall.modules.map((m) => m.id),
);
spinner.start('Preparing update...');
}
if (config.dryRun) {
spinner.stop();
console.log(chalk.cyan('\n🔍 Update Preview (Dry Run)\n'));
@ -2027,6 +2175,24 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
throw new Error(`BMAD not installed at ${bmadDir}`);
}
// Check for custom modules with missing sources
const manifest = await this.manifest.read(bmadDir);
if (manifest && manifest.customModules && manifest.customModules.length > 0) {
spinner.stop();
console.log(chalk.yellow('\nChecking custom module sources before compilation...'));
const customModuleSources = new Map();
for (const customModule of manifest.customModules) {
customModuleSources.set(customModule.id, customModule);
}
const projectRoot = getProjectRoot();
const installedModules = manifest.modules || [];
await this.handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, 'compile-agents', installedModules);
spinner.start('Rebuilding agent files...');
}
let agentCount = 0;
let taskCount = 0;
@ -2171,17 +2337,245 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
const existingInstall = await this.detector.detect(bmadDir);
const installedModules = existingInstall.modules.map((m) => m.id);
const configuredIdes = existingInstall.ides || [];
const projectRoot = path.dirname(bmadDir);
// Get custom module sources from manifest
const customModuleSources = new Map();
if (existingInstall.customModules) {
for (const customModule of existingInstall.customModules) {
// Ensure we have an absolute sourcePath
let absoluteSourcePath = customModule.sourcePath;
// Check if sourcePath is a cache-relative path (starts with _cfg/)
if (absoluteSourcePath && absoluteSourcePath.startsWith('_cfg')) {
// Convert cache-relative path to absolute path
absoluteSourcePath = path.join(bmadDir, absoluteSourcePath);
}
// If no sourcePath but we have relativePath, convert it
else if (!absoluteSourcePath && customModule.relativePath) {
// relativePath is relative to the project root (parent of bmad dir)
absoluteSourcePath = path.resolve(projectRoot, customModule.relativePath);
}
// Ensure sourcePath is absolute for anything else
else if (absoluteSourcePath && !path.isAbsolute(absoluteSourcePath)) {
absoluteSourcePath = path.resolve(absoluteSourcePath);
}
// Update the custom module object with the absolute path
const updatedModule = {
...customModule,
sourcePath: absoluteSourcePath,
};
customModuleSources.set(customModule.id, updatedModule);
}
}
// Load saved IDE configurations
const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir);
// Get available modules (what we have source for)
const availableModules = await this.moduleManager.listAvailable();
const availableModuleIds = new Set(availableModules.map((m) => m.id));
const availableModulesData = await this.moduleManager.listAvailable();
const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules];
// Add custom modules from manifest if their sources exist
for (const [moduleId, customModule] of customModuleSources) {
// Use the absolute sourcePath
const sourcePath = customModule.sourcePath;
// Check if source exists at the recorded path
if (
sourcePath &&
(await fs.pathExists(sourcePath)) && // Add to available modules if not already there
!availableModules.some((m) => m.id === moduleId)
) {
availableModules.push({
id: moduleId,
name: customModule.name || moduleId,
path: sourcePath,
isCustom: true,
fromManifest: true,
});
}
}
// Check for untracked custom modules (installed but not in manifest)
const untrackedCustomModules = [];
for (const installedModule of installedModules) {
// Skip standard modules and core
const standardModuleIds = ['bmb', 'bmgd', 'bmm', 'cis', 'core'];
if (standardModuleIds.includes(installedModule)) {
continue;
}
// Check if this installed module is not tracked in customModules
if (!customModuleSources.has(installedModule)) {
const modulePath = path.join(bmadDir, installedModule);
if (await fs.pathExists(modulePath)) {
untrackedCustomModules.push({
id: installedModule,
name: installedModule, // We don't have the original name
path: modulePath,
untracked: true,
});
}
}
}
// If we found untracked custom modules, offer to track them
if (untrackedCustomModules.length > 0) {
spinner.stop();
console.log(chalk.yellow(`\n⚠️ Found ${untrackedCustomModules.length} custom module(s) not tracked in manifest:`));
for (const untracked of untrackedCustomModules) {
console.log(chalk.dim(`${untracked.id} (installed at ${path.relative(projectRoot, untracked.path)})`));
}
const { trackModules } = await inquirer.prompt([
{
type: 'confirm',
name: 'trackModules',
message: chalk.cyan('Would you like to scan for their source locations?'),
default: true,
},
]);
if (trackModules) {
const { scanDirectory } = await inquirer.prompt([
{
type: 'input',
name: 'scanDirectory',
message: 'Enter directory to scan for custom module sources (or leave blank to skip):',
default: projectRoot,
validate: async (input) => {
if (input && input.trim() !== '') {
const expandedPath = path.resolve(input.trim());
if (!(await fs.pathExists(expandedPath))) {
return 'Directory does not exist';
}
const stats = await fs.stat(expandedPath);
if (!stats.isDirectory()) {
return 'Path must be a directory';
}
}
return true;
},
},
]);
if (scanDirectory && scanDirectory.trim() !== '') {
console.log(chalk.dim('\nScanning for custom module sources...'));
// Scan for all module.yaml files
const allModulePaths = await this.moduleManager.findModulesInProject(scanDirectory);
const { ModuleManager } = require('../modules/manager');
const mm = new ModuleManager({ scanProjectForModules: true });
for (const untracked of untrackedCustomModules) {
let foundSource = null;
// Try to find by module ID
for (const modulePath of allModulePaths) {
try {
const moduleInfo = await mm.getModuleInfo(modulePath);
if (moduleInfo && moduleInfo.id === untracked.id) {
foundSource = {
path: modulePath,
info: moduleInfo,
};
break;
}
} catch {
// Continue searching
}
}
if (foundSource) {
console.log(chalk.green(` ✓ Found source for ${untracked.id}: ${path.relative(projectRoot, foundSource.path)}`));
// Add to manifest
await this.manifest.addCustomModule(bmadDir, {
id: untracked.id,
name: foundSource.info.name || untracked.name,
sourcePath: path.resolve(foundSource.path),
installDate: new Date().toISOString(),
tracked: true,
});
// Add to customModuleSources for processing
customModuleSources.set(untracked.id, {
id: untracked.id,
name: foundSource.info.name || untracked.name,
sourcePath: path.resolve(foundSource.path),
});
} else {
console.log(chalk.yellow(` ⚠ Could not find source for ${untracked.id}`));
}
}
}
}
console.log(chalk.dim('\nUntracked custom modules will remain installed but cannot be updated without their source.'));
spinner.start('Preparing update...');
}
// Handle missing custom module sources using shared method
const customModuleResult = await this.handleMissingCustomSources(
customModuleSources,
bmadDir,
projectRoot,
'update',
installedModules,
);
// Handle both old return format (array) and new format (object)
let validCustomModules = [];
let keptModulesWithoutSources = [];
if (Array.isArray(customModuleResult)) {
// Old format - just an array
validCustomModules = customModuleResult;
} else if (customModuleResult && typeof customModuleResult === 'object') {
// New format - object with two arrays
validCustomModules = customModuleResult.validCustomModules || [];
keptModulesWithoutSources = customModuleResult.keptModulesWithoutSources || [];
}
const customModulesFromManifest = validCustomModules.map((m) => ({
...m,
isCustom: true,
hasUpdate: true,
}));
// Add untracked modules to the update list but mark them as untrackable
for (const untracked of untrackedCustomModules) {
if (!customModuleSources.has(untracked.id)) {
customModulesFromManifest.push({
...untracked,
isCustom: true,
hasUpdate: false, // Can't update without source
untracked: true,
});
}
}
const allAvailableModules = [...availableModules, ...customModulesFromManifest];
const availableModuleIds = new Set(allAvailableModules.map((m) => m.id));
// Core module is special - never include it in update flow
const nonCoreInstalledModules = installedModules.filter((id) => id !== 'core');
// Only update modules that are BOTH installed AND available (we have source for)
const modulesToUpdate = installedModules.filter((id) => availableModuleIds.has(id));
const skippedModules = installedModules.filter((id) => !availableModuleIds.has(id));
const modulesToUpdate = nonCoreInstalledModules.filter((id) => availableModuleIds.has(id));
const skippedModules = nonCoreInstalledModules.filter((id) => !availableModuleIds.has(id));
// Add custom modules that were kept without sources to the skipped modules
// This ensures their agents are preserved in the manifest
for (const keptModule of keptModulesWithoutSources) {
if (!skippedModules.includes(keptModule)) {
skippedModules.push(keptModule);
}
}
spinner.succeed(`Found ${modulesToUpdate.length} module(s) to update and ${configuredIdes.length} configured tool(s)`);
@ -2246,6 +2640,8 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
_quickUpdate: true, // Flag to skip certain prompts
_preserveModules: skippedModules, // Preserve these in manifest even though we didn't update them
_savedIdeConfigs: savedIdeConfigs, // Pass saved IDE configs to installer
_customModuleSources: customModuleSources, // Pass custom module sources for updates
_existingModules: installedModules, // Pass all installed modules for manifest generation
};
// Call the standard install method
@ -2885,6 +3281,230 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
}
}
}
/**
* Handle missing custom module sources interactively
* @param {Map} customModuleSources - Map of custom module ID to info
* @param {string} bmadDir - BMAD directory
* @param {string} projectRoot - Project root directory
* @param {string} operation - Current operation ('update', 'compile', etc.)
* @param {Array} installedModules - Array of installed module IDs (will be modified)
* @returns {Object} Object with validCustomModules array and keptModulesWithoutSources array
*/
async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules) {
const validCustomModules = [];
const keptModulesWithoutSources = []; // Track modules kept without sources
const customModulesWithMissingSources = [];
// Check which sources exist
for (const [moduleId, customInfo] of customModuleSources) {
if (await fs.pathExists(customInfo.sourcePath)) {
validCustomModules.push({
id: moduleId,
name: customInfo.name,
path: customInfo.sourcePath,
info: customInfo,
});
} else {
customModulesWithMissingSources.push({
id: moduleId,
name: customInfo.name,
sourcePath: customInfo.sourcePath,
relativePath: customInfo.relativePath,
info: customInfo,
});
}
}
// If no missing sources, return immediately
if (customModulesWithMissingSources.length === 0) {
return validCustomModules;
}
// Stop any spinner for interactive prompts
const currentSpinner = ora();
if (currentSpinner.isSpinning) {
currentSpinner.stop();
}
console.log(chalk.yellow(`\n⚠️ Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`));
const inquirer = require('inquirer');
let keptCount = 0;
let updatedCount = 0;
let removedCount = 0;
for (const missing of customModulesWithMissingSources) {
console.log(chalk.dim(`${missing.name} (${missing.id})`));
console.log(chalk.dim(` Original source: ${missing.relativePath}`));
console.log(chalk.dim(` Full path: ${missing.sourcePath}`));
const choices = [
{
name: 'Keep installed (will not be processed)',
value: 'keep',
short: 'Keep',
},
{
name: 'Specify new source location',
value: 'update',
short: 'Update',
},
];
// Only add remove option if not just compiling agents
if (operation !== 'compile-agents') {
choices.push({
name: '⚠️ REMOVE module completely (destructive!)',
value: 'remove',
short: 'Remove',
});
}
const { action } = await inquirer.prompt([
{
type: 'list',
name: 'action',
message: `How would you like to handle "${missing.name}"?`,
choices,
},
]);
switch (action) {
case 'update': {
const { newSourcePath } = await inquirer.prompt([
{
type: 'input',
name: 'newSourcePath',
message: 'Enter the new path to the custom module:',
default: missing.sourcePath,
validate: async (input) => {
if (!input || input.trim() === '') {
return 'Please enter a path';
}
const expandedPath = path.resolve(input.trim());
if (!(await fs.pathExists(expandedPath))) {
return 'Path does not exist';
}
// Check if it looks like a valid module
const moduleYamlPath = path.join(expandedPath, 'module.yaml');
const agentsPath = path.join(expandedPath, 'agents');
const workflowsPath = path.join(expandedPath, 'workflows');
if (!(await fs.pathExists(moduleYamlPath)) && !(await fs.pathExists(agentsPath)) && !(await fs.pathExists(workflowsPath))) {
return 'Path does not appear to contain a valid custom module';
}
return true;
},
},
]);
// Update the source in manifest
const resolvedPath = path.resolve(newSourcePath.trim());
missing.info.sourcePath = resolvedPath;
// Remove relativePath - we only store absolute sourcePath now
delete missing.info.relativePath;
await this.manifest.addCustomModule(bmadDir, missing.info);
validCustomModules.push({
id: moduleId,
name: missing.name,
path: resolvedPath,
info: missing.info,
});
updatedCount++;
console.log(chalk.green(`✓ Updated source location`));
break;
}
case 'remove': {
// Extra confirmation for destructive remove
console.log(chalk.red.bold(`\n⚠️ WARNING: This will PERMANENTLY DELETE "${missing.name}" and all its files!`));
console.log(chalk.red(` Module location: ${path.join(bmadDir, moduleId)}`));
const { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: chalk.red.bold('Are you absolutely sure you want to delete this module?'),
default: false,
},
]);
if (confirm) {
const { typedConfirm } = await inquirer.prompt([
{
type: 'input',
name: 'typedConfirm',
message: chalk.red.bold('Type "DELETE" to confirm permanent deletion:'),
validate: (input) => {
if (input !== 'DELETE') {
return chalk.red('You must type "DELETE" exactly to proceed');
}
return true;
},
},
]);
if (typedConfirm === 'DELETE') {
// Remove the module from filesystem and manifest
const modulePath = path.join(bmadDir, moduleId);
if (await fs.pathExists(modulePath)) {
const fsExtra = require('fs-extra');
await fsExtra.remove(modulePath);
console.log(chalk.yellow(` ✓ Deleted module directory: ${path.relative(projectRoot, modulePath)}`));
}
await this.manifest.removeModule(bmadDir, moduleId);
await this.manifest.removeCustomModule(bmadDir, moduleId);
console.log(chalk.yellow(` ✓ Removed from manifest`));
// Also remove from installedModules list
if (installedModules && installedModules.includes(moduleId)) {
const index = installedModules.indexOf(moduleId);
if (index !== -1) {
installedModules.splice(index, 1);
}
}
removedCount++;
console.log(chalk.red.bold(`✓ "${missing.name}" has been permanently removed`));
} else {
console.log(chalk.dim(' Removal cancelled - module will be kept'));
keptCount++;
}
} else {
console.log(chalk.dim(' Removal cancelled - module will be kept'));
keptCount++;
}
break;
}
case 'keep': {
keptCount++;
keptModulesWithoutSources.push(moduleId);
console.log(chalk.dim(` Module will be kept as-is`));
break;
}
// No default
}
}
// Show summary
if (keptCount > 0 || updatedCount > 0 || removedCount > 0) {
console.log(chalk.dim(`\nSummary for custom modules with missing sources:`));
if (keptCount > 0) console.log(chalk.dim(`${keptCount} module(s) kept as-is`));
if (updatedCount > 0) console.log(chalk.dim(`${updatedCount} module(s) updated with new sources`));
if (removedCount > 0) console.log(chalk.red(`${removedCount} module(s) permanently deleted`));
}
return {
validCustomModules,
keptModulesWithoutSources,
};
}
}
module.exports = { Installer };

View File

@ -41,7 +41,11 @@ class ManifestGenerator {
// Deduplicate modules list to prevent duplicates
this.modules = [...new Set(['core', ...selectedModules, ...preservedModules, ...installedModules])];
this.updatedModules = [...new Set(['core', ...selectedModules, ...installedModules])]; // All installed modules get rescanned
this.preservedModules = preservedModules; // These stay as-is in CSVs
// For CSV manifests, we need to include ALL modules that are installed
// preservedModules controls which modules stay as-is in the CSV (don't get rescanned)
// But all modules should be included in the final manifest
this.preservedModules = [...new Set([...preservedModules, ...selectedModules, ...installedModules])]; // Include all installed modules
this.bmadDir = bmadDir;
this.bmadFolderName = path.basename(bmadDir); // Get the actual folder name (e.g., '.bmad' or 'bmad')
this.allInstalledFiles = installedFiles;
@ -61,14 +65,14 @@ class ManifestGenerator {
// Collect workflow data
await this.collectWorkflows(selectedModules);
// Collect agent data
await this.collectAgents(selectedModules);
// Collect agent data - use updatedModules which includes all installed modules
await this.collectAgents(this.updatedModules);
// Collect task data
await this.collectTasks(selectedModules);
await this.collectTasks(this.updatedModules);
// Collect tool data
await this.collectTools(selectedModules);
await this.collectTools(this.updatedModules);
// Write manifest files and collect their paths
const manifestFiles = [
@ -450,6 +454,21 @@ class ManifestGenerator {
async writeMainManifest(cfgDir) {
const manifestPath = path.join(cfgDir, 'manifest.yaml');
// Read existing manifest to preserve custom modules
let existingCustomModules = [];
if (await fs.pathExists(manifestPath)) {
try {
const existingContent = await fs.readFile(manifestPath, 'utf8');
const existingManifest = yaml.load(existingContent);
if (existingManifest && existingManifest.customModules) {
existingCustomModules = existingManifest.customModules;
}
} catch {
// If we can't read the existing manifest, continue without preserving custom modules
console.warn('Warning: Could not read existing manifest to preserve custom modules');
}
}
const manifest = {
installation: {
version: packageJson.version,
@ -457,6 +476,7 @@ class ManifestGenerator {
lastUpdated: new Date().toISOString(),
},
modules: this.modules,
customModules: existingCustomModules, // Preserve custom modules
ides: this.selectedIdes,
};
@ -562,12 +582,47 @@ class ManifestGenerator {
async writeWorkflowManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'workflow-manifest.csv');
// Read existing manifest to preserve entries
const existingEntries = new Map();
if (await fs.pathExists(csvPath)) {
const content = await fs.readFile(csvPath, 'utf8');
const lines = content.split('\n').filter((line) => line.trim());
// Skip header
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line) {
// Parse CSV (simple parsing assuming no commas in quoted fields)
const parts = line.split('","');
if (parts.length >= 4) {
const name = parts[0].replace(/^"/, '');
const module = parts[2];
existingEntries.set(`${module}:${name}`, line);
}
}
}
}
// Create CSV header - removed standalone column as ALL workflows now generate commands
let csv = 'name,description,module,path\n';
// Add all workflows - no standalone property needed anymore
// Combine existing and new workflows
const allWorkflows = new Map();
// Add existing entries
for (const [key, value] of existingEntries) {
allWorkflows.set(key, value);
}
// Add/update new workflows
for (const workflow of this.workflows) {
csv += `"${workflow.name}","${workflow.description}","${workflow.module}","${workflow.path}"\n`;
const key = `${workflow.module}:${workflow.name}`;
allWorkflows.set(key, `"${workflow.name}","${workflow.description}","${workflow.module}","${workflow.path}"`);
}
// Write all workflows
for (const [, value] of allWorkflows) {
csv += value + '\n';
}
await fs.writeFile(csvPath, csv);
@ -581,12 +636,50 @@ class ManifestGenerator {
async writeAgentManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'agent-manifest.csv');
// Read existing manifest to preserve entries
const existingEntries = new Map();
if (await fs.pathExists(csvPath)) {
const content = await fs.readFile(csvPath, 'utf8');
const lines = content.split('\n').filter((line) => line.trim());
// Skip header
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line) {
// Parse CSV (simple parsing assuming no commas in quoted fields)
const parts = line.split('","');
if (parts.length >= 11) {
const name = parts[0].replace(/^"/, '');
const module = parts[8];
existingEntries.set(`${module}:${name}`, line);
}
}
}
}
// Create CSV header with persona fields
let csv = 'name,displayName,title,icon,role,identity,communicationStyle,principles,module,path\n';
// Add all agents
// Combine existing and new agents, preferring new data for duplicates
const allAgents = new Map();
// Add existing entries
for (const [key, value] of existingEntries) {
allAgents.set(key, value);
}
// Add/update new agents
for (const agent of this.agents) {
csv += `"${agent.name}","${agent.displayName}","${agent.title}","${agent.icon}","${agent.role}","${agent.identity}","${agent.communicationStyle}","${agent.principles}","${agent.module}","${agent.path}"\n`;
const key = `${agent.module}:${agent.name}`;
allAgents.set(
key,
`"${agent.name}","${agent.displayName}","${agent.title}","${agent.icon}","${agent.role}","${agent.identity}","${agent.communicationStyle}","${agent.principles}","${agent.module}","${agent.path}"`,
);
}
// Write all agents
for (const [, value] of allAgents) {
csv += value + '\n';
}
await fs.writeFile(csvPath, csv);
@ -600,12 +693,47 @@ class ManifestGenerator {
async writeTaskManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'task-manifest.csv');
// Read existing manifest to preserve entries
const existingEntries = new Map();
if (await fs.pathExists(csvPath)) {
const content = await fs.readFile(csvPath, 'utf8');
const lines = content.split('\n').filter((line) => line.trim());
// Skip header
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line) {
// Parse CSV (simple parsing assuming no commas in quoted fields)
const parts = line.split('","');
if (parts.length >= 6) {
const name = parts[0].replace(/^"/, '');
const module = parts[3];
existingEntries.set(`${module}:${name}`, line);
}
}
}
}
// Create CSV header with standalone column
let csv = 'name,displayName,description,module,path,standalone\n';
// Add all tasks
// Combine existing and new tasks
const allTasks = new Map();
// Add existing entries
for (const [key, value] of existingEntries) {
allTasks.set(key, value);
}
// Add/update new tasks
for (const task of this.tasks) {
csv += `"${task.name}","${task.displayName}","${task.description}","${task.module}","${task.path}","${task.standalone}"\n`;
const key = `${task.module}:${task.name}`;
allTasks.set(key, `"${task.name}","${task.displayName}","${task.description}","${task.module}","${task.path}","${task.standalone}"`);
}
// Write all tasks
for (const [, value] of allTasks) {
csv += value + '\n';
}
await fs.writeFile(csvPath, csv);
@ -619,12 +747,47 @@ class ManifestGenerator {
async writeToolManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'tool-manifest.csv');
// Read existing manifest to preserve entries
const existingEntries = new Map();
if (await fs.pathExists(csvPath)) {
const content = await fs.readFile(csvPath, 'utf8');
const lines = content.split('\n').filter((line) => line.trim());
// Skip header
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line) {
// Parse CSV (simple parsing assuming no commas in quoted fields)
const parts = line.split('","');
if (parts.length >= 6) {
const name = parts[0].replace(/^"/, '');
const module = parts[3];
existingEntries.set(`${module}:${name}`, line);
}
}
}
}
// Create CSV header with standalone column
let csv = 'name,displayName,description,module,path,standalone\n';
// Add all tools
// Combine existing and new tools
const allTools = new Map();
// Add existing entries
for (const [key, value] of existingEntries) {
allTools.set(key, value);
}
// Add/update new tools
for (const tool of this.tools) {
csv += `"${tool.name}","${tool.displayName}","${tool.description}","${tool.module}","${tool.path}","${tool.standalone}"\n`;
const key = `${tool.module}:${tool.name}`;
allTools.set(key, `"${tool.name}","${tool.displayName}","${tool.description}","${tool.module}","${tool.path}","${tool.standalone}"`);
}
// Write all tools
for (const [, value] of allTools) {
csv += value + '\n';
}
await fs.writeFile(csvPath, csv);

View File

@ -61,6 +61,7 @@ class Manifest {
installDate: manifestData.installation?.installDate,
lastUpdated: manifestData.installation?.lastUpdated,
modules: manifestData.modules || [],
customModules: manifestData.customModules || [],
ides: manifestData.ides || [],
};
} catch (error) {
@ -93,6 +94,7 @@ class Manifest {
lastUpdated: manifest.lastUpdated,
},
modules: manifest.modules || [],
customModules: manifest.customModules || [],
ides: manifest.ides || [],
};
@ -535,6 +537,51 @@ class Manifest {
return configs;
}
/**
* Add a custom module to the manifest with its source path
* @param {string} bmadDir - Path to bmad directory
* @param {Object} customModule - Custom module info
*/
async addCustomModule(bmadDir, customModule) {
const manifest = await this.read(bmadDir);
if (!manifest) {
throw new Error('No manifest found');
}
if (!manifest.customModules) {
manifest.customModules = [];
}
// Check if custom module already exists
const existingIndex = manifest.customModules.findIndex((m) => m.id === customModule.id);
if (existingIndex === -1) {
// Add new entry
manifest.customModules.push(customModule);
} else {
// Update existing entry
manifest.customModules[existingIndex] = customModule;
}
await this.update(bmadDir, { customModules: manifest.customModules });
}
/**
* Remove a custom module from the manifest
* @param {string} bmadDir - Path to bmad directory
* @param {string} moduleId - Module ID to remove
*/
async removeCustomModule(bmadDir, moduleId) {
const manifest = await this.read(bmadDir);
if (!manifest || !manifest.customModules) {
return;
}
const index = manifest.customModules.findIndex((m) => m.id === moduleId);
if (index !== -1) {
manifest.customModules.splice(index, 1);
await this.update(bmadDir, { customModules: manifest.customModules });
}
}
}
module.exports = { Manifest };

View File

@ -240,6 +240,25 @@ class ModuleManager {
}
}
}
// Also check for cached custom modules in _cfg/custom/
if (this.bmadDir) {
const customCacheDir = path.join(this.bmadDir, '_cfg', 'custom');
if (await fs.pathExists(customCacheDir)) {
const cacheEntries = await fs.readdir(customCacheDir, { withFileTypes: true });
for (const entry of cacheEntries) {
if (entry.isDirectory()) {
const cachePath = path.join(customCacheDir, entry.name);
const moduleInfo = await this.getModuleInfo(cachePath, entry.name, '_cfg/custom');
if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) {
moduleInfo.isCustom = true;
moduleInfo.fromCache = true;
customModules.push(moduleInfo);
}
}
}
}
}
}
return { modules, customModules };

View File

@ -3,6 +3,7 @@ const boxen = require('boxen');
const wrapAnsi = require('wrap-ansi');
const figlet = require('figlet');
const path = require('node:path');
const os = require('node:os');
const CLIUtils = {
/**
@ -205,6 +206,22 @@ const CLIUtils = {
// No longer clear screen or show boxes - just a simple completion message
// This is deprecated but kept for backwards compatibility
},
/**
* Expand path with ~ expansion
* @param {string} inputPath - Path to expand
* @returns {string} Expanded path
*/
expandPath(inputPath) {
if (!inputPath) return inputPath;
// Expand ~ to home directory
if (inputPath.startsWith('~')) {
return path.join(os.homedir(), inputPath.slice(1));
}
return inputPath;
},
};
module.exports = { CLIUtils };

View File

@ -59,11 +59,15 @@ class UI {
const bmadDir = await installer.findBmadDir(confirmedDirectory);
const hasExistingInstall = await fs.pathExists(bmadDir);
// Only ask for custom content if it's a NEW installation
// Always ask for custom content, but we'll handle it differently for new installs
let customContentConfig = { hasCustomContent: false };
if (!hasExistingInstall) {
// Prompt for custom content location (separate from installation directory)
customContentConfig = await this.promptCustomContentLocation();
if (hasExistingInstall) {
// Existing installation - prompt to add/update custom content
customContentConfig = await this.promptCustomContentForExisting();
} else {
// New installation - we'll prompt after creating the directory structure
// For now, set a flag to indicate we should ask later
customContentConfig._shouldAsk = true;
}
// Track action type (only set if there's an existing installation)
@ -126,6 +130,64 @@ class UI {
const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory);
const coreConfig = await this.collectCoreConfig(confirmedDirectory);
// For new installations, create the directory structure first so we can cache custom content
if (!hasExistingInstall && customContentConfig._shouldAsk) {
// Create the bmad directory based on core config
const path = require('node:path');
const fs = require('fs-extra');
const bmadFolderName = coreConfig.bmad_folder || 'bmad';
const bmadDir = path.join(confirmedDirectory, bmadFolderName);
await fs.ensureDir(bmadDir);
await fs.ensureDir(path.join(bmadDir, '_cfg'));
await fs.ensureDir(path.join(bmadDir, '_cfg', 'custom'));
// Now prompt for custom content
customContentConfig = await this.promptCustomContentLocation();
// If custom content found, cache it
if (customContentConfig.hasCustomContent) {
const { CustomModuleCache } = require('../installers/lib/core/custom-module-cache');
const cache = new CustomModuleCache(bmadDir);
const { CustomHandler } = require('../installers/lib/custom/handler');
const customHandler = new CustomHandler();
const customFiles = await customHandler.findCustomContent(customContentConfig.customPath);
for (const customFile of customFiles) {
const customInfo = await customHandler.getCustomInfo(customFile);
if (customInfo && customInfo.id) {
// Cache the module source
await cache.cacheModule(customInfo.id, customInfo.path, {
name: customInfo.name,
type: 'custom',
});
console.log(chalk.dim(` Cached ${customInfo.name} to _cfg/custom/${customInfo.id}`));
}
}
// Update config to use cached modules
customContentConfig.cachedModules = [];
for (const customFile of customFiles) {
const customInfo = await customHandler.getCustomInfo(customFile);
if (customInfo && customInfo.id) {
customContentConfig.cachedModules.push({
id: customInfo.id,
cachePath: path.join(bmadDir, '_cfg', 'custom', customInfo.id),
// Store relative path from cache for the manifest
relativePath: path.join('_cfg', 'custom', customInfo.id),
});
}
}
console.log(chalk.green(`✓ Cached ${customFiles.length} custom module(s)`));
}
// Clear the flag
delete customContentConfig._shouldAsk;
}
// Skip module selection during update/reinstall - keep existing modules
let selectedModules;
if (actionType === 'update' || actionType === 'reinstall') {
@ -139,8 +201,20 @@ class UI {
// Check which custom content items were selected
const selectedCustomContent = selectedModules.filter((mod) => mod.startsWith('__CUSTOM_CONTENT__'));
if (selectedCustomContent.length > 0) {
// For cached modules (new installs), check if any cached modules were selected
let selectedCachedModules = [];
if (customContentConfig.cachedModules) {
selectedCachedModules = selectedModules.filter(
(mod) => !mod.startsWith('__CUSTOM_CONTENT__') && customContentConfig.cachedModules.some((cm) => cm.id === mod),
);
}
if (selectedCustomContent.length > 0 || selectedCachedModules.length > 0) {
customContentConfig.selected = true;
// Handle directory-based custom content (existing installs)
if (selectedCustomContent.length > 0) {
customContentConfig.selectedFiles = selectedCustomContent.map((mod) => mod.replace('__CUSTOM_CONTENT__', ''));
// Convert custom content to module IDs for installation
const customContentModuleIds = [];
@ -155,10 +229,18 @@ class UI {
}
// Filter out custom content markers and add module IDs
selectedModules = [...selectedModules.filter((mod) => !mod.startsWith('__CUSTOM_CONTENT__')), ...customContentModuleIds];
}
// For cached modules, they're already module IDs, just mark as selected
if (selectedCachedModules.length > 0) {
customContentConfig.selectedCachedModules = selectedCachedModules;
// No need to filter since they're already proper module IDs
}
} else if (customContentConfig.hasCustomContent) {
// User provided custom content but didn't select any
customContentConfig.selected = false;
customContentConfig.selectedFiles = [];
customContentConfig.selectedCachedModules = [];
}
}
@ -528,9 +610,30 @@ class UI {
const customContentItems = [];
const hasCustomContentItems = false;
// Add custom content items from directory
if (customContentConfig && customContentConfig.hasCustomContent && customContentConfig.customPath) {
// Get the custom content info to display proper names
// Add custom content items
if (customContentConfig && customContentConfig.hasCustomContent) {
if (customContentConfig.cachedModules) {
// New installation - show cached modules
for (const cachedModule of customContentConfig.cachedModules) {
// Get the module info from cache
const yaml = require('js-yaml');
const fs = require('fs-extra');
const moduleYamlPath = path.join(cachedModule.cachePath, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
const moduleData = yaml.load(yamlContent);
customContentItems.push({
name: `${chalk.cyan('✓')} ${moduleData.name || cachedModule.id} ${chalk.gray('(cached)')}`,
value: cachedModule.id, // Use module ID directly
checked: true, // Default to selected
cached: true,
});
}
}
} else if (customContentConfig.customPath) {
// Existing installation - show from directory
const { CustomHandler } = require('../installers/lib/custom/handler');
const customHandler = new CustomHandler();
const customFiles = await customHandler.findCustomContent(customContentConfig.customPath);
@ -547,12 +650,16 @@ class UI {
}
}
}
}
// Add official modules
const { ModuleManager } = require('../installers/lib/modules/manager');
// Only scan project for modules if user selected custom content
// For new installations, don't scan project yet (will do after custom content is discovered)
// For existing installations, scan if user selected custom content
const shouldScanProject =
!isNewInstallation && customContentConfig && customContentConfig.hasCustomContent && customContentConfig.selected;
const moduleManager = new ModuleManager({
scanProjectForModules: customContentConfig && customContentConfig.hasCustomContent && customContentConfig.selected,
scanProjectForModules: shouldScanProject,
});
const { modules: availableModules, customModules: customModulesFromProject } = await moduleManager.listAvailable();
@ -1069,6 +1176,144 @@ class UI {
return (await fs.pathExists(hookPath)) && (await fs.pathExists(playTtsPath));
}
/**
* Prompt for custom content for existing installations
* @returns {Object} Custom content configuration
*/
async promptCustomContentForExisting() {
try {
CLIUtils.displaySection('Custom Content', 'Add new custom agents, workflows, or modules to your installation');
const { hasCustomContent } = await inquirer.prompt([
{
type: 'list',
name: 'hasCustomContent',
message: 'Do you want to add or update custom content?',
choices: [
{
name: 'No, continue with current installation only',
value: false,
},
{
name: 'Yes, I have custom content to add or update',
value: true,
},
],
default: false,
},
]);
if (!hasCustomContent) {
return { hasCustomContent: false };
}
// Get directory path
const { customPath } = await inquirer.prompt([
{
type: 'input',
name: 'customPath',
message: 'Enter directory to search for custom content (will scan subfolders):',
default: process.cwd(),
validate: async (input) => {
if (!input || input.trim() === '') {
return 'Please enter a directory path';
}
// Normalize and check if path exists
const expandedPath = CLIUtils.expandPath(input.trim());
const pathExists = await fs.pathExists(expandedPath);
if (!pathExists) {
return 'Directory does not exist';
}
// Check if it's actually a directory
const stats = await fs.stat(expandedPath);
if (!stats.isDirectory()) {
return 'Path must be a directory';
}
return true;
},
transformer: (input) => {
return CLIUtils.expandPath(input);
},
},
]);
const resolvedPath = CLIUtils.expandPath(customPath);
// Find custom content
const { CustomHandler } = require('../installers/lib/custom/handler');
const customHandler = new CustomHandler();
const customFiles = await customHandler.findCustomContent(resolvedPath);
if (customFiles.length === 0) {
console.log(chalk.yellow(`\nNo custom content found in ${resolvedPath}`));
const { tryDifferent } = await inquirer.prompt([
{
type: 'confirm',
name: 'tryDifferent',
message: 'Try a different directory?',
default: true,
},
]);
if (tryDifferent) {
return await this.promptCustomContentForExisting();
}
return { hasCustomContent: false };
}
// Display found items
console.log(chalk.cyan(`\nFound ${customFiles.length} custom content file(s):`));
const { CustomHandler: CustomHandler2 } = require('../installers/lib/custom/handler');
const customHandler2 = new CustomHandler2();
const customContentItems = [];
for (const customFile of customFiles) {
const customInfo = await customHandler2.getCustomInfo(customFile);
if (customInfo) {
customContentItems.push({
name: `${chalk.cyan('✓')} ${customInfo.name} ${chalk.gray(`(${customInfo.relativePath})`)}`,
value: `__CUSTOM_CONTENT__${customFile}`,
checked: true,
});
}
}
// Add option to keep existing custom content
console.log(chalk.yellow('\nExisting custom modules will be preserved unless you remove them'));
const { selectedFiles } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedFiles',
message: 'Select custom content to add:',
choices: customContentItems,
pageSize: 15,
validate: (answer) => {
if (answer.length === 0) {
return 'You must select at least one item';
}
return true;
},
},
]);
return {
hasCustomContent: true,
customPath: resolvedPath,
selected: true,
selectedFiles: selectedFiles,
};
} catch (error) {
console.error(chalk.red('Error configuring custom content:'), error);
return { hasCustomContent: false };
}
}
}
module.exports = { UI };

View File

@ -0,0 +1,124 @@
/**
* Migration script to convert relative paths to absolute paths in custom module manifests
* This should be run once to update existing installations
*/
const fs = require('fs-extra');
const path = require('node:path');
const yaml = require('yaml');
const chalk = require('chalk');
/**
* Find BMAD directory in project
*/
function findBmadDir(projectDir = process.cwd()) {
const possibleNames = ['bmad', '.bmad'];
for (const name of possibleNames) {
const bmadDir = path.join(projectDir, name);
if (fs.existsSync(bmadDir)) {
return bmadDir;
}
}
return null;
}
/**
* Update manifest to use absolute paths
*/
async function updateManifest(manifestPath, projectRoot) {
console.log(chalk.cyan(`\nUpdating manifest: ${manifestPath}`));
const content = await fs.readFile(manifestPath, 'utf8');
const manifest = yaml.parse(content);
if (!manifest.customModules || manifest.customModules.length === 0) {
console.log(chalk.dim(' No custom modules found'));
return false;
}
let updated = false;
for (const customModule of manifest.customModules) {
if (customModule.relativePath && !customModule.sourcePath) {
// Convert relative path to absolute
const absolutePath = path.resolve(projectRoot, customModule.relativePath);
customModule.sourcePath = absolutePath;
// Remove the old relativePath
delete customModule.relativePath;
console.log(chalk.green(` ✓ Updated ${customModule.id}: ${customModule.relativePath}${absolutePath}`));
updated = true;
} else if (customModule.sourcePath && !path.isAbsolute(customModule.sourcePath)) {
// Source path exists but is not absolute
const absolutePath = path.resolve(customModule.sourcePath);
customModule.sourcePath = absolutePath;
console.log(chalk.green(` ✓ Updated ${customModule.id}: ${customModule.sourcePath}${absolutePath}`));
updated = true;
}
}
if (updated) {
// Write back the updated manifest
const yamlStr = yaml.dump(manifest, {
indent: 2,
lineWidth: -1,
noRefs: true,
sortKeys: false,
});
await fs.writeFile(manifestPath, yamlStr);
console.log(chalk.green(' Manifest updated successfully'));
} else {
console.log(chalk.dim(' All paths already absolute'));
}
return updated;
}
/**
* Main migration function
*/
async function migrate(directory) {
const projectRoot = path.resolve(directory || process.cwd());
const bmadDir = findBmadDir(projectRoot);
if (!bmadDir) {
console.error(chalk.red('✗ No BMAD installation found in directory'));
process.exit(1);
}
console.log(chalk.blue.bold('🔄 BMAD Custom Module Path Migration'));
console.log(chalk.dim(`Project: ${projectRoot}`));
console.log(chalk.dim(`BMAD Directory: ${bmadDir}`));
const manifestPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
if (!fs.existsSync(manifestPath)) {
console.error(chalk.red('✗ No manifest.yaml found'));
process.exit(1);
}
const updated = await updateManifest(manifestPath, projectRoot);
if (updated) {
console.log(chalk.green.bold('\n✨ Migration completed successfully!'));
console.log(chalk.dim('Custom modules now use absolute source paths.'));
} else {
console.log(chalk.yellow('\n⚠ No migration needed - paths already absolute'));
}
}
// Run migration if called directly
if (require.main === module) {
const directory = process.argv[2];
migrate(directory).catch((error) => {
console.error(chalk.red('\n✗ Migration failed:'), error.message);
process.exit(1);
});
}
module.exports = { migrate };