From 5b80649d3a0205c6251a17a077bba821e3bc270a Mon Sep 17 00:00:00 2001 From: Davor Racic Date: Wed, 4 Feb 2026 00:36:54 +0100 Subject: [PATCH 1/5] fix(installer): Multiple installer fixes (#1492) * fix: support CRLF line endings and add task/tool templates for all IDEs * fix: preserve file extensions in IDE task/tool paths and update BMAD branding * fix: double extension issue in wrapper filename generation * fix: correct path handling and variable reference in task/tool command generator * fix: change default BMAD folder name from 'bmad' to '_bmad' across all IDE components * refactor: centralize BMAD_FOLDER_NAME constant in path-utils * fix: Replace the rest of BMAD_FOLDER magic values * fix: add safety checks for setBmadFolderName method calls in IdeManager * fix: convert absolute paths to relative in task-tool-command-generator * fix: support .xml task files in bmad-artifacts task discovery * fix: skip internal tasks in manifest generation and IDE command discovery * fix: skip empty artifact_types targets and remove unused vscode_settings target * fix: skip internal tools in manifest generation and improve Windows path handling in command generator * fix: use csv-parse library for proper CSV handling in manifest generation * refactor: extract CSV text cleaning to reusable method in manifest generator * fix: normalize path separators to forward slashes in agent file copying for cross-platform compatibility --------- Co-authored-by: Alex Verkhovsky Co-authored-by: Brian --- src/core/tasks/workflow.xml | 2 +- .../lib/core/dependency-resolver.js | 2 +- tools/cli/installers/lib/core/installer.js | 4 +- .../installers/lib/core/manifest-generator.js | 210 +++++++++++------- tools/cli/installers/lib/custom/handler.js | 2 +- tools/cli/installers/lib/ide/_base-ide.js | 10 +- .../cli/installers/lib/ide/_config-driven.js | 88 +++++++- tools/cli/installers/lib/ide/codex.js | 8 +- tools/cli/installers/lib/ide/manager.js | 10 +- .../installers/lib/ide/platform-codes.yaml | 3 - .../lib/ide/shared/agent-command-generator.js | 4 +- .../lib/ide/shared/bmad-artifacts.js | 17 +- .../installers/lib/ide/shared/path-utils.js | 11 +- .../ide/shared/task-tool-command-generator.js | 122 +++++++++- .../ide/shared/workflow-command-generator.js | 4 +- .../ide/templates/combined/default-task.md | 10 + .../ide/templates/combined/default-tool.md | 10 + .../ide/templates/combined/gemini-task.toml | 11 + .../ide/templates/combined/gemini-tool.toml | 11 + tools/cli/installers/lib/modules/manager.js | 5 +- tools/cli/lib/agent/installer.js | 2 +- 21 files changed, 423 insertions(+), 123 deletions(-) create mode 100644 tools/cli/installers/lib/ide/templates/combined/default-task.md create mode 100644 tools/cli/installers/lib/ide/templates/combined/default-tool.md create mode 100644 tools/cli/installers/lib/ide/templates/combined/gemini-task.toml create mode 100644 tools/cli/installers/lib/ide/templates/combined/gemini-tool.toml diff --git a/src/core/tasks/workflow.xml b/src/core/tasks/workflow.xml index fcf6f96be..8c55ec37f 100644 --- a/src/core/tasks/workflow.xml +++ b/src/core/tasks/workflow.xml @@ -1,4 +1,4 @@ - + Execute given workflow by loading its configuration, following instructions, and producing output diff --git a/tools/cli/installers/lib/core/dependency-resolver.js b/tools/cli/installers/lib/core/dependency-resolver.js index 317b07f8d..ee8a8a124 100644 --- a/tools/cli/installers/lib/core/dependency-resolver.js +++ b/tools/cli/installers/lib/core/dependency-resolver.js @@ -146,7 +146,7 @@ class DependencyResolver { const content = await fs.readFile(file.path, 'utf8'); // Parse YAML frontmatter for explicit dependencies - const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (frontmatterMatch) { try { // Pre-process to handle backticks in YAML values diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index a14c3d192..cb1462700 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -17,9 +17,7 @@ const { ManifestGenerator } = require('./manifest-generator'); const { IdeConfigManager } = require('./ide-config-manager'); const { CustomHandler } = require('../custom/handler'); const prompts = require('../../../lib/prompts'); - -// BMAD installation folder name - this is constant and should never change -const BMAD_FOLDER_NAME = '_bmad'; +const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils'); class Installer { constructor() { diff --git a/tools/cli/installers/lib/core/manifest-generator.js b/tools/cli/installers/lib/core/manifest-generator.js index 100164d52..fcaee8ada 100644 --- a/tools/cli/installers/lib/core/manifest-generator.js +++ b/tools/cli/installers/lib/core/manifest-generator.js @@ -2,6 +2,7 @@ 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 @@ -21,6 +22,19 @@ 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 @@ -161,7 +175,7 @@ class ManifestGenerator { workflow = yaml.parse(content); } else { // Parse MD workflow with YAML frontmatter - const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!frontmatterMatch) { if (debug) { console.log(`[DEBUG] Skipped (no frontmatter): ${fullPath}`); @@ -201,7 +215,7 @@ class ManifestGenerator { // Workflows with standalone: false are filtered out above workflows.push({ name: workflow.name, - description: workflow.description.replaceAll('"', '""'), // Escape quotes for CSV + description: this.cleanForCSV(workflow.description), module: moduleName, path: installPath, }); @@ -319,24 +333,15 @@ 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 ? cleanForCSV(roleMatch[1]) : '', - identity: identityMatch ? cleanForCSV(identityMatch[1]) : '', - communicationStyle: styleMatch ? cleanForCSV(styleMatch[1]) : '', - principles: principlesMatch ? cleanForCSV(principlesMatch[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]) : '', module: moduleName, path: installPath, }); @@ -385,6 +390,11 @@ 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 = ''; @@ -392,13 +402,13 @@ class ManifestGenerator { if (file.endsWith('.md')) { // Parse YAML frontmatter for .md tasks - const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (frontmatterMatch) { try { const frontmatter = yaml.parse(frontmatterMatch[1]); name = frontmatter.name || name; displayName = frontmatter.displayName || frontmatter.name || name; - description = frontmatter.description || ''; + description = this.cleanForCSV(frontmatter.description || ''); standalone = frontmatter.standalone === true || frontmatter.standalone === 'true'; } catch { // If YAML parsing fails, use defaults @@ -411,7 +421,7 @@ class ManifestGenerator { const descMatch = content.match(/description="([^"]+)"/); const objMatch = content.match(/([^<]+)<\/objective>/); - description = descMatch ? descMatch[1] : objMatch ? objMatch[1].trim() : ''; + description = this.cleanForCSV(descMatch ? descMatch[1] : objMatch ? objMatch[1].trim() : ''); const standaloneMatch = content.match(/]+standalone="true"/); standalone = !!standaloneMatch; @@ -424,7 +434,7 @@ class ManifestGenerator { tasks.push({ name: name, displayName: displayName, - description: description.replaceAll('"', '""'), + description: description, module: moduleName, path: installPath, standalone: standalone, @@ -474,6 +484,11 @@ 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 = ''; @@ -481,13 +496,13 @@ class ManifestGenerator { if (file.endsWith('.md')) { // Parse YAML frontmatter for .md tools - const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (frontmatterMatch) { try { const frontmatter = yaml.parse(frontmatterMatch[1]); name = frontmatter.name || name; displayName = frontmatter.displayName || frontmatter.name || name; - description = frontmatter.description || ''; + description = this.cleanForCSV(frontmatter.description || ''); standalone = frontmatter.standalone === true || frontmatter.standalone === 'true'; } catch { // If YAML parsing fails, use defaults @@ -500,7 +515,7 @@ class ManifestGenerator { const descMatch = content.match(/description="([^"]+)"/); const objMatch = content.match(/([^<]+)<\/objective>/); - description = descMatch ? descMatch[1] : objMatch ? objMatch[1].trim() : ''; + description = this.cleanForCSV(descMatch ? descMatch[1] : objMatch ? objMatch[1].trim() : ''); const standaloneMatch = content.match(/]+standalone="true"/); standalone = !!standaloneMatch; @@ -513,7 +528,7 @@ class ManifestGenerator { tools.push({ name: name, displayName: displayName, - description: description.replaceAll('"', '""'), + description: description, module: moduleName, path: installPath, standalone: standalone, @@ -773,30 +788,23 @@ 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 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); - } - } + const records = csv.parse(content, { + columns: true, + skip_empty_lines: true, + }); + for (const record of records) { + existingEntries.set(`${record.module}:${record.name}`, record); } } // Create CSV header with persona fields - let csv = 'name,displayName,title,icon,role,identity,communicationStyle,principles,module,path\n'; + let csvContent = '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(); @@ -809,18 +817,38 @@ class ManifestGenerator { // Add/update new agents for (const agent of this.agents) { 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}"`, - ); + 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, + }); } // Write all agents - for (const [, value] of allAgents) { - csv += value + '\n'; + 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'; } - await fs.writeFile(csvPath, csv); + await fs.writeFile(csvPath, csvContent); return csvPath; } @@ -830,30 +858,23 @@ 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 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); - } - } + const records = csv.parse(content, { + columns: true, + skip_empty_lines: true, + }); + for (const record of records) { + existingEntries.set(`${record.module}:${record.name}`, record); } } // Create CSV header with standalone column - let csv = 'name,displayName,description,module,path,standalone\n'; + let csvContent = 'name,displayName,description,module,path,standalone\n'; // Combine existing and new tasks const allTasks = new Map(); @@ -866,15 +887,30 @@ class ManifestGenerator { // Add/update new tasks for (const task of this.tasks) { const key = `${task.module}:${task.name}`; - allTasks.set(key, `"${task.name}","${task.displayName}","${task.description}","${task.module}","${task.path}","${task.standalone}"`); + allTasks.set(key, { + name: task.name, + displayName: task.displayName, + description: task.description, + module: task.module, + path: task.path, + standalone: task.standalone, + }); } // Write all tasks - for (const [, value] of allTasks) { - csv += value + '\n'; + 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'; } - await fs.writeFile(csvPath, csv); + await fs.writeFile(csvPath, csvContent); return csvPath; } @@ -884,30 +920,23 @@ 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 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); - } - } + const records = csv.parse(content, { + columns: true, + skip_empty_lines: true, + }); + for (const record of records) { + existingEntries.set(`${record.module}:${record.name}`, record); } } // Create CSV header with standalone column - let csv = 'name,displayName,description,module,path,standalone\n'; + let csvContent = 'name,displayName,description,module,path,standalone\n'; // Combine existing and new tools const allTools = new Map(); @@ -920,15 +949,30 @@ class ManifestGenerator { // Add/update new tools for (const tool of this.tools) { const key = `${tool.module}:${tool.name}`; - allTools.set(key, `"${tool.name}","${tool.displayName}","${tool.description}","${tool.module}","${tool.path}","${tool.standalone}"`); + allTools.set(key, { + name: tool.name, + displayName: tool.displayName, + description: tool.description, + module: tool.module, + path: tool.path, + standalone: tool.standalone, + }); } // Write all tools - for (const [, value] of allTools) { - csv += value + '\n'; + 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'; } - await fs.writeFile(csvPath, csv); + await fs.writeFile(csvPath, csvContent); return csvPath; } diff --git a/tools/cli/installers/lib/custom/handler.js b/tools/cli/installers/lib/custom/handler.js index c8aa52eee..8c730cf32 100644 --- a/tools/cli/installers/lib/custom/handler.js +++ b/tools/cli/installers/lib/custom/handler.js @@ -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); + const relativePath = path.relative(sourceAgentsPath, agentFile).split(path.sep).join('/'); const targetDir = path.join(targetAgentsPath, path.dirname(relativePath)); await fs.ensureDir(targetDir); diff --git a/tools/cli/installers/lib/ide/_base-ide.js b/tools/cli/installers/lib/ide/_base-ide.js index b16ee5184..4ae116772 100644 --- a/tools/cli/installers/lib/ide/_base-ide.js +++ b/tools/cli/installers/lib/ide/_base-ide.js @@ -3,6 +3,7 @@ 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 @@ -18,7 +19,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'; // Default, can be overridden + this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden } /** @@ -57,7 +58,7 @@ class BaseIdeSetup { if (this.configDir) { const configPath = path.join(projectDir, this.configDir); if (await fs.pathExists(configPath)) { - const bmadRulesPath = path.join(configPath, 'bmad'); + const bmadRulesPath = path.join(configPath, BMAD_FOLDER_NAME); if (await fs.pathExists(bmadRulesPath)) { await fs.remove(bmadRulesPath); console.log(chalk.dim(`Removed ${this.name} BMAD configuration`)); @@ -445,6 +446,11 @@ 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) diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index 87be7300a..486889267 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -66,6 +66,13 @@ 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); @@ -86,10 +93,11 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { results.workflows = await this.writeWorkflowArtifacts(targetPath, artifacts, template_type, config); } - // Install tasks and tools + // Install tasks and tools using template system (supports TOML for Gemini, MD for others) if (!artifact_types || artifact_types.includes('tasks') || artifact_types.includes('tools')) { - const taskToolGen = new TaskToolCommandGenerator(); - const taskToolResult = await taskToolGen.generateDashTaskToolCommands(projectDir, bmadDir, targetPath); + const taskToolGen = new TaskToolCommandGenerator(this.bmadFolderName); + const { artifacts } = await taskToolGen.collectTaskToolArtifacts(bmadDir); + const taskToolResult = await this.writeTaskToolArtifacts(targetPath, artifacts, template_type, config); results.tasks = taskToolResult.tasks || 0; results.tools = taskToolResult.tools || 0; } @@ -180,6 +188,53 @@ 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} 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.) @@ -316,10 +371,24 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} renderTemplate(template, artifact) { // Use the appropriate path property based on artifact type let pathToUse = artifact.relativePath || ''; - if (artifact.type === 'agent-launcher') { - pathToUse = artifact.agentPath || artifact.relativePath || ''; - } else if (artifact.type === 'workflow-command') { - pathToUse = artifact.workflowPath || artifact.relativePath || ''; + switch (artifact.type) { + case 'agent-launcher': { + pathToUse = artifact.agentPath || artifact.relativePath || ''; + + break; + } + case 'workflow-command': { + pathToUse = artifact.workflowPath || artifact.relativePath || ''; + + break; + } + case 'task': + case 'tool': { + pathToUse = artifact.path || artifact.relativePath || ''; + + break; + } + // No default } let rendered = template @@ -351,8 +420,9 @@ 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 -> .md) - const baseName = standardName.replace(/\.(yaml|yml)\.md$/, '.md'); + // 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'); // If using default markdown, preserve the bmad-agent- prefix for agents if (extension === '.md') { diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index 5cd503e24..29f595f6c 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -104,7 +104,10 @@ 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, @@ -116,7 +119,7 @@ class CodexSetup extends BaseIdeSetup { const workflowCount = await workflowGenerator.writeDashArtifacts(destDir, workflowArtifacts); // Also write tasks using underscore format - const ttGen = new TaskToolCommandGenerator(); + const ttGen = new TaskToolCommandGenerator(this.bmadFolderName); const tasksWritten = await ttGen.writeDashArtifacts(destDir, taskArtifacts); const written = agentCount + workflowCount + tasksWritten; @@ -214,7 +217,10 @@ 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, diff --git a/tools/cli/installers/lib/ide/manager.js b/tools/cli/installers/lib/ide/manager.js index 2b68dfad2..7d00588c0 100644 --- a/tools/cli/installers/lib/ide/manager.js +++ b/tools/cli/installers/lib/ide/manager.js @@ -1,6 +1,7 @@ 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 @@ -14,7 +15,7 @@ class IdeManager { constructor() { this.handlers = new Map(); this._initialized = false; - this.bmadFolderName = 'bmad'; // Default, can be overridden + this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden } /** @@ -73,6 +74,9 @@ 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); } } @@ -100,7 +104,9 @@ class IdeManager { if (!platformInfo.installer) continue; const handler = new ConfigDrivenIdeSetup(platformCode, platformInfo); - handler.setBmadFolderName(this.bmadFolderName); + if (typeof handler.setBmadFolderName === 'function') { + handler.setBmadFolderName(this.bmadFolderName); + } this.handlers.set(platformCode, handler); } } diff --git a/tools/cli/installers/lib/ide/platform-codes.yaml b/tools/cli/installers/lib/ide/platform-codes.yaml index 6a9078a8a..2ca32aed5 100644 --- a/tools/cli/installers/lib/ide/platform-codes.yaml +++ b/tools/cli/installers/lib/ide/platform-codes.yaml @@ -94,9 +94,6 @@ platforms: - target_dir: .github/agents template_type: copilot_agents artifact_types: [agents] - - target_dir: .vscode - template_type: vscode_settings - artifact_types: [] iflow: name: "iFlow" diff --git a/tools/cli/installers/lib/ide/shared/agent-command-generator.js b/tools/cli/installers/lib/ide/shared/agent-command-generator.js index dec22a12a..caf60614f 100644 --- a/tools/cli/installers/lib/ide/shared/agent-command-generator.js +++ b/tools/cli/installers/lib/ide/shared/agent-command-generator.js @@ -1,14 +1,14 @@ const path = require('node:path'); const fs = require('fs-extra'); const chalk = require('chalk'); -const { toColonPath, toDashPath, customAgentColonName, customAgentDashName } = require('./path-utils'); +const { toColonPath, toDashPath, customAgentColonName, customAgentDashName, BMAD_FOLDER_NAME } = require('./path-utils'); /** * Generates launcher command files for each agent * Similar to WorkflowCommandGenerator but for agents */ class AgentCommandGenerator { - constructor(bmadFolderName = 'bmad') { + constructor(bmadFolderName = BMAD_FOLDER_NAME) { this.templatePath = path.join(__dirname, '../templates/agent-command-template.md'); this.bmadFolderName = bmadFolderName; } diff --git a/tools/cli/installers/lib/ide/shared/bmad-artifacts.js b/tools/cli/installers/lib/ide/shared/bmad-artifacts.js index e88a64f5d..7bcfd6a79 100644 --- a/tools/cli/installers/lib/ide/shared/bmad-artifacts.js +++ b/tools/cli/installers/lib/ide/shared/bmad-artifacts.js @@ -141,13 +141,24 @@ async function getTasksFromDir(dirPath, moduleName) { const files = await fs.readdir(dirPath); for (const file of files) { - if (!file.endsWith('.md')) { + // Include both .md and .xml task files + if (!file.endsWith('.md') && !file.endsWith('.xml')) { 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: path.join(dirPath, file), - name: file.replace('.md', ''), + path: filePath, + name: file.replace(ext, ''), module: moduleName, }); } diff --git a/tools/cli/installers/lib/ide/shared/path-utils.js b/tools/cli/installers/lib/ide/shared/path-utils.js index d6ad00f51..519669233 100644 --- a/tools/cli/installers/lib/ide/shared/path-utils.js +++ b/tools/cli/installers/lib/ide/shared/path-utils.js @@ -18,6 +18,9 @@ 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' @@ -59,7 +62,9 @@ function toDashPath(relativePath) { return 'bmad-unknown.md'; } - const withoutExt = relativePath.replace('.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 parts = withoutExt.split(/[/\\]/); const module = parts[0]; @@ -183,7 +188,8 @@ function toUnderscoreName(module, type, name) { * @deprecated Use toDashPath instead */ function toUnderscorePath(relativePath) { - const withoutExt = relativePath.replace('.md', ''); + // Strip common file extensions (same as toDashPath for consistency) + const withoutExt = relativePath.replace(/\.(md|yaml|yml|json|xml|toml)$/i, ''); const parts = withoutExt.split(/[/\\]/); const module = parts[0]; @@ -289,4 +295,5 @@ module.exports = { TYPE_SEGMENTS, AGENT_SEGMENT, + BMAD_FOLDER_NAME, }; diff --git a/tools/cli/installers/lib/ide/shared/task-tool-command-generator.js b/tools/cli/installers/lib/ide/shared/task-tool-command-generator.js index a0c4bcf87..60eb54687 100644 --- a/tools/cli/installers/lib/ide/shared/task-tool-command-generator.js +++ b/tools/cli/installers/lib/ide/shared/task-tool-command-generator.js @@ -2,12 +2,98 @@ const path = require('node:path'); const fs = require('fs-extra'); const csv = require('csv-parse/sync'); const chalk = require('chalk'); -const { toColonName, toColonPath, toDashPath } = require('./path-utils'); +const { toColonName, toColonPath, toDashPath, BMAD_FOLDER_NAME } = 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} 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 @@ -65,9 +151,35 @@ 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' && itemPath.startsWith('bmad/')) { - itemPath = `{project-root}/${itemPath}`; + 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}`; + } } return `--- @@ -187,7 +299,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 underscore format: bmad_bmm_name.md + // Use dash 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)); @@ -198,7 +310,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 underscore format: bmad_bmm_name.md + // Use dash 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)); diff --git a/tools/cli/installers/lib/ide/shared/workflow-command-generator.js b/tools/cli/installers/lib/ide/shared/workflow-command-generator.js index 6dab1a3f2..5a23fda2f 100644 --- a/tools/cli/installers/lib/ide/shared/workflow-command-generator.js +++ b/tools/cli/installers/lib/ide/shared/workflow-command-generator.js @@ -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 } = require('./path-utils'); +const { toColonPath, toDashPath, customAgentColonName, customAgentDashName, BMAD_FOLDER_NAME } = require('./path-utils'); /** * Generates command files for each workflow in the manifest */ class WorkflowCommandGenerator { - constructor(bmadFolderName = 'bmad') { + constructor(bmadFolderName = BMAD_FOLDER_NAME) { this.templatePath = path.join(__dirname, '../templates/workflow-command-template.md'); this.bmadFolderName = bmadFolderName; } diff --git a/tools/cli/installers/lib/ide/templates/combined/default-task.md b/tools/cli/installers/lib/ide/templates/combined/default-task.md new file mode 100644 index 000000000..b865d6ffb --- /dev/null +++ b/tools/cli/installers/lib/ide/templates/combined/default-task.md @@ -0,0 +1,10 @@ +--- +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. diff --git a/tools/cli/installers/lib/ide/templates/combined/default-tool.md b/tools/cli/installers/lib/ide/templates/combined/default-tool.md new file mode 100644 index 000000000..11c6aac8d --- /dev/null +++ b/tools/cli/installers/lib/ide/templates/combined/default-tool.md @@ -0,0 +1,10 @@ +--- +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. diff --git a/tools/cli/installers/lib/ide/templates/combined/gemini-task.toml b/tools/cli/installers/lib/ide/templates/combined/gemini-task.toml new file mode 100644 index 000000000..7d15e2164 --- /dev/null +++ b/tools/cli/installers/lib/ide/templates/combined/gemini-task.toml @@ -0,0 +1,11 @@ +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}} +""" diff --git a/tools/cli/installers/lib/ide/templates/combined/gemini-tool.toml b/tools/cli/installers/lib/ide/templates/combined/gemini-tool.toml new file mode 100644 index 000000000..fc78c6b72 --- /dev/null +++ b/tools/cli/installers/lib/ide/templates/combined/gemini-tool.toml @@ -0,0 +1,11 @@ +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}} +""" diff --git a/tools/cli/installers/lib/modules/manager.js b/tools/cli/installers/lib/modules/manager.js index 60c087b19..1f523fba3 100644 --- a/tools/cli/installers/lib/modules/manager.js +++ b/tools/cli/installers/lib/modules/manager.js @@ -7,6 +7,7 @@ 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. @@ -27,7 +28,7 @@ const { ExternalModuleManager } = require('./external-manager'); class ModuleManager { constructor(options = {}) { this.xmlHandler = new XmlHandler(); - this.bmadFolderName = 'bmad'; // Default, can be overridden + this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden this.customModulePaths = new Map(); // Initialize custom module paths this.externalModuleManager = new ExternalModuleManager(); // For external official modules } @@ -870,7 +871,7 @@ class ModuleManager { for (const agentFile of agentFiles) { if (!agentFile.endsWith('.agent.yaml')) continue; - const relativePath = path.relative(sourceAgentsPath, agentFile); + const relativePath = path.relative(sourceAgentsPath, agentFile).split(path.sep).join('/'); const targetDir = path.join(targetAgentsPath, path.dirname(relativePath)); await fs.ensureDir(targetDir); diff --git a/tools/cli/lib/agent/installer.js b/tools/cli/lib/agent/installer.js index b55502edd..a76504530 100644 --- a/tools/cli/lib/agent/installer.js +++ b/tools/cli/lib/agent/installer.js @@ -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); } /** From 2d9ebcaf2f9e071ff3d155876c4c8dd78ac30a19 Mon Sep 17 00:00:00 2001 From: Davor Racic Date: Wed, 4 Feb 2026 00:39:05 +0100 Subject: [PATCH 2/5] feat: Update @clack/prompts to v1.0.0 and Add autocompleteMultiselect prompt (#1514) * feat: Update @clack/prompts to v1.0.0 and Add autocompleteMultiselect prompt * fix(cli): flexible tool selection (skip recommended or additional) + fix spacing * feat(cli): improve tool selection UX with autocomplete and upgrade path * feat(cli): display selected tools after IDE selection with preferred markers * fix: formatting * fix: make selection message more clear * fix: formatting * fix: Remove redundant colon --------- Co-authored-by: Brian --- package-lock.json | 18 +- package.json | 4 +- .../installers/lib/core/config-collector.js | 6 +- tools/cli/installers/lib/core/installer.js | 3 - tools/cli/lib/prompts.js | 198 ++++++++++++- tools/cli/lib/ui.js | 273 +++++++++++++----- 6 files changed, 406 insertions(+), 96 deletions(-) diff --git a/package-lock.json b/package-lock.json index fe41085b3..d3888b281 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "6.0.0-Beta.5", "license": "MIT", "dependencies": { - "@clack/prompts": "^0.11.0", + "@clack/core": "^1.0.0", + "@clack/prompts": "^1.0.0", "@kayvan/markdown-tree-parser": "^1.6.1", "boxen": "^5.1.2", "chalk": "^4.1.2", @@ -22,6 +23,7 @@ "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", @@ -756,9 +758,9 @@ } }, "node_modules/@clack/core": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.5.0.tgz", - "integrity": "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0.tgz", + "integrity": "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==", "license": "MIT", "dependencies": { "picocolors": "^1.0.0", @@ -766,12 +768,12 @@ } }, "node_modules/@clack/prompts": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.11.0.tgz", - "integrity": "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.0.tgz", + "integrity": "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==", "license": "MIT", "dependencies": { - "@clack/core": "0.5.0", + "@clack/core": "1.0.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } diff --git a/package.json b/package.json index 8df5ea009..8798a6208 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,8 @@ ] }, "dependencies": { - "@clack/prompts": "^0.11.0", + "@clack/core": "^1.0.0", + "@clack/prompts": "^1.0.0", "@kayvan/markdown-tree-parser": "^1.6.1", "boxen": "^5.1.2", "chalk": "^4.1.2", @@ -82,6 +83,7 @@ "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", diff --git a/tools/cli/installers/lib/core/config-collector.js b/tools/cli/installers/lib/core/config-collector.js index f8b2042a5..f4eaf5e3d 100644 --- a/tools/cli/installers/lib/core/config-collector.js +++ b/tools/cli/installers/lib/core/config-collector.js @@ -586,7 +586,11 @@ class ConfigCollector { console.log(); console.log(chalk.cyan('?') + ' ' + chalk.magenta(moduleDisplayName)); let customize = true; - if (moduleName !== 'core') { + 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) const customizeAnswer = await prompts.prompt([ { type: 'confirm', diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index cb1462700..edb15112c 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -695,9 +695,6 @@ 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 { diff --git a/tools/cli/lib/prompts.js b/tools/cli/lib/prompts.js index 5d85e2b44..7ab2d21ef 100644 --- a/tools/cli/lib/prompts.js +++ b/tools/cli/lib/prompts.js @@ -8,6 +8,8 @@ */ let _clack = null; +let _clackCore = null; +let _picocolors = null; /** * Lazy-load @clack/prompts (ESM module) @@ -20,6 +22,28 @@ async function getClack() { return _clack; } +/** + * Lazy-load @clack/core (ESM module) + * @returns {Promise} The clack core module + */ +async function getClackCore() { + if (!_clackCore) { + _clackCore = await import('@clack/core'); + } + return _clackCore; +} + +/** + * Lazy-load picocolors + * @returns {Promise} 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 @@ -191,6 +215,118 @@ 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 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 @@ -211,7 +347,12 @@ async function confirm(options) { } /** - * Text input prompt (replaces Inquirer 'input' type) + * 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. + * * @param {Object} options - Prompt options * @param {string} options.message - The question to ask * @param {string} [options.default] - Default value @@ -220,20 +361,64 @@ async function confirm(options) { * @returns {Promise} User's input */ async function text(options) { - const clack = await getClack(); + const core = await getClackCore(); + const color = await getPicocolors(); // 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 result = await clack.text({ - message: options.message, - defaultValue: options.default, - placeholder: typeof placeholder === 'string' ? placeholder : undefined, + const prompt = new core.TextPrompt({ + defaultValue, 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; } @@ -423,6 +608,7 @@ module.exports = { select, multiselect, groupMultiselect, + autocompleteMultiselect, confirm, text, password, diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index da5420cb2..89dc11c50 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -344,6 +344,9 @@ 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 */ @@ -366,95 +369,190 @@ class UI { const preferredIdes = ideManager.getPreferredIdes(); const otherIdes = ideManager.getOtherIdes(); - // Build grouped options object for groupMultiselect - const groupedOptions = {}; - const processedIdes = new Set(); - const initialValues = []; + // 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)); - // First, add previously configured IDEs, marked with ✅ - if (configuredIdes.length > 0) { - const configuredGroup = []; - for (const ideValue of configuredIdes) { - // Skip empty or invalid IDE values - if (!ideValue || typeof ideValue !== 'string') { - continue; - } - - // 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; - } + // 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(', ')}`)); } - // 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: `${ide.name} ⭐`, - value: ide.value, - }; + // ───────────────────────────────────────────────────────────────────────────── + // UPGRADE PATH: If tools already configured, show all tools with configured at top + // ───────────────────────────────────────────────────────────────────────────── + 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); + } + + return { ides: [], skipIde: true }; + } + + // Display selected tools + this.displaySelectedTools(selectedIdes, preferredIdes, allTools); + + return { ides: selectedIdes, skipIde: false }; + } + + // ───────────────────────────────────────────────────────────────────────────── + // NEW INSTALL: Show recommended tools first with "Browse All" option + // ───────────────────────────────────────────────────────────────────────────── + const recommendedOptions = preferredIdes.map((ide) => { + const isConfigured = configuredPreferred.includes(ide.value); + return { + label: isConfigured ? `${ide.name} ⭐ ✅` : `${ide.name} ⭐`, + value: ide.value, + }; + }); + + // Add "browse all" option at the end if there are additional tools + if (otherIdes.length > 0) { + const totalTools = preferredIdes.length + otherIdes.length; + recommendedOptions.push({ + label: `→ Browse all supported tools (${totalTools} total)...`, + value: '__BROWSE_ALL__', }); } - // 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, - })); - } + // Pre-select previously configured preferred tools + const recommendedInitialValues = configuredPreferred.length > 0 ? configuredPreferred : undefined; - // 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 recommendedSelected = await prompts.multiselect({ + message: `Integrate with ${chalk.dim('(↑/↓ to navigate • SPACE: select • ENTER: confirm)')}:`, + options: recommendedOptions, + initialValues: recommendedInitialValues, + required: false, }); - // 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 = []; + const selectedRecommended = recommendedSelected || []; + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 2: Handle "Browse All" selection - show additional tools if requested + // ───────────────────────────────────────────────────────────────────────────── + const wantsBrowseAll = selectedRecommended.includes('__BROWSE_ALL__'); + const filteredRecommended = selectedRecommended.filter((v) => v !== '__BROWSE_ALL__'); + + // Show additional tools if: + // 1. User explicitly chose "Browse All", OR + // 2. User has previously configured "other" tools, OR + // 3. User selected no recommended tools (allow them to pick from other tools) + const showAdditionalTools = wantsBrowseAll || configuredOther.length > 0 || filteredRecommended.length === 0; + + let selectedAdditionalOrAll = []; + + if (showAdditionalTools) { + // Show ALL tools if: + // - User explicitly chose "Browse All", OR + // - User selected nothing from recommended (so they can pick from everything) + // Otherwise, show only "other" tools as additional options + const showAllTools = wantsBrowseAll || filteredRecommended.length === 0; + const toolsToShow = showAllTools ? [...preferredIdes, ...otherIdes] : otherIdes; + + if (toolsToShow.length > 0) { + const allToolOptions = toolsToShow.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, + }; + }); + + // Pre-select: previously configured tools + any recommended tools already selected + const initialValues = [...configuredIdes, ...filteredRecommended].filter((v, i, arr) => arr.indexOf(v) === i); // dedupe + + // Use "additional" only if user already selected some recommended tools + const isAdditional = !wantsBrowseAll && filteredRecommended.length > 0; + + console.log(''); + const selected = await prompts.autocompleteMultiselect({ + message: isAdditional ? 'Select additional tools:' : 'Select tools:', + options: allToolOptions, + initialValues: initialValues.length > 0 ? initialValues : undefined, + required: false, + maxItems: 8, + }); + + selectedAdditionalOrAll = selected || []; + } } + // Combine selections: + // - If "Browse All" was used, the second prompt contains ALL selections + // - Otherwise, combine recommended + additional + const allSelectedIdes = wantsBrowseAll ? selectedAdditionalOrAll : [...filteredRecommended, ...selectedAdditionalOrAll]; + + // ───────────────────────────────────────────────────────────────────────────── + // 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); + } + + return { + ides: [], + skipIde: true, + }; + } + + // Display selected tools + const allTools = [...preferredIdes, ...otherIdes]; + this.displaySelectedTools(allSelectedIdes, preferredIdes, allTools); + return { - ides: selectedIdes || [], - skipIde: !selectedIdes || selectedIdes.length === 0, + ides: allSelectedIdes, + skipIde: allSelectedIdes.length === 0, }; } @@ -1655,6 +1753,27 @@ 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 }; From df176d42064d06988bd16b86d33afa42fd6d3e58 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Tue, 3 Feb 2026 21:36:21 -0600 Subject: [PATCH 3/5] installer remove double tool questioning --- tools/cli/lib/ui.js | 95 +++++++-------------------------------------- 1 file changed, 14 insertions(+), 81 deletions(-) diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 89dc11c50..cc135fea3 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -435,95 +435,29 @@ class UI { } // ───────────────────────────────────────────────────────────────────────────── - // NEW INSTALL: Show recommended tools first with "Browse All" option + // NEW INSTALL: Show all tools with search // ───────────────────────────────────────────────────────────────────────────── - const recommendedOptions = preferredIdes.map((ide) => { - const isConfigured = configuredPreferred.includes(ide.value); + 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 += ' ⭐'; return { - label: isConfigured ? `${ide.name} ⭐ ✅` : `${ide.name} ⭐`, + label, value: ide.value, }; }); - // Add "browse all" option at the end if there are additional tools - if (otherIdes.length > 0) { - const totalTools = preferredIdes.length + otherIdes.length; - recommendedOptions.push({ - label: `→ Browse all supported tools (${totalTools} total)...`, - value: '__BROWSE_ALL__', - }); - } - - // Pre-select previously configured preferred tools - const recommendedInitialValues = configuredPreferred.length > 0 ? configuredPreferred : undefined; - - const recommendedSelected = await prompts.multiselect({ - message: `Integrate with ${chalk.dim('(↑/↓ to navigate • SPACE: select • ENTER: confirm)')}:`, - options: recommendedOptions, - initialValues: recommendedInitialValues, + const selectedIdes = await prompts.autocompleteMultiselect({ + message: 'Select tools:', + options: allToolOptions, + initialValues: configuredIdes.length > 0 ? configuredIdes : undefined, required: false, + maxItems: 8, }); - const selectedRecommended = recommendedSelected || []; - - // ───────────────────────────────────────────────────────────────────────────── - // STEP 2: Handle "Browse All" selection - show additional tools if requested - // ───────────────────────────────────────────────────────────────────────────── - const wantsBrowseAll = selectedRecommended.includes('__BROWSE_ALL__'); - const filteredRecommended = selectedRecommended.filter((v) => v !== '__BROWSE_ALL__'); - - // Show additional tools if: - // 1. User explicitly chose "Browse All", OR - // 2. User has previously configured "other" tools, OR - // 3. User selected no recommended tools (allow them to pick from other tools) - const showAdditionalTools = wantsBrowseAll || configuredOther.length > 0 || filteredRecommended.length === 0; - - let selectedAdditionalOrAll = []; - - if (showAdditionalTools) { - // Show ALL tools if: - // - User explicitly chose "Browse All", OR - // - User selected nothing from recommended (so they can pick from everything) - // Otherwise, show only "other" tools as additional options - const showAllTools = wantsBrowseAll || filteredRecommended.length === 0; - const toolsToShow = showAllTools ? [...preferredIdes, ...otherIdes] : otherIdes; - - if (toolsToShow.length > 0) { - const allToolOptions = toolsToShow.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, - }; - }); - - // Pre-select: previously configured tools + any recommended tools already selected - const initialValues = [...configuredIdes, ...filteredRecommended].filter((v, i, arr) => arr.indexOf(v) === i); // dedupe - - // Use "additional" only if user already selected some recommended tools - const isAdditional = !wantsBrowseAll && filteredRecommended.length > 0; - - console.log(''); - const selected = await prompts.autocompleteMultiselect({ - message: isAdditional ? 'Select additional tools:' : 'Select tools:', - options: allToolOptions, - initialValues: initialValues.length > 0 ? initialValues : undefined, - required: false, - maxItems: 8, - }); - - selectedAdditionalOrAll = selected || []; - } - } - - // Combine selections: - // - If "Browse All" was used, the second prompt contains ALL selections - // - Otherwise, combine recommended + additional - const allSelectedIdes = wantsBrowseAll ? selectedAdditionalOrAll : [...filteredRecommended, ...selectedAdditionalOrAll]; + const allSelectedIdes = selectedIdes || []; // ───────────────────────────────────────────────────────────────────────────── // STEP 3: Confirm if no tools selected @@ -547,7 +481,6 @@ class UI { } // Display selected tools - const allTools = [...preferredIdes, ...otherIdes]; this.displaySelectedTools(allSelectedIdes, preferredIdes, allTools); return { From 006a1f707e40647a5fa448acdd3549a44561c677 Mon Sep 17 00:00:00 2001 From: murat Date: Wed, 4 Feb 2026 07:16:51 -0600 Subject: [PATCH 4/5] fix: docs llms order, bmgd draft, tea link --- docs/bmgd/game-types.md | 1 + docs/bmgd/index.md | 2 +- docs/bmgd/quick-flow-workflows.md | 1 + docs/reference/agents.md | 22 ++++++++++++++++++++ docs/reference/commands.md | 34 +++++++++++++++++++++++++++++++ docs/reference/testing.md | 21 +++++++++++++++++++ src/bmm/agents/qa.agent.yaml | 2 +- tools/build-docs.js | 22 +++++++++++++++++++- 8 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 docs/reference/agents.md create mode 100644 docs/reference/commands.md create mode 100644 docs/reference/testing.md diff --git a/docs/bmgd/game-types.md b/docs/bmgd/game-types.md index ed0a7164c..2ea407218 100644 --- a/docs/bmgd/game-types.md +++ b/docs/bmgd/game-types.md @@ -1,5 +1,6 @@ --- title: "Game Types Reference" +draft: true --- BMGD supports 24 game type templates. Each adds genre-specific sections to your GDD. diff --git a/docs/bmgd/index.md b/docs/bmgd/index.md index 08f2f6078..bd1565c12 100644 --- a/docs/bmgd/index.md +++ b/docs/bmgd/index.md @@ -1,6 +1,7 @@ --- title: "BMGD Quick Guide" description: Quick reference for BMad Game Dev Studio +draft: true --- ![BMGD Logo](bmgd-logo.png) @@ -110,4 +111,3 @@ Each template provides genre-specific GDD sections, mechanics patterns, testing - [Game Types Guide](game-types.md) - [Quick-Flow Guide](quick-flow-workflows.md) - diff --git a/docs/bmgd/quick-flow-workflows.md b/docs/bmgd/quick-flow-workflows.md index b86cc7674..69ac7506a 100644 --- a/docs/bmgd/quick-flow-workflows.md +++ b/docs/bmgd/quick-flow-workflows.md @@ -1,5 +1,6 @@ --- title: "Quick Flow Workflows" +draft: true --- How to create tech specs and execute implementations with Quick Flow. diff --git a/docs/reference/agents.md b/docs/reference/agents.md new file mode 100644 index 000000000..4771dae37 --- /dev/null +++ b/docs/reference/agents.md @@ -0,0 +1,22 @@ +--- +title: Agents +--- + +This page lists the default BMM (Agile suite) agents that install with BMAD Method, along with their menu triggers and primary workflows. + +Notes: +- Triggers are the short menu codes (e.g., `CP`) and fuzzy matches shown in each agent menu. +- Slash commands are generated separately. See `docs/reference/commands.md` for the slash command list and where they are defined. +- QA (Quinn) is the lightweight test automation agent in BMM. The full Test Architect (TEA) lives in its own module. + +| Agent | Triggers | Primary workflows | +| --------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------- | +| Analyst (Mary) | `BP`, `RS`, `CB`, `DP` | Brainstorm Project, Research, Create Brief, Document Project | +| Product Manager (John) | `CP`, `VP`, `EP`, `CE`, `IR`, `CC` | Create/Validate/Edit PRD, Create Epics and Stories, Implementation Readiness, Correct Course | +| Architect (Winston) | `CA`, `IR` | Create Architecture, Implementation Readiness | +| Scrum Master (Bob) | `SP`, `CS`, `ER`, `CC` | Sprint Planning, Create Story, Epic Retrospective, Correct Course | +| Developer (Amelia) | `DS`, `CR` | Dev Story, Code Review | +| QA Engineer (Quinn) | `qa` | Automate (generate tests for existing features) | +| Quick Flow Solo Dev (Barry) | `QS`, `QD`, `CR` | Quick Spec, Quick Dev, Code Review | +| UX Designer (Sally) | `CU` | Create UX Design | +| Technical Writer (Paige) | `DP`, `WD`, `US`, `MG`, `VD`, `EC` | Document Project, Write Document, Update Standards, Mermaid Generate, Validate Doc, Explain Concept | diff --git a/docs/reference/commands.md b/docs/reference/commands.md new file mode 100644 index 000000000..39b97f637 --- /dev/null +++ b/docs/reference/commands.md @@ -0,0 +1,34 @@ +--- +title: Commands +description: How BMAD commands are generated and where to find them. +--- + +# Commands + +BMAD slash commands are generated by the installer for your IDE and **reflect the modules you have installed**. +That means the authoritative list lives **in your project**, not in a static docs page. + +## How to Discover Commands (Recommended) + +- Type `/bmad` in your IDE and use autocomplete to browse agents/workflows. +- Run `/bmad-help` to get guided next steps and context-aware recommendations. + +## Where Commands Are Generated + +The installer writes command files into your project (example paths for Claude Code): + +- `.claude/commands/bmad//agents/` +- `.claude/commands/bmad//workflows/` + +These folders are the **canonical, project-specific command list**. + +## Common Commands + +- `/bmad-help` - Interactive help and next-step guidance +- `/bmad::agents:` - Load an agent (e.g. `/bmad:bmm:agents:dev`) +- `/bmad::workflows:` - Run a workflow (e.g. `/bmad:bmm:workflows:create-prd`) + +## Why This Page Is Short + +BMAD is modular, so the exact commands vary by install. +Use your IDE's autocomplete or the generated command folders above to see *everything* available. diff --git a/docs/reference/testing.md b/docs/reference/testing.md new file mode 100644 index 000000000..562526fb2 --- /dev/null +++ b/docs/reference/testing.md @@ -0,0 +1,21 @@ +--- +title: Testing Options +--- + +# Testing Options + +BMad provides a built-in QA agent for quick test automation and a separate Test Architect (TEA) module for advanced testing. + +## Built-in QA (Quinn) + +Use the built-in QA agent for fast, straightforward test coverage: + +- Trigger: `QA` or `bmad-bmm-qa-automate` +- Best for: small projects, quick coverage, standard patterns + +## Test Architect (TEA) Module + +TEA is a standalone module with advanced testing workflows (test design, ATDD, automate, review, trace, NFR assessment). + +- Documentation: +- Install: `npx bmad-method@alpha install` and select the TEA module diff --git a/src/bmm/agents/qa.agent.yaml b/src/bmm/agents/qa.agent.yaml index bad945cd4..07ca4022f 100644 --- a/src/bmm/agents/qa.agent.yaml +++ b/src/bmm/agents/qa.agent.yaml @@ -27,7 +27,7 @@ agent: - Focus on realistic user scenarios menu: - - trigger: qa + - trigger: QA or fuzzy match on qa-automate workflow: "{project-root}/_bmad/bmm/workflows/qa/automate/workflow.yaml" description: "[QA] Automate - Generate tests for existing features (simplified)" diff --git a/tools/build-docs.js b/tools/build-docs.js index 38bb379ed..dfb2c0a8e 100644 --- a/tools/build-docs.js +++ b/tools/build-docs.js @@ -38,6 +38,7 @@ const LLM_EXCLUDE_PATTERNS = [ 'faq', 'reference/glossary/', 'explanation/game-dev/', + 'bmgd/', // Note: Files/dirs starting with _ (like _STYLE_GUIDE.md, _archive/) are excluded in shouldExcludeFromLlm() ]; @@ -194,7 +195,7 @@ function generateLlmsFullTxt(docsDir, outputDir) { console.log(' → Generating llms-full.txt...'); const date = new Date().toISOString().split('T')[0]; - const files = getAllMarkdownFiles(docsDir); + const files = getAllMarkdownFiles(docsDir).sort(compareLlmDocs); const output = [ '# BMAD Method Documentation (Full)', @@ -236,6 +237,25 @@ function generateLlmsFullTxt(docsDir, outputDir) { ); } +function compareLlmDocs(a, b) { + const aKey = getLlmSortKey(a); + const bKey = getLlmSortKey(b); + + if (aKey !== bKey) return aKey - bKey; + return a.localeCompare(b); +} + +function getLlmSortKey(filePath) { + if (filePath === 'index.md') return 0; + if (filePath === 'downloads.md') return 1; + if (filePath.startsWith(`tutorials${path.sep}`) || filePath.startsWith('tutorials/')) return 2; + if (filePath.startsWith(`how-to${path.sep}`) || filePath.startsWith('how-to/')) return 3; + if (filePath.startsWith(`explanation${path.sep}`) || filePath.startsWith('explanation/')) return 4; + if (filePath.startsWith(`reference${path.sep}`) || filePath.startsWith('reference/')) return 5; + if (filePath.startsWith(`bmgd${path.sep}`) || filePath.startsWith('bmgd/')) return 6; + return 7; +} + /** * Collects all Markdown (.md) files under a directory and returns their paths relative to a base directory. * @param {string} dir - Directory to search for Markdown files. From 210bcc6a99bae63d8c2c8dbaab3b4bf18487881b Mon Sep 17 00:00:00 2001 From: murat Date: Wed, 4 Feb 2026 07:25:12 -0600 Subject: [PATCH 5/5] fix: docs llms order, bmgd draft, tea link2 --- docs/reference/agents.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/agents.md b/docs/reference/agents.md index 4771dae37..00a2da9d5 100644 --- a/docs/reference/agents.md +++ b/docs/reference/agents.md @@ -16,7 +16,7 @@ Notes: | Architect (Winston) | `CA`, `IR` | Create Architecture, Implementation Readiness | | Scrum Master (Bob) | `SP`, `CS`, `ER`, `CC` | Sprint Planning, Create Story, Epic Retrospective, Correct Course | | Developer (Amelia) | `DS`, `CR` | Dev Story, Code Review | -| QA Engineer (Quinn) | `qa` | Automate (generate tests for existing features) | +| QA Engineer (Quinn) | `QA` | Automate (generate tests for existing features) | | Quick Flow Solo Dev (Barry) | `QS`, `QD`, `CR` | Quick Spec, Quick Dev, Code Review | | UX Designer (Sally) | `CU` | Create UX Design | | Technical Writer (Paige) | `DP`, `WD`, `US`, `MG`, `VD`, `EC` | Document Project, Write Document, Update Standards, Mermaid Generate, Validate Doc, Explain Concept |