C L A C K
CLI prompts for Ruby. Zero dependencies.
A faithful Ruby port of @clack/prompts.
Why Clack?
The standard approach:
print "What is your project named? "
name = gets.chomp
print "Pick a framework (1=Rails, 2=Sinatra, 3=Roda): "
framework = gets.chomp.to_i
No navigation, no validation, no visual feedback. With Clack:
require "clack"
Clack.intro "create-app"
name = Clack.text(message: "What is your project named?", placeholder: "my-app")
# => Renders a gorgeous, navigable text input with placeholder text
framework = Clack.select(
message: "Pick a framework",
options: [
{ value: "rails", label: "Ruby on Rails", hint: "recommended" },
{ value: "sinatra", label: "Sinatra" },
{ value: "roda", label: "Roda" }
]
)
# => Arrow-key navigation, vim bindings, instant submit
Clack.outro "You're all set!"
Installation
# Gemfile
gem "clack"
# Or install directly
gem install clack
Quick Start
require "clack"
Clack.intro "create-app"
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!"
Prompts
All prompts return the user's input, or Clack::CANCEL if the user pressed Escape/Ctrl+C.
The demo GIF and the screenshots below were recorded before 0.7.0, so they still show a gray rail next to the active step and no keyboard hint footer under
select. See Guide rail and Keyboard hints for the current look.
Text
Single-line text input with placeholders, defaults, validation, and tab completion.
name = 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:
# Static list
cmd = Clack.text(
message: "Command?",
completions: %w[build test deploy lint format]
)
# Dynamic completions
file = Clack.text(
message: "File?",
completions: ->(input) { Dir.glob("#{input}*") }
)
Password
Masked text input for secrets and API keys.
secret = Clack.password(
message: "Enter your API key",
mask: "*" # Default: "▪"
)
Multiline Text
For when one line isn't enough. 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 }
)
Confirm
Yes/no toggle with customizable labels.
proceed = Clack.confirm(
message: "Deploy to production?",
active: "Yes, ship it!",
inactive: "No, abort",
initial_value: false
)
Long labels read better stacked. Pass vertical: true to put each option on its own line (up/down, left/right, and y/n all still work):
Clack.confirm(
message: "Overwrite ~/.zshrc?",
active: "Yes, back it up and replace it",
inactive: "No, keep my existing file",
initial_value: false,
vertical: true
)
# ◆ Overwrite ~/.zshrc?
# │ ○ Yes, back it up and replace it
# │ ● No, keep my existing file
# └
initial_value: is coerced to a Boolean, so nil (for example an unset CLI flag) starts on "No" and the prompt returns true, false, or Clack::CANCEL (unless you pass a transform:).
Select
Pick one from a list. Navigate with arrow keys or hjkl. A footer of keyboard hints (key names dimmed) lists the keys (↑/↓ to navigate • Enter: confirm); pass show_instructions: false to hide it.
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
)
Option shorthands
Every option-based prompt also accepts a Hash instead of an Array. For select, multiselect, autocomplete, autocomplete_multiselect, and select_key, keys are the values you get back, Hash values are the labels, and insertion order is the display order:
db = Clack.select(message: "Choose a database", options: {pg: "PostgreSQL", mysql: "MySQL", sqlite: "SQLite"})
# => :pg
# Use a nested Hash when you need a hint or a disabled entry
editor = Clack.select(
message: "Editor?",
options: {
nvim: { label: "Neovim", hint: "recommended" },
vim: "Vim",
nano: { label: "nano", disabled: true }
}
)
features = Clack.multiselect(message: "Features", options: {api: "API", auth: "Auth"}, initial_values: [:api])
group_multiselect takes group label => options at the top level; each group's options follow the same value => label rule (see Group Multiselect).
The orientation is always value => label. A label => value Hash (the Rails options_for_select shape) is not detected; flip it with .invert first. Arrays of [label, value] pairs are not detected either, since Array-valued options are legal; use .to_h to build a Hash instead. A single { value:, label: } option Hash must still be wrapped in an Array; passed bare, it is read as two value => label pairs.
Multiselect
Pick many. Toggle with Space. Select all with a. Invert with i.
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 select | a all | i invert | Enter confirm (the footer lists Space and Enter; a and i are extras not shown there; show_instructions: false hides the footer)
Autocomplete
Type to filter with fuzzy matching -- "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 up/down arrows
)
# Custom filter logic (receives the option and query string).
# The option is a value object; opt.label and opt[:label] both work.
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 in autocomplete -- all keyboard input feeds into the search field. Use arrow keys to navigate results.
Autocomplete Multiselect
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 up/down arrows
)
Shortcuts: Space select | Enter confirm | type to search
The
a(select all),i(invert), andj/k/h/lshortcuts from Multiselect are not available here -- all keyboard input feeds into the search field instead. Use arrow keys to navigate.
Path
Filesystem navigation with Tab completion and arrow key selection.
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 complete | ↑/↓ to select (all listed in the footer)
Date
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/left-right arrows between segments | up/down arrows to adjust value | type digits directly
Range
Visual slider for numeric selection.
volume = Clack.range(
message: "Set volume",
min: 0,
max: 100,
step: 5,
initial_value: 50
)
# Navigate with arrow keys or hjkl
# Fractional steps snap exactly: three steps from 0 is 0.3, not 0.30000000000000004
opacity = Clack.range(message: "Opacity", min: 0, max: 1, step: 0.1)
Returns an Integer when min and step are both integers, otherwise a Float.
Select Key
Instant selection via keyboard shortcuts. No arrow key navigation needed.
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" }
]
)
Keys match regardless of case by default. Pass case_sensitive: true to tell Y and y apart, and initial_value: to highlight a default that Enter accepts (it is also what CI mode returns). The highlight is a color effect, so when colors are off (NO_COLOR, piped output) mark the default in that option's hint: as well. validate: and transform: work like everywhere else.
answer = Clack.select_key(
message: "Apply migration?",
options: [
{ value: :yes_all, label: "Yes to all", key: "Y" },
{ value: :yes, label: "Yes", key: "y" },
{ value: :no, label: "No", key: "n", hint: "default" }
],
case_sensitive: true,
initial_value: :no,
validate: ->(v) { "Not on Fridays" if v == :yes_all && Date.today.friday? }
)
With the Hash shorthand the key defaults to the first character of the value. Pass a nested Hash to set it explicitly (initial_value: and case_sensitive: work the same way):
action = Clack.select_key(
message: "What would you like to do?",
options: {
create: { label: "Create new project", key: "c" },
open: "Open existing", # key "o"
quit: "Quit" # key "q"
},
initial_value: :open
)
Group Multiselect
Multiselect with options organized into named categories.
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
)
Groups can also be a Hash of group label => options, and each group's options accept the same value => label shorthand (or a plain Array):
addons = Clack.group_multiselect(
message: "Add-ons",
options: {
"Observability" => { prom: "Prometheus", dd: "Datadog" },
"Storage" => { s3: "S3", gcs: "GCS" }
}
)
# => [:prom, :s3]
Spinner
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. "Step 1..."
do_step_1
s. "Step 2..."
do_step_2
end
Cancel and error messages -- cancel/error with no argument, Ctrl+C, and exit use these instead of the in-progress text:
s = Clack.spinner(
cancel_message: "Deploy aborted", # default: "Cancelled"
error_message: "Deploy failed", # default: "Something went wrong"
on_cancel: -> { release_lock } # runs once after a cancel, no arguments
)
s.start("Deploying")
s.cancel # => "■ Deploy aborted", on_cancel runs
Inside Clack.spin, an exception raised by the block prints error: or the exception message; error_message: only applies to a bare s.error or an uncaught crash at process exit.
Ctrl+C and exit are safe. A spinner that is still running when the process exits (Ctrl+C, exit, an uncaught error) prints its cancel or error line and stops animating before the at_exit blocks your program registered earlier, instead of leaving a half-drawn frame. Clack.spin does the same when its block exits early (exit, break, throw, Interrupt):
Clack.spin("Building", cancel_message: "Build cancelled") { exit 2 }
# ■ Build cancelled (then the process exits with status 2)
Progress
A visual 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!")
Tasks
Run multiple tasks sequentially 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: ->() {
.call("Fetching packages...")
fetch_packages
.call("Compiling...")
compile
}}
])
# Conditionally skip tasks with enabled:
Clack.tasks(tasks: [
{ title: "Lint", task: -> { lint } },
{ title: "Deploy", task: -> { deploy }, enabled: ENV["DEPLOY"] == "true" }
])
Quick Reference Table (click to expand)
| 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:, vertical: |
active: "Yes", inactive: "No", initial_value: true, vertical: false |
| Select | Clack.select |
options: (Array or Hash), initial_value:, max_items:, show_instructions: |
-- |
| Multiselect | Clack.multiselect |
options:, initial_values:, required:, cursor_at:, show_instructions: |
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), case_sensitive:, initial_value: |
case_sensitive: false |
| 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 |
indicator:, cancel_message:, error_message:, on_cancel: |
indicator: :dots |
| Tasks | Clack.tasks |
tasks: ({title:, task:, enabled:}) |
enabled: true |
| Progress | Clack.progress |
total:, message: |
-- |
All prompts accept message:, validate: (a proc, Regexp, Symbol, Array, or Hash; see Validation Shorthands), help:, with_guide:, instructions:, and return Clack::CANCEL on Escape/Ctrl+C.
Cancellation
result = Clack.text(message: "Name?")
exit 1 if Clack.cancel?(result)
# Or the one-liner version (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")
# The default "Cancelled" text follows `Clack.update_settings(messages: {cancel: ...})`
Validation and Transforms
Every prompt supports validate: and transform:. The pipeline looks like this:
User Input --> Validation (raw) --> Transform (if valid) --> Final Value
Validation returns an error message, a Clack::Warning, or nil to pass.
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
# Symbol shortcuts (clean and idiomatic)
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) }
)
Validation Shorthands
validate: takes more than a lambda. Everything below is normalized by Clack::Validators.resolve when the prompt is built, so a typo raises ArgumentError immediately instead of crashing after the user has typed.
# Regexp: fails with "Invalid format" unless the whole value matches
slug = Clack.text(message: "Slug?", validate: /\A[a-z0-9-]+\z/)
# Symbol: a zero-argument built-in validator
email = Clack.text(message: "Email?", validate: :email)
# Hash: pattern (or symbol) => custom message, checked in order
user = Clack.text(
message: "Username?",
validate: {
required: "Username is required",
/\A[a-z0-9_]+\z/ => "Lowercase letters, numbers, and underscores only"
}
)
# Array: combine validators, first failure wins (nil or false entries are skipped)
handle = Clack.text(
message: "Handle?",
validate: [:required, /\A\w+\z/, ->(v) { "Taken" if TAKEN.include?(v) }]
)
# Anything that responds to #call: procs, Method objects, service objects
port = Clack.text(message: "Port?", validate: method(:check_port))
Symbols available as shorthands: :required, :email, :url, :integer (any text-style prompt); :path_exists, :directory_exists, :file_exists_warning (a path string, so text or path); :future_date, :past_date (a Date, so the date prompt). They run against the prompt's raw value, so :future_date on a text prompt raises at submit. Built-ins that take arguments (min_length, in_range, one_of, ...) are called explicitly and mixed in: validate: [:required, Clack::Validators.min_length(3)].
Regexps are checked with match? against value.to_s, so anchor with \A and \z (not ^/$) unless you want per-line matching. A Regexp always means "the value must match"; to soften the check, use a Clack::Warning as the Hash message: {/\A[a-z0-9-]+\z/ => Clack.warning("Unusual characters, continue?")} warns instead of blocking when the value doesn't match. The same works for symbol keys: {file_exists_warning: Clack.warning("Overwrite?")}.
Clack::Validators.combine and Clack::Validators.as_warning accept the same shapes: combine(:required, /\A\d+\z/), as_warning(:email).
Using a schema library? Wrap it in a lambda that returns the first error message:
contract = SignupContract.new # dry-validation
Clack.text(message: "Email?", validate: ->(v) { contract.call(email: v).errors[:email]&.first })
Don't pass the contract itself: its call returns a Result object, which is always truthy, so the prompt would treat every submit as an error.
Built-in Validators
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
# Zero-argument validators above also work as bare symbols or Hash keys:
Clack.text(message: "Email?", validate: :email)
Clack.text(message: "Email?", validate: {email: "That doesn't look right"})
Built-in Transformers
: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 digits
Prompt Groups
Chain multiple prompts and collect results in a hash. If the user cancels any prompt, the whole group returns Clack::CANCEL.
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...
end
Pretty Printing
Logging
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.("Custom message")
Clack.log.* and Clack.stream.* accept with_guide: false (or follow the global setting). The level symbol stays on the first line; only the continuation rail goes away.
Stream
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)
Note
Display important information in a box:
Clack.note(<<~MSG, title: "Next Steps")
cd my-project
bundle install
bin/rails server
MSG
Box
Render 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
)
Boxes sit inside the guide rail (a gray │ on every line) so they line up with the prompts around them; pass with_guide: false to render one flush left.
Task Log
Streaming log that clears on success and shows full output on failure. Great for build output:
tl = Clack.task_log(title: "Building...", limit: 10)
tl.("Compiling file 1...")
tl.("Compiling file 2...")
# On success: clears the log
tl.success("Build complete!")
# On error: keeps the log visible
# tl.error("Build failed!")
Session Markers
Clack.intro("my-cli v1.0") # ┌ my-cli v1.0
# ... your prompts ...
Clack.outro("Done!") # └ Done!
# Or on error:
Clack.cancel("Aborted") # └ Aborted (red)
# with_guide: false drops the ┌ │ └ symbols on all three
Configuration
# Add custom key bindings (merged with defaults)
Clack.update_settings(aliases: { "y" => :enter, "n" => :cancel })
# Hide the guide rail everywhere (per-call with_guide: overrides this)
Clack.update_settings(with_guide: false)
# Hide the keyboard hint footer under list prompts
Clack.update_settings(show_instructions: false)
# Localize the cancel/error text used by spinners and Clack.handle_cancel
Clack.update_settings(messages: { cancel: "Abgebrochen", error: "Etwas ging schief" })
# 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 (piped/non-TTY input 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. With :auto, detection looks at the prompt's own input: stream (stdin by default), so a StringIO input auto-submits as well.
Without CI mode, prompting on a piped or redirected stdin raises Clack::NotATerminalError (see Troubleshooting).
Clack also warns when terminal width is below 40 columns, since prompts may not render cleanly in very narrow terminals. Keyboard hint footers soft-wrap below roughly 51 columns rather than corrupting the frame; pass show_instructions: false or a shorter instructions: to avoid the wrap.
Environment variables
Color, cursor and symbol output are auto-detected from the terminal. Override with the usual variables:
| Variable | Effect |
|---|---|
NO_COLOR=1 |
Disable ANSI colors and cursor sequences, use ASCII symbols. Any non-empty value works; an empty value is ignored (per no-color.org). |
FORCE_COLOR=1 |
Enable colors even when output is piped or TERM=dumb, e.g. to keep colors in a CI log. NO_COLOR still wins. |
FORCE_COLOR=0 |
Disable colors everywhere, same as NO_COLOR=1. false works too. |
CLACK_UNICODE=1 / 0 |
Force Unicode or ASCII symbols regardless of color detection. |
CLACK_ESCAPE_TIMEOUT=250 |
Escape-key detection window in milliseconds, for slow SSH links where arrow keys get misread as Escape. |
FORCE_COLOR=0 ruby my_cli.rb # plain text in a real terminal
FORCE_COLOR=1 ruby my_cli.rb 2>&1 | tee run.log # keep colors in a captured log
Guide rail
Every prompt and message helper except progress draws the gray rail (│, ┌, └) that ties a session together. The rail next to the step you are answering is cyan; it turns yellow on a validation error. Turn the rail off globally or per call:
Clack.update_settings(with_guide: false) # everywhere
Clack.select(message: "DB?", options: dbs, with_guide: true) # per-call override wins
Clack.log.info("plain", with_guide: false)
With guides off the connector lines disappear and content starts flush left:
◆ Choose a database instead of │
● PostgreSQL ◆ Choose a database
○ MySQL │ ● PostgreSQL
↑/↓ to navigate • Enter: confirm │ ○ MySQL
│ ↑/↓ to navigate • Enter: confirm
└
Clack.log.* and Clack.stream.* keep their level symbol (●, ▲, ■) with guides off; only the rail goes away.
Keyboard hints
select, multiselect, group_multiselect, autocomplete, autocomplete_multiselect, and path end with a line of keyboard hints (key names dimmed). Hide it per prompt with show_instructions: false, everywhere with Clack.update_settings(show_instructions: false), or replace the text (for localized CLIs) with instructions:, a String or an Array of Strings joined with •:
Clack.multiselect(
message: "Funktionen",
options: features,
instructions: ["Leertaste: wählen", "a: alle", "i: umkehren", "Enter: bestätigen"]
)
# instructions: works on any prompt, even ones without built-in hints
Clack.text(message: "Slug?", instructions: "lowercase, digits and dashes")
Testing
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
end
The 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 |
Any object that responds to getc works as input:, so a plain StringIO is enough for simple cases. When the input runs out (EOF) the prompt cancels and returns Clack::CANCEL, so end scripted input with "\r" to submit (or "\u0004", Ctrl+D, for multiline_text):
Clack.select(message: "Pick", options: %w[a b c], input: StringIO.new("j\r"), output: StringIO.new)
# => "b"
Arrow keys do not work in a StringIO: it cannot be probed for follow-up bytes, so an escape sequence such as "\e[B" is read as a bare Escape (which cancels the prompt) followed by [ and B. Use the vim keys (j/k/h/l) in scripted input, or Clack::Testing.simulate / Clack::Testing::KeyQueue, which deliver whole key codes, for arrow navigation.
If your app sets ci_mode: :auto, reset it in test setup (Clack::Core::Settings.reset!): a simulated input is not a TTY, so :auto would auto-submit instead of following your script.
Troubleshooting
Clack::NotATerminalError: stdin is not an interactive terminal
Prompts read keystrokes in raw mode, which needs a real terminal. You will see this when stdin is a pipe or a file (echo y | ruby app.rb, ruby app.rb < answers.txt, a cron job, a CI step). Either run the script from a terminal, or opt in to non-interactive runs so prompts submit their defaults:
Clack.update_settings(ci_mode: :auto) # auto-submit defaults when input is not a TTY or a CI env var is set
To answer prompts from a script rather than skip them, pass the answers as input: (see Testing). The error is an IOError, so a top-level rescue IOError catches it.
Prompt exits with CANCEL as soon as piped input runs out
End of input is treated like Ctrl+C. Terminate each answer with "\r" (Enter), and for multiline_text finish with "\u0004" (Ctrl+D).
Arrow keys type letters, or do nothing, in tmux or after vim
Terminals in application cursor mode send ESC O A instead of ESC [ A for Up. Clack 0.7.0 understands both, and also folds the various Home/End encodings into one. If arrows still misbehave over a slow SSH or mosh link, raise the Escape detection window: CLACK_ESCAPE_TIMEOUT=250 (milliseconds, default 50).
Try It
ruby examples/full_demo.rb
Recording 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.6
Requirements
- Ruby 3.2+
- No runtime dependencies
- Unicode terminal recommended (ASCII fallbacks included)
Development
bundle install
bundle exec rake # Lint + tests
bundle exec rake spec # Tests only
COVERAGE=true bundle exec rake spec # With coverage
Roadmap
- 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.
Credits
This is a Ruby port of @clack/prompts, created by Nate Moore and the Astro team.
License
MIT -- See LICENSE