package

github.com/naqvis/option_builder

main / published Jun 5, 2026 / repository

Feature-rich CLI builder with nested subcommands, persistent flags, config files, shell completion, and more.

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.

FeatureStdliboption_builder
Basic flags✓✓
Subcommands✓ (basic)✓ (nested)
Persistent flags✗✓
Positional argsManual✓ (built-in)
Type safetyManual✓ (automatic)
Count flags✗✓
Array flags✗✓
Required flags✗✓
Flag groups✗✓
Choices validation✗✓
Arg validators✗✓
Help generationBasic✓ (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

TypeExample
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

API

  • OptionBuilder

    Cobra-inspired CLI builder on top of stdlib OptionParser.

  • OptionParser

    OptionParser is a class for command-line options processing.