Bunli
Guides

Interactive Prompts

Build interactive CLI experiences with prompts and user input

Create engaging CLI experiences with interactive prompts, confirmations, and selections.

Available Prompt Types

Bunli provides several built-in prompt utilities through the handler context:

import { PromptCancelledError } from "@bunli/runtime/prompt";

export default defineCommand({
  handler: async ({ prompt }) => {
    // Text input
    const name = await prompt("What is your name?");

    // Confirmation
    const confirmed = await prompt.confirm("Continue?");

    // Selection
    const choice = await prompt.select("Choose an option", {
      options: [
        { label: "Option A", value: "a" },
        { label: "Option B", value: "b" },
      ],
    });

    // Password input
    const password = await prompt.password("Enter password:");
  },
});

Bunli prompt primitives are provided by @bunli/runtime/prompt, and the handler prompt utility exposes lifecycle helpers like intro/outro/note/cancel:

export default defineCommand({
  handler: async ({ prompt }) => {
    prompt.intro("Setup");
    const name = await prompt.text("Name:");
    prompt.outro("Done");
  },
});

Clack Mental Model Mapping

If you're migrating from @clack/prompts, use this quick mapping:

// clack mental model -> @bunli/runtime/prompt
intro("Setup"); // -> prompt.intro('Setup')
outro("Done"); // -> prompt.outro('Done')
log.info("message"); // -> prompt.log.info('message')
const ok = await confirm("Continue?"); // -> await prompt.confirm('Continue?')

// Choice-based prompts use object options in Bunli:
const env = await prompt.select("Environment", {
  options: [
    { label: "Development", value: "dev" },
    { label: "Production", value: "prod" },
  ],
  default: "dev",
});

const features = await prompt.multiselect("Features", {
  options: [
    { label: "Testing", value: "testing" },
    { label: "Docker", value: "docker" },
  ],
  initialValues: ["testing"],
});

Alternate-Buffer Dialog Flows

For richer TUI flows in render() paths, use @bunli/tui/interactive dialog primitives:

import { useDialogManager, DialogDismissedError } from "@bunli/runtime/app";

function DeployScreen() {
  const dialogs = useDialogManager();

  async function deploy() {
    try {
      const env = await dialogs.choose({
        title: "Target environment",
        options: [
          { label: "Development", value: "dev", section: "General" },
          { label: "Staging", value: "staging", section: "General" },
          { label: "Production", value: "prod", section: "Protected", disabled: true },
        ],
      });

      const confirmed = await dialogs.confirm({
        title: "Confirm deploy",
        message: `Deploy to ${env}?`,
      });

      if (confirmed) {
        // run deploy
      }
    } catch (error) {
      if (error instanceof DialogDismissedError) {
        // dismissed by Esc / Ctrl+C
      }
    }
  }

  return <box />;
}

choose semantics:

  • Disabled options are skipped for selection/navigation.
  • initialIndex is normalized to the nearest enabled option.
  • Dialog rejects when no enabled options exist.

Keyboard defaults:

  • confirm: Left/h/y, Right/l/n, Tab, Enter
  • choose: Up/k, Down/j, Enter

Text Input Prompts

Basic Text Input

export default defineCommand({
  name: "init",
  handler: async ({ prompt, colors }) => {
    const projectName = await prompt("Project name:", {
      default: "my-project",
      validate: (value) => {
        if (!value) return "Project name is required";
        if (!/^[a-z0-9-]+$/.test(value)) {
          return "Project name can only contain lowercase letters, numbers, and dashes";
        }
        return true;
      },
    });

    console.log(colors.green("✓"), `Creating project: ${projectName}`);
  },
});

Multi-line Input

Note: Bunli's prompt() currently captures a single line of input.

export default defineCommand({
  name: "note",
  handler: async ({ prompt }) => {
    const content = await prompt("Enter your note (Ctrl+D to finish):", {
      multiline: true,
    });

    console.log("Note saved with", content.split("\n").length, "lines");
  },
});

Input with Validation

export default defineCommand({
  name: "register",
  handler: async ({ prompt }) => {
    const email = await prompt("Email address:", {
      validate: (value) => {
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        if (!emailRegex.test(value)) {
          return "Please enter a valid email address";
        }
        return true;
      },
    });

    const ageStr = await prompt("Age:", {
      validate: (value) => {
        const num = parseInt(value, 10);
        if (isNaN(num)) return "Please enter a number";
        if (num < 18) return "You must be 18 or older";
        if (num > 150) return "Please enter a valid age";
        return true;
      },
    });

    const age = parseInt(ageStr, 10);
    console.log(`Registered: ${email}, age ${age}`);
  },
});

Confirmation Prompts

Basic Confirmation

export default defineCommand({
  name: "delete",
  handler: async ({ prompt, colors, positional }) => {
    const [file] = positional;

    const confirmed = await prompt.confirm(`Are you sure you want to delete ${file}?`, {
      default: false,
    });

    if (confirmed) {
      console.log(colors.red("✗"), `Deleted ${file}`);
    } else {
      console.log(colors.yellow("⚠"), "Operation cancelled");
    }
  },
});

Dangerous Operations

export default defineCommand({
  name: "deploy",
  options: {
    env: option(z.enum(["dev", "staging", "production"])),
  },
  handler: async ({ flags, prompt, colors }) => {
    if (flags.env === "production") {
      console.log(colors.yellow("⚠ WARNING: Deploying to production!"));

      const confirmed = await prompt.confirm("This will affect live users. Continue?", {
        default: false,
      });

      if (!confirmed) {
        console.log("Deployment cancelled");
        process.exit(0);
      }

      // Double confirmation for critical operations
      const reallyConfirmed = await prompt.confirm("Are you REALLY sure?", { default: false });

      if (!reallyConfirmed) {
        console.log("Deployment cancelled");
        process.exit(0);
      }
    }

    console.log(`Deploying to ${flags.env}...`);
  },
});

Selection Prompts

Single Selection

export default defineCommand({
  name: "create",
  handler: async ({ prompt, colors }) => {
    const projectType = await prompt.select("What type of project?", {
      options: [
        { value: "web", label: "Web Application" },
        { value: "api", label: "REST API" },
        { value: "cli", label: "CLI Tool" },
        { value: "lib", label: "Library" },
      ],
    });

    const framework = await prompt.select("Choose a framework:", {
      options:
        projectType === "web"
          ? [
              { value: "next", label: "Next.js" },
              { value: "remix", label: "Remix" },
              { value: "astro", label: "Astro" },
            ]
          : [
              { value: "hono", label: "Hono" },
              { value: "elysia", label: "Elysia" },
              { value: "express", label: "Express" },
            ],
    });

    console.log(colors.green("✓"), `Creating ${projectType} with ${framework}`);
  },
});

Multi-Selection

export default defineCommand({
  name: "install",
  handler: async ({ prompt, spinner }) => {
    const features = await prompt.multiselect("Select features to install:", {
      options: [
        { value: "auth", label: "Authentication" },
        { value: "db", label: "Database" },
        { value: "email", label: "Email Service" },
        { value: "storage", label: "File Storage" },
        { value: "cache", label: "Caching Layer" },
        { value: "queue", label: "Job Queue" },
      ],
      initialValues: ["auth", "db"],
      min: 1,
      max: 4,
    });

    const spin = spinner("Installing features...");
    spin.start();

    for (const feature of features) {
      spin.update(`Installing ${feature}...`);
      await new Promise((resolve) => setTimeout(resolve, 1000));
    }

    spin.succeed(`Installed ${features.length} features`);
  },
});

Password Prompts

export default defineCommand({
  name: "login",
  handler: async ({ prompt, colors }) => {
    const username = await prompt("Username:");

    const password = await prompt.password("Password:", {
      validate: (value) => {
        if (value.length < 8) {
          return "Password must be at least 8 characters";
        }
        return true;
      },
    });

    // For sensitive operations, confirm password
    const confirmPassword = await prompt.password("Confirm password:");

    if (password !== confirmPassword) {
      console.log(colors.red("✗"), "Passwords do not match");
      process.exit(1);
    }

    console.log(colors.green("✓"), "Login successful");
  },
});

Complex Interactive Flows

Setup Wizard

export default defineCommand({
  name: "setup",
  handler: async ({ prompt, colors, spinner }) => {
    console.log(colors.blue("Welcome to the setup wizard!\n"));

    // Step 1: Basic Info
    const projectName = await prompt("Project name:", {
      default: "my-app",
    });

    const description = await prompt("Project description:", {
      default: "A Bunli CLI application",
    });

    // Step 2: Configuration
    const useTypeScript = await prompt.confirm("Use TypeScript?", {
      default: true,
    });

    const features = await prompt.multiselect("Select features:", {
      options: [
        { value: "tests", label: "Testing framework" },
        { value: "lint", label: "Linting" },
        { value: "format", label: "Code formatting" },
        { value: "git", label: "Git repository" },
        { value: "ci", label: "CI/CD pipeline" },
      ],
      initialValues: ["tests", "lint", "format", "git"],
    });

    // Step 3: Advanced Options
    let database = null;
    if (await prompt.confirm("Configure database?")) {
      database = await prompt.select("Database type:", {
        options: [
          { value: "sqlite", label: "SQLite" },
          { value: "postgres", label: "PostgreSQL" },
          { value: "mysql", label: "MySQL" },
          { value: "mongodb", label: "MongoDB" },
        ],
      });
    }

    // Step 4: Confirmation
    console.log("\n" + colors.blue("Configuration Summary:"));
    console.log(`  Name: ${projectName}`);
    console.log(`  Description: ${description}`);
    console.log(`  TypeScript: ${useTypeScript ? "Yes" : "No"}`);
    console.log(`  Features: ${features.join(", ")}`);
    if (database) {
      console.log(`  Database: ${database}`);
    }

    const proceed = await prompt.confirm("\nProceed with setup?", {
      default: true,
    });

    if (!proceed) {
      console.log(colors.yellow("Setup cancelled"));
      process.exit(0);
    }

    // Step 5: Execute Setup
    const spin = spinner("Setting up project...");
    spin.start();

    // Simulate setup steps
    const steps = [
      "Creating project structure",
      "Installing dependencies",
      "Configuring tools",
      "Initializing git repository",
    ];

    for (const step of steps) {
      spin.update(step + "...");
      await new Promise((resolve) => setTimeout(resolve, 1000));
    }

    spin.succeed("Project setup complete!");
    console.log(colors.green("\n✓"), `cd ${projectName} && bunli dev`);
  },
});

Interactive Configuration Editor

export default defineCommand({
  name: "config",
  handler: async ({ prompt, colors }) => {
    const action = await prompt.select("What would you like to do?", {
      options: [
        { value: "view", label: "View current configuration" },
        { value: "edit", label: "Edit configuration" },
        { value: "reset", label: "Reset to defaults" },
      ],
    });

    if (action === "edit") {
      const section = await prompt.select("Which section to edit?", {
        options: [
          { value: "general", label: "General Settings" },
          { value: "api", label: "API Configuration" },
          { value: "advanced", label: "Advanced Options" },
        ],
      });

      switch (section) {
        case "general":
          const theme = await prompt.select("Color theme:", {
            options: [
              { value: "auto", label: "Auto (system)" },
              { value: "light", label: "Light" },
              { value: "dark", label: "Dark" },
            ],
          });

          const verbose = await prompt.confirm("Enable verbose logging?");

          console.log(colors.green("✓"), "Settings updated");
          break;

        case "api":
          const endpoint = await prompt("API endpoint:", {
            default: "https://api.example.com",
            validate: (value) => {
              try {
                new URL(value);
                return true;
              } catch {
                return "Please enter a valid URL";
              }
            },
          });

          const timeout = await prompt("Request timeout (seconds):", {
            default: "30",
            validate: (value) => {
              const num = parseInt(value, 10);
              if (isNaN(num) || num <= 0) {
                return "Please enter a positive number";
              }
              return true;
            },
          });

          console.log(colors.green("✓"), "API configuration updated");
          break;
      }
    }
  },
});

Prompt Options

Common Options

interface PromptOptions {
  // Default value
  default?: string;

  // Validation function
  validate?: (value: string) => boolean | string;

  // Zod/Standard Schema validation
  schema?: StandardSchemaV1;

  // Placeholder text
  placeholder?: string;

  // Multi-line input
  multiline?: boolean;

  // Character limit
  charLimit?: number;

  // Display height
  height?: number;
}

Select Options

interface SelectOptions<T> {
  // Available options
  options: Array<{
    value: T;
    label: string;
    hint?: string;
  }>;

  // Default selected value
  default?: T;
}

Best Practices

  1. Provide Defaults: Always offer sensible defaults
  2. Validate Input: Catch errors early with validation
  3. Show Progress: Use spinners for long operations
  4. Confirm Dangerous Actions: Double-check destructive operations
  5. Group Related Prompts: Create logical flows
  6. Handle Cancellation: Allow users to exit gracefully

Error Handling

export default defineCommand({
  handler: async ({ prompt, colors }) => {
    try {
      const input = await prompt("Enter value:");
      // Process input
    } catch (error) {
      if (error instanceof PromptCancelledError) {
        console.log(colors.yellow("\nOperation cancelled"));
        process.exit(0);
      }
      throw error;
    }
  },
});

Type-Safe Interactive Prompts

When using type generation, you can create type-safe interactive prompts that leverage command metadata:

// Generated in .bunli/commands.gen.ts
import { getCommandApi, listCommands } from "./commands.gen";

// Get command metadata for dynamic prompts
const commands = listCommands();
const setupApi = getCommandApi("setup");

// Use command options for dynamic prompt generation
const availablePresets = Object.keys(setupApi.options.preset?.options || {});
const projectTypes = Object.keys(setupApi.options.type?.options || {});

// Type-safe interactive command execution
async function runInteractiveCommand(commandName: string, options: any) {
  const command = getCommandApi(commandName as any);

  // Validate options against command schema
  for (const [key, value] of Object.entries(options)) {
    if (command.options[key]) {
      // Type-safe option validation
      console.log(`Setting ${key}: ${value}`);
    }
  }
}

This enables:

  • Dynamic prompt generation from command metadata
  • Type-safe option validation in interactive flows
  • Command discovery for building wizards
  • IntelliSense for command-specific options

Generated types work perfectly with interactive commands, providing type safety for dynamic prompt generation and option validation.

Testing Interactive Commands

import { test, expect } from "bun:test";
import { testCommand } from "@bunli/test";

test("interactive setup", async () => {
  const result = await testCommand(setupCommand, {
    args: ["setup"],
    stdin: ["my-project", "y", "tests,git", "y"],
  });

  expect(result.exitCode).toBe(0);
  expect(result.stdout).toContain("Project setup complete");
});

For more control over prompt mocking, use the mockPromptResponses helper:

import { test, expect } from "bun:test";
import { testCommand, mockPromptResponses } from "@bunli/test";

test("interactive setup with mockPromptResponses", async () => {
  const result = await testCommand(setupCommand, {
    args: ["setup"],
    ...mockPromptResponses({
      "Project name:": "my-project",
      "Use TypeScript?": true,
      "Select features:": ["tests", "git"],
      "Proceed with setup?": true,
    }),
  });

  expect(result.exitCode).toBe(0);
  expect(result.stdout).toContain("Project setup complete");
});

Next Steps

On this page