DEV Community

Wilson Xu
Wilson Xu

Posted on

Build Interactive CLI Prompts That Don't Annoy Your Users

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 });
  });
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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;
  },
});
Enter fullscreen mode Exit fullscreen mode

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 }));
  },
});
Enter fullscreen mode Exit fullscreen mode

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`);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)