Build Interactive CLI Prompts That Don't Annoy Your Users
Interactive prompts in CLI tools walk a fine line. Done well, they guide users through complex inputs smoothly. Done poorly, they waste time, block automation, and frustrate power users who already know what they want.
This article shows how to build interactive prompts that are helpful when needed and invisible when not — using @inquirer/prompts (the modern Inquirer.js) and patterns from tools like create-next-app and npm init.
The Golden Rule: Prompts Are Optional
Every interactive prompt should have a CLI flag equivalent:
import { program } from 'commander';
import { input, select, confirm } from '@inquirer/prompts';
program
.command('init')
.option('-n, --name <name>', 'Project name')
.option('-t, --template <template>', 'Template to use')
.option('--no-git', 'Skip git initialization')
.option('-y, --yes', 'Accept all defaults')
.action(async (options) => {
// If --yes flag or not a TTY (CI), use defaults
const interactive = !options.yes && process.stdout.isTTY;
const name = options.name || (interactive
? await input({ message: 'Project name:', default: 'my-app' })
: 'my-app');
const template = options.template || (interactive
? await select({
message: 'Choose a template:',
choices: [
{ value: 'basic', name: 'Basic — minimal setup' },
{ value: 'full', name: 'Full — with testing and CI' },
{ value: 'api', name: 'API — REST server template' },
],
})
: 'basic');
const initGit = options.git !== false && (interactive
? await confirm({ message: 'Initialize git repository?', default: true })
: true);
await createProject({ name, template, initGit });
});
The process.stdout.isTTY check is critical — it detects whether the tool is running in an interactive terminal or being piped/scripted. In CI environments, TTY is false, so prompts are skipped automatically.
Smart Defaults Reduce Prompting
The best prompt is one you don't have to show. Infer as much as possible:
import { basename } from 'node:path';
import { readFile } from 'node:fs/promises';
async function inferDefaults() {
const cwd = process.cwd();
// Infer project name from directory name
const name = basename(cwd);
// Infer language from existing files
const hasTs = await fileExists('tsconfig.json');
const language = hasTs ? 'typescript' : 'javascript';
// Infer package manager
const pm = await fileExists('pnpm-lock.yaml') ? 'pnpm'
: await fileExists('yarn.lock') ? 'yarn'
: 'npm';
// Infer git status
const hasGit = await fileExists('.git');
return { name, language, pm, hasGit };
}
async function fileExists(path: string): Promise<boolean> {
try { await access(path); return true; } catch { return false; }
}
Now your prompts can show smart defaults:
const defaults = await inferDefaults();
const name = await input({
message: 'Project name:',
default: defaults.name, // Pre-filled from directory name
});
Validation That Helps
Don't just reject bad input — tell users exactly what's wrong:
const projectName = await input({
message: 'Project name:',
validate: (value) => {
if (!value.trim()) return 'Project name cannot be empty';
if (!/^[a-z0-9-]+$/.test(value)) return 'Only lowercase letters, numbers, and hyphens allowed';
if (value.length > 50) return 'Maximum 50 characters';
if (value.startsWith('-')) return 'Cannot start with a hyphen';
return true;
},
});
const port = await input({
message: 'Server port:',
default: '3000',
validate: (value) => {
const num = parseInt(value);
if (isNaN(num)) return 'Must be a number';
if (num < 1024) return 'Port must be 1024 or higher (lower ports require root)';
if (num > 65535) return 'Port must be 65535 or lower';
return true;
},
});
Multi-Select with Search
For long lists, add search/filtering:
import { checkbox, search } from '@inquirer/prompts';
// Checkbox for small lists
const features = await checkbox({
message: 'Select features:',
choices: [
{ value: 'eslint', name: 'ESLint', checked: true },
{ value: 'prettier', name: 'Prettier', checked: true },
{ value: 'vitest', name: 'Vitest' },
{ value: 'playwright', name: 'Playwright E2E' },
{ value: 'docker', name: 'Docker support' },
{ value: 'ci', name: 'GitHub Actions CI' },
],
});
// Search for large lists (e.g., timezone selection)
const timezone = await search({
message: 'Select timezone:',
source: async (term) => {
const allZones = Intl.supportedValuesOf('timeZone');
if (!term) return allZones.slice(0, 10).map(z => ({ value: z, name: z }));
return allZones
.filter(z => z.toLowerCase().includes(term.toLowerCase()))
.map(z => ({ value: z, name: z }));
},
});
Progress Indicators for Long Operations
Show progress during time-consuming steps:
import ora from 'ora';
async function createProject(options) {
const spinner = ora();
spinner.start('Creating project directory...');
await mkdir(options.name, { recursive: true });
spinner.succeed('Project directory created');
spinner.start('Installing dependencies...');
await exec(`npm install`, { cwd: options.name });
spinner.succeed(`Dependencies installed (${depCount} packages)`);
spinner.start('Initializing git repository...');
await exec('git init && git add . && git commit -m "Initial commit"', {
cwd: options.name,
});
spinner.succeed('Git repository initialized');
console.log(`\n ${chalk.green('✓')} Project created at ${chalk.cyan(options.name)}/`);
console.log(`\n Next steps:`);
console.log(` cd ${options.name}`);
console.log(` npm run dev\n`);
}
Confirmation for Destructive Actions
Always confirm before doing something irreversible:
async function resetDatabase(options) {
if (!options.force) {
const confirmed = await confirm({
message: chalk.red('This will DELETE all data. Are you sure?'),
default: false,
});
if (!confirmed) {
console.log('Aborted.');
process.exit(0);
}
}
// Proceed with reset
}
The Complete Pattern
Here's how create-next-app style tools structure their prompts:
async function main() {
// 1. Parse CLI args
const options = program.parse().opts();
// 2. Infer smart defaults
const defaults = await inferDefaults();
// 3. Only prompt for what's missing
const config = {
name: options.name || (interactive ? await promptName(defaults) : defaults.name),
template: options.template || (interactive ? await promptTemplate() : 'basic'),
features: options.features || (interactive ? await promptFeatures() : ['eslint', 'prettier']),
};
// 4. Show summary and confirm
if (interactive) {
console.log(chalk.bold('\n Project configuration:\n'));
console.log(` Name: ${chalk.cyan(config.name)}`);
console.log(` Template: ${chalk.cyan(config.template)}`);
console.log(` Features: ${chalk.cyan(config.features.join(', '))}`);
const proceed = await confirm({ message: '\nCreate project?', default: true });
if (!proceed) process.exit(0);
}
// 5. Execute with progress
await createProject(config);
}
Conclusion
Good interactive CLI prompts follow three principles: they're optional (always have flag equivalents), they're smart (infer defaults from context), and they're honest (confirm destructive actions, show progress for slow operations). Build prompts that help users when they need guidance and get out of the way when they don't.
Wilson Xu builds developer CLI tools with great UX. Find his work at dev.to/chengyixu and npm.
Top comments (0)