09 · Building CLIs with Thor or OptionParser¶
Ruby has two common ways to build a real command-line tool with flags and
subcommands: OptionParser from the standard library (zero dependencies,
good for a single-command script) and Thor (a gem, better for a tool
with multiple subcommands, like rails or bundle themselves).
OptionParser — standard library, single command¶
require 'optparse'
options = { verbose: false, count: 1 }
parser = OptionParser.new do |opts|
opts.banner = "Usage: greet.rb [options] NAME"
opts.on("-cN", "--count=N", Integer, "Number of greetings") { |n| options[:count] = n }
opts.on("-v", "--verbose", "Verbose output") { options[:verbose] = true }
opts.on("-h", "--help", "Show help") { puts opts; exit }
end
parser.parse!(ARGV)
name = ARGV[0] || "World"
options[:count].times do
puts options[:verbose] ? "[greeting] Hello, #{name}!" : "Hello, #{name}!"
end
$ ruby greet.rb --count=2 -v Ruby
[greeting] Hello, Ruby!
[greeting] Hello, Ruby!
$ ruby greet.rb Sam
Hello, Sam!
opts.on declares one flag, its long form, an optional type
(Integer auto-converts and validates), and its help text. parse!
mutates ARGV in place, stripping out recognized flags and leaving
positional arguments (ARGV[0] here) behind for you to read normally.
The -h/--help block calling puts opts prints the auto-generated
usage text built from every opts.on line's description.
Thor — subcommands with declarative options¶
Thor turns each public method on a class into a CLI subcommand, using
desc and method_option to declare help text and flags per-command —
the same pattern rails generate, rails db, etc. use internally:
require 'thor'
class Greet < Thor
desc "hello NAME", "greet NAME"
method_option :shout, type: :boolean, default: false, aliases: "-s"
def hello(name)
msg = "Hello, #{name}!"
msg = msg.upcase if options[:shout]
puts msg
end
desc "add A B", "add two numbers"
def add(a, b)
puts(a.to_i + b.to_i)
end
end
Greet.start(ARGV)
$ ruby greet.rb hello Ruby --shout
HELLO, RUBY!
$ ruby greet.rb add 3 4
7
$ ruby greet.rb help
Commands:
greet.rb add A B # add two numbers
greet.rb hello NAME # greet NAME
greet.rb help [COMMAND] # Describe available commands or one specific c...
greet.rb tree # Print a tree of all available commands
method_option before a method declares a flag scoped to that one
subcommand; inside the method, options (a hash) holds the parsed
values. Greet.start(ARGV) is the single line that turns ARGV into a
method dispatch — Thor figures out which subcommand was named and calls
the matching method with the remaining positional arguments.
OptionParser vs. Thor — when to use which¶
- OptionParser: one script, one job, a handful of flags — a
bin/script for a single task, no subcommand structure needed, zero gem dependencies to install. - Thor: a tool with multiple distinct actions (
mytool build,mytool deploy,mytool status) that each want their own flags and help text — the moment you'd otherwise hand-roll subcommand dispatch with acase ARGV[0], Thor is doing that for you plus auto-generated help.
CLI-specific traps¶
OptionParser#parse!vs#parse—parse!mutatesARGVdestructively, removing recognized flags so leftover positional arguments are easy to read afterward;parse(no bang) returns a new array and leavesARGVuntouched, which is easy to forget and then wonder why flags are still showing up inARGV[0].- Short flag clustering.
-cNin theopts.ondeclaration means-c5(no space) works, but writingopts.on("-c", "--count=N")instead requires a space or=:-c 5or--count=5, not-c5. The exact declaration syntax controls what the parser will accept. - Thor method arity must match the CLI call exactly —
def hello(name)requires exactly one positional argument; callingruby greet.rb hellowith no name raisesArgumentError, not a friendly "missing argument" CLI message, unless you give the parameter a default (def hello(name = "World")). Thor#optionsis read-only inside the method and reflects only the flags declared for that specific subcommand — flags declared on a different subcommand'smethod_optionare not visible in a sibling method.- Forgetting
Type: :boolean's default false-vs-nil distinction — a booleanmethod_optionwithoutdefault: falsereturnsnilwhen not passed rather thanfalse, which mostly behaves the same in anifcheck but prints differently if you ever inspectoptions[:shout]directly.
How It Actually Works¶
OptionParser builds a table mapping flag strings (-v, --verbose) to
handler blocks, then does a single linear pass over ARGV, matching each
token against that table and calling the matching block (or, for flags with
no --/- prefix, treating it as a positional argument) — ARGV itself
is just a global Array of the strings the shell split your command line
into, populated by MRI before your script's first line even runs. Thor
builds on the same idea but at the class level: methods on your Thor
subclass become subcommands via method_added (a hook MRI calls
automatically every time a method is defined on a class, letting Thor
intercept the definition and register it), and desc/option calls
immediately preceding a method attach metadata to it that the next
method_added callback picks up and associates with that specific command.
This is metaprogramming you'll see generalized in Level 3's dedicated
metaprogramming module — Thor's DSL is a practical, real-world use of
hooks most people first meet as an abstract concept.
Cheat sheet¶
| Task | OptionParser | Thor |
|---|---|---|
| Declare a flag | opts.on("-v", "--verbose") { ... } |
method_option :verbose, type: :boolean |
| Flag with a value | opts.on("--count=N", Integer) { \|n\| ... } |
method_option :count, type: :numeric |
| Read parsed value | block argument / local hash | options[:count] inside the method |
| Positional args | leftover ARGV after parse! |
method parameters |
| Auto-generated help | opts.banner + -h block |
desc + built-in help command |
| Multiple subcommands | manual case ARGV[0] dispatch |
one method per subcommand, automatic |
| Entry point | parser.parse!(ARGV) |
MyClass.start(ARGV) |
Exercise¶
Build a small CLI tool called taskcli with both approaches, to feel
the difference directly:
- As
OptionParser: a single-command scriptbin/task_note.rbthat takes a--priority=LOW|MED|HIGHflag (defaultMED) and a positional task description, printing"[PRIORITY] description". - As
Thor: aTaskCLI < Thorclass with two subcommands —add TEXT(appendsTEXTto an in-memory array and prints a confirmation) andlist(prints every added task, numbered) — note that since each invocation is a fresh process,listright afteraddin the same command won't see it; makelistaccept a--fromoption demonstrating how you'd wire real persistence (a file) if you were to add it, without actually implementing storage. - Run
ruby task_cli.rb helpand paste the generated help output.