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.
initialIndexis normalized to the nearest enabled option.- Dialog rejects when no enabled options exist.
Keyboard defaults:
confirm:Left/h/y,Right/l/n,Tab,Enterchoose: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
- Provide Defaults: Always offer sensible defaults
- Validate Input: Catch errors early with validation
- Show Progress: Use spinners for long operations
- Confirm Dangerous Actions: Double-check destructive operations
- Group Related Prompts: Create logical flows
- 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
- Type Generation Guide - Learn about code generation
- Testing - Test interactive commands
- Building Your First CLI - Complete example
- TUI Gallery - Runnable component examples and runtime recipes for
@bunli/tui