feat(cli): consolidate installer output to single spinner + summary

Replace ~40 lines of output from 15+ spinner start/stop cycles with a
single animated spinner during installation and a final note() summary
block showing checkmarks per step.

Key changes:
- Add results collector pattern in install() method
- Replace spinner.stop/start pairs with addResult + spinner.message
- Add renderInstallSummary() using prompts.note() with colored output
- Propagate silent flag through IDE handlers and module manager
- Add spinner race condition guards (start while spinning, stop while stopped)
- Add no-op spinner pattern for silent external module cloning
- Fix stdin listener limit to be defensive with Math.max
- Add GIT_TERMINAL_PROMPT=0 for non-interactive git operations
- Merge locked values into initialValue for autocomplete prompts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Davor Racić 2026-02-07 20:23:05 +01:00
parent fd09349683
commit 04edbf6ee3
10 changed files with 222 additions and 129 deletions

View File

@ -7,7 +7,8 @@ const prompts = require('./lib/prompts');
// The installer flow uses many sequential @clack/prompts, each adding keypress
// listeners to stdin. Raise the limit to avoid spurious EventEmitter warnings.
if (process.stdin?.setMaxListeners) {
process.stdin.setMaxListeners(25);
const currentLimit = process.stdin.getMaxListeners();
process.stdin.setMaxListeners(Math.max(currentLimit, 50));
}
// Check for updates - do this asynchronously so it doesn't block startup

View File

@ -164,8 +164,6 @@ class Installer {
const newlySelectedIdes = toolConfig.ides.filter((ide) => !previouslyConfiguredIdes.includes(ide));
if (newlySelectedIdes.length > 0) {
// @clack handles spacing
// Collect configuration for IDEs that support it
for (const ide of newlySelectedIdes) {
try {
@ -185,8 +183,10 @@ class Installer {
projectDir,
bmadDir,
});
} else {
// Config-driven IDEs don't need configuration - mark as ready
ideConfigurations[ide] = { _noConfigNeeded: true };
}
// Most config-driven IDEs don't need configuration - silently skip
} catch (error) {
// IDE doesn't support configuration or has an error
await prompts.log.warn(`Warning: Could not load configuration for ${ide}: ${error.message}`);
@ -697,10 +697,14 @@ class Installer {
config.skipIde = toolSelection.skipIde;
const ideConfigurations = toolSelection.configurations;
// Results collector for consolidated summary
const results = [];
const addResult = (step, status, detail = '') => results.push({ step, status, detail });
if (spinner.isSpinning) {
spinner.message('Continuing installation...');
spinner.message('Installing...');
} else {
spinner.start('Continuing installation...');
spinner.start('Installing...');
}
// Create bmad directory structure
@ -724,16 +728,16 @@ class Installer {
// Update module manager with the cached paths
this.moduleManager.setCustomModulePaths(customModulePaths);
spinner.stop('Custom modules cached');
addResult('Custom modules cached', 'ok');
}
const projectRoot = getProjectRoot();
// Step 1: Install core module first (if requested)
if (config.installCore) {
spinner.start('Installing BMAD core...');
spinner.message('Installing BMAD core...');
await this.installCoreWithDependencies(bmadDir, { core: {} });
spinner.stop('Core installed');
addResult('Core', 'ok', 'installed');
// Generate core config file
await this.generateModuleConfigs(bmadDir, { core: config.coreConfig || {} });
@ -808,7 +812,7 @@ class Installer {
moduleManager: tempModuleManager,
});
spinner.stop('Dependencies resolved');
spinner.message('Resolving dependencies...');
// Install modules with their dependencies
if (allModules && allModules.length > 0) {
@ -823,7 +827,7 @@ class Installer {
// Show appropriate message based on whether this is a quick update
const isQuickUpdate = config._quickUpdate || false;
spinner.start(`${isQuickUpdate ? 'Updating' : 'Installing'} module: ${moduleName}...`);
spinner.message(`${isQuickUpdate ? 'Updating' : 'Installing'} module: ${moduleName}...`);
// Check if this is a custom module
let isCustomModule = false;
@ -897,6 +901,7 @@ class Installer {
moduleConfig: collectedModuleConfig,
isQuickUpdate: config._quickUpdate || false,
installer: this,
silent: true,
},
);
@ -914,7 +919,7 @@ class Installer {
}
}
spinner.stop(`Module ${isQuickUpdate ? 'updated' : 'installed'}: ${moduleName}`);
addResult(`Module: ${moduleName}`, 'ok', isQuickUpdate ? 'updated' : 'installed');
}
// Install partial modules (only dependencies)
@ -928,9 +933,8 @@ class Installer {
files.data.length +
files.other.length;
if (totalFiles > 0) {
spinner.start(`Installing ${module} dependencies...`);
spinner.message(`Installing ${module} dependencies...`);
await this.installPartialModule(module, bmadDir, files);
spinner.stop(`${module} dependencies installed`);
}
}
}
@ -939,9 +943,9 @@ class Installer {
// All content is now installed as modules - no separate custom content handling needed
// Generate clean config.yaml files for each installed module
spinner.start('Generating module configurations...');
spinner.message('Generating module configurations...');
await this.generateModuleConfigs(bmadDir, moduleConfigs);
spinner.stop('Module configurations generated');
addResult('Configurations', 'ok', 'generated');
// Create agent configuration files
// Note: Legacy createAgentConfigs removed - using YAML customize system instead
@ -956,7 +960,7 @@ class Installer {
// Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes BEFORE IDE setup
// This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv
spinner.start('Generating workflow and agent manifests...');
spinner.message('Generating workflow and agent manifests...');
const manifestGen = new ManifestGenerator();
// For quick update, we need ALL installed modules in the manifest
@ -984,15 +988,17 @@ class Installer {
// Custom modules are now included in the main modules list - no separate tracking needed
spinner.stop(
`Manifests generated: ${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools, ${manifestStats.files} files`,
addResult(
'Manifests',
'ok',
`${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools`,
);
// Merge all module-help.csv files into bmad-help.csv
// This must happen AFTER generateManifests because it depends on agent-manifest.csv
spinner.start('Generating workflow help catalog...');
spinner.message('Generating workflow help catalog...');
await this.mergeModuleHelpCatalogs(bmadDir);
spinner.stop('Workflow help catalog generated');
addResult('Help catalog', 'ok');
// Configure IDEs and copy documentation
if (!config.skipIde && config.ides && config.ides.length > 0) {
@ -1003,15 +1009,11 @@ class Installer {
const validIdes = config.ides.filter((ide) => ide && typeof ide === 'string');
if (validIdes.length === 0) {
await prompts.log.warn('No valid IDEs selected. Skipping IDE configuration.');
addResult('IDE configuration', 'warn', 'no valid IDEs selected');
} else {
// Check if any IDE might need prompting (no pre-collected config)
const needsPrompting = validIdes.some((ide) => !ideConfigurations[ide]);
if (!needsPrompting) {
spinner.start('Configuring IDEs...');
}
// Temporarily suppress console output if not verbose
const originalLog = console.log;
if (!config.verbose) {
@ -1019,22 +1021,23 @@ class Installer {
}
for (const ide of validIdes) {
// Only show spinner if we have pre-collected config (no prompts expected)
if (ideConfigurations[ide] && !needsPrompting) {
if (!needsPrompting || ideConfigurations[ide]) {
// All IDEs pre-configured, or this specific IDE has config: keep spinner running
spinner.message(`Configuring ${ide}...`);
} else if (!ideConfigurations[ide]) {
// Stop spinner before prompting
} else {
// This IDE needs prompting: stop spinner to allow user interaction
if (spinner.isSpinning) {
spinner.stop('Ready for IDE configuration');
}
await prompts.log.info(`Configuring ${ide}...`);
}
// Pass pre-collected configuration to avoid re-prompting
await this.ideManager.setup(ide, projectDir, bmadDir, {
// Silent when this IDE has pre-collected config (no prompts for THIS IDE)
const ideHasConfig = Boolean(ideConfigurations[ide]);
const setupResult = await this.ideManager.setup(ide, projectDir, bmadDir, {
selectedModules: allModules || [],
preCollectedConfig: ideConfigurations[ide] || null,
verbose: config.verbose,
silent: ideHasConfig,
});
// Save IDE configuration for future updates
@ -1042,25 +1045,26 @@ class Installer {
await this.ideConfigManager.saveIdeConfig(bmadDir, ide, ideConfigurations[ide]);
}
// Restart spinner if we stopped it
if (!ideConfigurations[ide] && !spinner.isSpinning) {
// Collect result for summary
if (setupResult.success) {
addResult(ide, 'ok', setupResult.detail || '');
} else {
addResult(ide, 'error', setupResult.error || 'failed');
}
// Restart spinner if we stopped it for prompting
if (needsPrompting && !spinner.isSpinning) {
spinner.start('Configuring IDEs...');
}
}
// Restore console.log
console.log = originalLog;
if (spinner.isSpinning) {
spinner.stop(`Configured: ${validIdes.join(', ')}`);
} else {
await prompts.log.success(`Configured: ${validIdes.join(', ')}`);
}
}
}
// Run module-specific installers after IDE setup
spinner.start('Running module-specific installers...');
spinner.message('Running module-specific installers...');
// Create a conditional logger based on verbose mode
const verboseMode = process.env.BMAD_VERBOSE_INSTALL === 'true' || config.verbose;
@ -1079,6 +1083,7 @@ class Installer {
moduleConfig: moduleConfigs.core || {},
coreConfig: moduleConfigs.core || {},
logger: moduleLogger,
silent: true,
});
}
@ -1093,11 +1098,12 @@ class Installer {
moduleConfig: moduleConfigs[moduleName] || {},
coreConfig: moduleConfigs.core || {},
logger: moduleLogger,
silent: true,
});
}
}
spinner.stop('Module-specific installers completed');
addResult('Module installers', 'ok');
// Note: Manifest files are already created by ManifestGenerator above
// No need to create legacy manifest.csv anymore
@ -1107,7 +1113,7 @@ class Installer {
let modifiedFiles = [];
if (config._isUpdate) {
if (config._customFiles && config._customFiles.length > 0) {
spinner.start(`Restoring ${config._customFiles.length} custom files...`);
spinner.message(`Restoring ${config._customFiles.length} custom files...`);
for (const originalPath of config._customFiles) {
const relativePath = path.relative(bmadDir, originalPath);
@ -1124,7 +1130,6 @@ class Installer {
await fs.remove(config._tempBackupDir);
}
spinner.stop(`Restored ${config._customFiles.length} custom files`);
customFiles = config._customFiles;
}
@ -1133,7 +1138,7 @@ class Installer {
// Restore modified files as .bak files
if (config._tempModifiedBackupDir && (await fs.pathExists(config._tempModifiedBackupDir))) {
spinner.start(`Restoring ${modifiedFiles.length} modified files as .bak...`);
spinner.message(`Restoring ${modifiedFiles.length} modified files as .bak...`);
for (const modifiedFile of modifiedFiles) {
const relativePath = path.relative(bmadDir, modifiedFile.path);
@ -1148,37 +1153,20 @@ class Installer {
// Clean up temp backup
await fs.remove(config._tempModifiedBackupDir);
spinner.stop(`Restored ${modifiedFiles.length} modified files as .bak`);
}
}
}
if (spinner.isSpinning) {
spinner.stop('Installation finalized');
}
// Stop the single installation spinner
spinner.stop('Installation complete');
// Report custom and modified files if any were found
if (customFiles.length > 0) {
await prompts.log.info(`Custom files preserved: ${customFiles.length}`);
}
if (modifiedFiles.length > 0) {
await prompts.log.warn(`User modified files detected: ${modifiedFiles.length}`);
await prompts.log.message(
'These user modified files have been updated with the new version, search the project for .bak files that had your customizations.',
);
await prompts.log.message('Remove these .bak files if no longer needed');
}
// Display completion message
const { UI } = require('../../../lib/ui');
const ui = new UI();
await ui.showInstallSummary({
path: bmadDir,
// Render consolidated summary
await this.renderInstallSummary(results, {
bmadDir,
modules: config.modules,
ides: config.ides,
customFiles: customFiles.length > 0 ? customFiles : undefined,
modifiedFiles: modifiedFiles.length > 0 ? modifiedFiles : undefined,
});
return {
@ -1194,6 +1182,52 @@ class Installer {
}
}
/**
* Render a consolidated install summary using prompts.note()
* @param {Array} results - Array of {step, status: 'ok'|'error'|'warn', detail}
* @param {Object} context - {bmadDir, modules, ides, customFiles, modifiedFiles}
*/
async renderInstallSummary(results, context = {}) {
const color = await prompts.getColor();
// Build step lines with status indicators
const lines = [];
for (const r of results) {
let icon;
if (r.status === 'ok') {
icon = color.green('\u2713');
} else if (r.status === 'warn') {
icon = color.yellow('!');
} else {
icon = color.red('\u2717');
}
const detail = r.detail ? color.dim(` (${r.detail})`) : '';
lines.push(` ${icon} ${r.step}${detail}`);
}
// Add context info
lines.push('');
if (context.bmadDir) {
lines.push(` Installed to: ${color.dim(context.bmadDir)}`);
}
if (context.modules && context.modules.length > 0) {
lines.push(` Modules: ${color.dim(context.modules.join(', '))}`);
}
if (context.ides && context.ides.length > 0) {
lines.push(` Tools: ${color.dim(context.ides.join(', '))}`);
}
// Custom/modified file warnings
if (context.customFiles && context.customFiles.length > 0) {
lines.push(` ${color.cyan(`Custom files preserved: ${context.customFiles.length}`)}`);
}
if (context.modifiedFiles && context.modifiedFiles.length > 0) {
lines.push(` ${color.yellow(`Modified files backed up (.bak): ${context.modifiedFiles.length}`)}`);
}
await prompts.note(lines.join('\n'), 'BMAD is ready to use!');
}
/**
* Update existing installation
*/
@ -1728,6 +1762,7 @@ class Installer {
skipModuleInstaller: true, // We'll run it later after IDE setup
moduleConfig: moduleConfig, // Pass module config for conditional filtering
installer: this,
silent: true,
},
);

View File

@ -53,7 +53,7 @@ class BaseIdeSetup {
* Cleanup IDE configuration
* @param {string} projectDir - Project directory
*/
async cleanup(projectDir) {
async cleanup(projectDir, options = {}) {
// Default implementation - can be overridden
if (this.configDir) {
const configPath = path.join(projectDir, this.configDir);
@ -61,7 +61,7 @@ class BaseIdeSetup {
const bmadRulesPath = path.join(configPath, BMAD_FOLDER_NAME);
if (await fs.pathExists(bmadRulesPath)) {
await fs.remove(bmadRulesPath);
await prompts.log.message(`Removed ${this.name} BMAD configuration`);
if (!options.silent) await prompts.log.message(`Removed ${this.name} BMAD configuration`);
}
}
}

View File

@ -34,10 +34,10 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
* @returns {Promise<Object>} Setup result
*/
async setup(projectDir, bmadDir, options = {}) {
await prompts.log.info(`Setting up ${this.name}...`);
if (!options.silent) await prompts.log.info(`Setting up ${this.name}...`);
// Clean up any old BMAD installation first
await this.cleanup(projectDir);
await this.cleanup(projectDir, options);
if (!this.installerConfig) {
return { success: false, reason: 'no-config' };
@ -102,7 +102,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
results.tools = taskToolResult.tools || 0;
}
await this.printSummary(results, target_dir);
await this.printSummary(results, target_dir, options);
return { success: true, results };
}
@ -439,7 +439,8 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
* @param {Object} results - Installation results
* @param {string} targetDir - Target directory (relative)
*/
async printSummary(results, targetDir) {
async printSummary(results, targetDir, options = {}) {
if (options.silent) return;
const parts = [];
if (results.agents > 0) parts.push(`${results.agents} agents`);
if (results.workflows > 0) parts.push(`${results.workflows} workflows`);
@ -452,14 +453,14 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
* Cleanup IDE configuration
* @param {string} projectDir - Project directory
*/
async cleanup(projectDir) {
async cleanup(projectDir, options = {}) {
// Clean all target directories
if (this.installerConfig?.targets) {
for (const target of this.installerConfig.targets) {
await this.cleanupTarget(projectDir, target.target_dir);
await this.cleanupTarget(projectDir, target.target_dir, options);
}
} else if (this.installerConfig?.target_dir) {
await this.cleanupTarget(projectDir, this.installerConfig.target_dir);
await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options);
}
}
@ -468,7 +469,7 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
* @param {string} projectDir - Project directory
* @param {string} targetDir - Target directory to clean
*/
async cleanupTarget(projectDir, targetDir) {
async cleanupTarget(projectDir, targetDir, options = {}) {
const targetPath = path.join(projectDir, targetDir);
if (!(await fs.pathExists(targetPath))) {
@ -508,7 +509,7 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
}
}
if (removedCount > 0) {
if (removedCount > 0 && !options.silent) {
await prompts.log.message(` Cleaned ${removedCount} BMAD files from ${targetDir}`);
}
}

View File

@ -42,11 +42,11 @@ class CodexSetup extends BaseIdeSetup {
default: 'global',
});
// Display detailed instructions for the chosen option
// Show brief confirmation hint (detailed instructions available via verbose)
if (installLocation === 'project') {
await prompts.note(this.getProjectSpecificInstructions(), 'Codex Project Installation');
await prompts.log.info('Prompts installed to: <project>/.codex/prompts (requires CODEX_HOME)');
} else {
await prompts.note(this.getGlobalInstructions(), 'Codex Global Installation');
await prompts.log.info('Prompts installed to: ~/.codex/prompts');
}
// Confirm the choice
@ -70,7 +70,7 @@ class CodexSetup extends BaseIdeSetup {
* @param {Object} options - Setup options
*/
async setup(projectDir, bmadDir, options = {}) {
await prompts.log.info(`Setting up ${this.name}...`);
if (!options.silent) await prompts.log.info(`Setting up ${this.name}...`);
// Always use CLI mode
const mode = 'cli';
@ -82,7 +82,7 @@ class CodexSetup extends BaseIdeSetup {
const destDir = this.getCodexPromptDir(projectDir, installLocation);
await fs.ensureDir(destDir);
await this.clearOldBmadFiles(destDir);
await this.clearOldBmadFiles(destDir, options);
// Collect artifacts and write using underscore format
const agentGen = new AgentCommandGenerator(this.bmadFolderName);
@ -122,9 +122,11 @@ class CodexSetup extends BaseIdeSetup {
const written = agentCount + workflowCount + tasksWritten;
await prompts.log.success(
`${this.name} configured: ${counts.agents} agents, ${counts.workflows} workflows, ${counts.tasks} tasks, ${written} files → ${destDir}`,
);
if (!options.silent) {
await prompts.log.success(
`${this.name} configured: ${counts.agents} agents, ${counts.workflows} workflows, ${counts.tasks} tasks, ${written} files → ${destDir}`,
);
}
return {
success: true,
@ -253,7 +255,7 @@ class CodexSetup extends BaseIdeSetup {
return written;
}
async clearOldBmadFiles(destDir) {
async clearOldBmadFiles(destDir, options = {}) {
if (!(await fs.pathExists(destDir))) {
return;
}
@ -263,7 +265,7 @@ class CodexSetup extends BaseIdeSetup {
entries = await fs.readdir(destDir);
} catch (error) {
// Directory exists but can't be read - skip cleanup
await prompts.log.warn(`Warning: Could not read directory ${destDir}: ${error.message}`);
if (!options.silent) await prompts.log.warn(`Warning: Could not read directory ${destDir}: ${error.message}`);
return;
}

View File

@ -23,10 +23,10 @@ class KiloSetup extends BaseIdeSetup {
* @param {Object} options - Setup options
*/
async setup(projectDir, bmadDir, options = {}) {
await prompts.log.info(`Setting up ${this.name}...`);
if (!options.silent) await prompts.log.info(`Setting up ${this.name}...`);
// Clean up any old BMAD installation first
await this.cleanup(projectDir);
await this.cleanup(projectDir, options);
// Load existing config (may contain non-BMAD modes and other settings)
const kiloModesPath = path.join(projectDir, this.configFile);
@ -88,9 +88,11 @@ class KiloSetup extends BaseIdeSetup {
const taskCount = taskToolCounts.tasks || 0;
const toolCount = taskToolCounts.tools || 0;
await prompts.log.success(
`${this.name} configured: ${addedCount} modes, ${workflowCount} workflows, ${taskCount} tasks, ${toolCount} tools → ${this.configFile}`,
);
if (!options.silent) {
await prompts.log.success(
`${this.name} configured: ${addedCount} modes, ${workflowCount} workflows, ${taskCount} tasks, ${toolCount} tools → ${this.configFile}`,
);
}
return {
success: true,
@ -169,7 +171,7 @@ class KiloSetup extends BaseIdeSetup {
/**
* Cleanup KiloCode configuration
*/
async cleanup(projectDir) {
async cleanup(projectDir, options = {}) {
const fs = require('fs-extra');
const kiloModesPath = path.join(projectDir, this.configFile);
@ -187,12 +189,12 @@ class KiloSetup extends BaseIdeSetup {
if (removedCount > 0) {
await fs.writeFile(kiloModesPath, yaml.stringify(config, { lineWidth: 0 }));
await prompts.log.message(`Removed ${removedCount} BMAD modes from .kilocodemodes`);
if (!options.silent) await prompts.log.message(`Removed ${removedCount} BMAD modes from .kilocodemodes`);
}
}
} catch {
// If parsing fails, leave file as-is
await prompts.log.warn('Warning: Could not parse .kilocodemodes for cleanup');
if (!options.silent) await prompts.log.warn('Warning: Could not parse .kilocodemodes for cleanup');
}
}

View File

@ -18,7 +18,7 @@ class KiroCliSetup extends BaseIdeSetup {
* Cleanup old BMAD installation before reinstalling
* @param {string} projectDir - Project directory
*/
async cleanup(projectDir) {
async cleanup(projectDir, options = {}) {
const bmadAgentsDir = path.join(projectDir, this.configDir, this.agentsDir);
if (await fs.pathExists(bmadAgentsDir)) {
@ -29,7 +29,7 @@ class KiroCliSetup extends BaseIdeSetup {
await fs.remove(path.join(bmadAgentsDir, file));
}
}
await prompts.log.message(` Cleaned old BMAD agents from ${this.name}`);
if (!options.silent) await prompts.log.message(` Cleaned old BMAD agents from ${this.name}`);
}
}
@ -40,9 +40,9 @@ class KiroCliSetup extends BaseIdeSetup {
* @param {Object} options - Setup options
*/
async setup(projectDir, bmadDir, options = {}) {
await prompts.log.info(`Setting up ${this.name}...`);
if (!options.silent) await prompts.log.info(`Setting up ${this.name}...`);
await this.cleanup(projectDir);
await this.cleanup(projectDir, options);
const kiroDir = path.join(projectDir, this.configDir);
const agentsDir = path.join(kiroDir, this.agentsDir);
@ -52,7 +52,7 @@ class KiroCliSetup extends BaseIdeSetup {
// Create BMad agents from source YAML files
await this.createBmadAgentsFromSource(agentsDir, projectDir);
await prompts.log.success(`${this.name} configured with BMad agents`);
if (!options.silent) await prompts.log.success(`${this.name} configured with BMad agents`);
}
/**

View File

@ -177,11 +177,39 @@ class IdeManager {
}
try {
await handler.setup(projectDir, bmadDir, options);
return { success: true, ide: ideName };
const handlerResult = await handler.setup(projectDir, bmadDir, options);
// Build detail string from handler-returned data
let detail = '';
if (handlerResult && handlerResult.results) {
// Config-driven handlers return { success, results: { agents, workflows, tasks, tools } }
const r = handlerResult.results;
const parts = [];
if (r.agents > 0) parts.push(`${r.agents} agents`);
if (r.workflows > 0) parts.push(`${r.workflows} workflows`);
if (r.tasks > 0) parts.push(`${r.tasks} tasks`);
if (r.tools > 0) parts.push(`${r.tools} tools`);
detail = parts.join(', ');
} else if (handlerResult && handlerResult.counts) {
// Codex handler returns { success, counts: { agents, workflows, tasks }, written }
const c = handlerResult.counts;
const parts = [];
if (c.agents > 0) parts.push(`${c.agents} agents`);
if (c.workflows > 0) parts.push(`${c.workflows} workflows`);
if (c.tasks > 0) parts.push(`${c.tasks} tasks`);
detail = parts.join(', ');
} else if (handlerResult && handlerResult.modes !== undefined) {
// Kilo handler returns { success, modes, workflows, tasks, tools }
const parts = [];
if (handlerResult.modes > 0) parts.push(`${handlerResult.modes} agents`);
if (handlerResult.workflows > 0) parts.push(`${handlerResult.workflows} workflows`);
if (handlerResult.tasks > 0) parts.push(`${handlerResult.tasks} tasks`);
if (handlerResult.tools > 0) parts.push(`${handlerResult.tools} tools`);
detail = parts.join(', ');
}
return { success: true, ide: ideName, detail, handlerResult };
} catch (error) {
await prompts.log.error(`Failed to setup ${ideName}: ${error.message}`);
return { success: false, error: error.message };
return { success: false, ide: ideName, error: error.message };
}
}

View File

@ -298,7 +298,7 @@ class ModuleManager {
* @param {string} moduleCode - Code of the module to find (from module.yaml)
* @returns {string|null} Path to the module source or null if not found
*/
async findModuleSource(moduleCode) {
async findModuleSource(moduleCode, options = {}) {
const projectRoot = getProjectRoot();
// First check custom module paths if they exist
@ -315,7 +315,7 @@ class ModuleManager {
}
// Check external official modules
const externalSource = await this.findExternalModuleSource(moduleCode);
const externalSource = await this.findExternalModuleSource(moduleCode, options);
if (externalSource) {
return externalSource;
}
@ -347,7 +347,7 @@ class ModuleManager {
* @param {string} moduleCode - Code of the external module
* @returns {string} Path to the cloned repository
*/
async cloneExternalModule(moduleCode) {
async cloneExternalModule(moduleCode, options = {}) {
const { execSync } = require('node:child_process');
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleCode);
@ -357,10 +357,19 @@ class ModuleManager {
const cacheDir = this.getExternalCacheDir();
const moduleCacheDir = path.join(cacheDir, moduleCode);
const silent = options.silent || false;
// Create cache directory if it doesn't exist
await fs.ensureDir(cacheDir);
// Helper to create a spinner or a no-op when silent
const createSpinner = async () => {
if (silent) {
return { start() {}, stop() {}, error() {}, message() {} };
}
return await prompts.spinner();
};
// Track if we need to install dependencies
let needsDependencyInstall = false;
let wasNewClone = false;
@ -368,13 +377,21 @@ class ModuleManager {
// Check if already cloned
if (await fs.pathExists(moduleCacheDir)) {
// Try to update if it's a git repo
const fetchSpinner = await prompts.spinner();
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
try {
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
// Fetch and reset to remote - works better with shallow clones than pull
execSync('git fetch origin --depth 1', { cwd: moduleCacheDir, stdio: 'pipe' });
execSync('git reset --hard origin/HEAD', { cwd: moduleCacheDir, stdio: 'pipe' });
execSync('git fetch origin --depth 1', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
execSync('git reset --hard origin/HEAD', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
@ -394,11 +411,12 @@ class ModuleManager {
// Clone if not exists or was removed
if (wasNewClone) {
const fetchSpinner = await prompts.spinner();
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
try {
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
stdio: 'pipe',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
} catch (error) {
@ -416,18 +434,18 @@ class ModuleManager {
// Force install if we updated or cloned new
if (needsDependencyInstall || wasNewClone || nodeModulesMissing) {
const installSpinner = await prompts.spinner();
const installSpinner = await createSpinner();
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
try {
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
cwd: moduleCacheDir,
stdio: 'pipe',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120_000, // 2 minute timeout
});
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
} catch (error) {
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
await prompts.log.warn(` Warning: ${error.message}`);
if (!silent) await prompts.log.warn(` Warning: ${error.message}`);
}
} else {
// Check if package.json is newer than node_modules
@ -442,18 +460,18 @@ class ModuleManager {
}
if (packageJsonNewer) {
const installSpinner = await prompts.spinner();
const installSpinner = await createSpinner();
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
try {
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
cwd: moduleCacheDir,
stdio: 'pipe',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120_000, // 2 minute timeout
});
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
} catch (error) {
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
await prompts.log.warn(` Warning: ${error.message}`);
if (!silent) await prompts.log.warn(` Warning: ${error.message}`);
}
}
}
@ -467,7 +485,7 @@ class ModuleManager {
* @param {string} moduleCode - Code of the external module
* @returns {string|null} Path to the module source or null if not found
*/
async findExternalModuleSource(moduleCode) {
async findExternalModuleSource(moduleCode, options = {}) {
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleCode);
if (!moduleInfo) {
@ -475,7 +493,7 @@ class ModuleManager {
}
// Clone the external module repo
const cloneDir = await this.cloneExternalModule(moduleCode);
const cloneDir = await this.cloneExternalModule(moduleCode, options);
// The module-definition specifies the path to module.yaml relative to repo root
// We need to return the directory containing module.yaml
@ -496,7 +514,7 @@ class ModuleManager {
* @param {Object} options.logger - Logger instance for output
*/
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
const sourcePath = await this.findModuleSource(moduleName);
const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent });
const targetPath = path.join(bmadDir, moduleName);
// Check if source module exists
@ -1240,7 +1258,7 @@ class ModuleManager {
if (moduleName === 'core') {
sourcePath = getSourcePath('core');
} else {
sourcePath = await this.findModuleSource(moduleName);
sourcePath = await this.findModuleSource(moduleName, { silent: options.silent });
if (!sourcePath) {
// No source found, skip module installer
return;

View File

@ -99,12 +99,18 @@ async function spinner() {
return {
start: (msg) => {
spinning = true;
s.start(msg);
if (spinning) {
s.message(msg);
} else {
spinning = true;
s.start(msg);
}
},
stop: (msg) => {
spinning = false;
s.stop(msg);
if (spinning) {
spinning = false;
s.stop(msg);
}
},
message: (msg) => s.message(msg),
error: (msg) => {
@ -264,7 +270,7 @@ async function autocompleteMultiselect(options) {
return 'Please select at least one item';
}
},
initialValue: options.initialValues,
initialValue: [...new Set([...(options.initialValues || []), ...(options.lockedValues || [])])],
render() {
const barColor = this.state === 'error' ? color.yellow : color.cyan;
const bar = barColor(clack.S_BAR);