Skip to content

Fix: Clack Not Working — Prompts Not Displaying, Spinners Stuck, or Cancel Not Handled

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix @clack/prompts issues — interactive CLI prompts, spinners, multi-select, confirm dialogs, grouped tasks, cancellation handling, and building CLI tools with beautiful output.

The Problem

Clack prompts don’t display any output:

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

const name = await text({ message: 'What is your name?' });
// Terminal shows nothing — hangs indefinitely

Or the spinner starts but never stops:

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

const s = spinner();
s.start('Loading...');
// Spinner runs forever

Or cancellation crashes the program:

const value = await text({ message: 'Enter value:' });
// User presses Ctrl+C
// UnhandledPromiseRejection: Symbol(clack:cancel)

Why This Happens

Clack (@clack/prompts) provides terminal prompts for Node.js scripts using a render loop that owns the cursor and a few lines of output. Most failures come from the fact that Clack is not a passive printer — it requires a real interactive terminal session, expects you to participate in its lifecycle, and treats cancellation as data rather than as an exception.

Common causes:

  • Clack writes to stdout/stderr and reads from stdin — if your script runs in an environment without a TTY (piped output, CI, some IDE output panels, Docker without -it), interactive prompts cannot read input. The process hangs waiting for input that never comes. process.stdin.isTTY is the canonical check.
  • Spinners must be explicitly stoppeds.start() begins the animation and takes over a line of output. You must call s.stop() to end it. If an error throws between start and stop, the spinner runs forever and corrupts terminal output because the render loop never releases the cursor.
  • Cancellation returns a special symbol — when the user presses Ctrl+C or Escape, Clack returns Symbol(clack:cancel). If you do not check for it with isCancel(), your code tries to use the symbol as a string, causing TypeError: Cannot convert a Symbol value to a string, or it falls through to logic that assumes a valid answer.
  • intro() and outro() are decorative wrappers — they frame the output but are not required. However, calling outro() without intro() or nesting prompts incorrectly breaks the visual formatting because the box-drawing characters expect a matching open and close.
  • Node version matters@clack/prompts targets modern Node. On Node 16 you can hit ESM import errors because the package ships as ESM. Use Node 18+ or run via a loader like tsx.

Version History That Changes the Failure Mode

The shape of the bug you hit depends heavily on which @clack/prompts version you installed and which alternative CLI library you migrated from. The API has been mostly additive, but the supporting primitives changed significantly.

  • @clack/prompts v0.6 (March 2023) — original public release. Provided text, confirm, select, multiselect, password, spinner, intro, outro, cancel, and isCancel. If you are on this version, group() does not exist and grouped cancellation must be handled per prompt.
  • @clack/prompts v0.7 (mid 2023) — added groupMultiselect() for selecting items grouped under headers, and refined the select option hint rendering. Code that uses groupMultiselect will not type-check on v0.6.
  • @clack/prompts v0.8 (late 2023) — reworked spinner output to be more resilient to non-TTY environments. Earlier versions wrote raw ANSI escape codes even when piped, producing garbage in log files. v0.8 detects non-TTY and degrades gracefully. If your CI logs show [2K[1G sequences, upgrade.
  • @clack/prompts v0.9 (2024) — added the tasks() API for running a sequence of named async steps with per-step spinners. Replaces the common pattern of manually starting and stopping a spinner for each step. Prior to v0.9, you had to roll this yourself.
  • @clack/core 0.3 (2024) — the lower-level primitive package, used directly only if you build custom prompts. Stream handling changed in 0.3, so custom prompts written against 0.2 may not detect Ctrl+C correctly.
  • Ecosystem comparison — Inquirer.js (v9, ESM-only since 2022) is the older alternative with a heavier rendering layer. Enquirer ships smaller but has a less polished default theme. Prompts (prompts on npm) is the minimal option used by create-vite. Clack sits between Enquirer and Inquirer — opinionated visuals, small surface, ESM-only.

If you are on Node 16, none of these recent versions install cleanly. Upgrade Node before debugging anything else.

Fix 1: Build a Complete CLI Flow

npm install @clack/prompts
#!/usr/bin/env node
// cli.ts
import {
  intro,
  outro,
  text,
  select,
  multiselect,
  confirm,
  spinner,
  note,
  cancel,
  isCancel,
  log,
  group,
} from '@clack/prompts';
import color from 'picocolors';

async function main() {
  intro(color.inverse(' My CLI Tool '));

  // Text input
  const name = await text({
    message: 'What is your project name?',
    placeholder: 'my-awesome-project',
    validate(value) {
      if (!value) return 'Name is required';
      if (!/^[a-z0-9-]+$/.test(value)) return 'Only lowercase letters, numbers, and hyphens';
      if (value.length < 3) return 'At least 3 characters';
    },
  });

  // ALWAYS check for cancellation after each prompt
  if (isCancel(name)) {
    cancel('Operation cancelled.');
    process.exit(0);
  }

  // Select (single choice)
  const framework = await select({
    message: 'Pick a framework:',
    options: [
      { value: 'next', label: 'Next.js', hint: 'React framework' },
      { value: 'svelte', label: 'SvelteKit', hint: 'Svelte framework' },
      { value: 'astro', label: 'Astro', hint: 'Content-focused' },
      { value: 'remix', label: 'Remix', hint: 'Full-stack React' },
    ],
  });

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

  // Multi-select
  const features = await multiselect({
    message: 'Select features:',
    options: [
      { value: 'typescript', label: 'TypeScript' },
      { value: 'eslint', label: 'ESLint' },
      { value: 'prettier', label: 'Prettier' },
      { value: 'tailwind', label: 'Tailwind CSS' },
      { value: 'testing', label: 'Testing (Vitest)' },
    ],
    required: true,  // At least one must be selected
  });

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

  // Confirm
  const shouldInstall = await confirm({
    message: 'Install dependencies?',
    initialValue: true,
  });

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

  // Spinner for async operations
  const s = spinner();
  s.start('Creating project...');

  try {
    await createProject(name, framework, features);
    s.stop('Project created');
  } catch (error) {
    s.stop('Failed to create project');
    log.error(String(error));
    process.exit(1);
  }

  if (shouldInstall) {
    s.start('Installing dependencies...');
    try {
      await installDeps(name);
      s.stop('Dependencies installed');
    } catch (error) {
      s.stop('Install failed');
      log.warn('You can install manually with: npm install');
    }
  }

  // Summary note
  note(
    `cd ${name}\nnpm run dev`,
    'Next steps'
  );

  outro(color.green('Done! Happy coding.'));
}

main().catch(console.error);

Fix 2: Group Prompts Together

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

async function main() {
  intro('Project Setup');

  // group() collects all prompts into an object
  const project = await group(
    {
      name: () => text({
        message: 'Project name?',
        placeholder: 'my-project',
        validate: (v) => !v ? 'Required' : undefined,
      }),
      type: () => select({
        message: 'Project type?',
        options: [
          { value: 'app', label: 'Application' },
          { value: 'lib', label: 'Library' },
          { value: 'monorepo', label: 'Monorepo' },
        ],
      }),
      git: () => confirm({
        message: 'Initialize git?',
        initialValue: true,
      }),
    },
    {
      // Handle cancellation for all prompts in the group
      onCancel: () => {
        cancel('Setup cancelled.');
        process.exit(0);
      },
    },
  );

  // project = { name: 'my-project', type: 'app', git: true }
  console.log(project);

  outro('Setup complete!');
}

Fix 3: Logging and Output

import { log, note, intro, outro } from '@clack/prompts';
import color from 'picocolors';

intro('Build Report');

// Structured log messages
log.info('Starting build...');
log.success('TypeScript compiled successfully');
log.warn('3 deprecation warnings found');
log.error('Failed to minify CSS');
log.message('Plain message without icon');

// Step indicator
log.step('Step 1: Compile');
log.step('Step 2: Bundle');
log.step('Step 3: Optimize');

// Note box — highlighted information
note(
  'Build output: 245KB (gzipped: 82KB)\nChunks: 3\nAssets: 12',
  'Build Summary',
);

// Colored output with picocolors
log.info(`Found ${color.bold('42')} files to process`);
log.success(`Deployed to ${color.cyan(color.underline('https://myapp.com'))}`);
log.warn(`${color.yellow('Warning:')} Node.js 18 is end-of-life`);

outro('Build complete');

Fix 4: Password and Secret Input

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

// Password input — characters are masked
const secret = await password({
  message: 'Enter your API key:',
  validate(value) {
    if (!value) return 'API key is required';
    if (!value.startsWith('sk_')) return 'API key must start with sk_';
  },
});

// Path input with default
const outputDir = await text({
  message: 'Output directory?',
  placeholder: './dist',
  defaultValue: './dist',
});

Fix 5: Handle Non-TTY Environments

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

async function main() {
  // Check if running in interactive mode
  if (!process.stdin.isTTY) {
    // Non-interactive — use defaults or CLI args
    console.log('Running in non-interactive mode');
    const name = process.argv[2] || 'default-project';
    await createProject(name, 'next', ['typescript']);
    return;
  }

  // Interactive mode — show prompts
  const name = await text({ message: 'Project name?' });
  if (isCancel(name)) {
    process.exit(0);
  }
  // ...
}

// Or accept CLI flags as overrides
import { parseArgs } from 'node:util';

const { values } = parseArgs({
  options: {
    name: { type: 'string', short: 'n' },
    framework: { type: 'string', short: 'f' },
    yes: { type: 'boolean', short: 'y' },  // Skip prompts
  },
});

async function main() {
  // Skip prompts if --yes flag is passed
  const name = values.name || await text({ message: 'Project name?' });
  const framework = values.framework || await select({
    message: 'Framework?',
    options: [/* ... */],
  });
}

Fix 6: Build a Full CLI Tool

// bin/cli.ts — entry point
#!/usr/bin/env node

import { intro, outro, spinner, log, confirm, isCancel, cancel } from '@clack/prompts';
import color from 'picocolors';
import { execSync } from 'child_process';

const args = process.argv.slice(2);
const command = args[0];

switch (command) {
  case 'init':
    await initProject();
    break;
  case 'deploy':
    await deploy();
    break;
  case 'help':
  default:
    showHelp();
}

async function deploy() {
  intro(color.bgCyan(' Deploy '));

  const proceed = await confirm({
    message: `Deploy to ${color.bold('production')}?`,
  });

  if (isCancel(proceed) || !proceed) {
    cancel('Deploy cancelled.');
    process.exit(0);
  }

  const s = spinner();

  s.start('Running tests...');
  try {
    execSync('npm test', { stdio: 'pipe' });
    s.stop(color.green('Tests passed'));
  } catch {
    s.stop(color.red('Tests failed'));
    log.error('Fix failing tests before deploying');
    process.exit(1);
  }

  s.start('Building...');
  execSync('npm run build', { stdio: 'pipe' });
  s.stop(color.green('Build complete'));

  s.start('Deploying...');
  execSync('npx wrangler deploy', { stdio: 'pipe' });
  s.stop(color.green('Deployed!'));

  outro(color.green('Successfully deployed to production'));
}

function showHelp() {
  console.log(`
  ${color.bold('my-cli')} - Project management tool

  ${color.bold('Commands:')}
    init     Create a new project
    deploy   Deploy to production
    help     Show this help message
  `);
}
// package.json — make it executable
{
  "name": "my-cli",
  "bin": {
    "my-cli": "./dist/cli.js"
  },
  "scripts": {
    "build": "tsup src/cli.ts --format esm",
    "dev": "tsx src/cli.ts"
  }
}

Still Not Working?

Prompts hang without showing anything — the script is likely running in a non-TTY environment (piped output, some CI runners, IDE output panels). Check process.stdin.isTTY — if it is false, prompts cannot read input. Provide fallback behavior or require the --yes flag for non-interactive mode.

Spinner corrupts terminal output — if an error throws between s.start() and s.stop(), the spinner keeps running. Always wrap spinner operations in try/catch and call s.stop() in both the success and error paths. The stop message can indicate success or failure.

isCancel() doesn’t catch Ctrl+CisCancel() checks for the cancel symbol returned by Clack prompts. Ctrl+C during a prompt returns this symbol. But Ctrl+C outside a prompt sends SIGINT to the process. Handle both: check isCancel() after each prompt, and add process.on('SIGINT', () => process.exit(0)) for graceful global exit.

Multi-select returns empty array — if required: false (default), the user can submit without selecting anything. Set required: true to enforce at least one selection. The user presses Space to toggle items and Enter to confirm.

ERR_REQUIRE_ESM when importing @clack/prompts — the package is ESM-only. You cannot require() it from CommonJS. Either convert your entry file to ESM by adding "type": "module" to package.json, run the script through tsx (which transparently loads ESM), or dynamically import it with await import('@clack/prompts') from an async function.

Output looks broken on Windows Terminal or older terminals — Clack uses Unicode box-drawing characters and ANSI escapes. On legacy Windows consoles without VT mode (cmd.exe pre-Windows 10 build 1607) the box characters render as ?. Run inside Windows Terminal, PowerShell 7, or WSL. For old terminals, force a plain rendering by setting FORCE_COLOR=0 and stripping the framing.

Spinner clears the success message immediately — on some terminals, s.stop('Done') writes the line and then the next console.log or intro redraws and clears it. Avoid mixing raw console.log between Clack calls. Use log.message, log.info, log.success, etc., which are aware of the render loop.

For related CLI tool issues, see Fix: esbuild Not Working, Fix: Bun Not Working, Fix: tsx Not Working, and Fix: tsup Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles