Effortlessly beautiful CLI prompts for Ruby.
A faithful Ruby port of @clack/prompts. 16+ prompt types, zero dependencies, Ruby 3.2+. gem "clack" and go.
- Beautiful by default — Thoughtfully designed prompts that just look right
- Vim-friendly — Navigate with
hjklor arrow keys - Accessible — Graceful ASCII fallbacks for limited terminals
- Composable — Group prompts together with
Clack.group - Zero dependencies — Everything in one gem
# Gemfile
gem "clack"
# Or from GitHub
gem "clack", github: "swhitt/clackrb"# Or install directly
gem install clackrequire "clack"
Clack.intro "project-setup"
result = Clack.group do |g|
g.prompt(:name) { Clack.text(message: "Project name?", placeholder: "my-app") }
g.prompt(:framework) do
Clack.select(
message: "Pick a framework",
options: [
{ value: "rails", label: "Ruby on Rails", hint: "recommended" },
{ value: "sinatra", label: "Sinatra" },
{ value: "roda", label: "Roda" }
]
)
end
g.prompt(:features) do
Clack.multiselect(
message: "Select features",
options: %w[api auth admin websockets]
)
end
end
if Clack.cancel?(result)
Clack.cancel("Setup cancelled")
exit 1
end
Clack.outro "You're all set!"Try it yourself:
ruby examples/full_demo.rbRecording the demo GIF
Requires asciinema, agg, and expect:
# Record the demo (automated via expect script)
asciinema rec examples/demo.cast --command "expect examples/demo.exp" --overwrite -q
# Split batched frames to show typing (Ruby buffers terminal output)
ruby examples/split_cast.rb
# Convert to GIF
agg examples/demo.cast examples/demo.gif --font-size 18 --cols 80 --rows 28 --speed 0.6All prompts return the user's input, or Clack::CANCEL if they pressed Escape/Ctrl+C.
# Check for cancellation
result = Clack.text(message: "Name?")
exit 1 if Clack.cancel?(result)
# Or use handle_cancel for a one-liner that prints "Cancelled" and returns true
result = Clack.text(message: "Name?")
exit 1 if Clack.handle_cancel(result)
# With a custom message
exit 1 if Clack.handle_cancel(result, "Aborted by user")Prompts support validate: and transform: options.
User Input → Validation (raw) → Transform (if valid) → Final Value
Validation returns an error message, a Clack::Warning, or nil to pass. Transforms normalize the value after validation passes.
Validation results:
nilorfalse- passes validation- String - shows error (red), user must fix input
Clack::Warning.new(message)- shows warning (yellow), user can confirm with Enter or edit
# Use symbol shortcuts (preferred)
name = Clack.text(message: "Name?", transform: :strip)
code = Clack.text(message: "Code?", transform: :upcase)
# Chain multiple transforms
username = Clack.text(
message: "Username?",
transform: Clack::Transformers.chain(:strip, :downcase)
)
# Combine validation and transform
amount = Clack.text(
message: "Amount?",
validate: ->(v) { "Must be a number" unless v.match?(/\A\d+\z/) },
transform: :to_integer
)
# Warning validation (soft failure, user can confirm or edit)
file = Clack.text(
message: "Output file?",
validate: ->(v) { Clack::Warning.new("File exists. Overwrite?") if File.exist?(v) }
)Built-in validators and transformers:
# Validators - return error message, Warning, or nil
Clack::Validators.required # Non-empty input
Clack::Validators.min_length(3) # Minimum character count
Clack::Validators.max_length(100) # Maximum character count
Clack::Validators.format(/\A[a-z]+\z/, "Only lowercase")
Clack::Validators.email # Email format (user@host.tld)
Clack::Validators.url # URL format (http/https)
Clack::Validators.integer # Integer string ("-5", "42")
Clack::Validators.in_range(1..100) # Numeric range (parses as int)
Clack::Validators.one_of(%w[a b c]) # Allowlist check
Clack::Validators.path_exists # File/dir exists on disk
Clack::Validators.directory_exists # Directory exists on disk
Clack::Validators.future_date # Date strictly after today
Clack::Validators.past_date # Date strictly before today
Clack::Validators.date_range(min: d1, max: d2) # Date within range
Clack::Validators.combine(v1, v2) # First error/warning wins
# Warning validators - allow user to confirm or edit
Clack::Validators.file_exists_warning # For file overwrite confirmations
Clack::Validators.as_warning(validator) # Convert any validator to warning
# Transformers - normalize the value (use :symbol or Clack::Transformers.name)
:strip / :trim # Remove leading/trailing whitespace
:downcase / :upcase # Change case
:capitalize # "hello world" -> "Hello world"
:titlecase # "hello world" -> "Hello World"
:squish # Collapse whitespace to single spaces
:compact # Remove all whitespace
:to_integer # Parse as integer
:to_float # Parse as float
:digits_only # Extract only digitsname = Clack.text(
message: "What is your project named?",
placeholder: "my-project", # Shown when empty (dim)
default_value: "untitled", # Used if submitted empty
initial_value: "hello-world", # Pre-filled, editable
validate: ->(v) { "Required!" if v.empty? },
help: "Letters, numbers, and dashes only" # Contextual help text
)Tab completion - press Tab to fill the longest common prefix of matching candidates:
# Tab completion from a static list
cmd = Clack.text(
message: "Command?",
completions: %w[build test deploy lint format]
)
# Dynamic tab completion
file = Clack.text(
message: "File?",
completions: ->(input) { Dir.glob("#{input}*") }
)secret = Clack.password(
message: "Enter your API key",
mask: "*" # Default: "▪"
)Multi-line text input. Enter inserts a newline, Ctrl+D submits.
bio = Clack.multiline_text(
message: "Tell us about yourself",
initial_value: "Hello!\n",
validate: ->(v) { "Too short" if v.strip.length < 10 }
)proceed = Clack.confirm(
message: "Deploy to production?",
active: "Yes, ship it!",
inactive: "No, abort",
initial_value: false
)Single selection with keyboard navigation.
db = Clack.select(
message: "Choose a database",
options: [
{ value: "pg", label: "PostgreSQL", hint: "recommended" },
{ value: "mysql", label: "MySQL" },
{ value: "sqlite", label: "SQLite", disabled: true }
],
initial_value: "pg",
max_items: 5 # Enable scrolling
)Multiple selections with toggle controls.
features = Clack.multiselect(
message: "Select features to install",
options: [
{ value: "api", label: "API Mode" },
{ value: "auth", label: "Authentication" },
{ value: "jobs", label: "Background Jobs" }
],
initial_values: ["api"],
required: true, # Must select at least one
max_items: 5 # Enable scrolling
)Shortcuts: Space toggle | a all | i invert
Type to filter from a list of options. Filtering uses fuzzy matching by default -- characters must appear in order but don't need to be consecutive (e.g. "fb" matches "foobar"). Pass filter: to override with custom logic.
color = Clack.autocomplete(
message: "Pick a color",
options: %w[red orange yellow green blue indigo violet],
placeholder: "Type to search...",
max_items: 5 # Default; scrollable via ↑↓
)
# Custom filter logic (receives option hash and query string)
cmd = Clack.autocomplete(
message: "Select command",
options: commands,
filter: ->(opt, query) { opt[:label].start_with?(query) }
)Vim-style
j/k/h/lnavigation is not available — all keyboard input feeds into the search field. Use↑↓arrow keys to navigate results.
Type to filter with multi-selection support.
colors = Clack.autocomplete_multiselect(
message: "Pick colors",
options: %w[red orange yellow green blue indigo violet],
placeholder: "Type to filter...",
required: true, # At least one selection required
initial_values: ["red"], # Pre-selected values
max_items: 5 # Default; scrollable via ↑↓
)Shortcuts: Space toggle | Enter confirm
The
a(select all),i(invert), andj/k/h/lshortcuts fromMultiselectare not available here — all keyboard input feeds into the search field instead. Use↑↓arrow keys to navigate.
File/directory path selector with filesystem navigation.
project_dir = Clack.path(
message: "Where should we create your project?",
only_directories: true, # Only show directories
root: "." # Starting directory
)Navigation: Type to filter | Tab to autocomplete | ↑↓ to select
Segmented date picker with three format modes.
date = Clack.date(
message: "Release date?",
format: :us, # :iso (YYYY-MM-DD), :us (MM/DD/YYYY), :eu (DD/MM/YYYY)
initial_value: Date.today + 7,
min: Date.today,
max: Date.today + 365,
validate: ->(d) { "Not a Friday" unless d.friday? }
)Navigation: Tab/←→ between segments | ↑↓ adjust value | type digits directly
Numeric selection with a visual slider track. Navigate with ←→ or ↑↓ arrow keys (or hjkl).
volume = Clack.range(
message: "Set volume",
min: 0,
max: 100,
step: 5,
initial_value: 50
)Quick selection using keyboard shortcuts.
action = Clack.select_key(
message: "What would you like to do?",
options: [
{ value: "create", label: "Create new project", key: "c" },
{ value: "open", label: "Open existing", key: "o" },
{ value: "quit", label: "Quit", key: "q" }
]
)Non-blocking animated indicator for async work.
spinner = Clack.spinner
spinner.start("Installing dependencies...")
# Do your work...
sleep 2
spinner.stop("Dependencies installed!")
# Or: spinner.error("Installation failed")
# Or: spinner.cancel("Cancelled")Block form - wraps a block with automatic success/error handling:
result = Clack.spin("Installing dependencies...") { system("npm install") }
# With custom messages
Clack.spin("Compiling...", success: "Build complete!") { build_project }
# Access the spinner inside the block
Clack.spin("Working...") do |s|
s.message "Step 1..."
do_step_1
s.message "Step 2..."
do_step_2
endVisual progress bar for measurable operations.
progress = Clack.progress(total: 100, message: "Downloading...")
progress.start
files.each_with_index do |file, i|
download(file)
progress.update(i + 1)
end
progress.stop("Download complete!")Run multiple tasks with status indicators.
results = Clack.tasks(tasks: [
{ title: "Checking dependencies", task: -> { check_deps } },
{ title: "Building project", task: -> { build } },
{ title: "Running tests", task: -> { run_tests } }
])
# Update the spinner message mid-task
Clack.tasks(tasks: [
{ title: "Installing", task: ->(message) {
message.call("Fetching packages...")
fetch_packages
message.call("Compiling...")
compile
}}
])
# Conditionally skip tasks with enabled:
Clack.tasks(tasks: [
{ title: "Lint", task: -> { lint } },
{ title: "Deploy", task: -> { deploy }, enabled: ENV["DEPLOY"] == "true" }
])Multiselect with options organized into groups.
features = Clack.group_multiselect(
message: "Select features",
options: [
{
label: "Frontend",
options: [
{ value: "hotwire", label: "Hotwire" },
{ value: "stimulus", label: "Stimulus" }
]
},
{
label: "Background",
options: [
{ value: "sidekiq", label: "Sidekiq" },
{ value: "solid_queue", label: "Solid Queue" }
]
}
],
selectable_groups: true, # Toggle all options in a group at once
group_spacing: 1 # Blank lines between groups
)| Prompt | Method | Key Options | Defaults |
|---|---|---|---|
| Text | Clack.text |
placeholder:, default_value:, initial_value:, completions: |
— |
| Password | Clack.password |
mask:, validate: |
mask: "▪" |
| Confirm | Clack.confirm |
active:, inactive:, initial_value: |
active: "Yes", inactive: "No", initial_value: true |
| Select | Clack.select |
options:, initial_value:, max_items: |
— |
| Multiselect | Clack.multiselect |
options:, initial_values:, required:, cursor_at: |
required: true |
| Group Multiselect | Clack.group_multiselect |
options: (nested), selectable_groups:, group_spacing: |
selectable_groups: false |
| Autocomplete | Clack.autocomplete |
options:, placeholder:, filter:, max_items: |
max_items: 5 |
| Autocomplete Multiselect | Clack.autocomplete_multiselect |
options:, required:, initial_values:, filter: |
required: true, max_items: 5 |
| Select Key | Clack.select_key |
options: (with :key) |
— |
| Path | Clack.path |
root:, only_directories: |
root: "." |
| Date | Clack.date |
format:, initial_value:, min:, max: |
format: :iso |
| Range | Clack.range |
min:, max:, step:, initial_value: |
min: 0, max: 100, step: 1 |
| Multiline Text | Clack.multiline_text |
initial_value:, validate: |
Submit with Ctrl+D |
| Spinner | Clack.spinner / Clack.spin |
block form auto-handles success/error | — |
| Tasks | Clack.tasks |
tasks: ({title:, task:, enabled:}) |
enabled: true |
| Progress | Clack.progress |
total:, message: |
— |
All prompts accept message:, validate:, help:, and return Clack::CANCEL on Escape/Ctrl+C.
Chain multiple prompts and collect results in a hash. Cancellation is handled automatically.
result = Clack.group do |g|
g.prompt(:name) { Clack.text(message: "Your name?") }
g.prompt(:email) { Clack.text(message: "Your email?") }
g.prompt(:confirm) { |r| Clack.confirm(message: "Create account for #{r[:email]}?") }
end
return if Clack.cancel?(result)
puts "Welcome, #{result[:name]}!"Handle cancellation with a callback:
Clack.group(on_cancel: ->(r) { cleanup(r) }) do |g|
# prompts...
endBeautiful, consistent log messages.
Clack.log.info("Starting build...")
Clack.log.success("Build completed!")
Clack.log.warn("Cache is stale")
Clack.log.error("Build failed")
Clack.log.step("Running migrations")
Clack.log.message("Custom message")Stream output from iterables, enumerables, or shell commands:
# Stream from an array or enumerable
Clack.stream.info(["Line 1", "Line 2", "Line 3"])
Clack.stream.step(["Step 1", "Step 2", "Step 3"])
# Stream from a shell command (returns true/false for success)
success = Clack.stream.command("npm install", type: :info)
# Stream from any IO or StringIO
Clack.stream.success(io_stream)Display important information in a box.
Clack.note(<<~MSG, title: "Next Steps")
cd my-project
bundle install
bin/rails server
MSGRender a customizable bordered box.
Clack.box("Hello, World!", title: "Greeting")
# With options
Clack.box(
"Centered content",
title: "My Box",
content_align: :center, # :left, :center, :right
title_align: :center,
width: 40, # or :auto to fit content
rounded: true # rounded or square corners
)Streaming log that clears on success and shows full output on failure. Useful for build output.
tl = Clack.task_log(title: "Building...", limit: 10)
tl.message("Compiling file 1...")
tl.message("Compiling file 2...")
# On success: clears the log
tl.success("Build complete!")
# On error: keeps the log visible
# tl.error("Build failed!")Clack.intro("my-cli v1.0") # ┌ my-cli v1.0
# ... your prompts ...
Clack.outro("Done!") # └ Done!
# Or on error:
Clack.cancel("Aborted") # └ Aborted (red)Customize key bindings and display options globally:
# Add custom key bindings (merged with defaults)
Clack.update_settings(aliases: { "y" => :enter, "n" => :cancel })
# Disable guide bars
Clack.update_settings(with_guide: false)
# CI / non-interactive mode (prompts auto-submit with defaults)
Clack.update_settings(ci_mode: true) # Always on
Clack.update_settings(ci_mode: :auto) # Auto-detect (non-TTY or CI env vars)When CI mode is active, prompts immediately submit with their default values instead of waiting for input. Useful for CI pipelines and scripted environments where stdin is not a TTY.
Clack also warns when terminal width is below 40 columns, since prompts may not render cleanly in very narrow terminals.
Clack ships with first-class test helpers. Require clack/testing explicitly (it is not auto-loaded):
require "clack/testing"
# Simulate a text prompt
result = Clack::Testing.simulate(Clack.method(:text), message: "Name?") do |prompt|
prompt.type("Alice")
prompt.submit
end
# => "Alice"
# Capture rendered output alongside the result
result, output = Clack::Testing.simulate_with_output(Clack.method(:confirm), message: "Sure?") do |prompt|
prompt.left # switch to "No"
prompt.submit
endThe PromptDriver yielded to the block provides these methods:
| Method | Description |
|---|---|
type(text) |
Type a string character by character |
submit |
Press Enter |
cancel |
Press Escape |
up / down / left / right |
Arrow keys |
toggle |
Press Space (for multiselect) |
tab |
Press Tab |
backspace |
Press Backspace |
ctrl_d |
Press Ctrl+D (submit multiline text) |
key(sym_or_char) |
Press an arbitrary key by symbol (e.g. :escape) or raw character |
- Ruby 3.2+
- No runtime dependencies
bundle install
bundle exec rake # Lint + tests
bundle exec rake spec # Tests only
COVERAGE=true bundle exec rake spec # With coverage- Wizard mode - Multi-step flows with back navigation (
Clack.wizard). The currentClack.groupruns prompts as sequential Ruby code, so there's no way to "go back" and re-answer a previous question. A declarative wizard API would define steps as a graph with branching and let the engine handle forward/back navigation.
This is a Ruby port of Clack, created by Nate Moore and the Astro team.
MIT - See LICENSE
