Skip to content

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

FixDevs ·

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 beautiful CLI prompts for Node.js scripts. Issues arise from:

  • Clack writes to stdout/stderr — if your script runs in an environment without a TTY (piped output, CI, some IDE terminals), interactive prompts can’t read input. The process hangs waiting for input that never comes.
  • Spinners must be explicitly stoppeds.start() begins the animation. You must call s.stop() to end it. If an error throws between start and stop, the spinner runs forever and corrupts terminal output.
  • Cancellation returns a special symbol — when the user presses Ctrl+C or Escape, Clack returns Symbol(clack:cancel). If you don’t check for it with isCancel(), your code tries to use the symbol as a string, causing crashes.
  • intro() and outro() are decorative wrappers — they frame the output but aren’t required. However, calling outro() without intro() or nesting prompts incorrectly breaks the visual formatting.

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’s false, prompts can’t 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.

For related CLI tool issues, see Fix: esbuild Not Working and Fix: Bun 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