option_builder

Feature-rich CLI builder for Crystal built on stdlib OptionParser. Nested subcommands, persistent flags, type-safe options, enum/custom types, config files, @file values, env vars, flag groups, shell completion, lifecycle hooks, validation, and typo suggestions.

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

  1. Fork it (https://github.com/naqvis/option_builder/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Contributors

Repository

option_builder

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 0
  • about 7 hours ago
  • April 3, 2026
License

MIT License

Links
Synced at

Fri, 03 Apr 2026 12:44:10 GMT

Languages