diff --git a/package-lock.json b/package-lock.json index ba812f09..18fd9a25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "fs-extra": "^11.3.0", "glob": "^11.0.3", "ignore": "^7.0.5", - "inquirer": "^9.3.8", "js-yaml": "^4.1.0", "ora": "^5.4.1", "semver": "^7.6.3", @@ -34,6 +33,7 @@ "devDependencies": { "@astrojs/sitemap": "^3.6.0", "@astrojs/starlight": "^0.37.0", + "@clack/prompts": "^0.11.0", "@eslint/js": "^9.33.0", "archiver": "^7.0.1", "astro": "^5.16.0", @@ -244,7 +244,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -756,6 +755,29 @@ "node": ">=18" } }, + "node_modules/@clack/core": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.5.0.tgz", + "integrity": "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "0.5.0", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -1998,36 +2020,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@inquirer/external-editor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", - "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -3641,9 +3633,8 @@ "version": "25.0.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3983,7 +3974,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4031,6 +4021,7 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, "license": "MIT", "dependencies": { "type-fest": "^0.21.3" @@ -4046,6 +4037,7 @@ "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -4290,7 +4282,6 @@ "integrity": "sha512-6mF/YrvwwRxLTu+aMEa5pwzKUNl5ZetWbTyZCs9Um0F12HUmxUiF5UHiZPy4rifzU3gtpM3xP2DfdmkNX9eZRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.5", @@ -5358,7 +5349,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5601,12 +5591,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "license": "MIT" - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -5787,15 +5771,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -6689,7 +6664,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8269,22 +8243,6 @@ "@babel/runtime": "^7.23.2" } }, - "node_modules/iconv-lite": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", - "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -8420,43 +8378,6 @@ "dev": true, "license": "MIT" }, - "node_modules/inquirer": { - "version": "9.3.8", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.3.8.tgz", - "integrity": "sha512-pFGGdaHrmRKMh4WoDDSowddgjT1Vkl90atobmTeSmcPGdYiwikch/m/Ef5wRaiamHejtw0cUUMMerzDUXCci2w==", - "license": "MIT", - "dependencies": { - "@inquirer/external-editor": "^1.0.2", - "@inquirer/figures": "^1.0.3", - "ansi-escapes": "^4.3.2", - "cli-width": "^4.1.0", - "mute-stream": "1.0.0", - "ora": "^5.4.1", - "run-async": "^3.0.0", - "rxjs": "^7.8.1", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/inquirer/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -10304,7 +10225,6 @@ "integrity": "sha512-p3JTemJJbkiMjXEMiFwgm0v6ym5g8K+b2oDny+6xdl300tUKySxvilJQLSea48C6OaYNmO30kH9KxpiAg5bWJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "globby": "15.0.0", "js-yaml": "4.1.1", @@ -11576,15 +11496,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/nano-spawn": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", @@ -12378,7 +12289,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12444,7 +12354,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13273,7 +13182,6 @@ "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -13310,15 +13218,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/run-async": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", - "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -13343,15 +13242,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -13372,12 +13262,6 @@ ], "license": "MIT" }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/sax": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", @@ -14251,6 +14135,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, "node_modules/type-check": { @@ -14335,7 +14220,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unicode-properties": { @@ -14837,7 +14722,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -15111,7 +14995,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -15270,18 +15153,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zip-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", @@ -15303,7 +15174,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 0f8953f3..7cf03d1c 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ ] }, "dependencies": { + "@clack/prompts": "^0.11.0", "@kayvan/markdown-tree-parser": "^1.6.1", "boxen": "^5.1.2", "chalk": "^4.1.2", @@ -77,7 +78,6 @@ "fs-extra": "^11.3.0", "glob": "^11.0.3", "ignore": "^7.0.5", - "inquirer": "^9.3.8", "js-yaml": "^4.1.0", "ora": "^5.4.1", "semver": "^7.6.3", diff --git a/tools/cli/bmad-cli.js b/tools/cli/bmad-cli.js index 0cba5814..ad3aac34 100755 --- a/tools/cli/bmad-cli.js +++ b/tools/cli/bmad-cli.js @@ -3,7 +3,7 @@ const path = require('node:path'); const fs = require('node:fs'); // Fix for stdin issues when running through npm on Windows -// Ensures keyboard interaction works properly with inquirer prompts +// Ensures keyboard interaction works properly with CLI prompts if (process.stdin.isTTY) { try { process.stdin.resume(); diff --git a/tools/cli/commands/install.js b/tools/cli/commands/install.js index f71d5679..26b6425e 100644 --- a/tools/cli/commands/install.js +++ b/tools/cli/commands/install.js @@ -71,14 +71,10 @@ module.exports = { console.log(chalk.dim(' • ElevenLabs AI (150+ premium voices)')); console.log(chalk.dim(' • Piper TTS (50+ free voices)\n')); - const { default: inquirer } = await import('inquirer'); - await inquirer.prompt([ - { - type: 'input', - name: 'continue', - message: chalk.green('Press Enter to start AgentVibes installer...'), - }, - ]); + const prompts = require('../lib/prompts'); + await prompts.text({ + message: chalk.green('Press Enter to start AgentVibes installer...'), + }); console.log(''); diff --git a/tools/cli/installers/lib/core/config-collector.js b/tools/cli/installers/lib/core/config-collector.js index fb48b68d..ee486955 100644 --- a/tools/cli/installers/lib/core/config-collector.js +++ b/tools/cli/installers/lib/core/config-collector.js @@ -4,15 +4,7 @@ const yaml = require('yaml'); const chalk = require('chalk'); const { getProjectRoot, getModulePath } = require('../../../lib/project-root'); const { CLIUtils } = require('../../../lib/cli-utils'); - -// Lazy-load inquirer (ESM module) to avoid ERR_REQUIRE_ESM -let _inquirer = null; -async function getInquirer() { - if (!_inquirer) { - _inquirer = (await import('inquirer')).default; - } - return _inquirer; -} +const prompts = require('../../../lib/prompts'); class ConfigCollector { constructor() { @@ -183,7 +175,6 @@ class ConfigCollector { * @returns {boolean} True if new fields were prompted, false if all fields existed */ async collectModuleConfigQuick(moduleName, projectDir, silentMode = true) { - const inquirer = await getInquirer(); this.currentProjectDir = projectDir; // Load existing config if not already loaded @@ -359,7 +350,7 @@ class ConfigCollector { // Only show header if we actually have questions CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader); console.log(); // Line break before questions - const promptedAnswers = await inquirer.prompt(questions); + const promptedAnswers = await prompts.prompt(questions); // Merge prompted answers with static answers Object.assign(allAnswers, promptedAnswers); @@ -502,7 +493,6 @@ class ConfigCollector { * @param {boolean} skipCompletion - Skip showing completion message (for early core collection) */ async collectModuleConfig(moduleName, projectDir, skipLoadExisting = false, skipCompletion = false) { - const inquirer = await getInquirer(); this.currentProjectDir = projectDir; // Load existing config if needed and not already loaded if (!skipLoadExisting && !this.existingConfig) { @@ -597,7 +587,7 @@ class ConfigCollector { console.log(chalk.cyan('?') + ' ' + chalk.magenta(moduleDisplayName)); let customize = true; if (moduleName !== 'core') { - const customizeAnswer = await inquirer.prompt([ + const customizeAnswer = await prompts.prompt([ { type: 'confirm', name: 'customize', @@ -614,7 +604,7 @@ class ConfigCollector { if (questionsWithoutDefaults.length > 0) { console.log(chalk.dim(`\n Asking required questions for ${moduleName.toUpperCase()}...`)); - const promptedAnswers = await inquirer.prompt(questionsWithoutDefaults); + const promptedAnswers = await prompts.prompt(questionsWithoutDefaults); Object.assign(allAnswers, promptedAnswers); } @@ -628,7 +618,7 @@ class ConfigCollector { allAnswers[question.name] = question.default; } } else { - const promptedAnswers = await inquirer.prompt(questions); + const promptedAnswers = await prompts.prompt(questions); Object.assign(allAnswers, promptedAnswers); } } @@ -750,7 +740,7 @@ class ConfigCollector { console.log(chalk.cyan('?') + ' ' + chalk.magenta(moduleDisplayName)); // Ask user if they want to accept defaults or customize on the next line - const { customize } = await inquirer.prompt([ + const { customize } = await prompts.prompt([ { type: 'confirm', name: 'customize', @@ -845,7 +835,7 @@ class ConfigCollector { } /** - * Build an inquirer question from a config item + * Build a prompt question from a config item * @param {string} moduleName - Module name * @param {string} key - Config key * @param {Object} item - Config item definition @@ -1007,7 +997,7 @@ class ConfigCollector { message: message, }; - // Set default - if it's dynamic, use a function that inquirer will evaluate with current answers + // Set default - if it's dynamic, use a function that the prompt will evaluate with current answers // But if we have an existing value, always use that instead if (existingValue !== null && existingValue !== undefined && questionType !== 'list') { question.default = existingValue; diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 8b7e05fd..97506847 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -16,6 +16,7 @@ const { CLIUtils } = require('../../../lib/cli-utils'); 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'; @@ -758,6 +759,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 { @@ -2139,15 +2143,11 @@ class Installer { * Private: Prompt for update action */ async promptUpdateAction() { - const { default: inquirer } = await import('inquirer'); - return await inquirer.prompt([ - { - type: 'list', - name: 'action', - message: 'What would you like to do?', - choices: [{ name: 'Update existing installation', value: 'update' }], - }, - ]); + const action = await prompts.select({ + message: 'What would you like to do?', + choices: [{ name: 'Update existing installation', value: 'update' }], + }); + return { action }; } /** @@ -2156,8 +2156,6 @@ class Installer { * @param {Object} _legacyV4 - Legacy V4 detection result (unused in simplified version) */ async handleLegacyV4Migration(_projectDir, _legacyV4) { - const { default: inquirer } = await import('inquirer'); - console.log(''); console.log(chalk.yellow.bold('⚠️ Legacy BMAD v4 detected')); console.log(chalk.yellow('─'.repeat(80))); @@ -2172,26 +2170,22 @@ class Installer { console.log(chalk.dim('If your v4 installation set up rules or commands, you should remove those as well.')); console.log(''); - const { proceed } = await inquirer.prompt([ - { - type: 'list', - name: 'proceed', - message: 'What would you like to do?', - choices: [ - { - name: 'Exit and clean up manually (recommended)', - value: 'exit', - short: 'Exit installation', - }, - { - name: 'Continue with installation anyway', - value: 'continue', - short: 'Continue', - }, - ], - default: 'exit', - }, - ]); + const proceed = await prompts.select({ + message: 'What would you like to do?', + choices: [ + { + name: 'Exit and clean up manually (recommended)', + value: 'exit', + hint: 'Exit installation', + }, + { + name: 'Continue with installation anyway', + value: 'continue', + hint: 'Continue', + }, + ], + default: 'exit', + }); if (proceed === 'exit') { console.log(''); @@ -2437,7 +2431,6 @@ class Installer { console.log(chalk.yellow(`\n⚠️ Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`)); - const { default: inquirer } = await import('inquirer'); let keptCount = 0; let updatedCount = 0; let removedCount = 0; @@ -2451,12 +2444,12 @@ class Installer { { name: 'Keep installed (will not be processed)', value: 'keep', - short: 'Keep', + hint: 'Keep', }, { name: 'Specify new source location', value: 'update', - short: 'Update', + hint: 'Update', }, ]; @@ -2465,47 +2458,40 @@ class Installer { choices.push({ name: '⚠️ REMOVE module completely (destructive!)', value: 'remove', - short: 'Remove', + hint: 'Remove', }); } - const { action } = await inquirer.prompt([ - { - type: 'list', - name: 'action', - message: `How would you like to handle "${missing.name}"?`, - choices, - }, - ]); + const action = await prompts.select({ + message: `How would you like to handle "${missing.name}"?`, + choices, + }); switch (action) { case 'update': { - const { newSourcePath } = await inquirer.prompt([ - { - type: 'input', - name: 'newSourcePath', - message: 'Enter the new path to the custom module:', - default: missing.sourcePath, - validate: async (input) => { - if (!input || input.trim() === '') { - return 'Please enter a path'; - } - const expandedPath = path.resolve(input.trim()); - if (!(await fs.pathExists(expandedPath))) { - return 'Path does not exist'; - } - // Check if it looks like a valid module - const moduleYamlPath = path.join(expandedPath, 'module.yaml'); - const agentsPath = path.join(expandedPath, 'agents'); - const workflowsPath = path.join(expandedPath, 'workflows'); + // Use sync validation because @clack/prompts doesn't support async validate + const newSourcePath = await prompts.text({ + message: 'Enter the new path to the custom module:', + default: missing.sourcePath, + validate: (input) => { + if (!input || input.trim() === '') { + return 'Please enter a path'; + } + const expandedPath = path.resolve(input.trim()); + if (!fs.pathExistsSync(expandedPath)) { + return 'Path does not exist'; + } + // Check if it looks like a valid module + const moduleYamlPath = path.join(expandedPath, 'module.yaml'); + const agentsPath = path.join(expandedPath, 'agents'); + const workflowsPath = path.join(expandedPath, 'workflows'); - if (!(await fs.pathExists(moduleYamlPath)) && !(await fs.pathExists(agentsPath)) && !(await fs.pathExists(workflowsPath))) { - return 'Path does not appear to contain a valid custom module'; - } - return true; - }, + if (!fs.pathExistsSync(moduleYamlPath) && !fs.pathExistsSync(agentsPath) && !fs.pathExistsSync(workflowsPath)) { + return 'Path does not appear to contain a valid custom module'; + } + return; // clack expects undefined for valid input }, - ]); + }); // Update the source in manifest const resolvedPath = path.resolve(newSourcePath.trim()); @@ -2531,46 +2517,38 @@ class Installer { console.log(chalk.red.bold(`\n⚠️ WARNING: This will PERMANENTLY DELETE "${missing.name}" and all its files!`)); console.log(chalk.red(` Module location: ${path.join(bmadDir, missing.id)}`)); - const { confirm } = await inquirer.prompt([ - { - type: 'confirm', - name: 'confirm', - message: chalk.red.bold('Are you absolutely sure you want to delete this module?'), - default: false, - }, - ]); + const confirmDelete = await prompts.confirm({ + message: chalk.red.bold('Are you absolutely sure you want to delete this module?'), + default: false, + }); - if (confirm) { - const { typedConfirm } = await inquirer.prompt([ - { - type: 'input', - name: 'typedConfirm', - message: chalk.red.bold('Type "DELETE" to confirm permanent deletion:'), - validate: (input) => { - if (input !== 'DELETE') { - return chalk.red('You must type "DELETE" exactly to proceed'); - } - return true; - }, + if (confirmDelete) { + const typedConfirm = await prompts.text({ + message: chalk.red.bold('Type "DELETE" to confirm permanent deletion:'), + validate: (input) => { + if (input !== 'DELETE') { + return chalk.red('You must type "DELETE" exactly to proceed'); + } + return; // clack expects undefined for valid input }, - ]); + }); if (typedConfirm === 'DELETE') { // Remove the module from filesystem and manifest - const modulePath = path.join(bmadDir, moduleId); + const modulePath = path.join(bmadDir, missing.id); if (await fs.pathExists(modulePath)) { const fsExtra = require('fs-extra'); await fsExtra.remove(modulePath); console.log(chalk.yellow(` ✓ Deleted module directory: ${path.relative(projectRoot, modulePath)}`)); } - await this.manifest.removeModule(bmadDir, moduleId); - await this.manifest.removeCustomModule(bmadDir, moduleId); + await this.manifest.removeModule(bmadDir, missing.id); + await this.manifest.removeCustomModule(bmadDir, missing.id); console.log(chalk.yellow(` ✓ Removed from manifest`)); // Also remove from installedModules list - if (installedModules && installedModules.includes(moduleId)) { - const index = installedModules.indexOf(moduleId); + if (installedModules && installedModules.includes(missing.id)) { + const index = installedModules.indexOf(missing.id); if (index !== -1) { installedModules.splice(index, 1); } @@ -2591,7 +2569,7 @@ class Installer { } case 'keep': { keptCount++; - keptModulesWithoutSources.push(moduleId); + keptModulesWithoutSources.push(missing.id); console.log(chalk.dim(` Module will be kept as-is`)); break; diff --git a/tools/cli/installers/lib/ide/antigravity.js b/tools/cli/installers/lib/ide/antigravity.js index c896d62d..57071cdc 100644 --- a/tools/cli/installers/lib/ide/antigravity.js +++ b/tools/cli/installers/lib/ide/antigravity.js @@ -13,6 +13,7 @@ const { resolveSubagentFiles, } = require('./shared/module-injections'); const { getAgentsFromBmad, getAgentsFromDir } = require('./shared/bmad-artifacts'); +const prompts = require('../../../lib/prompts'); /** * Google Antigravity IDE setup handler @@ -26,6 +27,21 @@ class AntigravitySetup extends BaseIdeSetup { this.workflowsDir = 'workflows'; } + /** + * Prompt for subagent installation location + * @returns {Promise} Selected location ('project' or 'user') + */ + async _promptInstallLocation() { + return prompts.select({ + message: 'Where would you like to install Antigravity subagents?', + choices: [ + { name: 'Project level (.agent/agents/)', value: 'project' }, + { name: 'User level (~/.agent/agents/)', value: 'user' }, + ], + default: 'project', + }); + } + /** * Collect configuration choices before installation * @param {Object} options - Configuration options @@ -57,21 +73,7 @@ class AntigravitySetup extends BaseIdeSetup { config.subagentChoices = await this.promptSubagentInstallation(injectionConfig.subagents); if (config.subagentChoices.install !== 'none') { - // Ask for installation location - const { default: inquirer } = await import('inquirer'); - const locationAnswer = await inquirer.prompt([ - { - type: 'list', - name: 'location', - message: 'Where would you like to install Antigravity subagents?', - choices: [ - { name: 'Project level (.agent/agents/)', value: 'project' }, - { name: 'User level (~/.agent/agents/)', value: 'user' }, - ], - default: 'project', - }, - ]); - config.installLocation = locationAnswer.location; + config.installLocation = await this._promptInstallLocation(); } } } catch (error) { @@ -297,20 +299,7 @@ class AntigravitySetup extends BaseIdeSetup { choices = await this.promptSubagentInstallation(config.subagents); if (choices.install !== 'none') { - const { default: inquirer } = await import('inquirer'); - const locationAnswer = await inquirer.prompt([ - { - type: 'list', - name: 'location', - message: 'Where would you like to install Antigravity subagents?', - choices: [ - { name: 'Project level (.agent/agents/)', value: 'project' }, - { name: 'User level (~/.agent/agents/)', value: 'user' }, - ], - default: 'project', - }, - ]); - location = locationAnswer.location; + location = await this._promptInstallLocation(); } } @@ -334,22 +323,16 @@ class AntigravitySetup extends BaseIdeSetup { * Prompt user for subagent installation preferences */ async promptSubagentInstallation(subagentConfig) { - const { default: inquirer } = await import('inquirer'); - // First ask if they want to install subagents - const { install } = await inquirer.prompt([ - { - type: 'list', - name: 'install', - message: 'Would you like to install Antigravity subagents for enhanced functionality?', - choices: [ - { name: 'Yes, install all subagents', value: 'all' }, - { name: 'Yes, let me choose specific subagents', value: 'selective' }, - { name: 'No, skip subagent installation', value: 'none' }, - ], - default: 'all', - }, - ]); + const install = await prompts.select({ + message: 'Would you like to install Antigravity subagents for enhanced functionality?', + choices: [ + { name: 'Yes, install all subagents', value: 'all' }, + { name: 'Yes, let me choose specific subagents', value: 'selective' }, + { name: 'No, skip subagent installation', value: 'none' }, + ], + default: 'all', + }); if (install === 'selective') { // Show list of available subagents with descriptions @@ -361,18 +344,14 @@ class AntigravitySetup extends BaseIdeSetup { 'document-reviewer.md': 'Document quality review', }; - const { selected } = await inquirer.prompt([ - { - type: 'checkbox', - name: 'selected', - message: 'Select subagents to install:', - choices: subagentConfig.files.map((file) => ({ - name: `${file.replace('.md', '')} - ${subagentInfo[file] || 'Specialized assistant'}`, - value: file, - checked: true, - })), - }, - ]); + const selected = await prompts.multiselect({ + message: `Select subagents to install ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`, + choices: subagentConfig.files.map((file) => ({ + name: `${file.replace('.md', '')} - ${subagentInfo[file] || 'Specialized assistant'}`, + value: file, + checked: true, + })), + }); return { install: 'selective', selected }; } diff --git a/tools/cli/installers/lib/ide/claude-code.js b/tools/cli/installers/lib/ide/claude-code.js index f2a33221..02c65b40 100644 --- a/tools/cli/installers/lib/ide/claude-code.js +++ b/tools/cli/installers/lib/ide/claude-code.js @@ -13,6 +13,7 @@ const { resolveSubagentFiles, } = require('./shared/module-injections'); const { getAgentsFromBmad, getAgentsFromDir } = require('./shared/bmad-artifacts'); +const prompts = require('../../../lib/prompts'); /** * Claude Code IDE setup handler @@ -25,6 +26,21 @@ class ClaudeCodeSetup extends BaseIdeSetup { this.agentsDir = 'agents'; } + /** + * Prompt for subagent installation location + * @returns {Promise} Selected location ('project' or 'user') + */ + async promptInstallLocation() { + return prompts.select({ + message: 'Where would you like to install Claude Code subagents?', + choices: [ + { name: 'Project level (.claude/agents/)', value: 'project' }, + { name: 'User level (~/.claude/agents/)', value: 'user' }, + ], + default: 'project', + }); + } + /** * Collect configuration choices before installation * @param {Object} options - Configuration options @@ -56,21 +72,7 @@ class ClaudeCodeSetup extends BaseIdeSetup { config.subagentChoices = await this.promptSubagentInstallation(injectionConfig.subagents); if (config.subagentChoices.install !== 'none') { - // Ask for installation location - const { default: inquirer } = await import('inquirer'); - const locationAnswer = await inquirer.prompt([ - { - type: 'list', - name: 'location', - message: 'Where would you like to install Claude Code subagents?', - choices: [ - { name: 'Project level (.claude/agents/)', value: 'project' }, - { name: 'User level (~/.claude/agents/)', value: 'user' }, - ], - default: 'project', - }, - ]); - config.installLocation = locationAnswer.location; + config.installLocation = await this.promptInstallLocation(); } } } catch (error) { @@ -305,20 +307,7 @@ class ClaudeCodeSetup extends BaseIdeSetup { choices = await this.promptSubagentInstallation(config.subagents); if (choices.install !== 'none') { - const { default: inquirer } = await import('inquirer'); - const locationAnswer = await inquirer.prompt([ - { - type: 'list', - name: 'location', - message: 'Where would you like to install Claude Code subagents?', - choices: [ - { name: 'Project level (.claude/agents/)', value: 'project' }, - { name: 'User level (~/.claude/agents/)', value: 'user' }, - ], - default: 'project', - }, - ]); - location = locationAnswer.location; + location = await this.promptInstallLocation(); } } @@ -342,22 +331,16 @@ class ClaudeCodeSetup extends BaseIdeSetup { * Prompt user for subagent installation preferences */ async promptSubagentInstallation(subagentConfig) { - const { default: inquirer } = await import('inquirer'); - // First ask if they want to install subagents - const { install } = await inquirer.prompt([ - { - type: 'list', - name: 'install', - message: 'Would you like to install Claude Code subagents for enhanced functionality?', - choices: [ - { name: 'Yes, install all subagents', value: 'all' }, - { name: 'Yes, let me choose specific subagents', value: 'selective' }, - { name: 'No, skip subagent installation', value: 'none' }, - ], - default: 'all', - }, - ]); + const install = await prompts.select({ + message: 'Would you like to install Claude Code subagents for enhanced functionality?', + choices: [ + { name: 'Yes, install all subagents', value: 'all' }, + { name: 'Yes, let me choose specific subagents', value: 'selective' }, + { name: 'No, skip subagent installation', value: 'none' }, + ], + default: 'all', + }); if (install === 'selective') { // Show list of available subagents with descriptions @@ -369,18 +352,14 @@ class ClaudeCodeSetup extends BaseIdeSetup { 'document-reviewer.md': 'Document quality review', }; - const { selected } = await inquirer.prompt([ - { - type: 'checkbox', - name: 'selected', - message: 'Select subagents to install:', - choices: subagentConfig.files.map((file) => ({ - name: `${file.replace('.md', '')} - ${subagentInfo[file] || 'Specialized assistant'}`, - value: file, - checked: true, - })), - }, - ]); + const selected = await prompts.multiselect({ + message: `Select subagents to install ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`, + options: subagentConfig.files.map((file) => ({ + label: `${file.replace('.md', '')} - ${subagentInfo[file] || 'Specialized assistant'}`, + value: file, + })), + initialValues: subagentConfig.files, + }); return { install: 'selective', selected }; } diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index 3ce9d910..e037a779 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -6,6 +6,7 @@ const { BaseIdeSetup } = require('./_base-ide'); const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator'); const { AgentCommandGenerator } = require('./shared/agent-command-generator'); const { getTasksFromBmad } = require('./shared/bmad-artifacts'); +const prompts = require('../../../lib/prompts'); /** * Codex setup handler (CLI mode) @@ -21,32 +22,24 @@ class CodexSetup extends BaseIdeSetup { * @returns {Object} Collected configuration */ async collectConfiguration(options = {}) { - const { default: inquirer } = await import('inquirer'); - let confirmed = false; let installLocation = 'global'; while (!confirmed) { - const { location } = await inquirer.prompt([ - { - type: 'list', - name: 'location', - message: 'Where would you like to install Codex CLI prompts?', - choices: [ - { - name: 'Global - Simple for single project ' + '(~/.codex/prompts, but references THIS project only)', - value: 'global', - }, - { - name: `Project-specific - Recommended for real work (requires CODEX_HOME=${path.sep}.codex)`, - value: 'project', - }, - ], - default: 'global', - }, - ]); - - installLocation = location; + installLocation = await prompts.select({ + message: 'Where would you like to install Codex CLI prompts?', + choices: [ + { + name: 'Global - Simple for single project ' + '(~/.codex/prompts, but references THIS project only)', + value: 'global', + }, + { + name: `Project-specific - Recommended for real work (requires CODEX_HOME=${path.sep}.codex)`, + value: 'project', + }, + ], + default: 'global', + }); // Display detailed instructions for the chosen option console.log(''); @@ -57,16 +50,10 @@ class CodexSetup extends BaseIdeSetup { } // Confirm the choice - const { proceed } = await inquirer.prompt([ - { - type: 'confirm', - name: 'proceed', - message: 'Proceed with this installation option?', - default: true, - }, - ]); - - confirmed = proceed; + confirmed = await prompts.confirm({ + message: 'Proceed with this installation option?', + default: true, + }); if (!confirmed) { console.log(chalk.yellow("\n Let's choose a different installation option.\n")); diff --git a/tools/cli/installers/lib/ide/github-copilot.js b/tools/cli/installers/lib/ide/github-copilot.js index b9dd5f98..c500a284 100644 --- a/tools/cli/installers/lib/ide/github-copilot.js +++ b/tools/cli/installers/lib/ide/github-copilot.js @@ -2,6 +2,7 @@ const path = require('node:path'); const { BaseIdeSetup } = require('./_base-ide'); const chalk = require('chalk'); const { AgentCommandGenerator } = require('./shared/agent-command-generator'); +const prompts = require('../../../lib/prompts'); /** * GitHub Copilot setup handler @@ -21,29 +22,23 @@ class GitHubCopilotSetup extends BaseIdeSetup { * @returns {Object} Collected configuration */ async collectConfiguration(options = {}) { - const { default: inquirer } = await import('inquirer'); const config = {}; console.log('\n' + chalk.blue(' 🔧 VS Code Settings Configuration')); console.log(chalk.dim(' GitHub Copilot works best with specific settings\n')); - const response = await inquirer.prompt([ - { - type: 'list', - name: 'configChoice', - message: 'How would you like to configure VS Code settings?', - choices: [ - { name: 'Use recommended defaults (fastest)', value: 'defaults' }, - { name: 'Configure each setting manually', value: 'manual' }, - { name: 'Skip settings configuration', value: 'skip' }, - ], - default: 'defaults', - }, - ]); - config.vsCodeConfig = response.configChoice; + config.vsCodeConfig = await prompts.select({ + message: 'How would you like to configure VS Code settings?', + choices: [ + { name: 'Use recommended defaults (fastest)', value: 'defaults' }, + { name: 'Configure each setting manually', value: 'manual' }, + { name: 'Skip settings configuration', value: 'skip' }, + ], + default: 'defaults', + }); - if (response.configChoice === 'manual') { - config.manualSettings = await inquirer.prompt([ + if (config.vsCodeConfig === 'manual') { + config.manualSettings = await prompts.prompt([ { type: 'input', name: 'maxRequests', @@ -52,7 +47,8 @@ class GitHubCopilotSetup extends BaseIdeSetup { validate: (input) => { const num = parseInt(input, 10); if (isNaN(num)) return 'Enter a valid number 1-50'; - return (num >= 1 && num <= 50) || 'Enter 1-50'; + if (num < 1 || num > 50) return 'Enter a number between 1-50'; + return true; }, }, { diff --git a/tools/cli/lib/prompts.js b/tools/cli/lib/prompts.js new file mode 100644 index 00000000..96b80ba1 --- /dev/null +++ b/tools/cli/lib/prompts.js @@ -0,0 +1,432 @@ +/** + * @clack/prompts wrapper for BMAD CLI + * + * This module provides a unified interface for CLI prompts using @clack/prompts. + * It replaces Inquirer.js to fix Windows arrow key navigation issues (libuv #852). + * + * @module prompts + */ + +let _clack = null; + +/** + * Lazy-load @clack/prompts (ESM module) + * @returns {Promise} The clack prompts module + */ +async function getClack() { + if (!_clack) { + _clack = await import('@clack/prompts'); + } + return _clack; +} + +/** + * Handle user cancellation gracefully + * @param {any} value - The value to check + * @param {string} [message='Operation cancelled'] - Message to display + * @returns {boolean} True if cancelled + */ +async function handleCancel(value, message = 'Operation cancelled') { + const clack = await getClack(); + if (clack.isCancel(value)) { + clack.cancel(message); + process.exit(0); + } + return false; +} + +/** + * Display intro message + * @param {string} message - The intro message + */ +async function intro(message) { + const clack = await getClack(); + clack.intro(message); +} + +/** + * Display outro message + * @param {string} message - The outro message + */ +async function outro(message) { + const clack = await getClack(); + clack.outro(message); +} + +/** + * Display a note/info box + * @param {string} message - The note content + * @param {string} [title] - Optional title + */ +async function note(message, title) { + const clack = await getClack(); + clack.note(message, title); +} + +/** + * Display a spinner for async operations + * @returns {Object} Spinner controller with start, stop, message methods + */ +async function spinner() { + const clack = await getClack(); + return clack.spinner(); +} + +/** + * Single-select prompt (replaces Inquirer 'list' type) + * @param {Object} options - Prompt options + * @param {string} options.message - The question to ask + * @param {Array} options.choices - Array of choices [{name, value, hint?}] + * @param {any} [options.default] - Default selected value + * @returns {Promise} Selected value + */ +async function select(options) { + const clack = await getClack(); + + // Convert Inquirer-style choices to clack format + // Handle both object choices {name, value, hint} and primitive choices (string/number) + const clackOptions = options.choices + .filter((c) => c.type !== 'separator') // Skip separators for now + .map((choice) => { + if (typeof choice === 'string' || typeof choice === 'number') { + return { value: choice, label: String(choice) }; + } + return { + value: choice.value === undefined ? choice.name : choice.value, + label: choice.name || choice.label || String(choice.value), + hint: choice.hint || choice.description, + }; + }); + + // Find initial value + let initialValue; + if (options.default !== undefined) { + initialValue = options.default; + } + + const result = await clack.select({ + message: options.message, + options: clackOptions, + initialValue, + }); + + await handleCancel(result); + return result; +} + +/** + * Multi-select prompt (replaces Inquirer 'checkbox' type) + * @param {Object} options - Prompt options + * @param {string} options.message - The question to ask + * @param {Array} options.choices - Array of choices [{name, value, checked?, hint?}] + * @param {boolean} [options.required=false] - Whether at least one must be selected + * @returns {Promise} Array of selected values + */ +async function multiselect(options) { + const clack = await getClack(); + + // Support both clack-native (options) and Inquirer-style (choices) APIs + let clackOptions; + let initialValues; + + if (options.options) { + // Native clack format: options with label/value + clackOptions = options.options; + initialValues = options.initialValues || []; + } else { + // Convert Inquirer-style choices to clack format + // Handle both object choices {name, value, hint} and primitive choices (string/number) + clackOptions = options.choices + .filter((c) => c.type !== 'separator') // Skip separators + .map((choice) => { + if (typeof choice === 'string' || typeof choice === 'number') { + return { value: choice, label: String(choice) }; + } + return { + value: choice.value === undefined ? choice.name : choice.value, + label: choice.name || choice.label || String(choice.value), + hint: choice.hint || choice.description, + }; + }); + + // Find initial values (pre-checked items) + initialValues = options.choices + .filter((c) => c.checked && c.type !== 'separator') + .map((c) => (c.value === undefined ? c.name : c.value)); + } + + const result = await clack.multiselect({ + message: options.message, + options: clackOptions, + initialValues: initialValues.length > 0 ? initialValues : undefined, + required: options.required || false, + }); + + await handleCancel(result); + return result; +} + +/** + * Grouped multi-select prompt for categorized options + * @param {Object} options - Prompt options + * @param {string} options.message - The question to ask + * @param {Object} options.options - Object mapping group names to arrays of choices + * @param {Array} [options.initialValues] - Array of initially selected values + * @param {boolean} [options.required=false] - Whether at least one must be selected + * @param {boolean} [options.selectableGroups=false] - Whether groups can be selected as a whole + * @returns {Promise} Array of selected values + */ +async function groupMultiselect(options) { + const clack = await getClack(); + + const result = await clack.groupMultiselect({ + message: options.message, + options: options.options, + initialValues: options.initialValues, + required: options.required || false, + }); + + await handleCancel(result); + return result; +} + +/** + * Confirm prompt (replaces Inquirer 'confirm' type) + * @param {Object} options - Prompt options + * @param {string} options.message - The question to ask + * @param {boolean} [options.default=true] - Default value + * @returns {Promise} User's answer + */ +async function confirm(options) { + const clack = await getClack(); + + const result = await clack.confirm({ + message: options.message, + initialValue: options.default === undefined ? true : options.default, + }); + + await handleCancel(result); + return result; +} + +/** + * 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 + * @param {string} [options.placeholder] - Placeholder text (defaults to options.default if not provided) + * @param {Function} [options.validate] - Validation function + * @returns {Promise} User's input + */ +async function text(options) { + 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 result = await clack.text({ + message: options.message, + defaultValue: options.default, + placeholder: typeof placeholder === 'string' ? placeholder : undefined, + validate: options.validate, + }); + + await handleCancel(result); + return result; +} + +/** + * Password input prompt (replaces Inquirer 'password' type) + * @param {Object} options - Prompt options + * @param {string} options.message - The question to ask + * @param {Function} [options.validate] - Validation function + * @returns {Promise} User's input + */ +async function password(options) { + const clack = await getClack(); + + const result = await clack.password({ + message: options.message, + validate: options.validate, + }); + + await handleCancel(result); + return result; +} + +/** + * Group multiple prompts together + * @param {Object} prompts - Object of prompt functions + * @param {Object} [options] - Group options + * @returns {Promise} Object with all answers + */ +async function group(prompts, options = {}) { + const clack = await getClack(); + + const result = await clack.group(prompts, { + onCancel: () => { + clack.cancel('Operation cancelled'); + process.exit(0); + }, + ...options, + }); + + return result; +} + +/** + * Run tasks with spinner feedback + * @param {Array} tasks - Array of task objects [{title, task, enabled?}] + * @returns {Promise} + */ +async function tasks(taskList) { + const clack = await getClack(); + await clack.tasks(taskList); +} + +/** + * Log messages with styling + */ +const log = { + async info(message) { + const clack = await getClack(); + clack.log.info(message); + }, + async success(message) { + const clack = await getClack(); + clack.log.success(message); + }, + async warn(message) { + const clack = await getClack(); + clack.log.warn(message); + }, + async error(message) { + const clack = await getClack(); + clack.log.error(message); + }, + async message(message) { + const clack = await getClack(); + clack.log.message(message); + }, + async step(message) { + const clack = await getClack(); + clack.log.step(message); + }, +}; + +/** + * Execute an array of Inquirer-style questions using @clack/prompts + * This provides compatibility with dynamic question arrays + * @param {Array} questions - Array of Inquirer-style question objects + * @returns {Promise} Object with answers keyed by question name + */ +async function prompt(questions) { + const answers = {}; + + for (const question of questions) { + const { type, name, message, choices, default: defaultValue, validate, when } = question; + + // Handle conditional questions via 'when' property + if (when !== undefined) { + const shouldAsk = typeof when === 'function' ? await when(answers) : when; + if (!shouldAsk) continue; + } + + let answer; + + switch (type) { + case 'input': { + // Note: @clack/prompts doesn't support async validation, so validate must be sync + answer = await text({ + message, + default: typeof defaultValue === 'function' ? defaultValue(answers) : defaultValue, + validate: validate + ? (val) => { + const result = validate(val, answers); + if (result instanceof Promise) { + throw new TypeError('Async validation is not supported by @clack/prompts. Please use synchronous validation.'); + } + return result === true ? undefined : result; + } + : undefined, + }); + break; + } + + case 'confirm': { + answer = await confirm({ + message, + default: typeof defaultValue === 'function' ? defaultValue(answers) : defaultValue, + }); + break; + } + + case 'list': { + answer = await select({ + message, + choices: choices || [], + default: typeof defaultValue === 'function' ? defaultValue(answers) : defaultValue, + }); + break; + } + + case 'checkbox': { + answer = await multiselect({ + message, + choices: choices || [], + required: false, + }); + break; + } + + case 'password': { + // Note: @clack/prompts doesn't support async validation, so validate must be sync + answer = await password({ + message, + validate: validate + ? (val) => { + const result = validate(val, answers); + if (result instanceof Promise) { + throw new TypeError('Async validation is not supported by @clack/prompts. Please use synchronous validation.'); + } + return result === true ? undefined : result; + } + : undefined, + }); + break; + } + + default: { + // Default to text input for unknown types + answer = await text({ + message, + default: typeof defaultValue === 'function' ? defaultValue(answers) : defaultValue, + }); + } + } + + answers[name] = answer; + } + + return answers; +} + +module.exports = { + getClack, + handleCancel, + intro, + outro, + note, + spinner, + select, + multiselect, + groupMultiselect, + confirm, + text, + password, + group, + tasks, + log, + prompt, +}; diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 85f31ce1..a78cfec9 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -4,16 +4,21 @@ const os = require('node:os'); const fs = require('fs-extra'); const { CLIUtils } = require('./cli-utils'); const { CustomHandler } = require('../installers/lib/custom/handler'); +const prompts = require('./prompts'); -// Lazy-load inquirer (ESM module) to avoid ERR_REQUIRE_ESM -let _inquirer = null; -async function getInquirer() { - if (!_inquirer) { - _inquirer = (await import('inquirer')).default; +// Separator class for visual grouping in select/multiselect prompts +// Note: @clack/prompts doesn't support separators natively, they are filtered out +class Separator { + constructor(text = '────────') { + this.line = text; + this.name = text; } - return _inquirer; + type = 'separator'; } +// Separator for choice lists (compatible interface) +const choiceUtils = { Separator }; + /** * UI utilities for the installer */ @@ -23,7 +28,6 @@ class UI { * @returns {Object} Installation configuration */ async promptInstall() { - const inquirer = await getInquirer(); CLIUtils.displayLogo(); // Display version-specific start message from install-messages.yaml @@ -113,26 +117,20 @@ class UI { console.log(chalk.yellow('─'.repeat(80))); console.log(''); - const { proceed } = await inquirer.prompt([ - { - type: 'list', - name: 'proceed', - message: 'What would you like to do?', - choices: [ - { - name: 'Cancel and do a fresh install (recommended)', - value: 'cancel', - short: 'Cancel installation', - }, - { - name: 'Proceed anyway (will attempt update, potentially may fail or have unstable behavior)', - value: 'proceed', - short: 'Proceed with update', - }, - ], - default: 'cancel', - }, - ]); + const proceed = await prompts.select({ + message: 'What would you like to do?', + choices: [ + { + name: 'Cancel and do a fresh install (recommended)', + value: 'cancel', + }, + { + name: 'Proceed anyway (will attempt update, potentially may fail or have unstable behavior)', + value: 'proceed', + }, + ], + default: 'cancel', + }); if (proceed === 'cancel') { console.log(''); @@ -188,14 +186,10 @@ class UI { // If Claude Code was selected, ask about TTS if (claudeCodeSelected) { - const { enableTts } = await inquirer.prompt([ - { - type: 'confirm', - name: 'enableTts', - message: 'Claude Code supports TTS (Text-to-Speech). Would you like to enable it?', - default: false, - }, - ]); + const enableTts = await prompts.confirm({ + message: 'Claude Code supports TTS (Text-to-Speech). Would you like to enable it?', + default: false, + }); if (enableTts) { agentVibesConfig = { enabled: true, alreadyInstalled: false }; @@ -250,18 +244,11 @@ class UI { // Common actions choices.push({ name: 'Modify BMAD Installation', value: 'update' }); - const promptResult = await inquirer.prompt([ - { - type: 'list', - name: 'actionType', - message: 'What would you like to do?', - choices: choices, - default: choices[0].value, // Use the first option as default - }, - ]); - - // Extract actionType from prompt result - actionType = promptResult.actionType; + actionType = await prompts.select({ + message: 'What would you like to do?', + choices: choices, + default: choices[0].value, + }); // Handle quick update separately if (actionType === 'quick-update') { @@ -290,14 +277,10 @@ class UI { const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory); console.log(chalk.dim(` Found existing modules: ${[...installedModuleIds].join(', ')}`)); - const { changeModuleSelection } = await inquirer.prompt([ - { - type: 'confirm', - name: 'changeModuleSelection', - message: 'Modify official module selection (BMad Method, BMad Builder, Creative Innovation Suite)?', - default: false, - }, - ]); + const changeModuleSelection = await prompts.confirm({ + message: 'Modify official module selection (BMad Method, BMad Builder, Creative Innovation Suite)?', + default: false, + }); let selectedModules = []; if (changeModuleSelection) { @@ -310,14 +293,10 @@ class UI { // After module selection, ask about custom modules console.log(''); - const { changeCustomModules } = await inquirer.prompt([ - { - type: 'confirm', - name: 'changeCustomModules', - message: 'Modify custom module selection (add, update, or remove custom modules/agents/workflows)?', - default: false, - }, - ]); + const changeCustomModules = await prompts.confirm({ + message: 'Modify custom module selection (add, update, or remove custom modules/agents/workflows)?', + default: false, + }); let customModuleResult = { selectedCustomModules: [], customContentConfig: { hasCustomContent: false } }; if (changeCustomModules) { @@ -352,15 +331,10 @@ class UI { let enableTts = false; if (hasClaudeCode) { - const { enableTts: enable } = await inquirer.prompt([ - { - type: 'confirm', - name: 'enableTts', - message: 'Claude Code supports TTS (Text-to-Speech). Would you like to enable it?', - default: false, - }, - ]); - enableTts = enable; + enableTts = await prompts.confirm({ + message: 'Claude Code supports TTS (Text-to-Speech). Would you like to enable it?', + default: false, + }); } // Core config with existing defaults (ask after TTS) @@ -385,14 +359,10 @@ class UI { const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory); // Ask about official modules for new installations - const { wantsOfficialModules } = await inquirer.prompt([ - { - type: 'confirm', - name: 'wantsOfficialModules', - message: 'Will you be installing any official BMad modules (BMad Method, BMad Builder, Creative Innovation Suite)?', - default: true, - }, - ]); + const wantsOfficialModules = await prompts.confirm({ + message: 'Will you be installing any official BMad modules (BMad Method, BMad Builder, Creative Innovation Suite)?', + default: true, + }); let selectedOfficialModules = []; if (wantsOfficialModules) { @@ -401,14 +371,10 @@ class UI { } // Ask about custom content - const { wantsCustomContent } = await inquirer.prompt([ - { - type: 'confirm', - name: 'wantsCustomContent', - message: 'Would you like to install a local custom module (this includes custom agents and workflows also)?', - default: false, - }, - ]); + const wantsCustomContent = await prompts.confirm({ + message: 'Would you like to install a local custom module (this includes custom agents and workflows also)?', + default: false, + }); if (wantsCustomContent) { customContentConfig = await this.promptCustomContentSource(); @@ -459,7 +425,6 @@ class UI { * @returns {Object} Tool configuration */ async promptToolSelection(projectDir, selectedModules) { - const inquirer = await getInquirer(); // Check for existing configured IDEs - use findBmadDir to detect custom folder names const { Detector } = require('../installers/lib/core/detector'); const { Installer } = require('../installers/lib/core/installer'); @@ -477,13 +442,14 @@ class UI { const preferredIdes = ideManager.getPreferredIdes(); const otherIdes = ideManager.getOtherIdes(); - // Build IDE choices array with separators - const ideChoices = []; + // Build grouped options object for groupMultiselect + const groupedOptions = {}; const processedIdes = new Set(); + const initialValues = []; // First, add previously configured IDEs at the top, marked with ✅ if (configuredIdes.length > 0) { - ideChoices.push(new inquirer.Separator('── Previously Configured ──')); + const configuredGroup = []; for (const ideValue of configuredIdes) { // Skip empty or invalid IDE values if (!ideValue || typeof ideValue !== 'string') { @@ -496,81 +462,71 @@ class UI { const ide = preferredIde || otherIde; if (ide) { - ideChoices.push({ - name: `${ide.name} ✅`, + configuredGroup.push({ + label: `${ide.name} ✅`, value: ide.value, - checked: true, // Previously configured IDEs are checked by default }); 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; + } } // Add preferred tools (excluding already processed) const remainingPreferred = preferredIdes.filter((ide) => !processedIdes.has(ide.value)); if (remainingPreferred.length > 0) { - ideChoices.push(new inquirer.Separator('── Recommended Tools ──')); - for (const ide of remainingPreferred) { - ideChoices.push({ - name: `${ide.name} ⭐`, - value: ide.value, - checked: false, - }); + groupedOptions['Recommended Tools'] = remainingPreferred.map((ide) => { processedIdes.add(ide.value); - } + return { + label: `${ide.name} ⭐`, + value: ide.value, + }; + }); } // Add other tools (excluding already processed) const remainingOther = otherIdes.filter((ide) => !processedIdes.has(ide.value)); if (remainingOther.length > 0) { - ideChoices.push(new inquirer.Separator('── Additional Tools ──')); - for (const ide of remainingOther) { - ideChoices.push({ - name: ide.name, - value: ide.value, - checked: false, - }); - } + groupedOptions['Additional Tools'] = remainingOther.map((ide) => ({ + label: ide.name, + value: ide.value, + })); } - let answers; + let selectedIdes = []; let userConfirmedNoTools = false; // Loop until user selects at least one tool OR explicitly confirms no tools while (!userConfirmedNoTools) { - answers = await inquirer.prompt([ - { - type: 'checkbox', - name: 'ides', - message: 'Select tools to configure:', - choices: ideChoices, - pageSize: 30, - }, - ]); + selectedIdes = await prompts.groupMultiselect({ + message: `Select tools to configure ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`, + options: groupedOptions, + initialValues: initialValues.length > 0 ? initialValues : undefined, + required: false, + }); // If tools were selected, we're done - if (answers.ides && answers.ides.length > 0) { + if (selectedIdes && selectedIdes.length > 0) { break; } // Warn that no tools were selected - users often miss the spacebar requirement console.log(); console.log(chalk.red.bold('⚠️ WARNING: No tools were selected!')); - console.log(chalk.red(' You must press SPACEBAR to select items, then ENTER to confirm.')); + console.log(chalk.red(' You must press SPACE to select items, then ENTER to confirm.')); console.log(chalk.red(' Simply highlighting an item does NOT select it.')); console.log(); - const { goBack } = await inquirer.prompt([ - { - type: 'confirm', - name: 'goBack', - message: chalk.yellow('Would you like to go back and select at least one tool?'), - default: true, - }, - ]); + const goBack = await prompts.confirm({ + message: chalk.yellow('Would you like to go back and select at least one tool?'), + default: true, + }); if (goBack) { // Re-display a message before looping back @@ -582,8 +538,8 @@ class UI { } return { - ides: answers.ides || [], - skipIde: !answers.ides || answers.ides.length === 0, + ides: selectedIdes || [], + skipIde: !selectedIdes || selectedIdes.length === 0, }; } @@ -592,23 +548,17 @@ class UI { * @returns {Object} Update configuration */ async promptUpdate() { - const inquirer = await getInquirer(); - const answers = await inquirer.prompt([ - { - type: 'confirm', - name: 'backupFirst', - message: 'Create backup before updating?', - default: true, - }, - { - type: 'confirm', - name: 'preserveCustomizations', - message: 'Preserve local customizations?', - default: true, - }, - ]); + const backupFirst = await prompts.confirm({ + message: 'Create backup before updating?', + default: true, + }); - return answers; + const preserveCustomizations = await prompts.confirm({ + message: 'Preserve local customizations?', + default: true, + }); + + return { backupFirst, preserveCustomizations }; } /** @@ -617,27 +567,17 @@ class UI { * @returns {Array} Selected modules */ async promptModules(modules) { - const inquirer = await getInquirer(); const choices = modules.map((mod) => ({ name: `${mod.name} - ${mod.description}`, value: mod.id, checked: false, })); - const { selectedModules } = await inquirer.prompt([ - { - type: 'checkbox', - name: 'selectedModules', - message: 'Select modules to add:', - choices, - validate: (answer) => { - if (answer.length === 0) { - return 'You must choose at least one module.'; - } - return true; - }, - }, - ]); + const selectedModules = await prompts.multiselect({ + message: `Select modules to add ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`, + choices, + required: true, + }); return selectedModules; } @@ -649,17 +589,10 @@ class UI { * @returns {boolean} User confirmation */ async confirm(message, defaultValue = false) { - const inquirer = await getInquirer(); - const { confirmed } = await inquirer.prompt([ - { - type: 'confirm', - name: 'confirmed', - message, - default: defaultValue, - }, - ]); - - return confirmed; + return await prompts.confirm({ + message, + default: defaultValue, + }); } /** @@ -753,10 +686,9 @@ class UI { * Get module choices for selection * @param {Set} installedModuleIds - Currently installed module IDs * @param {Object} customContentConfig - Custom content configuration - * @returns {Array} Module choices for inquirer + * @returns {Array} Module choices for prompt */ async getModuleChoices(installedModuleIds, customContentConfig = null) { - const inquirer = await getInquirer(); const moduleChoices = []; const isNewInstallation = installedModuleIds.size === 0; @@ -811,9 +743,9 @@ class UI { if (allCustomModules.length > 0) { // Add separator for custom content, all custom modules, and official content separator moduleChoices.push( - new inquirer.Separator('── Custom Content ──'), + new choiceUtils.Separator('── Custom Content ──'), ...allCustomModules, - new inquirer.Separator('── Official Content ──'), + new choiceUtils.Separator('── Official Content ──'), ); } @@ -837,44 +769,43 @@ class UI { * @returns {Array} Selected module IDs */ async selectModules(moduleChoices, defaultSelections = []) { - const inquirer = await getInquirer(); - const moduleAnswer = await inquirer.prompt([ - { - type: 'checkbox', - name: 'modules', - message: 'Select modules to install:', - choices: moduleChoices, - default: defaultSelections, - }, - ]); + // Mark choices as checked based on defaultSelections + const choicesWithDefaults = moduleChoices.map((choice) => ({ + ...choice, + checked: defaultSelections.includes(choice.value), + })); - const selected = moduleAnswer.modules || []; + const selected = await prompts.multiselect({ + message: `Select modules to install ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`, + choices: choicesWithDefaults, + required: false, + }); - return selected; + return selected || []; } /** * Prompt for directory selection - * @returns {Object} Directory answer from inquirer + * @returns {Object} Directory answer from prompt */ async promptForDirectory() { - const inquirer = await getInquirer(); - return await inquirer.prompt([ - { - type: 'input', - name: 'directory', - message: `Installation directory:`, - default: process.cwd(), - validate: async (input) => this.validateDirectory(input), - filter: (input) => { - // If empty, use the default - if (!input || input.trim() === '') { - return process.cwd(); - } - return this.expandUserPath(input); - }, - }, - ]); + // Use sync validation because @clack/prompts doesn't support async validate + const directory = await prompts.text({ + message: 'Installation directory:', + default: process.cwd(), + placeholder: process.cwd(), + validate: (input) => this.validateDirectorySync(input), + }); + + // Apply filter logic + let filteredDir = directory; + if (!filteredDir || filteredDir.trim() === '') { + filteredDir = process.cwd(); + } else { + filteredDir = this.expandUserPath(filteredDir); + } + + return { directory: filteredDir }; } /** @@ -915,45 +846,92 @@ class UI { * @returns {boolean} Whether user confirmed */ async confirmDirectory(directory) { - const inquirer = await getInquirer(); const dirExists = await fs.pathExists(directory); if (dirExists) { - const confirmAnswer = await inquirer.prompt([ - { - type: 'confirm', - name: 'proceed', - message: `Install to this directory?`, - default: true, - }, - ]); + const proceed = await prompts.confirm({ + message: 'Install to this directory?', + default: true, + }); - if (!confirmAnswer.proceed) { + if (!proceed) { console.log(chalk.yellow("\nLet's try again with a different path.\n")); } - return confirmAnswer.proceed; + return proceed; } else { // Ask for confirmation to create the directory - const createConfirm = await inquirer.prompt([ - { - type: 'confirm', - name: 'create', - message: `The directory '${directory}' doesn't exist. Would you like to create it?`, - default: false, - }, - ]); + const create = await prompts.confirm({ + message: `The directory '${directory}' doesn't exist. Would you like to create it?`, + default: false, + }); - if (!createConfirm.create) { + if (!create) { console.log(chalk.yellow("\nLet's try again with a different path.\n")); } - return createConfirm.create; + return create; } } /** - * Validate directory path for installation + * Validate directory path for installation (sync version for clack prompts) + * @param {string} input - User input path + * @returns {string|undefined} Error message or undefined if valid + */ + validateDirectorySync(input) { + // Allow empty input to use the default + if (!input || input.trim() === '') { + return; // Empty means use default, undefined = valid for clack + } + + let expandedPath; + try { + expandedPath = this.expandUserPath(input.trim()); + } catch (error) { + return error.message; + } + + // Check if the path exists + const pathExists = fs.pathExistsSync(expandedPath); + + if (!pathExists) { + // Find the first existing parent directory + const existingParent = this.findExistingParentSync(expandedPath); + + if (!existingParent) { + return 'Cannot create directory: no existing parent directory found'; + } + + // Check if the existing parent is writable + try { + fs.accessSync(existingParent, fs.constants.W_OK); + // Path doesn't exist but can be created - will prompt for confirmation later + return; + } catch { + // Provide a detailed error message explaining both issues + return `Directory '${expandedPath}' does not exist and cannot be created: parent directory '${existingParent}' is not writable`; + } + } + + // If it exists, validate it's a directory and writable + const stat = fs.statSync(expandedPath); + if (!stat.isDirectory()) { + return `Path exists but is not a directory: ${expandedPath}`; + } + + // Check write permissions + try { + fs.accessSync(expandedPath, fs.constants.W_OK); + } catch { + return `Directory is not writable: ${expandedPath}`; + } + + return; + } + + /** + * Validate directory path for installation (async version) * @param {string} input - User input path * @returns {string|true} Error message or true if valid */ @@ -1009,7 +987,28 @@ class UI { } /** - * Find the first existing parent directory + * Find the first existing parent directory (sync version) + * @param {string} targetPath - The path to check + * @returns {string|null} The first existing parent directory, or null if none found + */ + findExistingParentSync(targetPath) { + let currentPath = path.resolve(targetPath); + + // Walk up the directory tree until we find an existing directory + while (currentPath !== path.dirname(currentPath)) { + // Stop at root + const parent = path.dirname(currentPath); + if (fs.pathExistsSync(parent)) { + return parent; + } + currentPath = parent; + } + + return null; // No existing parent found (shouldn't happen in practice) + } + + /** + * Find the first existing parent directory (async version) * @param {string} targetPath - The path to check * @returns {string|null} The first existing parent directory, or null if none found */ @@ -1071,7 +1070,7 @@ class UI { * @sideeffects None - pure user input collection, no files written * @edgecases Shows warning if user enables TTS but AgentVibes not detected * @calledby promptInstall() during installation flow, after core config, before IDE selection - * @calls checkAgentVibesInstalled(), inquirer.prompt(), chalk.green/yellow/dim() + * @calls checkAgentVibesInstalled(), prompts.select(), chalk.green/yellow/dim() * * AI NOTE: This prompt is strategically positioned in installation flow: * - AFTER core config (user_name, etc) @@ -1102,7 +1101,6 @@ class UI { * - GitHub Issue: paulpreibisch/AgentVibes#36 */ async promptAgentVibes(projectDir) { - const inquirer = await getInquirer(); CLIUtils.displaySection('🎤 Voice Features', 'Enable TTS for multi-agent conversations'); // Check if AgentVibes is already installed @@ -1114,23 +1112,19 @@ class UI { console.log(chalk.dim(' AgentVibes not detected')); } - const answers = await inquirer.prompt([ - { - type: 'confirm', - name: 'enableTts', - message: 'Enable Agents to Speak Out loud (powered by Agent Vibes? Claude Code only currently)', - default: false, // Default to yes - recommended for best experience - }, - ]); + const enableTts = await prompts.confirm({ + message: 'Enable Agents to Speak Out loud (powered by Agent Vibes? Claude Code only currently)', + default: false, + }); - if (answers.enableTts && !agentVibesInstalled) { + if (enableTts && !agentVibesInstalled) { console.log(chalk.yellow('\n ⚠️ AgentVibes not installed')); console.log(chalk.dim(' Install AgentVibes separately to enable TTS:')); console.log(chalk.dim(' https://github.com/paulpreibisch/AgentVibes\n')); } return { - enabled: answers.enableTts, + enabled: enableTts, alreadyInstalled: agentVibesInstalled, }; } @@ -1248,30 +1242,75 @@ class UI { return existingInstall.ides || []; } + /** + * Validate custom content path synchronously + * @param {string} input - User input path + * @returns {string|undefined} Error message or undefined if valid + */ + validateCustomContentPathSync(input) { + // Allow empty input to cancel + if (!input || input.trim() === '') { + return; // Allow empty to exit + } + + try { + // Expand the path + const expandedPath = this.expandUserPath(input.trim()); + + // Check if path exists + if (!fs.pathExistsSync(expandedPath)) { + return 'Path does not exist'; + } + + // Check if it's a directory + const stat = fs.statSync(expandedPath); + if (!stat.isDirectory()) { + return 'Path must be a directory'; + } + + // Check for module.yaml in the root + const moduleYamlPath = path.join(expandedPath, 'module.yaml'); + if (!fs.pathExistsSync(moduleYamlPath)) { + return 'Directory must contain a module.yaml file in the root'; + } + + // Try to parse the module.yaml to get the module ID + try { + const yaml = require('yaml'); + const content = fs.readFileSync(moduleYamlPath, 'utf8'); + const moduleData = yaml.parse(content); + if (!moduleData.code) { + return 'module.yaml must contain a "code" field for the module ID'; + } + } catch (error) { + return 'Invalid module.yaml file: ' + error.message; + } + + return; // Valid + } catch (error) { + return 'Error validating path: ' + error.message; + } + } + /** * Prompt user for custom content source location * @returns {Object} Custom content configuration */ async promptCustomContentSource() { - const inquirer = await getInquirer(); const customContentConfig = { hasCustomContent: true, sources: [] }; // Keep asking for more sources until user is done while (true) { // First ask if user wants to add another module or continue if (customContentConfig.sources.length > 0) { - const { action } = await inquirer.prompt([ - { - type: 'list', - name: 'action', - message: 'Would you like to:', - choices: [ - { name: 'Add another custom module', value: 'add' }, - { name: 'Continue with installation', value: 'continue' }, - ], - default: 'continue', - }, - ]); + const action = await prompts.select({ + message: 'Would you like to:', + choices: [ + { name: 'Add another custom module', value: 'add' }, + { name: 'Continue with installation', value: 'continue' }, + ], + default: 'continue', + }); if (action === 'continue') { break; @@ -1282,57 +1321,11 @@ class UI { let isValid = false; while (!isValid) { - const { path: inputPath } = await inquirer.prompt([ - { - type: 'input', - name: 'path', - message: 'Enter the path to your custom content folder (or press Enter to cancel):', - validate: async (input) => { - // Allow empty input to cancel - if (!input || input.trim() === '') { - return true; // Allow empty to exit - } - - try { - // Expand the path - const expandedPath = this.expandUserPath(input.trim()); - - // Check if path exists - if (!(await fs.pathExists(expandedPath))) { - return 'Path does not exist'; - } - - // Check if it's a directory - const stat = await fs.stat(expandedPath); - if (!stat.isDirectory()) { - return 'Path must be a directory'; - } - - // Check for module.yaml in the root - const moduleYamlPath = path.join(expandedPath, 'module.yaml'); - if (!(await fs.pathExists(moduleYamlPath))) { - return 'Directory must contain a module.yaml file in the root'; - } - - // Try to parse the module.yaml to get the module ID - try { - const yaml = require('yaml'); - const content = await fs.readFile(moduleYamlPath, 'utf8'); - const moduleData = yaml.parse(content); - if (!moduleData.code) { - return 'module.yaml must contain a "code" field for the module ID'; - } - } catch (error) { - return 'Invalid module.yaml file: ' + error.message; - } - - return true; - } catch (error) { - return 'Error validating path: ' + error.message; - } - }, - }, - ]); + // Use sync validation because @clack/prompts doesn't support async validate + const inputPath = await prompts.text({ + message: 'Enter the path to your custom content folder (or press Enter to cancel):', + validate: (input) => this.validateCustomContentPathSync(input), + }); // If user pressed Enter without typing anything, exit the loop if (!inputPath || inputPath.trim() === '') { @@ -1364,14 +1357,10 @@ class UI { } // Ask if user wants to add these to the installation - const { shouldInstall } = await inquirer.prompt([ - { - type: 'confirm', - name: 'shouldInstall', - message: `Install ${customContentConfig.sources.length} custom module(s) now?`, - default: true, - }, - ]); + const shouldInstall = await prompts.confirm({ + message: `Install ${customContentConfig.sources.length} custom module(s) now?`, + default: true, + }); if (shouldInstall) { customContentConfig.selected = true; @@ -1391,7 +1380,6 @@ class UI { * @returns {Object} Result with selected custom modules and custom content config */ async handleCustomModulesInModifyFlow(directory, selectedModules) { - const inquirer = await getInquirer(); // Get existing installation to find custom modules const { existingInstall } = await this.getExistingInstallation(directory); @@ -1451,16 +1439,11 @@ class UI { choices.push({ name: 'Add new custom modules', value: 'add' }, { name: 'Cancel (no custom modules)', value: 'cancel' }); } - const { customAction } = await inquirer.prompt([ - { - type: 'list', - name: 'customAction', - message: - cachedCustomModules.length > 0 ? 'What would you like to do with custom modules?' : 'Would you like to add custom modules?', - choices: choices, - default: cachedCustomModules.length > 0 ? 'keep' : 'add', - }, - ]); + const customAction = await prompts.select({ + message: cachedCustomModules.length > 0 ? 'What would you like to do with custom modules?' : 'Would you like to add custom modules?', + choices: choices, + default: cachedCustomModules.length > 0 ? 'keep' : 'add', + }); switch (customAction) { case 'keep': { @@ -1472,21 +1455,18 @@ class UI { case 'select': { // Let user choose which to keep - const choices = cachedCustomModules.map((m) => ({ + const selectChoices = cachedCustomModules.map((m) => ({ name: `${m.name} ${chalk.gray(`(${m.id})`)}`, value: m.id, + checked: m.checked, })); - const { keepModules } = await inquirer.prompt([ - { - type: 'checkbox', - name: 'keepModules', - message: 'Select custom modules to keep:', - choices: choices, - default: cachedCustomModules.filter((m) => m.checked).map((m) => m.id), - }, - ]); - result.selectedCustomModules = keepModules; + const keepModules = await prompts.multiselect({ + message: `Select custom modules to keep ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`, + choices: selectChoices, + required: false, + }); + result.selectedCustomModules = keepModules || []; break; } @@ -1586,7 +1566,6 @@ class UI { * @returns {Promise} True if user wants to proceed, false if they cancel */ async showOldAlphaVersionWarning(installedVersion, currentVersion, bmadFolderName) { - const inquirer = await getInquirer(); const versionInfo = this.checkAlphaVersionAge(installedVersion, currentVersion); // Also warn if version is unknown or can't be parsed (legacy/unsupported) @@ -1627,26 +1606,20 @@ class UI { console.log(chalk.yellow('─'.repeat(80))); console.log(''); - const { proceed } = await inquirer.prompt([ - { - type: 'list', - name: 'proceed', - message: 'What would you like to do?', - choices: [ - { - name: 'Proceed with update anyway (may have issues)', - value: 'proceed', - short: 'Proceed with update', - }, - { - name: 'Cancel (recommended - do a fresh install instead)', - value: 'cancel', - short: 'Cancel installation', - }, - ], - default: 'cancel', - }, - ]); + const proceed = await prompts.select({ + message: 'What would you like to do?', + choices: [ + { + name: 'Proceed with update anyway (may have issues)', + value: 'proceed', + }, + { + name: 'Cancel (recommended - do a fresh install instead)', + value: 'cancel', + }, + ], + default: 'cancel', + }); if (proceed === 'cancel') { console.log('');