/** * @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; let _clackCore = null; let _picocolors = 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; } /** * 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 * @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, selectableGroups: options.selectableGroups || false, }); await handleCancel(result); 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 * @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 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 * @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 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 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; } /** * 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, autocompleteMultiselect, confirm, text, password, group, tasks, log, prompt, };