Skip to main content

Command Palette

Search for a command to run...

Mastering Interactive CLI Prompts in Node.js: Beyond readline

Published
•14 min read•View as Markdown

Mastering Interactive CLI Prompts in Node.js: Beyond readline

Command-line interfaces have come a long way from static flags and positional arguments. Modern CLIs guide users through complex workflows with interactive prompts, spinners, progress bars, and even full text-based user interfaces. If you've ever used create-react-app, npm init, or degit, you've experienced what good interactive CLI design feels like.

In this guide, we'll move beyond Node.js's built-in readline module and explore the rich ecosystem of prompt libraries, animation utilities, and TUI frameworks that make CLI tools genuinely pleasant to use. Along the way, we'll build a complete project scaffolder CLI from scratch.

Why Interactive Prompts Transform CLI UX

Traditional CLIs rely on users memorizing flags. Consider:

mycli create --name my-project --template react --typescript --eslint --prettier --git

That's seven flags a user has to know about. Now imagine the alternative:

? What's your project name? my-project
? Pick a template: (Use arrow keys)
  > React
    Vue
    Svelte
    Vanilla
? Enable TypeScript? Yes
? Select linting tools: (Press space to select)
  > [x] ESLint
    [x] Prettier
    [ ] Stylelint

Interactive prompts are self-documenting. Users don't need to read help text or man pages. They discover options as they go. The CLI becomes a guided conversation rather than a pop quiz.

But the benefits go beyond discoverability:

  • Validation happens inline. You can reject bad input before proceeding to the next step.
  • Defaults reduce friction. Power users can hit Enter through sensible defaults.
  • Conditional flows are natural. If a user picks "React," you can ask about JSX transforms. If they pick "Vue," you ask about the Composition API instead.
  • Error recovery is graceful. Instead of aborting with a cryptic message, you re-prompt the specific question.

The Prompts Ecosystem

Node.js's built-in readline module handles raw line input, but building interactive prompts on top of it means reimplementing cursor management, ANSI escape codes, key bindings, and validation. Several libraries abstract this away. Let's compare the major players.

Inquirer.js

The original. Inquirer has been around since 2013 and remains the most downloaded prompt library on npm. Version 9+ was rewritten as a collection of standalone ES module packages.

import input from '@inquirer/input';
import select from '@inquirer/select';

const name = await input({ message: 'Project name:' });
const framework = await select({
  message: 'Framework:',
  choices: [
    { name: 'React', value: 'react' },
    { name: 'Vue', value: 'vue' },
    { name: 'Svelte', value: 'svelte' },
  ],
});

Strengths: Massive ecosystem, well-tested, modular in v9+. Weaknesses: Heavier than alternatives, the v9 migration broke many plugins.

prompts (by terkelg)

A lightweight alternative that uses a single function call with an array of question objects. It's closer in spirit to the old Inquirer API but significantly smaller.

import prompts from 'prompts';

const response = await prompts([
  { type: 'text', name: 'name', message: 'Project name:' },
  { type: 'select', name: 'framework', message: 'Framework:', choices: [
    { title: 'React', value: 'react' },
    { title: 'Vue', value: 'vue' },
  ]},
]);

Strengths: Tiny footprint (~4KB), no dependencies, clean API. Weaknesses: Fewer prompt types, less active maintenance.

@clack/prompts

The newest contender, created by the Astro team's Nate Moore. Clack focuses on beautiful output with minimal API surface. It wraps prompts in visual groupings with intro() and outro().

import { intro, outro, text, select, confirm, spinner } from '@clack/prompts';

intro('create-my-app');

const name = await text({ message: 'Project name:', placeholder: 'my-app' });
const framework = await select({
  message: 'Framework:',
  options: [
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue' },
  ],
});

outro('Done! Happy coding.');

Strengths: Gorgeous output, built-in spinner, excellent cancellation handling, actively maintained. Weaknesses: Fewer prompt types than Inquirer, relatively new.

Enquirer

Built by the authors of micromatch and braces, Enquirer is performance-focused and supports prompt composition.

import Enquirer from 'enquirer';

const response = await Enquirer.prompt([
  { type: 'input', name: 'name', message: 'Project name:' },
  { type: 'autocomplete', name: 'framework', message: 'Framework:', choices: ['React', 'Vue', 'Svelte'] },
]);

Strengths: Fast, supports autocomplete natively, composable prompt classes. Weaknesses: Maintenance has slowed, TypeScript types are community-maintained.

Which Should You Choose?

For new projects in 2024+, @clack/prompts is the strongest default. It produces beautiful output, handles cancellation correctly out of the box, and has a minimal API. Use Inquirer v9 if you need a specific prompt type that Clack doesn't support (like editor or checkbox with advanced filtering). Use prompts if bundle size is critical. Use Enquirer if you need autocomplete with large datasets.

Prompt Types Deep Dive

All major libraries support these core prompt types. Understanding when to use each one is key to good CLI UX.

Text Input

The simplest prompt. Use for project names, descriptions, or any freeform string.

const name = await text({
  message: 'Project name:',
  placeholder: 'my-awesome-app',
  defaultValue: 'my-app',
  validate: (value) => {
    if (!/^[a-z0-9-]+$/.test(value)) return 'Use lowercase letters, numbers, and hyphens only.';
  },
});

Select (Single Choice)

Use when there's a fixed set of options and the user must pick exactly one. Arrow keys navigate, Enter confirms.

const license = await select({
  message: 'License:',
  options: [
    { value: 'mit', label: 'MIT', hint: 'Permissive, most popular' },
    { value: 'apache2', label: 'Apache 2.0', hint: 'Permissive with patent grant' },
    { value: 'gpl3', label: 'GPL 3.0', hint: 'Copyleft' },
  ],
});

Multi-Select

Like select, but the user can toggle multiple options on and off with the spacebar.

const features = await multiselect({
  message: 'Additional features:',
  options: [
    { value: 'eslint', label: 'ESLint' },
    { value: 'prettier', label: 'Prettier' },
    { value: 'husky', label: 'Git hooks (Husky)' },
    { value: 'tests', label: 'Testing (Vitest)' },
  ],
  required: true,
});

Confirm (Yes/No)

A simple boolean question. Use for feature flags and opt-in decisions.

const useTypeScript = await confirm({ message: 'Use TypeScript?' });

Password

Masks input with asterisks or dots. Use for API keys, tokens, or actual passwords during setup flows.

const token = await password({ message: 'GitHub token:', mask: '*' });

Autocomplete

Not available in all libraries (Enquirer and Inquirer support it natively). Essential when the option list is large, like choosing from hundreds of npm packages or timezone identifiers.

const timezone = await autocomplete({
  message: 'Timezone:',
  choices: Intl.supportedValuesOf('timeZone'),
});

Building Wizard-Style Multi-Step Flows

Real CLIs rarely ask a flat list of questions. They branch based on answers. Here's a pattern for conditional flows using @clack/prompts:

import { intro, text, select, confirm, group, outro, isCancel, cancel } from '@clack/prompts';

intro('project-scaffolder');

const project = await group(
  {
    name: () => text({ message: 'Project name:', validate: (v) => v.length === 0 ? 'Required' : undefined }),
    type: () => select({
      message: 'Project type:',
      options: [
        { value: 'app', label: 'Application' },
        { value: 'lib', label: 'Library' },
      ],
    }),
    framework: ({ results }) => {
      if (results.type === 'lib') return select({
        message: 'Build tool:',
        options: [
          { value: 'tsup', label: 'tsup' },
          { value: 'rollup', label: 'Rollup' },
        ],
      });
      return select({
        message: 'Framework:',
        options: [
          { value: 'react', label: 'React' },
          { value: 'vue', label: 'Vue' },
          { value: 'svelte', label: 'Svelte' },
        ],
      });
    },
    typescript: () => confirm({ message: 'Use TypeScript?' }),
  },
  {
    onCancel: () => {
      cancel('Setup cancelled.');
      process.exit(0);
    },
  }
);

outro(`Creating ${project.name} with ${project.framework}...`);

The group function from Clack runs prompts sequentially, passes accumulated results to each step, and handles cancellation uniformly. This pattern eliminates the messy if/else chains that accumulate when you wire prompts together manually.

Validation and Inline Error Messages

Good validation is specific and immediate. Don't just say "Invalid input" -- tell the user exactly what's wrong and what you expect.

const port = await text({
  message: 'Dev server port:',
  defaultValue: '3000',
  validate: (value) => {
    const num = parseInt(value, 10);
    if (isNaN(num)) return 'Must be a number.';
    if (num < 1024) return 'Must be >= 1024 (ports below are reserved).';
    if (num > 65535) return 'Must be <= 65535.';
  },
});

When validation fails, most prompt libraries re-render the prompt with the error message inline, directly below the input. The user corrects their answer without losing context. This is dramatically better than the traditional pattern of parsing all arguments upfront and dumping all validation errors at once.

For async validation (checking if a project name is taken on npm, for instance):

const name = await text({
  message: 'Package name:',
  validate: async (value) => {
    const res = await fetch(`https://registry.npmjs.org/${value}`);
    if (res.ok) return `"${value}" is already taken on npm.`;
  },
});

Spinners and Progress Bars

Long operations need visual feedback. Without it, users assume the CLI is frozen.

Spinners with ora

import ora from 'ora';

const spinner = ora('Installing dependencies...').start();

try {
  await execa('npm', ['install'], { cwd: projectDir });
  spinner.succeed('Dependencies installed.');
} catch {
  spinner.fail('Installation failed.');
  process.exit(1);
}

Ora provides dozens of spinner styles (dots, line, arc, bouncingBar) and supports color. For @clack/prompts, there's a built-in spinner:

import { spinner } from '@clack/prompts';

const s = spinner();
s.start('Installing dependencies...');
await execa('npm', ['install'], { cwd: projectDir });
s.stop('Dependencies installed.');

Progress Bars with cli-progress

For operations where you know the total count (downloading files, processing records), a progress bar is more informative than a spinner.

import { SingleBar, Presets } from 'cli-progress';

const bar = new SingleBar({
  format: ' {bar} {percentage}% | {value}/{total} files',
}, Presets.shades_classic);

bar.start(files.length, 0);

for (const file of files) {
  await processFile(file);
  bar.increment();
}

bar.stop();

You can also use multi-bars for parallel operations -- downloading multiple assets simultaneously, each with its own progress bar.

Keyboard Shortcuts and Hotkeys

For long-running CLIs (dev servers, watchers), keyboard shortcuts add power-user ergonomics. Node.js's readline and raw stdin mode make this possible:

import readline from 'readline';

readline.emitKeypressEvents(process.stdin);
if (process.stdin.isTTY) process.stdin.setRawMode(true);

console.log('Press r to restart, o to open browser, q to quit.');

process.stdin.on('keypress', (str, key) => {
  if (key.name === 'r') restartServer();
  if (key.name === 'o') openBrowser();
  if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
    cleanup();
    process.exit(0);
  }
});

Vite uses this pattern extensively. When you run vite dev, pressing r restarts the server, o opens the browser, and h shows help. It's a small touch that makes the CLI feel like a professional tool rather than a script.

Building a TUI with blessed and Ink

When your CLI needs more than sequential prompts -- dashboards, split panes, scrollable lists -- you enter TUI territory.

Ink: React for the Terminal

Ink lets you build terminal UIs with React components. It uses Yoga (the same layout engine as React Native) for flexbox in the terminal.

import React, { useState, useEffect } from 'react';
import { render, Box, Text, useInput } from 'ink';

function Dashboard() {
  const [selected, setSelected] = useState(0);
  const items = ['Build', 'Test', 'Deploy', 'Logs'];

  useInput((input, key) => {
    if (key.upArrow) setSelected(s => Math.max(0, s - 1));
    if (key.downArrow) setSelected(s => Math.min(items.length - 1, s + 1));
  });

  return (
    <Box flexDirection="column" padding={1}>
      <Text bold>Project Dashboard</Text>
      {items.map((item, i) => (
        <Text key={item} color={i === selected ? 'cyan' : 'white'}>
          {i === selected ? '> ' : '  '}{item}
        </Text>
      ))}
    </Box>
  );
}

render(<Dashboard />);

Ink is used in production by Gatsby, Prisma, and Shopify's CLI tools. Its React model means you can use hooks, context, and component composition -- skills that transfer directly from web development.

blessed and blessed-contrib

For more traditional TUI applications (think htop or midnight commander), blessed provides a curses-like API with windows, forms, tables, and charts. blessed-contrib adds dashboard widgets like line charts, bar graphs, maps, and sparklines.

import blessed from 'blessed';
import contrib from 'blessed-contrib';

const screen = blessed.screen({ smartCSR: true });
const grid = new contrib.grid({ rows: 2, cols: 2, screen });

const line = grid.set(0, 0, 1, 1, contrib.line, {
  label: 'Requests/sec',
  showLegend: true,
});

const log = grid.set(1, 0, 1, 2, contrib.log, {
  label: 'Server Log',
});

screen.key(['q', 'C-c'], () => process.exit(0));
screen.render();

While blessed hasn't been actively maintained, it remains the most feature-complete TUI framework for Node.js and is still widely used.

Graceful Cancellation with Ctrl+C

Users will press Ctrl+C. Your CLI must handle this gracefully -- cleaning up temporary files, killing child processes, and restoring terminal state.

The @clack/prompts approach

Clack's isCancel() utility makes this declarative:

import { text, isCancel, cancel } from '@clack/prompts';

const name = await text({ message: 'Project name:' });

if (isCancel(name)) {
  cancel('Operation cancelled.');
  process.exit(0);
}

Global cleanup with process signals

For broader cleanup, listen to process signals:

const cleanup = () => {
  // Remove temp directories
  if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true });
  // Kill child processes
  childProcesses.forEach(cp => cp.kill());
  // Restore terminal
  process.stdout.write('\x1B[?25h'); // Show cursor
  console.log('\nCleaned up. Goodbye.');
};

process.on('SIGINT', () => { cleanup(); process.exit(130); });
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
process.on('uncaughtException', (err) => { cleanup(); throw err; });

Always restore the cursor (\x1B[?25h). Some prompt libraries hide it during rendering and if the process exits abnormally, users are left with an invisible cursor -- a frustrating experience.

Accessibility Considerations

Terminal accessibility is often overlooked, but screen reader users do use CLIs. Some guidelines:

  1. Don't rely solely on color. Use prefixes like [ERROR], [WARN], [OK] alongside color coding. The chalk library's level option lets you detect color support and degrade gracefully.

  2. Provide non-interactive fallbacks. Detect when stdin isn't a TTY (piped input, CI environments) and accept the same options as flags:

const isTTY = process.stdin.isTTY;

const name = isTTY
  ? await text({ message: 'Project name:' })
  : process.env.PROJECT_NAME || 'my-app';
  1. Use semantic structure. Screen readers parse terminal output linearly. Keep output structured with consistent indentation and clear section headers.

  2. Announce state changes. When a spinner completes, the success/failure message should be self-contained. Don't rely on the animation itself to communicate progress.

  3. Support --no-interactive flags. Many CI systems set CI=true. Detect this and skip prompts entirely, using defaults or environment variables.

const isCI = process.env.CI === 'true' || process.env.CI === '1';
if (isCI && !allFlagsProvided) {
  console.error('Missing required flags in non-interactive mode. Run with --help.');
  process.exit(1);
}

Putting It All Together: A Project Scaffolder

Let's build a complete CLI that combines everything we've covered. This scaffolder creates a new project with the user's chosen framework, features, and configuration.

#!/usr/bin/env node

import { intro, outro, text, select, multiselect, confirm, spinner, isCancel, cancel, note } from '@clack/prompts';
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import color from 'picocolors';

// Handle non-interactive mode
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
  console.log(`
  Usage: create-project [options]

  Options:
    --name <name>       Project name
    --template <name>   Template (react, vue, svelte)
    --typescript        Enable TypeScript
    --no-interactive    Skip prompts, use defaults
    -h, --help          Show this help
  `);
  process.exit(0);
}

intro(color.bgCyan(color.black(' create-project ')));

const project = {};

// Step 1: Project name
project.name = await text({
  message: 'What is your project name?',
  placeholder: 'my-app',
  validate: (value) => {
    if (!value) return 'Project name is required.';
    if (!/^[a-z0-9-_]+$/.test(value)) return 'Use lowercase letters, numbers, hyphens, or underscores.';
    if (fs.existsSync(value)) return `Directory "${value}" already exists.`;
  },
});
if (isCancel(project.name)) { cancel('Cancelled.'); process.exit(0); }

// Step 2: Template
project.template = await select({
  message: 'Pick a project template:',
  options: [
    { value: 'react', label: 'React', hint: 'with Vite' },
    { value: 'vue', label: 'Vue', hint: 'with Vite' },
    { value: 'svelte', label: 'Svelte', hint: 'with SvelteKit' },
    { value: 'node', label: 'Node.js', hint: 'plain Node.js project' },
  ],
});
if (isCancel(project.template)) { cancel('Cancelled.'); process.exit(0); }

// Step 3: TypeScript
project.typescript = await confirm({ message: 'Use TypeScript?', initialValue: true });
if (isCancel(project.typescript)) { cancel('Cancelled.'); process.exit(0); }

// Step 4: Features (conditional on project type)
if (project.template !== 'node') {
  project.features = await multiselect({
    message: 'Select features:',
    options: [
      { value: 'eslint', label: 'ESLint', hint: 'code linting' },
      { value: 'prettier', label: 'Prettier', hint: 'code formatting' },
      { value: 'vitest', label: 'Vitest', hint: 'unit testing' },
      { value: 'husky', label: 'Husky', hint: 'git hooks' },
    ],
    required: false,
  });
  if (isCancel(project.features)) { cancel('Cancelled.'); process.exit(0); }
}

// Step 5: Git init
project.git = await confirm({ message: 'Initialize git repository?', initialValue: true });
if (isCancel(project.git)) { cancel('Cancelled.'); process.exit(0); }

// Summary
const lang = project.typescript ? 'TypeScript' : 'JavaScript';
note(
  `Name: ${project.name}\nTemplate: ${project.template}\nLanguage: ${lang}\nFeatures: ${(project.features || []).join(', ') || 'none'}\nGit: ${project.git ? 'yes' : 'no'}`,
  'Project Summary'
);

// Scaffold
const s = spinner();

s.start('Creating project structure...');
fs.mkdirSync(project.name, { recursive: true });
// ... copy template files based on selections ...
s.stop('Project structure created.');

s.start('Installing dependencies...');
execSync('npm install', { cwd: project.name, stdio: 'ignore' });
s.stop('Dependencies installed.');

if (project.git) {
  s.start('Initializing git...');
  execSync('git init && git add -A && git commit -m "Initial commit"', {
    cwd: project.name,
    stdio: 'ignore',
  });
  s.stop('Git repository initialized.');
}

outro(color.green(`Done! cd ${project.name} and start building.`));

This scaffolder demonstrates the full lifecycle: intro/outro framing, sequential prompts with cancellation, conditional branching, validation, a summary step for confirmation, and spinners for long operations.

Wrapping Up

The gap between a script that works and a CLI that people enjoy using is interactive design. The Node.js ecosystem provides everything you need:

  • @clack/prompts or Inquirer for guided input collection
  • ora or Clack's built-in spinner for progress indication
  • cli-progress for determinate progress bars
  • Ink for full React-powered terminal UIs
  • Proper signal handling for graceful cleanup

Start with @clack/prompts for your next CLI. It takes five minutes to replace raw process.argv parsing with guided prompts, and your users will thank you for it. The best CLI tools feel like a conversation, not an exam -- and with these libraries, building that experience is straightforward.

More from this blog

W

Wilson Xu

108 posts