option_builder
option_builder
A Crystal shard for building CLI applications, inspired by Cobra. Built on top of Crystal's stdlib OptionParser, it provides a higher-level API with nested subcommands, persistent flags, positional arguments, and automatic help generation.
Why option_builder?
Crystal's stdlib OptionParser is great for simple scripts, but falls short for real CLI apps. option_builder builds on top of it and adds everything you'd expect from a modern CLI framework — without pulling in external dependencies.
| Feature | Stdlib | option_builder |
|---|---|---|
| Basic flags | ✓ | ✓ |
| Subcommands | ✓ (basic) | ✓ (nested) |
| Persistent flags | ✗ | ✓ |
| Positional args | Manual | ✓ (built-in) |
| Type safety | Manual | ✓ (automatic) |
| Count flags | ✗ | ✓ |
| Array flags | ✗ | ✓ |
| Required flags | ✗ | ✓ |
| Flag groups | ✗ | ✓ |
| Choices validation | ✗ | ✓ |
| Arg validators | ✗ | ✓ |
| Help generation | Basic | ✓ (rich) |
| Validation hooks | ✗ | ✓ |
| Lifecycle hooks | ✗ | ✓ |
| Command/flag aliases | ✗ | ✓ |
| Hidden cmds & flags | ✗ | ✓ |
| Deprecated flags | ✗ | ✓ |
| Bool flag negation | ✗ | ✓ |
| Default subcommand | ✗ | ✓ |
| Flag group headings | ✗ | ✓ |
| Custom error handler | ✗ | ✓ |
| Pipe detection | ✗ | ✓ |
| Enum flags | ✗ | ✓ |
| Custom flag types | ✗ | ✓ |
| Subcommand groups | ✗ | ✓ |
| Typo suggestions | ✗ | ✓ |
| Shell completion | ✗ | ✓ |
| Env var support | ✗ | ✓ |
| Config file support | ✗ | ✓ |
| @file flag values | ✗ | ✓ |
Installation
Add the dependency to your shard.yml:
dependencies:
option_builder:
github: naqvis/option_builder
Then run shards install.
Quick Start
require "option_builder"
verbose = false
name = "World"
cmd = OptionBuilder.command("greet", "A greeting application") do |c|
c.version "1.0.0"
c.flag('v', "verbose", Bool, description: "Verbose output") { |v| verbose = v }
c.flag('n', "name", String, default: "World", description: "Name to greet") { |n| name = n }
c.run { |_| puts verbose ? "Hello, #{name}! (verbose mode)" : "Hello, #{name}!" }
end
cmd.execute
Sub-commands
Nest as deep as you need. Persistent flags are inherited by all children.
require "option_builder"
verbose = false
port = 3000
cmd = OptionBuilder.command("myapp", "My application") do |c|
c.version "1.0.0"
c.persistent_flag('v', "verbose", Bool, description: "Verbose output") { |v| verbose = v }
c.subcommand("serve", "Start the server") do |serve|
serve.flag('p', "port", Int32, default: 3000, description: "Port") { |p| port = p }
serve.run { |_| puts "Starting on port #{port}..." }
end
c.subcommand("db", "Database operations") do |db|
db.subcommand("migrate", "Run migrations") do |m|
m.run { |_| puts "Running migrations..." }
end
db.subcommand("seed", "Seed database") do |s|
s.run { |_| puts "Seeding..." }
end
end
end
cmd.execute
Positional Arguments
source = ""
dest = ""
cmd = OptionBuilder.command("copy", "Copy files") do |c|
c.positional("source", String, required: true, description: "Source file") { |s| source = s }
c.positional("dest", String, required: true, description: "Destination") { |d| dest = d }
c.run { |_| puts "Copying #{source} to #{dest}" }
end
Extra args beyond defined positionals are passed to the run block.
Flags
Count Flags
Increment on each use — handy for verbosity:
c.count_flag('v', "verbose", description: "Verbosity level") { |n| verbosity = n }
# -v -v -v or -vvv => verbosity = 3
Array Flags
Collect repeated values:
c.array_flag('i', "input", String, description: "Input files") { |files| inputs = files }
# --input a.txt --input b.txt -i c.txt
Required Flags
c.flag('p', "port", Int32, required: true, description: "Port") { |p| port = p }
Environment Variables
Fall back to an env var when the flag isn't passed:
c.flag('p', "port", Int32, default: 3000, env: "PORT") { |p| port = p }
Config File
Add a flag that loads defaults from a YAML or JSON file. The config is loaded before all other flags, so values satisfy required flags and are overridden by env vars and CLI flags.
Precedence: programmatic default < config file < env var < CLI flag.
c.config_flag 'c', "config", description: "Path to config file"
c.config_flag nil, "config", env: "APP_CONFIG" # also check env var
Config file format — keys match flag long names:
port: 8080
host: example.com
verbose: true
Choices
Restrict values to a known set:
c.flag('e', "env", String,
choices: ["development", "staging", "production"],
description: "Environment") { |e| env = e }
Aliases
c.subcommand("serve", "Start server", aliases: ["s", "start"]) do |serve|
serve.flag('f', "format", String, aliases: ["output"]) { |f| format = f }
serve.run { |_| }
end
Hidden Commands & Flags
Commands and flags marked hidden: true still work normally but are excluded from help output. Useful for debug or internal tooling:
c.subcommand("debug", "Debug info", hidden: true) { |d| d.run { |_| } }
c.flag(nil, "internal", String, hidden: true) { |_| }
Marking Flags as Deprecated
Warn users when a flag is outdated:
c.flag(nil, "old-config", String, deprecated: "Use --config instead") { |_| }
File-Based Flag Values
Any flag value starting with @ reads the content from a file instead. Useful for secrets and large values:
myapp --token @/path/to/secret.txt
myapp --cert @./cert.pem
The file content is trimmed of trailing newlines. If the file doesn't exist, an error is raised. A bare @ is treated as a literal value.
Bool Flag Negation
Every Bool flag automatically gets a --no-<name> counterpart:
c.flag(nil, "color", Bool, description: "Enable colors") { |v| color = v }
# --color sets true, --no-color sets false
Default Subcommand
When no sub-command is given, help is shown automatically. Override this to route to a specific sub-command instead:
c.default_subcommand "serve"
Flag Group Headings
Group flags under custom headings in help output instead of one flat list:
c.flag('h', "host", String, description: "Host") { |h| host = h }
c.flag('p', "port", Int32, description: "Port") { |p| port = p }
c.flag('u', "username", String, description: "Username") { |u| user = u }
c.flag_group "Server Options", "host", "port"
c.flag_group "Auth Options", "username"
Sub-command Groups
Group sub-commands under custom headings in help — like git does with "Main commands" vs "Ancillary commands":
c.subcommand_group "Main Commands", "serve", "build"
c.subcommand_group "Operations", "db", "deploy"
Custom Error Handler
Replace the default STDERR + exit(1) behavior:
c.on_error do |ex|
STDERR.puts "FATAL: #{ex.message}"
exit(2)
end
Pipe Detection
Check if STDIN is piped so commands can behave differently in interactive vs piped mode:
c.run do |args|
if OptionBuilder.piped?
# Read from STDIN
process(STDIN.gets_to_end)
else
process(args.first)
end
end
Enum Flags
Map string values directly to Crystal enum members — type-safe with auto-generated choices:
enum Environment
Development
Staging
Production
end
c.enum_flag('e', "env", Environment, default: Environment::Development,
description: "Target environment") { |e| env = e }
# --env=production => env = Environment::Production
# Invalid values show: "Must be one of: development, staging, production"
Custom Flag Types
Register your own type parser for flags like URI, Path, Regex, etc.:
c.custom_flag(nil, "url", URI, description: "Endpoint URL",
parser: ->(s : String) { URI.parse(s) }) { |u| endpoint = u }
c.custom_flag(nil, "dir", Path, default: Path.new("/tmp"),
parser: ->(s : String) { Path.new(s) }) { |p| dir = p }
Flag Groups
# All or none
c.require_flags_together("username", "password")
# At most one
c.flags_exclusive("json", "yaml")
# At least one
c.require_one_of("json", "yaml")
Argument Validators
Constrain how many positional args a command accepts. These run against the remaining args after named positionals are consumed:
c.args = OptionBuilder.exact_args(2)
c.args = OptionBuilder.minimum_args(1)
c.args = OptionBuilder.maximum_args(3)
c.args = OptionBuilder.range_args(1, 3)
c.args = OptionBuilder.no_args
# Custom
c.args { |args| raise OptionBuilder::CommandError.new("Need even count") unless args.size.even? }
Validation
Run custom checks after parsing, before execution. Raise CommandError to abort:
env = ""
force = false
cmd = OptionBuilder.command("deploy", "Deploy application") do |c|
c.flag('e', "env", String, description: "Environment") { |e| env = e }
c.flag('f', "force", Bool, description: "Force deploy") { |f| force = f }
c.validate do
raise OptionBuilder::CommandError.new("Production deploys require --force") if env == "production" && !force
end
c.run { |_| puts "Deploying to #{env}..." }
end
Lifecycle Hooks
Execution order:
validate → before → persistent_pre_run → pre_run → run → post_run → persistent_post_run → after
c.before { setup_logging }
c.pre_run { check_deps }
c.run { |args| do_work(args) }
c.post_run { print_summary }
c.after { cleanup }
# Inherited by all subcommands
c.persistent_pre_run { init_telemetry }
c.persistent_post_run { flush_telemetry }
Shell Completion
Generate scripts for bash, zsh, fish, or powershell. Flags with choices get value completion automatically:
puts cmd.generate_completion("bash")
Install:
myapp completion bash > /etc/bash_completion.d/myapp # Bash
myapp completion zsh > ~/.zsh/completions/_myapp # Zsh
myapp completion fish > ~/.config/fish/completions/myapp.fish # Fish
myapp completion powershell >> $PROFILE # PowerShell
Supported Flag Types
| Type | Example |
|---|---|
Bool |
--verbose / -v |
Int32 |
--port 8080 |
Int64 |
--size 1099511627776 |
Float64 |
--rate 0.5 |
String |
--name hello |
Error Handling
execute catches CommandError and OptionParser::Exception, prints to STDERR, and exits 1. Use on_error to customize this, or parse_and_execute to handle errors yourself.
Development
crystal spec
For a comprehensive example of all features, see examples/demo.cr.
Contributing
- Fork it (https://github.com/naqvis/option_builder/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Ali Naqvi - creator and maintainer
option_builder
- 0
- 0
- 0
- 0
- 0
- about 7 hours ago
- April 3, 2026
MIT License
Fri, 03 Apr 2026 12:44:10 GMT