Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Verkhovsky 1c6c72e874
Merge bd58d06d1c into 594235522c 2026-02-03 21:38:37 +01:00
26 changed files with 219 additions and 762 deletions

18
package-lock.json generated
View File

@ -9,8 +9,7 @@
"version": "6.0.0-Beta.5",
"license": "MIT",
"dependencies": {
"@clack/core": "^1.0.0",
"@clack/prompts": "^1.0.0",
"@clack/prompts": "^0.11.0",
"@kayvan/markdown-tree-parser": "^1.6.1",
"boxen": "^5.1.2",
"chalk": "^4.1.2",
@ -23,7 +22,6 @@
"ignore": "^7.0.5",
"js-yaml": "^4.1.0",
"ora": "^5.4.1",
"picocolors": "^1.1.1",
"semver": "^7.6.3",
"wrap-ansi": "^7.0.0",
"xml2js": "^0.6.2",
@ -758,9 +756,9 @@
}
},
"node_modules/@clack/core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0.tgz",
"integrity": "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-0.5.0.tgz",
"integrity": "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==",
"license": "MIT",
"dependencies": {
"picocolors": "^1.0.0",
@ -768,12 +766,12 @@
}
},
"node_modules/@clack/prompts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.0.tgz",
"integrity": "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==",
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.11.0.tgz",
"integrity": "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==",
"license": "MIT",
"dependencies": {
"@clack/core": "1.0.0",
"@clack/core": "0.5.0",
"picocolors": "^1.0.0",
"sisteransi": "^1.0.5"
}

View File

@ -69,8 +69,7 @@
]
},
"dependencies": {
"@clack/core": "^1.0.0",
"@clack/prompts": "^1.0.0",
"@clack/prompts": "^0.11.0",
"@kayvan/markdown-tree-parser": "^1.6.1",
"boxen": "^5.1.2",
"chalk": "^4.1.2",
@ -83,7 +82,6 @@
"ignore": "^7.0.5",
"js-yaml": "^4.1.0",
"ora": "^5.4.1",
"picocolors": "^1.1.1",
"semver": "^7.6.3",
"wrap-ansi": "^7.0.0",
"xml2js": "^0.6.2",

View File

@ -1,4 +1,4 @@
<task id="_bmad/core/tasks/workflow.xml" name="Execute Workflow" standalone="false" internal="true">
<task id="_bmad/core/tasks/workflow.xml" name="Execute Workflow" standalone="false">
<objective>Execute given workflow by loading its configuration, following instructions, and producing output</objective>
<llm critical="true">

View File

@ -586,11 +586,7 @@ class ConfigCollector {
console.log();
console.log(chalk.cyan('?') + ' ' + chalk.magenta(moduleDisplayName));
let customize = true;
if (moduleName === 'core') {
// Core module: no confirm prompt, so add spacing manually to match visual style
console.log(chalk.gray('│'));
} else {
// Non-core modules: show "Accept Defaults?" confirm prompt (clack adds spacing)
if (moduleName !== 'core') {
const customizeAnswer = await prompts.prompt([
{
type: 'confirm',

View File

@ -146,7 +146,7 @@ class DependencyResolver {
const content = await fs.readFile(file.path, 'utf8');
// Parse YAML frontmatter for explicit dependencies
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
try {
// Pre-process to handle backticks in YAML values

View File

@ -17,7 +17,9 @@ const { ManifestGenerator } = require('./manifest-generator');
const { IdeConfigManager } = require('./ide-config-manager');
const { CustomHandler } = require('../custom/handler');
const prompts = require('../../../lib/prompts');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
// BMAD installation folder name - this is constant and should never change
const BMAD_FOLDER_NAME = '_bmad';
class Installer {
constructor() {
@ -695,6 +697,9 @@ class Installer {
config.skipIde = toolSelection.skipIde;
const ideConfigurations = toolSelection.configurations;
// Add spacing after prompts before installation progress
console.log('');
if (spinner.isSpinning) {
spinner.text = 'Continuing installation...';
} else {

View File

@ -2,7 +2,6 @@ const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const crypto = require('node:crypto');
const csv = require('csv-parse/sync');
const { getSourcePath, getModulePath } = require('../../../lib/project-root');
// Load package.json for version info
@ -22,19 +21,6 @@ class ManifestGenerator {
this.selectedIdes = [];
}
/**
* Clean text for CSV output by normalizing whitespace and escaping quotes
* @param {string} text - Text to clean
* @returns {string} Cleaned text safe for CSV
*/
cleanForCSV(text) {
if (!text) return '';
return text
.trim()
.replaceAll(/\s+/g, ' ') // Normalize all whitespace (including newlines) to single space
.replaceAll('"', '""'); // Escape quotes for CSV
}
/**
* Generate all manifests for the installation
* @param {string} bmadDir - _bmad
@ -175,7 +161,7 @@ class ManifestGenerator {
workflow = yaml.parse(content);
} else {
// Parse MD workflow with YAML frontmatter
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) {
if (debug) {
console.log(`[DEBUG] Skipped (no frontmatter): ${fullPath}`);
@ -215,7 +201,7 @@ class ManifestGenerator {
// Workflows with standalone: false are filtered out above
workflows.push({
name: workflow.name,
description: this.cleanForCSV(workflow.description),
description: workflow.description.replaceAll('"', '""'), // Escape quotes for CSV
module: moduleName,
path: installPath,
});
@ -333,15 +319,24 @@ class ManifestGenerator {
const agentName = entry.name.replace('.md', '');
// Helper function to clean and escape CSV content
const cleanForCSV = (text) => {
if (!text) return '';
return text
.trim()
.replaceAll(/\s+/g, ' ') // Normalize whitespace
.replaceAll('"', '""'); // Escape quotes for CSV
};
agents.push({
name: agentName,
displayName: nameMatch ? nameMatch[1] : agentName,
title: titleMatch ? titleMatch[1] : '',
icon: iconMatch ? iconMatch[1] : '',
role: roleMatch ? this.cleanForCSV(roleMatch[1]) : '',
identity: identityMatch ? this.cleanForCSV(identityMatch[1]) : '',
communicationStyle: styleMatch ? this.cleanForCSV(styleMatch[1]) : '',
principles: principlesMatch ? this.cleanForCSV(principlesMatch[1]) : '',
role: roleMatch ? cleanForCSV(roleMatch[1]) : '',
identity: identityMatch ? cleanForCSV(identityMatch[1]) : '',
communicationStyle: styleMatch ? cleanForCSV(styleMatch[1]) : '',
principles: principlesMatch ? cleanForCSV(principlesMatch[1]) : '',
module: moduleName,
path: installPath,
});
@ -390,11 +385,6 @@ class ManifestGenerator {
const filePath = path.join(dirPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Skip internal/engine files (not user-facing tasks)
if (content.includes('internal="true"')) {
continue;
}
let name = file.replace(/\.(xml|md)$/, '');
let displayName = name;
let description = '';
@ -402,13 +392,13 @@ class ManifestGenerator {
if (file.endsWith('.md')) {
// Parse YAML frontmatter for .md tasks
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
try {
const frontmatter = yaml.parse(frontmatterMatch[1]);
name = frontmatter.name || name;
displayName = frontmatter.displayName || frontmatter.name || name;
description = this.cleanForCSV(frontmatter.description || '');
description = frontmatter.description || '';
standalone = frontmatter.standalone === true || frontmatter.standalone === 'true';
} catch {
// If YAML parsing fails, use defaults
@ -421,7 +411,7 @@ class ManifestGenerator {
const descMatch = content.match(/description="([^"]+)"/);
const objMatch = content.match(/<objective>([^<]+)<\/objective>/);
description = this.cleanForCSV(descMatch ? descMatch[1] : objMatch ? objMatch[1].trim() : '');
description = descMatch ? descMatch[1] : objMatch ? objMatch[1].trim() : '';
const standaloneMatch = content.match(/<task[^>]+standalone="true"/);
standalone = !!standaloneMatch;
@ -434,7 +424,7 @@ class ManifestGenerator {
tasks.push({
name: name,
displayName: displayName,
description: description,
description: description.replaceAll('"', '""'),
module: moduleName,
path: installPath,
standalone: standalone,
@ -484,11 +474,6 @@ class ManifestGenerator {
const filePath = path.join(dirPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Skip internal tools (same as tasks)
if (content.includes('internal="true"')) {
continue;
}
let name = file.replace(/\.(xml|md)$/, '');
let displayName = name;
let description = '';
@ -496,13 +481,13 @@ class ManifestGenerator {
if (file.endsWith('.md')) {
// Parse YAML frontmatter for .md tools
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
try {
const frontmatter = yaml.parse(frontmatterMatch[1]);
name = frontmatter.name || name;
displayName = frontmatter.displayName || frontmatter.name || name;
description = this.cleanForCSV(frontmatter.description || '');
description = frontmatter.description || '';
standalone = frontmatter.standalone === true || frontmatter.standalone === 'true';
} catch {
// If YAML parsing fails, use defaults
@ -515,7 +500,7 @@ class ManifestGenerator {
const descMatch = content.match(/description="([^"]+)"/);
const objMatch = content.match(/<objective>([^<]+)<\/objective>/);
description = this.cleanForCSV(descMatch ? descMatch[1] : objMatch ? objMatch[1].trim() : '');
description = descMatch ? descMatch[1] : objMatch ? objMatch[1].trim() : '';
const standaloneMatch = content.match(/<tool[^>]+standalone="true"/);
standalone = !!standaloneMatch;
@ -528,7 +513,7 @@ class ManifestGenerator {
tools.push({
name: name,
displayName: displayName,
description: description,
description: description.replaceAll('"', '""'),
module: moduleName,
path: installPath,
standalone: standalone,
@ -788,23 +773,30 @@ class ManifestGenerator {
*/
async writeAgentManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'agent-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
// Read existing manifest to preserve entries
const existingEntries = new Map();
if (await fs.pathExists(csvPath)) {
const content = await fs.readFile(csvPath, 'utf8');
const records = csv.parse(content, {
columns: true,
skip_empty_lines: true,
});
for (const record of records) {
existingEntries.set(`${record.module}:${record.name}`, record);
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 csvContent = 'name,displayName,title,icon,role,identity,communicationStyle,principles,module,path\n';
let csv = 'name,displayName,title,icon,role,identity,communicationStyle,principles,module,path\n';
// Combine existing and new agents, preferring new data for duplicates
const allAgents = new Map();
@ -817,38 +809,18 @@ class ManifestGenerator {
// Add/update new agents
for (const agent of this.agents) {
const key = `${agent.module}:${agent.name}`;
allAgents.set(key, {
name: agent.name,
displayName: agent.displayName,
title: agent.title,
icon: agent.icon,
role: agent.role,
identity: agent.identity,
communicationStyle: agent.communicationStyle,
principles: agent.principles,
module: agent.module,
path: agent.path,
});
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 [, record] of allAgents) {
const row = [
escapeCsv(record.name),
escapeCsv(record.displayName),
escapeCsv(record.title),
escapeCsv(record.icon),
escapeCsv(record.role),
escapeCsv(record.identity),
escapeCsv(record.communicationStyle),
escapeCsv(record.principles),
escapeCsv(record.module),
escapeCsv(record.path),
].join(',');
csvContent += row + '\n';
for (const [, value] of allAgents) {
csv += value + '\n';
}
await fs.writeFile(csvPath, csvContent);
await fs.writeFile(csvPath, csv);
return csvPath;
}
@ -858,23 +830,30 @@ class ManifestGenerator {
*/
async writeTaskManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'task-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
// Read existing manifest to preserve entries
const existingEntries = new Map();
if (await fs.pathExists(csvPath)) {
const content = await fs.readFile(csvPath, 'utf8');
const records = csv.parse(content, {
columns: true,
skip_empty_lines: true,
});
for (const record of records) {
existingEntries.set(`${record.module}:${record.name}`, record);
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 csvContent = 'name,displayName,description,module,path,standalone\n';
let csv = 'name,displayName,description,module,path,standalone\n';
// Combine existing and new tasks
const allTasks = new Map();
@ -887,30 +866,15 @@ class ManifestGenerator {
// Add/update new tasks
for (const task of this.tasks) {
const key = `${task.module}:${task.name}`;
allTasks.set(key, {
name: task.name,
displayName: task.displayName,
description: task.description,
module: task.module,
path: task.path,
standalone: task.standalone,
});
allTasks.set(key, `"${task.name}","${task.displayName}","${task.description}","${task.module}","${task.path}","${task.standalone}"`);
}
// Write all tasks
for (const [, record] of allTasks) {
const row = [
escapeCsv(record.name),
escapeCsv(record.displayName),
escapeCsv(record.description),
escapeCsv(record.module),
escapeCsv(record.path),
escapeCsv(record.standalone),
].join(',');
csvContent += row + '\n';
for (const [, value] of allTasks) {
csv += value + '\n';
}
await fs.writeFile(csvPath, csvContent);
await fs.writeFile(csvPath, csv);
return csvPath;
}
@ -920,23 +884,30 @@ class ManifestGenerator {
*/
async writeToolManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'tool-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
// Read existing manifest to preserve entries
const existingEntries = new Map();
if (await fs.pathExists(csvPath)) {
const content = await fs.readFile(csvPath, 'utf8');
const records = csv.parse(content, {
columns: true,
skip_empty_lines: true,
});
for (const record of records) {
existingEntries.set(`${record.module}:${record.name}`, record);
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 csvContent = 'name,displayName,description,module,path,standalone\n';
let csv = 'name,displayName,description,module,path,standalone\n';
// Combine existing and new tools
const allTools = new Map();
@ -949,30 +920,15 @@ class ManifestGenerator {
// Add/update new tools
for (const tool of this.tools) {
const key = `${tool.module}:${tool.name}`;
allTools.set(key, {
name: tool.name,
displayName: tool.displayName,
description: tool.description,
module: tool.module,
path: tool.path,
standalone: tool.standalone,
});
allTools.set(key, `"${tool.name}","${tool.displayName}","${tool.description}","${tool.module}","${tool.path}","${tool.standalone}"`);
}
// Write all tools
for (const [, record] of allTools) {
const row = [
escapeCsv(record.name),
escapeCsv(record.displayName),
escapeCsv(record.description),
escapeCsv(record.module),
escapeCsv(record.path),
escapeCsv(record.standalone),
].join(',');
csvContent += row + '\n';
for (const [, value] of allTools) {
csv += value + '\n';
}
await fs.writeFile(csvPath, csvContent);
await fs.writeFile(csvPath, csv);
return csvPath;
}

View File

@ -297,7 +297,7 @@ class CustomHandler {
const agentFiles = await this.findFilesRecursively(sourceAgentsPath, ['.agent.yaml']);
for (const agentFile of agentFiles) {
const relativePath = path.relative(sourceAgentsPath, agentFile).split(path.sep).join('/');
const relativePath = path.relative(sourceAgentsPath, agentFile);
const targetDir = path.join(targetAgentsPath, path.dirname(relativePath));
await fs.ensureDir(targetDir);

View File

@ -3,7 +3,6 @@ const fs = require('fs-extra');
const chalk = require('chalk');
const { XmlHandler } = require('../../../lib/xml-handler');
const { getSourcePath } = require('../../../lib/project-root');
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
/**
* Base class for IDE-specific setup
@ -19,7 +18,7 @@ class BaseIdeSetup {
this.configFile = null; // Override in subclasses when detection is file-based
this.detectionPaths = []; // Additional paths that indicate the IDE is configured
this.xmlHandler = new XmlHandler();
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
this.bmadFolderName = 'bmad'; // Default, can be overridden
}
/**
@ -58,7 +57,7 @@ class BaseIdeSetup {
if (this.configDir) {
const configPath = path.join(projectDir, this.configDir);
if (await fs.pathExists(configPath)) {
const bmadRulesPath = path.join(configPath, BMAD_FOLDER_NAME);
const bmadRulesPath = path.join(configPath, 'bmad');
if (await fs.pathExists(bmadRulesPath)) {
await fs.remove(bmadRulesPath);
console.log(chalk.dim(`Removed ${this.name} BMAD configuration`));
@ -446,11 +445,6 @@ class BaseIdeSetup {
try {
const content = await fs.readFile(fullPath, 'utf8');
// Skip internal/engine files (not user-facing tasks/tools)
if (content.includes('internal="true"')) {
continue;
}
// Check for standalone="true" in XML files
if (entry.name.endsWith('.xml')) {
// Look for standalone="true" in the opening tag (task or tool)

View File

@ -66,13 +66,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
*/
async installToTarget(projectDir, bmadDir, config, options) {
const { target_dir, template_type, artifact_types } = config;
// Skip targets with explicitly empty artifact_types array
// This prevents creating empty directories when no artifacts will be written
if (Array.isArray(artifact_types) && artifact_types.length === 0) {
return { success: true, results: { agents: 0, workflows: 0, tasks: 0, tools: 0 } };
}
const targetPath = path.join(projectDir, target_dir);
await this.ensureDir(targetPath);
@ -93,11 +86,10 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
results.workflows = await this.writeWorkflowArtifacts(targetPath, artifacts, template_type, config);
}
// Install tasks and tools using template system (supports TOML for Gemini, MD for others)
// Install tasks and tools
if (!artifact_types || artifact_types.includes('tasks') || artifact_types.includes('tools')) {
const taskToolGen = new TaskToolCommandGenerator(this.bmadFolderName);
const { artifacts } = await taskToolGen.collectTaskToolArtifacts(bmadDir);
const taskToolResult = await this.writeTaskToolArtifacts(targetPath, artifacts, template_type, config);
const taskToolGen = new TaskToolCommandGenerator();
const taskToolResult = await taskToolGen.generateDashTaskToolCommands(projectDir, bmadDir, targetPath);
results.tasks = taskToolResult.tasks || 0;
results.tools = taskToolResult.tools || 0;
}
@ -188,53 +180,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
return count;
}
/**
* Write task/tool artifacts to target directory using templates
* @param {string} targetPath - Target directory path
* @param {Array} artifacts - Task/tool artifacts
* @param {string} templateType - Template type to use
* @param {Object} config - Installation configuration
* @returns {Promise<Object>} Counts of tasks and tools written
*/
async writeTaskToolArtifacts(targetPath, artifacts, templateType, config = {}) {
let taskCount = 0;
let toolCount = 0;
// Pre-load templates to avoid repeated file I/O in the loop
const taskTemplate = await this.loadTemplate(templateType, 'task', config, 'default-task');
const toolTemplate = await this.loadTemplate(templateType, 'tool', config, 'default-tool');
const { artifact_types } = config;
for (const artifact of artifacts) {
if (artifact.type !== 'task' && artifact.type !== 'tool') {
continue;
}
// Skip if the specific artifact type is not requested in config
if (artifact_types) {
if (artifact.type === 'task' && !artifact_types.includes('tasks')) continue;
if (artifact.type === 'tool' && !artifact_types.includes('tools')) continue;
}
// Use pre-loaded template based on artifact type
const { content: template, extension } = artifact.type === 'task' ? taskTemplate : toolTemplate;
const content = this.renderTemplate(template, artifact);
const filename = this.generateFilename(artifact, artifact.type, extension);
const filePath = path.join(targetPath, filename);
await this.writeFile(filePath, content);
if (artifact.type === 'task') {
taskCount++;
} else {
toolCount++;
}
}
return { tasks: taskCount, tools: toolCount };
}
/**
* Load template based on type and configuration
* @param {string} templateType - Template type (claude, windsurf, etc.)
@ -371,24 +316,10 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
renderTemplate(template, artifact) {
// Use the appropriate path property based on artifact type
let pathToUse = artifact.relativePath || '';
switch (artifact.type) {
case 'agent-launcher': {
if (artifact.type === 'agent-launcher') {
pathToUse = artifact.agentPath || artifact.relativePath || '';
break;
}
case 'workflow-command': {
} else if (artifact.type === 'workflow-command') {
pathToUse = artifact.workflowPath || artifact.relativePath || '';
break;
}
case 'task':
case 'tool': {
pathToUse = artifact.path || artifact.relativePath || '';
break;
}
// No default
}
let rendered = template
@ -420,9 +351,8 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
// Reuse central logic to ensure consistent naming conventions
const standardName = toDashPath(artifact.relativePath);
// Clean up potential double extensions from source files (e.g. .yaml.md, .xml.md -> .md)
// This handles any extensions that might slip through toDashPath()
const baseName = standardName.replace(/\.(md|yaml|yml|json|xml|toml)\.md$/i, '.md');
// Clean up potential double extensions from source files (e.g. .yaml.md -> .md)
const baseName = standardName.replace(/\.(yaml|yml)\.md$/, '.md');
// If using default markdown, preserve the bmad-agent- prefix for agents
if (extension === '.md') {

View File

@ -104,10 +104,7 @@ class CodexSetup extends BaseIdeSetup {
);
taskArtifacts.push({
type: 'task',
name: task.name,
displayName: task.name,
module: task.module,
path: task.path,
sourcePath: task.path,
relativePath: path.join(task.module, 'tasks', `${task.name}.md`),
content,
@ -119,7 +116,7 @@ class CodexSetup extends BaseIdeSetup {
const workflowCount = await workflowGenerator.writeDashArtifacts(destDir, workflowArtifacts);
// Also write tasks using underscore format
const ttGen = new TaskToolCommandGenerator(this.bmadFolderName);
const ttGen = new TaskToolCommandGenerator();
const tasksWritten = await ttGen.writeDashArtifacts(destDir, taskArtifacts);
const written = agentCount + workflowCount + tasksWritten;
@ -217,10 +214,7 @@ class CodexSetup extends BaseIdeSetup {
artifacts.push({
type: 'task',
name: task.name,
displayName: task.name,
module: task.module,
path: task.path,
sourcePath: task.path,
relativePath: path.join(task.module, 'tasks', `${task.name}.md`),
content,

View File

@ -1,7 +1,6 @@
const fs = require('fs-extra');
const path = require('node:path');
const chalk = require('chalk');
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
/**
* IDE Manager - handles IDE-specific setup
@ -15,7 +14,7 @@ class IdeManager {
constructor() {
this.handlers = new Map();
this._initialized = false;
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
this.bmadFolderName = 'bmad'; // Default, can be overridden
}
/**
@ -74,9 +73,6 @@ class IdeManager {
if (HandlerClass) {
const instance = new HandlerClass();
if (instance.name && typeof instance.name === 'string') {
if (typeof instance.setBmadFolderName === 'function') {
instance.setBmadFolderName(this.bmadFolderName);
}
this.handlers.set(instance.name, instance);
}
}
@ -104,9 +100,7 @@ class IdeManager {
if (!platformInfo.installer) continue;
const handler = new ConfigDrivenIdeSetup(platformCode, platformInfo);
if (typeof handler.setBmadFolderName === 'function') {
handler.setBmadFolderName(this.bmadFolderName);
}
this.handlers.set(platformCode, handler);
}
}

View File

@ -94,6 +94,9 @@ platforms:
- target_dir: .github/agents
template_type: copilot_agents
artifact_types: [agents]
- target_dir: .vscode
template_type: vscode_settings
artifact_types: []
iflow:
name: "iFlow"

View File

@ -1,14 +1,14 @@
const path = require('node:path');
const fs = require('fs-extra');
const chalk = require('chalk');
const { toColonPath, toDashPath, customAgentColonName, customAgentDashName, BMAD_FOLDER_NAME } = require('./path-utils');
const { toColonPath, toDashPath, customAgentColonName, customAgentDashName } = require('./path-utils');
/**
* Generates launcher command files for each agent
* Similar to WorkflowCommandGenerator but for agents
*/
class AgentCommandGenerator {
constructor(bmadFolderName = BMAD_FOLDER_NAME) {
constructor(bmadFolderName = 'bmad') {
this.templatePath = path.join(__dirname, '../templates/agent-command-template.md');
this.bmadFolderName = bmadFolderName;
}

View File

@ -141,24 +141,13 @@ async function getTasksFromDir(dirPath, moduleName) {
const files = await fs.readdir(dirPath);
for (const file of files) {
// Include both .md and .xml task files
if (!file.endsWith('.md') && !file.endsWith('.xml')) {
if (!file.endsWith('.md')) {
continue;
}
const filePath = path.join(dirPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Skip internal/engine files (not user-facing tasks)
if (content.includes('internal="true"')) {
continue;
}
// Remove extension to get task name
const ext = file.endsWith('.xml') ? '.xml' : '.md';
tasks.push({
path: filePath,
name: file.replace(ext, ''),
path: path.join(dirPath, file),
name: file.replace('.md', ''),
module: moduleName,
});
}

View File

@ -18,9 +18,6 @@
const TYPE_SEGMENTS = ['workflows', 'tasks', 'tools'];
const AGENT_SEGMENT = 'agents';
// BMAD installation folder name - centralized constant for all installers
const BMAD_FOLDER_NAME = '_bmad';
/**
* Convert hierarchical path to flat dash-separated name (NEW STANDARD)
* Converts: 'bmm', 'agents', 'pm' 'bmad-agent-bmm-pm.md'
@ -62,9 +59,7 @@ function toDashPath(relativePath) {
return 'bmad-unknown.md';
}
// Strip common file extensions to avoid double extensions in generated filenames
// e.g., 'create-story.xml' → 'create-story', 'workflow.yaml' → 'workflow'
const withoutExt = relativePath.replace(/\.(md|yaml|yml|json|xml|toml)$/i, '');
const withoutExt = relativePath.replace('.md', '');
const parts = withoutExt.split(/[/\\]/);
const module = parts[0];
@ -188,8 +183,7 @@ function toUnderscoreName(module, type, name) {
* @deprecated Use toDashPath instead
*/
function toUnderscorePath(relativePath) {
// Strip common file extensions (same as toDashPath for consistency)
const withoutExt = relativePath.replace(/\.(md|yaml|yml|json|xml|toml)$/i, '');
const withoutExt = relativePath.replace('.md', '');
const parts = withoutExt.split(/[/\\]/);
const module = parts[0];
@ -295,5 +289,4 @@ module.exports = {
TYPE_SEGMENTS,
AGENT_SEGMENT,
BMAD_FOLDER_NAME,
};

View File

@ -2,98 +2,12 @@ const path = require('node:path');
const fs = require('fs-extra');
const csv = require('csv-parse/sync');
const chalk = require('chalk');
const { toColonName, toColonPath, toDashPath, BMAD_FOLDER_NAME } = require('./path-utils');
const { toColonName, toColonPath, toDashPath } = require('./path-utils');
/**
* Generates command files for standalone tasks and tools
*/
class TaskToolCommandGenerator {
/**
* @param {string} bmadFolderName - Name of the BMAD folder for template rendering (default: '_bmad')
* Note: This parameter is accepted for API consistency with AgentCommandGenerator and
* WorkflowCommandGenerator, but is not used for path stripping. The manifest always stores
* filesystem paths with '_bmad/' prefix (the actual folder name), while bmadFolderName is
* used for template placeholder rendering ({{bmadFolderName}}).
*/
constructor(bmadFolderName = BMAD_FOLDER_NAME) {
this.bmadFolderName = bmadFolderName;
}
/**
* Collect task and tool artifacts for IDE installation
* @param {string} bmadDir - BMAD installation directory
* @returns {Promise<Object>} Artifacts array with metadata
*/
async collectTaskToolArtifacts(bmadDir) {
const tasks = await this.loadTaskManifest(bmadDir);
const tools = await this.loadToolManifest(bmadDir);
// Filter to only standalone items
const standaloneTasks = tasks ? tasks.filter((t) => t.standalone === 'true' || t.standalone === true) : [];
const standaloneTools = tools ? tools.filter((t) => t.standalone === 'true' || t.standalone === true) : [];
const artifacts = [];
const bmadPrefix = `${BMAD_FOLDER_NAME}/`;
// Collect task artifacts
for (const task of standaloneTasks) {
let taskPath = (task.path || '').replaceAll('\\', '/');
// Convert absolute paths to relative paths
if (path.isAbsolute(taskPath)) {
taskPath = path.relative(bmadDir, taskPath).replaceAll('\\', '/');
}
// Remove _bmad/ prefix if present to get relative path within bmad folder
if (taskPath.startsWith(bmadPrefix)) {
taskPath = taskPath.slice(bmadPrefix.length);
}
const taskExt = path.extname(taskPath) || '.md';
artifacts.push({
type: 'task',
name: task.name,
displayName: task.displayName || task.name,
description: task.description || `Execute ${task.displayName || task.name}`,
module: task.module,
// Use forward slashes for cross-platform consistency (not path.join which uses backslashes on Windows)
relativePath: `${task.module}/tasks/${task.name}${taskExt}`,
path: taskPath,
});
}
// Collect tool artifacts
for (const tool of standaloneTools) {
let toolPath = (tool.path || '').replaceAll('\\', '/');
// Convert absolute paths to relative paths
if (path.isAbsolute(toolPath)) {
toolPath = path.relative(bmadDir, toolPath).replaceAll('\\', '/');
}
// Remove _bmad/ prefix if present to get relative path within bmad folder
if (toolPath.startsWith(bmadPrefix)) {
toolPath = toolPath.slice(bmadPrefix.length);
}
const toolExt = path.extname(toolPath) || '.md';
artifacts.push({
type: 'tool',
name: tool.name,
displayName: tool.displayName || tool.name,
description: tool.description || `Execute ${tool.displayName || tool.name}`,
module: tool.module,
// Use forward slashes for cross-platform consistency (not path.join which uses backslashes on Windows)
relativePath: `${tool.module}/tools/${tool.name}${toolExt}`,
path: toolPath,
});
}
return {
artifacts,
counts: {
tasks: standaloneTasks.length,
tools: standaloneTools.length,
},
};
}
/**
* Generate task and tool commands from manifest CSVs
* @param {string} projectDir - Project directory
@ -151,35 +65,9 @@ class TaskToolCommandGenerator {
const description = item.description || `Execute ${item.displayName || item.name}`;
// Convert path to use {project-root} placeholder
// Handle undefined/missing path by constructing from module and name
let itemPath = item.path;
if (!itemPath || typeof itemPath !== 'string') {
// Fallback: construct path from module and name if path is missing
const typePlural = type === 'task' ? 'tasks' : 'tools';
itemPath = `{project-root}/${this.bmadFolderName}/${item.module}/${typePlural}/${item.name}.md`;
} else {
// Normalize path separators to forward slashes
itemPath = itemPath.replaceAll('\\', '/');
// Extract relative path from absolute paths (Windows or Unix)
// Look for _bmad/ or bmad/ in the path and extract everything after it
// Match patterns like: /_bmad/core/tasks/... or /bmad/core/tasks/...
// Use [/\\] to handle both Unix forward slashes and Windows backslashes,
// and also paths without a leading separator (e.g., C:/_bmad/...)
const bmadMatch = itemPath.match(/[/\\]_bmad[/\\](.+)$/) || itemPath.match(/[/\\]bmad[/\\](.+)$/);
if (bmadMatch) {
// Found /_bmad/ or /bmad/ - use relative path after it
itemPath = `{project-root}/${this.bmadFolderName}/${bmadMatch[1]}`;
} else if (itemPath.startsWith(`${BMAD_FOLDER_NAME}/`)) {
// Relative path starting with _bmad/
itemPath = `{project-root}/${this.bmadFolderName}/${itemPath.slice(BMAD_FOLDER_NAME.length + 1)}`;
} else if (itemPath.startsWith('bmad/')) {
// Relative path starting with bmad/
itemPath = `{project-root}/${this.bmadFolderName}/${itemPath.slice(5)}`;
} else if (!itemPath.startsWith('{project-root}')) {
// For other relative paths, prefix with project root and bmad folder
itemPath = `{project-root}/${this.bmadFolderName}/${itemPath}`;
}
if (itemPath && typeof itemPath === 'string' && itemPath.startsWith('bmad/')) {
itemPath = `{project-root}/${itemPath}`;
}
return `---
@ -299,7 +187,7 @@ Follow all instructions in the ${type} file exactly as written.
// Generate command files for tasks
for (const task of standaloneTasks) {
const commandContent = this.generateCommandContent(task, 'task');
// Use dash format: bmad-bmm-name.md
// Use underscore format: bmad_bmm_name.md
const flatName = toDashPath(`${task.module}/tasks/${task.name}.md`);
const commandPath = path.join(baseCommandsDir, flatName);
await fs.ensureDir(path.dirname(commandPath));
@ -310,7 +198,7 @@ Follow all instructions in the ${type} file exactly as written.
// Generate command files for tools
for (const tool of standaloneTools) {
const commandContent = this.generateCommandContent(tool, 'tool');
// Use dash format: bmad-bmm-name.md
// Use underscore format: bmad_bmm_name.md
const flatName = toDashPath(`${tool.module}/tools/${tool.name}.md`);
const commandPath = path.join(baseCommandsDir, flatName);
await fs.ensureDir(path.dirname(commandPath));

View File

@ -2,13 +2,13 @@ const path = require('node:path');
const fs = require('fs-extra');
const csv = require('csv-parse/sync');
const chalk = require('chalk');
const { toColonPath, toDashPath, customAgentColonName, customAgentDashName, BMAD_FOLDER_NAME } = require('./path-utils');
const { toColonPath, toDashPath, customAgentColonName, customAgentDashName } = require('./path-utils');
/**
* Generates command files for each workflow in the manifest
*/
class WorkflowCommandGenerator {
constructor(bmadFolderName = BMAD_FOLDER_NAME) {
constructor(bmadFolderName = 'bmad') {
this.templatePath = path.join(__dirname, '../templates/workflow-command-template.md');
this.bmadFolderName = bmadFolderName;
}

View File

@ -1,10 +0,0 @@
---
name: '{{name}}'
description: '{{description}}'
---
# {{name}}
Read the entire task file at: {project-root}/{{bmadFolderName}}/{{path}}
Follow all instructions in the task file exactly as written.

View File

@ -1,10 +0,0 @@
---
name: '{{name}}'
description: '{{description}}'
---
# {{name}}
Read the entire tool file at: {project-root}/{{bmadFolderName}}/{{path}}
Follow all instructions in the tool file exactly as written.

View File

@ -1,11 +0,0 @@
description = "Executes the {{name}} task from the BMAD Method."
prompt = """
Execute the BMAD '{{name}}' task.
TASK INSTRUCTIONS:
1. LOAD the task file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents
3. FOLLOW every instruction precisely as specified
TASK FILE: {project-root}/{{bmadFolderName}}/{{path}}
"""

View File

@ -1,11 +0,0 @@
description = "Executes the {{name}} tool from the BMAD Method."
prompt = """
Execute the BMAD '{{name}}' tool.
TOOL INSTRUCTIONS:
1. LOAD the tool file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents
3. FOLLOW every instruction precisely as specified
TOOL FILE: {project-root}/{{bmadFolderName}}/{{path}}
"""

View File

@ -7,7 +7,6 @@ const { XmlHandler } = require('../../../lib/xml-handler');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { filterCustomizationData } = require('../../../lib/agent/compiler');
const { ExternalModuleManager } = require('./external-manager');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
/**
* Manages the installation, updating, and removal of BMAD modules.
@ -28,7 +27,7 @@ const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
class ModuleManager {
constructor(options = {}) {
this.xmlHandler = new XmlHandler();
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
this.bmadFolderName = 'bmad'; // Default, can be overridden
this.customModulePaths = new Map(); // Initialize custom module paths
this.externalModuleManager = new ExternalModuleManager(); // For external official modules
}
@ -871,7 +870,7 @@ class ModuleManager {
for (const agentFile of agentFiles) {
if (!agentFile.endsWith('.agent.yaml')) continue;
const relativePath = path.relative(sourceAgentsPath, agentFile).split(path.sep).join('/');
const relativePath = path.relative(sourceAgentsPath, agentFile);
const targetDir = path.join(targetAgentsPath, path.dirname(relativePath));
await fs.ensureDir(targetDir);

View File

@ -42,7 +42,7 @@ function findBmadConfig(startPath = process.cwd()) {
* @returns {string} Resolved path
*/
function resolvePath(pathStr, context) {
return pathStr.replaceAll('{project-root}', context.projectRoot).replaceAll('{bmad-folder}', context.bmadFolder);
return pathStr.replaceAll('{project-root}', context.projectRoot).replaceAll('{bmad-folder}', context_bmadFolder);
}
/**

View File

@ -8,8 +8,6 @@
*/
let _clack = null;
let _clackCore = null;
let _picocolors = null;
/**
* Lazy-load @clack/prompts (ESM module)
@ -22,28 +20,6 @@ async function getClack() {
return _clack;
}
/**
* Lazy-load @clack/core (ESM module)
* @returns {Promise<Object>} The clack core module
*/
async function getClackCore() {
if (!_clackCore) {
_clackCore = await import('@clack/core');
}
return _clackCore;
}
/**
* Lazy-load picocolors
* @returns {Promise<Object>} The picocolors module
*/
async function getPicocolors() {
if (!_picocolors) {
_picocolors = (await import('picocolors')).default;
}
return _picocolors;
}
/**
* Handle user cancellation gracefully
* @param {any} value - The value to check
@ -215,118 +191,6 @@ async function groupMultiselect(options) {
return result;
}
/**
* Default filter function for autocomplete - case-insensitive label matching
* @param {string} search - Search string
* @param {Object} option - Option object with label
* @returns {boolean} Whether the option matches
*/
function defaultAutocompleteFilter(search, option) {
const label = option.label ?? String(option.value ?? '');
return label.toLowerCase().includes(search.toLowerCase());
}
/**
* Autocomplete multi-select prompt with type-ahead filtering
* Custom implementation that always shows "Space/Tab:" in the hint
* @param {Object} options - Prompt options
* @param {string} options.message - The question to ask
* @param {Array} options.options - Array of choices [{label, value, hint?}]
* @param {string} [options.placeholder] - Placeholder text for search input
* @param {Array} [options.initialValues] - Array of initially selected values
* @param {boolean} [options.required=false] - Whether at least one must be selected
* @param {number} [options.maxItems=5] - Maximum visible items in scrollable list
* @param {Function} [options.filter] - Custom filter function (search, option) => boolean
* @returns {Promise<Array>} Array of selected values
*/
async function autocompleteMultiselect(options) {
const core = await getClackCore();
const clack = await getClack();
const color = await getPicocolors();
const filterFn = options.filter ?? defaultAutocompleteFilter;
const prompt = new core.AutocompletePrompt({
options: options.options,
multiple: true,
filter: filterFn,
validate: () => {
if (options.required && prompt.selectedValues.length === 0) {
return 'Please select at least one item';
}
},
initialValue: options.initialValues,
render() {
const barColor = this.state === 'error' ? color.yellow : color.cyan;
const bar = barColor(clack.S_BAR);
const barEnd = barColor(clack.S_BAR_END);
const title = `${color.gray(clack.S_BAR)}\n${clack.symbol(this.state)} ${options.message}\n`;
const userInput = this.userInput;
const placeholder = options.placeholder || 'Type to search...';
const hasPlaceholder = userInput === '' && placeholder !== undefined;
// Show placeholder or user input with cursor
const searchDisplay =
this.isNavigating || hasPlaceholder ? color.dim(hasPlaceholder ? placeholder : userInput) : this.userInputWithCursor;
const allOptions = this.options;
const matchCount =
this.filteredOptions.length === allOptions.length
? ''
: color.dim(` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`);
// Render option with checkbox
const renderOption = (opt, isHighlighted) => {
const isSelected = this.selectedValues.includes(opt.value);
const label = opt.label ?? String(opt.value ?? '');
const hintText = opt.hint && opt.value === this.focusedValue ? color.dim(` (${opt.hint})`) : '';
const checkbox = isSelected ? color.green(clack.S_CHECKBOX_SELECTED) : color.dim(clack.S_CHECKBOX_INACTIVE);
return isHighlighted ? `${checkbox} ${label}${hintText}` : `${checkbox} ${color.dim(label)}`;
};
switch (this.state) {
case 'submit': {
return `${title}${color.gray(clack.S_BAR)} ${color.dim(`${this.selectedValues.length} items selected`)}`;
}
case 'cancel': {
return `${title}${color.gray(clack.S_BAR)} ${color.strikethrough(color.dim(userInput))}`;
}
default: {
// Always show "SPACE:" regardless of isNavigating state
const hints = [`${color.dim('↑/↓')} to navigate`, `${color.dim('TAB/SPACE:')} select`, `${color.dim('ENTER:')} confirm`];
const noMatchesLine = this.filteredOptions.length === 0 && userInput ? [`${bar} ${color.yellow('No matches found')}`] : [];
const errorLine = this.state === 'error' ? [`${bar} ${color.yellow(this.error)}`] : [];
const headerLines = [...`${title}${bar}`.split('\n'), `${bar} ${searchDisplay}${matchCount}`, ...noMatchesLine, ...errorLine];
const footerLines = [`${bar} ${color.dim(hints.join(' • '))}`, `${barEnd}`];
const optionLines = clack.limitOptions({
cursor: this.cursor,
options: this.filteredOptions,
style: renderOption,
maxItems: options.maxItems || 5,
output: options.output,
rowPadding: headerLines.length + footerLines.length,
});
return [...headerLines, ...optionLines.map((line) => `${bar} ${line}`), ...footerLines].join('\n');
}
}
},
});
const result = await prompt.prompt();
await handleCancel(result);
return result;
}
/**
* Confirm prompt (replaces Inquirer 'confirm' type)
* @param {Object} options - Prompt options
@ -347,12 +211,7 @@ async function confirm(options) {
}
/**
* Text input prompt with Tab-to-fill-placeholder support (replaces Inquirer 'input' type)
*
* This custom implementation restores the Tab-to-fill-placeholder behavior that was
* intentionally removed in @clack/prompts v1.0.0 (placeholder became purely visual).
* Uses @clack/core's TextPrompt primitive with custom key handling.
*
* Text input prompt (replaces Inquirer 'input' type)
* @param {Object} options - Prompt options
* @param {string} options.message - The question to ask
* @param {string} [options.default] - Default value
@ -361,64 +220,20 @@ async function confirm(options) {
* @returns {Promise<string>} User's input
*/
async function text(options) {
const core = await getClackCore();
const color = await getPicocolors();
const clack = await getClack();
// Use default as placeholder if placeholder not explicitly provided
// This shows the default value as grayed-out hint text
const placeholder = options.placeholder === undefined ? options.default : options.placeholder;
const defaultValue = options.default;
const prompt = new core.TextPrompt({
defaultValue,
const result = await clack.text({
message: options.message,
defaultValue: options.default,
placeholder: typeof placeholder === 'string' ? placeholder : undefined,
validate: options.validate,
render() {
const title = `${color.gray('◆')} ${options.message}`;
// Show placeholder as dim text when input is empty
let valueDisplay;
if (this.state === 'error') {
valueDisplay = color.yellow(this.userInputWithCursor);
} else if (this.userInput) {
valueDisplay = this.userInputWithCursor;
} else if (placeholder) {
// Show placeholder with cursor indicator when empty
valueDisplay = `${color.inverse(color.hidden('_'))}${color.dim(placeholder)}`;
} else {
valueDisplay = color.inverse(color.hidden('_'));
}
const bar = color.gray('│');
// Handle different states
if (this.state === 'submit') {
return `${color.gray('◇')} ${options.message}\n${bar} ${color.dim(this.value || defaultValue || '')}`;
}
if (this.state === 'cancel') {
return `${color.gray('◇')} ${options.message}\n${bar} ${color.strikethrough(color.dim(this.userInput || ''))}`;
}
if (this.state === 'error') {
return `${color.yellow('▲')} ${options.message}\n${bar} ${valueDisplay}\n${color.yellow('│')} ${color.yellow(this.error)}`;
}
return `${title}\n${bar} ${valueDisplay}\n${bar}`;
},
});
// Add Tab key handler to fill placeholder into input
prompt.on('key', (char) => {
if (char === '\t' && placeholder && !prompt.userInput) {
// Use _setUserInput with write=true to populate the readline and update internal state
prompt._setUserInput(placeholder, true);
}
});
const result = await prompt.prompt();
await handleCancel(result);
// TextPrompt's finalize handler already applies defaultValue for empty input
return result;
}
@ -608,7 +423,6 @@ module.exports = {
select,
multiselect,
groupMultiselect,
autocompleteMultiselect,
confirm,
text,
password,

View File

@ -344,9 +344,6 @@ class UI {
/**
* Prompt for tool/IDE selection (called after module configuration)
* Uses a split prompt approach:
* 1. Recommended tools - standard multiselect for 3 preferred tools
* 2. Additional tools - autocompleteMultiselect with search capability
* @param {string} projectDir - Project directory to check for existing IDEs
* @returns {Object} Tool configuration
*/
@ -369,123 +366,95 @@ class UI {
const preferredIdes = ideManager.getPreferredIdes();
const otherIdes = ideManager.getOtherIdes();
// Determine which configured IDEs are in "preferred" vs "other" categories
const configuredPreferred = configuredIdes.filter((id) => preferredIdes.some((ide) => ide.value === id));
const configuredOther = configuredIdes.filter((id) => otherIdes.some((ide) => ide.value === id));
// Build grouped options object for groupMultiselect
const groupedOptions = {};
const processedIdes = new Set();
const initialValues = [];
// Warn about previously configured tools that are no longer available
const allKnownValues = new Set([...preferredIdes, ...otherIdes].map((ide) => ide.value));
const unknownTools = configuredIdes.filter((id) => id && typeof id === 'string' && !allKnownValues.has(id));
if (unknownTools.length > 0) {
console.log(chalk.yellow(`⚠️ Previously configured tools are no longer available: ${unknownTools.join(', ')}`));
}
// ─────────────────────────────────────────────────────────────────────────────
// UPGRADE PATH: If tools already configured, show all tools with configured at top
// ─────────────────────────────────────────────────────────────────────────────
// First, add previously configured IDEs, marked with ✅
if (configuredIdes.length > 0) {
const allTools = [...preferredIdes, ...otherIdes];
// Sort: configured tools first, then preferred, then others
const sortedTools = [
...allTools.filter((ide) => configuredIdes.includes(ide.value)),
...allTools.filter((ide) => !configuredIdes.includes(ide.value)),
];
const upgradeOptions = sortedTools.map((ide) => {
const isConfigured = configuredIdes.includes(ide.value);
const isPreferred = preferredIdes.some((p) => p.value === ide.value);
let label = ide.name;
if (isPreferred) label += ' ⭐';
if (isConfigured) label += ' ✅';
return { label, value: ide.value };
});
// Sort initialValues to match display order
const sortedInitialValues = sortedTools.filter((ide) => configuredIdes.includes(ide.value)).map((ide) => ide.value);
const upgradeSelected = await prompts.autocompleteMultiselect({
message: 'Integrate with',
options: upgradeOptions,
initialValues: sortedInitialValues,
required: false,
maxItems: 8,
});
const selectedIdes = upgradeSelected || [];
if (selectedIdes.length === 0) {
console.log('');
const confirmNoTools = await prompts.confirm({
message: 'No tools selected. Continue without installing any tools?',
default: false,
});
if (!confirmNoTools) {
return this.promptToolSelection(projectDir);
const configuredGroup = [];
for (const ideValue of configuredIdes) {
// Skip empty or invalid IDE values
if (!ideValue || typeof ideValue !== 'string') {
continue;
}
return { ides: [], skipIde: true };
// Find the IDE in either preferred or other lists
const preferredIde = preferredIdes.find((ide) => ide.value === ideValue);
const otherIde = otherIdes.find((ide) => ide.value === ideValue);
const ide = preferredIde || otherIde;
if (ide) {
configuredGroup.push({
label: `${ide.name}`,
value: ide.value,
});
processedIdes.add(ide.value);
initialValues.push(ide.value); // Pre-select configured IDEs
} else {
// Warn about unrecognized IDE (but don't fail)
console.log(chalk.yellow(`⚠️ Previously configured IDE '${ideValue}' is no longer available`));
}
}
if (configuredGroup.length > 0) {
groupedOptions['Previously Configured'] = configuredGroup;
}
}
// Display selected tools
this.displaySelectedTools(selectedIdes, preferredIdes, allTools);
return { ides: selectedIdes, skipIde: false };
}
// ─────────────────────────────────────────────────────────────────────────────
// NEW INSTALL: Show all tools with search
// ─────────────────────────────────────────────────────────────────────────────
const allTools = [...preferredIdes, ...otherIdes];
const allToolOptions = allTools.map((ide) => {
const isPreferred = preferredIdes.some((p) => p.value === ide.value);
let label = ide.name;
if (isPreferred) label += ' ⭐';
// Add preferred tools (excluding already processed)
const remainingPreferred = preferredIdes.filter((ide) => !processedIdes.has(ide.value));
if (remainingPreferred.length > 0) {
groupedOptions['Recommended Tools'] = remainingPreferred.map((ide) => {
processedIdes.add(ide.value);
return {
label,
label: `${ide.name}`,
value: ide.value,
};
});
}
const selectedIdes = await prompts.autocompleteMultiselect({
message: 'Select tools:',
options: allToolOptions,
initialValues: configuredIdes.length > 0 ? configuredIdes : undefined,
required: false,
maxItems: 8,
// Add other tools (excluding already processed)
const remainingOther = otherIdes.filter((ide) => !processedIdes.has(ide.value));
if (remainingOther.length > 0) {
groupedOptions['Additional Tools'] = remainingOther.map((ide) => ({
label: ide.name,
value: ide.value,
}));
}
// Add standalone "None" option at the end
groupedOptions[' '] = [
{
label: '⚠ None - I am not installing any tools',
value: '__NONE__',
},
];
let selectedIdes = [];
selectedIdes = await prompts.groupMultiselect({
message: `Select tools to configure ${chalk.dim('(↑/↓ navigates, SPACE toggles, ENTER to confirm)')}:`,
options: groupedOptions,
initialValues: initialValues.length > 0 ? initialValues : undefined,
required: true,
selectableGroups: false,
});
const allSelectedIdes = selectedIdes || [];
// ─────────────────────────────────────────────────────────────────────────────
// STEP 3: Confirm if no tools selected
// ─────────────────────────────────────────────────────────────────────────────
if (allSelectedIdes.length === 0) {
console.log('');
const confirmNoTools = await prompts.confirm({
message: 'No tools selected. Continue without installing any tools?',
default: false,
});
if (!confirmNoTools) {
// User wants to select tools - recurse
return this.promptToolSelection(projectDir);
// If user selected both "__NONE__" and other tools, honor the "None" choice
if (selectedIdes && selectedIdes.includes('__NONE__') && selectedIdes.length > 1) {
console.log();
console.log(chalk.yellow('⚠️ "None - I am not installing any tools" was selected, so no tools will be configured.'));
console.log();
selectedIdes = [];
} else if (selectedIdes && selectedIdes.includes('__NONE__')) {
// Only "__NONE__" was selected
selectedIdes = [];
}
return {
ides: [],
skipIde: true,
};
}
// Display selected tools
this.displaySelectedTools(allSelectedIdes, preferredIdes, allTools);
return {
ides: allSelectedIdes,
skipIde: allSelectedIdes.length === 0,
ides: selectedIdes || [],
skipIde: !selectedIdes || selectedIdes.length === 0,
};
}
@ -1686,27 +1655,6 @@ class UI {
console.log('');
}
/**
* Display list of selected tools after IDE selection
* @param {Array} selectedIdes - Array of selected IDE values
* @param {Array} preferredIdes - Array of preferred IDE objects
* @param {Array} allTools - Array of all tool objects
*/
displaySelectedTools(selectedIdes, preferredIdes, allTools) {
if (selectedIdes.length === 0) return;
const preferredValues = new Set(preferredIdes.map((ide) => ide.value));
console.log('');
console.log(chalk.dim(' Selected tools:'));
for (const ideValue of selectedIdes) {
const tool = allTools.find((t) => t.value === ideValue);
const name = tool?.name || ideValue;
const marker = preferredValues.has(ideValue) ? ' ⭐' : '';
console.log(chalk.dim(`${name}${marker}`));
}
}
}
module.exports = { UI };