BMAD-METHOD/tools/cli/lib/prompts.js

620 lines
19 KiB
JavaScript

/**
* @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<Object>} The clack prompts module
*/
async function getClack() {
if (!_clack) {
_clack = await import('@clack/prompts');
}
return _clack;
}
/**
* Lazy-load @clack/core (ESM module)
* @returns {Promise<Object>} The clack core module
*/
async function getClackCore() {
if (!_clackCore) {
_clackCore = await import('@clack/core');
}
return _clackCore;
}
/**
* Lazy-load picocolors
* @returns {Promise<Object>} The picocolors module
*/
async function getPicocolors() {
if (!_picocolors) {
_picocolors = (await import('picocolors')).default;
}
return _picocolors;
}
/**
* Handle user cancellation gracefully
* @param {any} value - The value to check
* @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<any>} 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>} 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>} 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>} 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<boolean>} 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<string>} 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<string>} 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>} 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<void>}
*/
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>} 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,
};