Command-Line Programs
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them.
A command-line program is a Ruby file that the operating system starts with a list of arguments, an environment, three open
streams and a way to deliver signals, and that reports back with a number, the exit status. Ruby exposes each part as a global:
ARGV and ARGF for the arguments, ENV for the environment, $stdin, $stdout and $stderr for the streams,
Signal.trap for signals and exit for the status. The standard library adds OptionParser, which turns --limit 5 and -vv
into values and writes the --help text. This page covers all of these, the traps each hides, and finishes by growing the
seven-line exe/shelf of the running shelf gem into a small, testable program with subcommands, JSON output, colour and
correct exit codes.
This page is about writing the program. How the ruby executable itself is invoked (-w, -I, -r, -e, -n, -p, -i,
RUBYOPT) is in Ruby Command Line and Environment;
files and directories are in IO, Files and Directories;
starting other programs, fork and the signal details are in
Processes, Signals and Shell; packaging the executable in a
gem is in Creating and Publishing Gems, and Rake; and
tests for the program are in Testing with Minitest. The design of a good
command-line interface (naming, streams, exit codes, machine-readable output, safety) is a language-independent topic that is
not repeated here; it lives in the Building CLIs for AI Agents section, and each part is linked
where it applies. The same ground in other languages is in
Go Command-Line Programs and
Command-Line PHP. The open issue about a general comparison of command-line
applications (#236) is not covered by a page yet.
Every example was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the
configuration of the shelf gem (target Ruby 4.0, double-quoted strings). Output shown is the real output. Files that need the
gem are run from the gem root with -Ilib, or after bundle exec (see the shelf gem source). One example (streams.rb) carries two rubocop:disable Style/GlobalStdStream comments because it exists to show the
constant STDOUT next to the global $stdout.
The shape of a command-line program
Whatever the language, a command-line program sits in the same small contract. The shell hands it arguments and an environment,
it reads standard input and writes standard output and standard error, it may be interrupted by a signal, and it ends with an
exit status that the caller inspects ($? in a POSIX shell, Process::Status in Ruby). The figure follows one run of the
shelf program built at the end of this page.
Two habits make a Ruby program behave like a good citizen: results go to standard output and everything else (diagnostics,
progress, prompts) to standard error, so that shelf search ruby | wc -l counts books and not messages; and failures are
reported through the exit status, not only through text. The sections below take the parts in order.
The script file
A program is an ordinary Ruby file. To run it directly instead of through ruby file.rb, give it a shebang line and the
executable permission:
#!/usr/bin/env ruby
# frozen_string_literal: true
puts "hi"
$ chmod +x hi
$ ./hi
hi
#!/usr/bin/env ruby makes the shell look ruby up in PATH, so the version manager’s Ruby is the one that runs; a hard-coded
/usr/bin/ruby would pick the system Ruby. The magic comment # frozen_string_literal: true follows the shebang (the shebang
must be the first line). On Windows the shebang is ignored; RubyInstaller associates the .rb extension with Ruby, and a gem’s
executable gets a generated .bat wrapper (see RubyGems and Bundler).
By convention a gem keeps its executables in exe/ (Bundler’s bundle gem generates that directory; older gems used bin/),
lists them in the gemspec (spec.bindir = "exe" and spec.executables = ["shelf"] in the shelf gem) and keeps the file
short: it requires the library and calls one method. All the logic lives in lib/, where tests can reach it without starting a
process. A file that is both a library and a program uses the idiom below to run its program part only when it is the file
Ruby was started with:
exit Shelf::CLI.run(ARGV) if __FILE__ == $PROGRAM_NAME
$PROGRAM_NAME (alias $0) is the script name as given on the command line, and __FILE__ is the current file, so they are
equal only in the file that was started. A gem’s exe/shelf does not need the guard, because nothing else ever requires it.
Source: Pre-defined global variables
ARGV
ARGV (also $*) is an array of strings holding the arguments that follow the script name, after the shell has done its
quoting, globbing and expansion. It is a plain, unfrozen Array, and the common idiom is to consume it with shift, which is
exactly what the option parsers below do.
# frozen_string_literal: true
p ARGV
p ARGV.frozen?
p ARGV.class
p __FILE__ == $PROGRAM_NAME
command = ARGV.shift
p command
p ARGV
Run as ruby -w argv_basics.rb search --limit 3 "ruby basics" "":
["search", "--limit", "3", "ruby basics", ""]
false
Array
true
"search"
["--limit", "3", "ruby basics", ""]
Source: Pre-defined global variables
Points that surprise newcomers:
-
Everything is a string:
3is"3", and an empty argument ("") is kept. Ruby does not interpret--limit; options are only conventions until a parser reads them. -
Options of the
rubyexecutable itself must come before the script name. Anything after the script name belongs to the script:ruby a.rb -vgives the script["-v"](checked withruby -w), not Ruby’s version flag. -
A lone
--after the script name is not removed by Ruby, soruby a.rb — x -wgives["--", "x", "-w"]. Handling--is the option parser’s job. Onlyruby -e 'p ARGV' -- -x y(where--ends Ruby’s own options) hides it. -
The strings are in the locale encoding:
Encoding.default_externalfollowsLANG/LC_ALL, and a shell withLANG=CproducesUS-ASCIIarguments, which then fail to compare with UTF-8 text. Set a UTF-8 locale (LANG=C.UTF-8oren_US.UTF-8), or pass-E UTF-8, and see Encodings and Unicode. -
ruby -s script.rb -debug -name=valueis an old switch-parsing feature that defines$debugand$name; it works, butOptionParserreplaces it.
ARGF: the arguments as one input stream
ARGF (also $<) is an object that behaves like a single IO made of all the files named in ARGV, read in order, or of
standard input when ARGV is empty. This is the model of Unix filters such as cat and grep: prog a.txt b.txt reads two
files, prog < a.txt and prog with a pipe read standard input, and - as a file name means standard input at that position.
Reading from ARGF removes each file name from ARGV as it is opened.
# frozen_string_literal: true
# Reads every file named in ARGV, or standard input when ARGV is empty.
ARGF.each_line do |line|
printf("%<file>s:%<lineno>d: %<text>s\n", file: ARGF.filename, lineno: ARGF.file.lineno, text: line.chomp)
end
puts "done after #{ARGF.lineno} lines"
With two files, with a pipe and with a file, a - and another file:
titles.txt:1: ruby basics
titles.txt:2: freezing objects
more.txt:1: gems and bundler
done after 3 lines
-:1: piped
done after 1 lines
titles.txt:1: ruby basics
titles.txt:2: freezing objects
-:1: piped
more.txt:1: gems and bundler
done after 4 lines
Source: ARGF
ARGF.lineno counts lines across all files, while ARGF.file.lineno restarts for each file. ARGF.filename is "-" for
standard input, and $FILENAME is a global with the same value. The reading methods are the ones of IO: each_line, gets,
read, readlines, each_char, each_byte, readpartial, with eof? for the current file. ARGF.skip abandons the rest of
the current file, and ARGF.argv is the (shrinking) argument list.
# frozen_string_literal: true
p ARGF.class
p ARGF.filename
first = ARGF.gets
p first
p ARGF.filename
p ARGF.eof?
ARGF.skip
p ARGF.filename
rest = ARGF.read
p rest
p ARGF.argv
p $FILENAME
Run as ruby -w argf_misc.rb titles.txt more.txt:
ARGF.class
"titles.txt"
"ruby basics\n"
"titles.txt"
false
"more.txt"
"gems and bundler\n"
[]
"more.txt"
Source: ARGF
Kernel#gets reads ARGF, not standard input
The most common trap: Kernel#gets, Kernel#readline and Kernel#readlines read from ARGF. If the program was started with
file names, gets reads those files, not the terminal. To read standard input unconditionally, use $stdin.gets (or
STDIN.gets).
# frozen_string_literal: true
# Kernel#gets reads from ARGF, so a file name in ARGV is read instead of standard input.
line = gets
p line
p $stdin.gets
Run as echo from-stdin | ruby -w gets_trap.rb titles.txt:
"ruby basics\n"
"from-stdin\n"
Source: Kernel#gets
So an interactive prompt that must accept file names as ordinary arguments reads with $stdin.gets, and a filter that wants the
Unix convention reads with ARGF. Do not mix the two on the same run unless you intend it.
Editing files in place
Setting ARGF.inplace_mode to a backup extension (or ruby -i.bak, see
Ruby Command Line and Environment) makes ARGF rewrite each
file: while a file is being read, $stdout writes into its replacement, and the original is kept with the extension.
# frozen_string_literal: true
ARGF.inplace_mode = ".bak" # same as ruby -i.bak: edit each file in place, keep a backup
ARGF.each_line do |line|
puts line.sub("ruby", "Ruby") # puts writes into the file being edited, not to the terminal
end
Run as ruby -w argf_inplace.rb t3.txt:
$ cat t3.txt
Ruby basics
freezing objects
$ cat t3.txt.bak
ruby basics
freezing objects
An empty extension ("") edits without a backup, which is only safe on files that are in version control.
Standard streams
A process starts with three open streams, file descriptors 0, 1 and 2. Ruby wraps them as the constants STDIN, STDOUT
and STDERR and exposes them through the globals $stdin, $stdout and $stderr, which are the ones that gets, puts
and warn actually use. Reassigning a global redirects Ruby-level output without touching the real descriptor; the constants
keep the original objects.
# frozen_string_literal: true
require "stringio"
p $stdout.equal?(STDOUT) # rubocop:disable Style/GlobalStdStream
p [$stdin.fileno, $stdout.fileno, $stderr.fileno]
p $stdout.sync
p $stderr.sync
puts "to standard output"
warn "to standard error"
$stderr.print "also to standard error\n"
$stdout.print "no newline, "
$stdout.write("write returns the byte count\n")
# Redirect the global for a block of code, for example in a test.
captured = StringIO.new
original = $stdout
begin
$stdout = captured
puts "captured"
ensure
$stdout = original
end
p captured.string
# STDOUT is the original stream even when $stdout was reassigned.
$stdout = StringIO.new
STDOUT.puts "still the terminal" # rubocop:disable Style/GlobalStdStream
$stdout = STDOUT
Standard output, then standard error (run as ruby -w streams.rb 2>err.txt and shown separately):
true
[0, 1, 2]
false
true
to standard output
no newline, write returns the byte count
"captured\n"
still the terminal
to standard error
also to standard error
Source: IO
Details worth knowing:
-
puts,print,pandppwrite to$stdout;warnwrites to$stderr.preturns its argument;putsreturnsnil. -
$stdout.syncisfalsewhen the output is a file or a pipe (Ruby buffers) and$stderr.syncis alwaystrue. When a program prints progress while another process reads the pipe, set$stdout.sync = trueor call$stdout.flush. On a terminal output is flushed at each newline. -
warnis not a plain$stderr.puts: it is silenced by-W0($VERBOSE = nil), it accepts several messages,uplevel:(prefixes the caller’s location) andcategory:, andWarning.warncan be overridden. Fatal diagnostics that must always appear go throughabortor$stderr.puts; with-W0the first prints andwarn "hidden"does not (checked on 4.0.7). -
Redirecting
$stdoutto aStringIOcaptures output in a test; restore it in anensure. A better design passes the stream as an argument, as the shelf program below does. -
Ruby sets
SIGPIPEto ignored at startup, so writing to a closed pipe raisesErrno::EPIPE. A program that is not handling it dies silently with the status a shell reports as 141 (128 plusSIGPIPE, 13) when the reader quits early, for exampleshelf list | head -1; this is the status of the plain script in the test below.
# frozen_string_literal: true
begin
1_000_000.times { |i| puts "line #{i}" }
rescue Errno::EPIPE
exit 0
end
Run as ruby -w pipe_epipe.rb | head -1; the first line is the text, and the last line is the status that the shell reported
for the Ruby process (the same script without the rescue reported 141):
line 0
0
Source: Errno
Reading standard input
$stdin is an IO; the usual reading methods are gets (returns nil at the end of the input), each_line, read (the
rest; "" at the end, not nil), readlines, eof? and getc. gets(chomp: true) strips the newline. Reading is
buffered, so several calls share one buffer.
# frozen_string_literal: true
first = $stdin.gets(chomp: true)
p first
p $stdin.eof?
rest = $stdin.each_line(chomp: true).first(2)
p rest
p $stdin.read
p $stdin.read
p $stdin.gets
p $stdin.eof?
Run as printf 'one\ntwo\nthree\nfour\nfive' | ruby -w stdin_read.rb:
"one"
false
["two", "three"]
"four\nfive"
""
nil
true
Source: IO#gets
$stdin.read waits until the writer closes the stream, so a program that calls it on a terminal waits for Ctrl-D (end of file
on Unix; Ctrl-Z then Enter on Windows). Check $stdin.tty? first to tell a person from a pipe (see
Terminals, colour and prompts).
Exit status
The exit status is an integer from 0 to 255 that tells the caller what happened: 0 means success, anything else failure. Ruby
produces it in four ways: exit, exit!, abort, and falling off the end or raising an exception.
| Call | Effect | Status |
|---|---|---|
End of the script |
The program finishes normally. |
0 |
|
Raises |
0 |
|
Raises |
1 |
|
Raises |
n modulo 256 |
|
Prints the message to |
1 |
|
Ends the process at once: no |
1 or n |
Uncaught exception |
Ruby prints the message and backtrace to |
1 |
Uncaught |
Prints the error; the process then ends as if killed by |
130 in a shell |
Killed by |
Not a Ruby decision; the shell reports 128 plus the signal number. |
143 |
exit is implemented as an exception, which is why it composes with ensure and why rescue Exception swallows it. A bare
rescue (which catches StandardError) does not.
# frozen_string_literal: true
require "English"
at_exit { puts "at_exit handler runs last (#{$ERROR_INFO.inspect})" }
begin
exit 3
rescue SystemExit => e
puts "caught SystemExit: status=#{e.status} success?=#{e.success?} message=#{e.message.inspect}"
end
begin
exit(false)
rescue SystemExit => e
puts "exit(false): status=#{e.status}"
end
begin
abort "fatal: cannot open the catalogue"
rescue SystemExit => e
puts "abort: status=#{e.status}"
end
begin
exit
ensure
puts "ensure blocks run on exit"
end
fatal: cannot open the catalogue
caught SystemExit: status=3 success?=false message="exit"
exit(false): status=1
abort: status=1
ensure blocks run on exit
at_exit handler runs last (#<SystemExit: exit>)
status: 0
The abort message goes to standard error and appears first only because the terminal shows both streams; each SystemExit
carries the status and success? of the call, message is "exit", and the at_exit handler runs after everything else with
$ERROR_INFO ($!) still holding the SystemExit.
Source: Kernel#exit, SystemExit, Kernel#at_exit
# frozen_string_literal: true
at_exit { puts "never printed" }
begin
exit! 4
ensure
puts "never printed either"
end
Source: Kernel#exit!
# frozen_string_literal: true
Integer("12x")
Source: Ruby documentation: Exceptions
# frozen_string_literal: true
t = Thread.new { exit 5 }
t.join
puts "not reached"
The statuses of these scripts and of a few one-liners, as the shell reported them:
$ ruby -e 'abort'
status=1
$ ruby -e 'exit 256'
status=0
$ ruby -e 'exit(-1)'
status=255
$ ruby -e 'at_exit { puts 1 }; at_exit { puts 2 }; exit 7'
2
1
status=7
$ ruby -e 'at_exit { exit 9 }; exit 7'
status=9
$ ruby -e 'raise Interrupt'
-e:1:in '<main>': Interrupt (Interrupt)
status=130
$ ruby -e 'Process.kill("TERM", $$); sleep'
status=143
$ ruby exit_bang.rb
status=4
$ ruby exit_in_thread.rb
status=5
An uncaught exception prints the message in the 3.4+ format ('Kernel#Integer' with straight quotes and the class name) and
exits with status 1; the carets are the error highlight feature. exit inside a thread raises SystemExit in that thread, which
ends the process: the main thread’s puts "not reached" never ran and the status was 5.
at_exit handlers run in the reverse order of registration, and an exit inside a handler replaces the status, which is how
a handler can change the final result (it ended with 9, not 7).
Source: Kernel#exit!, Kernel#abort
Which numbers to use
Only 0 means success. The rest is convention, and the conventions that are worth following are few:
| Status | Meaning |
|---|---|
0 |
Success. |
1 |
General failure. The status of |
2 |
Misuse of a shell builtin in Bash and the value many Unix tools use for a usage error; Ruby’s own |
64 |
|
65 to 78 |
Other |
126, 127 |
Reported by the shell: found but not executable, command not found. A program should not return them. |
128 plus n |
Killed by signal n: 130 is |
The streams-and-exit-codes chapter of the CLI guide explains why callers, scripts and agents depend on the distinction:
Streams & Exit Codes. In Ruby, name the numbers as constants, as
Shelf::CLI does (EX_USAGE = 64), and return them from a method instead of calling exit in the middle of the library.
A parent Ruby process reads a child’s status from $?, a Process::Status: exitstatus, success?, signaled? and
termsig (see Processes, Signals and Shell).
Source: Process::Status
OptionParser
OptionParser (from the optparse default gem; require "optparse") is the standard answer to "parse --long, -s and
operands". It is declarative: each on call describes an option (its names, whether it takes an argument, the type to convert
to, a description) and gives a block that runs when the option is seen. The descriptions double as the --help text. It is
version 0.8.1 in Ruby 4.0.
A first program with typed options, grouped help and the three exit paths of a command-line program (success, help, failure):
# frozen_string_literal: true
require "optparse"
Options = Struct.new(:term, :limit, :format, :color, :verbose, :tags)
def search_options(opts, options)
opts.separator "Search options:"
opts.on("-t", "--title=TERM", "Match titles containing TERM") { |value| options.term = value }
opts.on("-n", "--limit=N", Integer, "Show at most N books (default #{options.limit})") { |n| options.limit = n }
opts.on("-f", "--format=FORMAT", %i[text json], "Output format: text or json") { |v| options.format = v }
opts.on("--tag=TAG[,TAG]", Array, "Only books with these tags") { |value| options.tags.concat(value) }
end
def finish(text)
puts text
exit
end
def common_options(opts, options)
opts.separator "Common options:"
opts.on("--[no-]color", "Colorize the output (default on)") { |value| options.color = value }
opts.on("-v", "--verbose", "Increase verbosity (repeatable)") { options.verbose += 1 }
opts.on_tail("-h", "--help", "Show this help") { finish(opts.help) }
opts.on_tail("--version", "Show the version") { finish(opts.ver) }
end
def build_parser(options)
OptionParser.new do |opts|
opts.program_name = "shelf"
opts.version = "0.1.0"
opts.banner = "Usage: shelf [options] [TERM]"
opts.separator ""
search_options(opts, options)
opts.separator ""
common_options(opts, options)
end
end
def parse_options(argv)
options = Options.new(nil, 10, :text, true, 0, [])
rest = build_parser(options).parse(argv)
options.term ||= rest.shift
[options, rest]
end
options, rest = parse_options(ARGV)
p options.to_h
p rest
The generated help (run as ruby -w options_basic.rb --help):
Usage: shelf [options] [TERM]
Search options:
-t, --title=TERM Match titles containing TERM
-n, --limit=N Show at most N books (default 10)
-f, --format=FORMAT Output format: text or json
--tag=TAG[,TAG] Only books with these tags
Common options:
--[no-]color Colorize the output (default on)
-v, --verbose Increase verbosity (repeatable)
-h, --help Show this help
--version Show the version
A run that uses abbreviations of the typed options and a repeated option (run as
ruby -w options_basic.rb -vv -n 5 --format json --tag ruby,gems --tag oop --no-color ruby extra):
{term: "ruby", limit: 5, format: :json, color: false, verbose: 2, tags: ["ruby", "gems", "oop"]}
["extra"]
And a bad value, which raises an exception that the program has to handle (run as ruby -w options_basic.rb -n abc):
options_basic.rb:42:in 'Object#parse_options': invalid argument: -n abc (OptionParser::InvalidArgument)
from options_basic.rb:47:in '<main>'
Source: OptionParser, OptionParser tutorial
The shape to remember: OptionParser.new { |opts| ... } builds the parser, parse(argv) runs it and returns the operands (the
arguments that are not options), and the blocks store what they see. The sections below take the pieces in turn.
Option declarations
The strings given to on are interpreted by their form:
| Declaration | Meaning |
|---|---|
|
A short flag. Clusters work: |
|
A long flag. |
|
Both names for the same option; any number of names may be given. |
|
Defines |
|
An option with a required argument; the word is only a placeholder shown in the help. |
|
An option with an optional argument. The block receives |
a String that does not start with |
A description line of the help. Several descriptions are allowed and become several help lines. |
a class such as |
The type to convert the argument to. |
an Array, Hash or Regexp |
The permitted values (see below). |
a Range as the last element, for example |
The permitted range for a numeric argument. |
on_tail puts an option at the end of the help (so --help and --version come last), on_head at the beginning, and
separator "text" adds a heading or a blank line. opts.banner replaces the Usage: line (the default is
Usage: program_name [options]), opts.program_name the name used in the banner and in --version, and
OptionParser.new(banner, width, indent) changes the layout; the default option-name column is 32 wide with a 4-space
indent (summary_width and summary_indent).
The automatic options
Without any declaration, OptionParser already recognizes -h and --help, which print the help and exit with status 0, and
-v and --version, which print program_name and the version (set with opts.version = "1.2.3"; without one it prints
prog: version unknown and exits with status 1). Declaring a name yourself replaces only that name: in the example above -v
means --verbose, and --help and --version are declared again with on_tail so that the program controls what they do. Use
that when exit inside the parser is not wanted (a test, or the catch in Shelf::CLI below). opts.ver returns the version
string and opts.help (or to_s) the help text without printing it.
Argument types and converters
When the declaration contains a class, the argument text is converted before the block sees it, and a value that does not
match raises OptionParser::InvalidArgument. These types are always available:
| Type | Accepts |
|---|---|
|
Anything (the default). |
|
Decimal, |
|
Decimal digits only, so |
|
Octal, or hexadecimal and binary with a prefix. |
|
|
|
An integer, a float or a rational such as |
|
Decimal digits, a float or a rational, with no octal interpretation. |
|
|
|
A comma-separated list, delivered as an array of strings: |
|
A regular expression written like |
There is no built-in Symbol type; use an enumeration (below). Further types are loaded on demand: require "optparse/time" adds
Time, optparse/date adds Date and DateTime, optparse/uri adds URI, optparse/shellwords adds Shellwords, and
optparse/kwargs and optparse/ac add keyword-argument and autoconf-style helpers.
Enumerations, patterns and ranges
Passing an array, a hash or a regular expression restricts the values. An array of strings or symbols also accepts unambiguous abbreviations and delivers the full value; a hash maps a name to an arbitrary value; a Regexp must match the argument and the block receives the matched text and then any captures. A range after the type restricts the number.
# frozen_string_literal: true
require "optparse"
def try(parser, *argv)
options = {}
rest = parser.parse(argv, into: options)
puts "#{argv.join(' ').ljust(24)} => #{options} #{rest}"
rescue OptionParser::ParseError => e
puts "#{argv.join(' ').ljust(24)} => #{e.class.name.split('::').last}: #{e.message}"
end
parser = OptionParser.new
parser.on("-n", "--dry-run", "Do nothing")
parser.on("-d", "--draft", "Draft mode")
parser.on("-x", "--extra", "Short flag")
parser.on("-l", "--limit N", Integer, "Required argument")
parser.on("-o", "--out [FILE]", "Optional argument")
parser.on("--mode=MODE", %w[fast slow], "One of a list")
parser.on("--level=LEVEL", { "low" => 1, "high" => 3 }, "Name to value")
parser.on("--pct=N", Integer, "Percent", 1..100)
try(parser, "--dry") # unique prefix
try(parser, "--d") # ambiguous prefix
try(parser, "-nx") # clustered short flags
try(parser, "-l5") # short option with attached value
try(parser, "-l", "-5") # a value that looks like an option
try(parser, "--limit") # missing value
try(parser, "--out", "--extra") # optional value is not taken from an option
try(parser, "--out", "file.txt")
try(parser, "--mode", "fa") # unique abbreviation of an enumerated value
try(parser, "--level", "hi") # hash: abbreviation maps to the value
try(parser, "--pct", "101")
try(parser, "--draft", "--", "--dry") # -- ends option parsing
try(parser, "a", "--draft", "b") # operands and options may be mixed
parser.require_exact = true
try(parser, "--dry") # abbreviations switched off
The output:
--dry => {"dry-run": true} []
--d => AmbiguousOption: ambiguous option: --d
-nx => {"dry-run": true, extra: true} []
-l5 => {limit: 5} []
-l -5 => {limit: -5} []
--limit => MissingArgument: missing argument: --limit
--out --extra => {out: nil, extra: true} []
--out file.txt => {out: "file.txt"} []
--mode fa => {mode: "fast"} []
--level hi => {level: 3} []
--pct 101 => InvalidArgument: invalid argument: --pct 101
--draft -- --dry => {draft: true} ["--dry"]
a --draft b => {draft: true} ["a", "b"]
--dry => InvalidOption: invalid option: --dry
Source: OptionParser tutorial
This run shows the rules in one place: a unique prefix of a long option is accepted (--dry for --dry-run) and an ambiguous one
raises AmbiguousOption; short flags can be clustered and take attached values (-l5); a required argument may look like an
option when it is a number (-l -5) and an optional argument is never taken from a token that starts with a dash; -- ends option
processing and the rest are operands; options and operands can be mixed; and require_exact = true switches abbreviations off.
Abbreviations are convenient for people but a liability in scripts: a new option can make an old abbreviation ambiguous, so
scripts, CI files and documentation should use full names.
Collecting values with into:
Every parse method accepts into:, a hash that receives one entry per option seen, keyed by the long name as a symbol (so
--dry-run becomes the key :"dry-run", as the output above shows, and --[no-]color stores true or false under
:color). It replaces most of the blocks:
# frozen_string_literal: true
require "optparse"
require "optparse/time"
require "shelf"
# A custom converter: OptionParser calls the block with the matched text and uses the result as the value.
# An ArgumentError is not translated automatically, so report a bad value as InvalidArgument.
OptionParser.accept(Shelf::Isbn) do |value|
Shelf::Isbn.new(value)
rescue ArgumentError
raise OptionParser::InvalidArgument, value
end
OptionParser.accept(Pathname) { |value| Pathname(value).expand_path }
parser = OptionParser.new
options = {}
parser.on("--since=TIME", Time, "Only loans since TIME")
parser.on("--isbn=ISBN", Shelf::Isbn, "ISBN to look up")
parser.on("--store=PATH", Pathname, "Store file")
parser.on("--level=LEVEL", { "quiet" => 0, "normal" => 1, "loud" => 2 }, "Verbosity name")
parser.on("--ratio=R", Float, "A ratio such as 0.75")
parser.on("--count=N", OptionParser::DecimalInteger, "Decimal integer")
parser.on("--code=CODE", /\A[A-Z]{3}\d{2}\z/, "A shelf code such as ABC12")
parser.on("--[no-]dry-run", "Do not change anything")
parser.on("--name [NAME]", "Optional value")
parser.on("-d", "--dir DIR", "A separate argument form")
argv = %w[--since 2026-10-01T12:00:00Z --isbn 978-0-306-40615-7 --store /srv/shelf/./books.json --level loud
--ratio .5 --count 42 --code ABC12 --no-dry-run --name -d books extra]
rest = parser.parse!(argv, into: options)
pp options
p rest
p argv
begin
parser.parse(%w[--isbn 978-0-306-40615-8])
rescue OptionParser::InvalidArgument => e
puts "#{e.class}: #{e.message}"
end
begin
parser.parse(%w[--count 0x10])
rescue OptionParser::InvalidArgument => e
puts "#{e.class}: #{e.message}"
end
The output (the paths differ per machine):
{since: 2026-10-01 12:00:00 UTC,
isbn: #<data Shelf::Isbn digits="9780306406157">,
store: #<Pathname:/srv/shelf/books.json>,
level: 2,
ratio: 0.5,
count: 42,
code: "ABC12",
"dry-run": false,
name: nil,
dir: "books"}
["extra"]
["extra"]
OptionParser::InvalidArgument: invalid argument: --isbn 978-0-306-40615-8
OptionParser::InvalidArgument: invalid argument: --count 0x10
Source: OptionParser.accept
into: also gives defaults (start the hash with them: options = { limit: 10 }) and a way to check required options
(compare options.keys with the required list afterwards). OptionParser has no "required option" feature; a missing mandatory
option is the program’s job to detect and report with a usage error.
Custom converters are registered with OptionParser.accept(Class) { |text| value } (for every parser, global) or
parser.accept (for one parser). The block receives the argument text and returns the value. A converter that cannot convert
must raise OptionParser::InvalidArgument: as the output shows, an ArgumentError raised by the block (here from
Shelf::Isbn.new) would otherwise escape as an unrelated exception and crash the program with a backtrace.
parse, order and permute
There are six parsing methods: parse, parse!, order, order!, permute and permute!. The versions with ! work in
place on the array (and default to ARGV through default_argv); the others return a new array and leave the argument alone.
parse and parse! are permute and permute!.
-
permute (the default) scans the whole array and moves operands out of the way, so options may follow operands:
shelf search ruby --limit 3works. -
order stops at the first operand. This is what a program with subcommands needs: global options come first, the first operand is the command name, and everything after it belongs to the command, which may have an
OptionParserof its own. -
If the environment variable
POSIXLY_CORRECTexists (any value),permutebehaves likeorder, as the POSIX convention asks.
# frozen_string_literal: true
require "optparse"
def build(seen)
OptionParser.new do |opts|
opts.on("-a") { seen << :a }
opts.on("-b VALUE") { |v| seen << [:b, v] }
end
end
argv = %w[-a one -b two three]
seen = []
p build(seen).parse(argv) # permute: options may follow operands; returns a new array
p seen
p argv # the original is untouched
seen = []
p build(seen).order(argv) # order: stops at the first operand
p seen
seen = []
copy = argv.dup
build(seen).permute!(copy) # same as parse!, modifies the array in place
p copy
# getopts: the classic single-letter interface; results come back as a hash of strings
params = ARGV.getopts("ab:", "long", "name:default")
p params
Run as ruby -w options_modes.rb -a --long -b 1 --name=x file:
["one", "three"]
[:a, [:b, "two"]]
["-a", "one", "-b", "two", "three"]
["one", "-b", "two", "three"]
[:a]
["one", "three"]
{"a" => true, "b" => "1", "long" => true, "name" => "x"}
Source: OptionParser#order!, OptionParser#permute!
The last part of the example is getopts, the compact, older interface (ARGV.getopts("ab:", "long", "name:default") or
OptionParser.getopts): single-letter options are described in one string, long ones as further strings, and the result is a hash
of strings and booleans keyed by the names without dashes. It is handy for a ten-line script and has no help text or types; in a
program that will grow, use on.
Errors raised by parsing
All parse failures are subclasses of OptionParser::ParseError (itself a RuntimeError), so one rescue handles them:
| Class | Raised when |
|---|---|
|
An option is not defined ( |
|
An option that needs an argument is last, or is followed by another option. |
|
The argument fails its conversion, enumeration, pattern or range. |
|
An argument was given to an option that takes none ( |
|
An abbreviation matches several options. |
|
An abbreviated enumerated argument matches several values. |
The exception message is already formatted for users (invalid argument: -n abc), and e.reason is the short phrase. The
conventional handling prints a one-line message and a pointer to --help on standard error, and exits with a usage status;
printing the backtrace, as the uncaught exception above did, is for the developer. Shelf::CLI#run below does exactly this.
opts.abort(message) and opts.warn(message) are small helpers that prefix the program name.
Environment variables as defaults
opts.environment("SHELF_OPTS") (also OptionParser#load) parses extra options from an environment variable or a file, which is
how tools like less (LESS) and grep offer user defaults. For a Ruby program, an explicit ENV.fetch for each setting, as in
the configuration section below, is usually clearer.
Subcommands
Programs such as git and bundle have a command word and options per command. With OptionParser the pattern is: a
global parser uses order! so that it stops at the command word; the program takes the word with shift and gives the rest of
ARGV to a parser that belongs to that command. The shelf program below does this, with list, search, add and import.
Each command parser has its own Usage: banner, so shelf search --help shows the options of search only. Because every
option parser owns its options, the global --format must come before the command (shelf --format json search ruby), the
same rule as git -C dir status.
A hash from command name to a method, or a registry that each command class adds itself to, is the usual way to avoid a long
case when there are many commands. Gems such as Thor, dry-cli and GLI (see Other libraries) provide the registry, per-command
help and shell completion ready-made.
Signals and Ctrl-C
When the user presses Ctrl-C the terminal sends SIGINT to the foreground process, and kill sends SIGTERM by default. Ruby
turns an unhandled SIGINT into an Interrupt exception raised in the main thread, and any other unhandled signal that has a
Ruby default into a SignalException. Interrupt is a subclass of SignalException, not of StandardError: a bare rescue
does not catch it, which is what you want, and ensure blocks still run while it propagates.
# frozen_string_literal: true
Thread.new do
sleep 0.1
Process.kill("INT", Process.pid)
end
begin
sleep # waits for input, a socket or a long computation in a real program
rescue Interrupt => e
puts "caught #{e.class} (#{e.message.inspect}), signo=#{e.signo}"
puts "Interrupt is a SignalException, not a StandardError: #{Interrupt.ancestors.take(3).inspect}"
ensure
puts "ensure still runs"
end
caught Interrupt (""), signo=2
Interrupt is a SignalException, not a StandardError: [Interrupt, SignalException, Exception]
ensure still runs
Source: Interrupt, SignalException
There are two ways to react. The first is to rescue Interrupt where the program can clean up (flush the output, remove a
temporary file, print a one-line message) and then end with the conventional status 130 (128 plus 2); exe/shelf below does so
at the outermost level and nowhere else. The second is Signal.trap (or Kernel#trap), which installs a handler for a signal; a
good handler only records the request, and the main loop acts on it at a safe point:
# frozen_string_literal: true
# Graceful shutdown: the handler only sets a flag, the main loop notices it between units of work.
stop = false
%w[INT TERM].each { |name| Signal.trap(name) { stop = true } }
Thread.new do # stands in for the user pressing Ctrl-C
sleep 0.2
Process.kill("INT", Process.pid)
end
processed = 0
until stop
processed += 1 # one unit of work, for example importing one row
sleep 0.05
end
puts "interrupted after #{processed.positive? ? 'some' : 'no'} work; cleaning up"
exit 130
Run as ruby -w signals_flag.rb:
interrupted after some work; cleaning up
status: 130
Source: Signal.trap, Signals
The rules for handlers, which exist because a handler runs in a restricted "trap context" between two instructions of whatever code was running:
-
Keep it tiny: set a flag, push to a queue or write to a self-pipe. Do not take locks:
Mutex#lockraisesThreadError("can’t be called from trap context") and so do most blocking synchronization calls. ALoggercall inside a handler fails for the same reason: it printslog writing failed. can't be called from trap contextinstead of the message (checked on 4.0.7). -
KILLandSTOPcannot be trapped.Signal.trap("KILL")raisesErrno::EINVAL. -
trapreturns the previous handler (the string"DEFAULT","IGNORE","SYSTEM_DEFAULT","EXIT"or aProc), so a library can restore it. The special value"EXIT"ends the process, and"SYSTEM_DEFAULT"gives the operating system’s behaviour instead of Ruby’s. Ruby hasINT,TERMandHUPat the default (raise or exit) andPIPEignored. -
Signal.listmaps names to numbers (they are platform-specific:USR1is 30 on the macOS machine used here and 10 on Linux) andSignal.signame(n)goes the other way. -
Windows supports only a few signals (Ctrl-C is
INT); a program meant to be portable should rely onInterruptandexit.
# frozen_string_literal: true
mutex = Thread::Mutex.new
Signal.trap("USR1") do
mutex.synchronize { puts "unreachable" }
rescue ThreadError => e
puts "in a trap handler: #{e.message}"
end
Process.kill("USR1", Process.pid)
sleep 0.1
p Signal.list.slice("INT", "TERM", "HUP", "USR1", "PIPE", "KILL", "WINCH")
p Signal.signame(15)
begin
Signal.trap("KILL") { nil }
rescue Errno::EINVAL => e
puts e.message
end
in a trap handler: can't be called from trap context
{"INT" => 2, "TERM" => 15, "HUP" => 1, "USR1" => 30, "PIPE" => 13, "KILL" => 9, "WINCH" => 28}
"TERM"
Invalid argument - SIGKILL
Source: Kernel#trap
A long-running service usually traps TERM (the signal that orchestrators such as systemd and containers send for a graceful
stop) to start an orderly shutdown, and leaves INT to produce an exception in the foreground. After clean-up, a program that
wants the parent to see "killed by signal" instead of "exited 130" can restore the default handler and send the signal to itself
(Signal.trap("INT", "DEFAULT"); Process.kill("INT", Process.pid)); most programs just call exit 130. Sending signals,
fork and process groups are in
Processes, Signals and Shell. Timeout and Thread#raise,
the other ways of interrupting running code, are in Threads and the GVL.
Terminals, colour and prompts
Is there a person?
IO#tty? (alias isatty) is true when the stream is a terminal. It is the test a program uses to decide whether output is for
a person or for a pipe: colour, progress bars, column layout and prompts are for terminals; pipes get plain, stable text. Test
the stream that is actually involved: a program whose standard output is piped to less may still have an interactive standard
error.
| Situation | Convention |
|---|---|
|
No colour, no progress bars, no cursor movement; stable, parseable output. |
|
Never prompt; read from the pipe, or fail with a clear message naming the option that supplies the value. |
|
No colour, even on a terminal (https://no-color.org). |
|
No colour or cursor control. |
|
Explicit flags override detection ( |
# frozen_string_literal: true
require "io/console"
p [$stdin.tty?, $stdout.tty?, $stderr.tty?]
p ENV.fetch("TERM", nil)
color = $stdout.tty? && !ENV.key?("NO_COLOR") && ENV["TERM"] != "dumb"
puts "colour is #{color ? 'on' : 'off'}"
puts "\e[1m\e[32mbold green\e[0m" if color
p $stdout.winsize if $stdout.tty? # raises Errno::ENOTTY on a pipe, so ask only a terminal
width = $stdout.tty? ? $stdout.winsize.last : Integer(ENV.fetch("COLUMNS", 80))
p width
Piped (ruby -w tty_colour.rb | cat), on a terminal of 30 rows and 100 columns (the output was captured through a
pseudo-terminal, and ^[ is the escape character), and on a terminal with NO_COLOR=1:
[false, false, false]
nil
colour is off
80
[true, true, true]
"xterm-256color"
colour is on
^[[1m^[[32mbold green^[[0m
[30, 100]
100
[true, true, true]
"xterm-256color"
colour is off
[30, 100]
100
Source: IO#tty?, IO#winsize
require "io/console" (a default gem that ships with Ruby) adds terminal methods to IO: winsize (rows and columns; raises
Errno::ENOTTY when the stream is not a terminal, as the comment in the example says), getch (one key without Enter), raw,
noecho, echo=, console (the controlling terminal as an IO, available even when the standard streams are redirected) and
IO.console_size from io/console/size (rows and columns with a fallback of 25 by 80). A program that formats text to the
terminal width should fall back to the COLUMNS variable and then to 80 when it is not on a terminal.
Colour
Colour is ANSI escape sequences: "\e[31m" starts red, "\e[0m" resets, "\e[1m" is bold. They are plain text bytes, which is
why they must not be written to a pipe or a log file. A small object that decides once is easier to test than a check at every
puts; the shelf program uses this one:
# frozen_string_literal: true
module Shelf
# Adds ANSI colour to text only when it will be read by a person at a terminal.
class Painter
CODES = { red: 31, green: 32, yellow: 33, bold: 1 }.freeze
# mode is :auto, :always or :never; https://no-color.org asks programs to honour NO_COLOR.
def initialize(io, mode: :auto, env: ENV)
@enabled =
case mode
when :always then true
when :never then false
else io.tty? && !env.key?("NO_COLOR") && env["TERM"] != "dumb"
end
end
def paint(text, style) = @enabled ? "\e[#{CODES.fetch(style)}m#{text}\e[0m" : text
end
end
Source: IO#tty?
Colour belongs to labels (an error prefix, a status word) and never to the only carrier of a meaning: output has to be as clear
without it. Libraries such as rainbow, pastel and tty-color wrap the same escape codes and the detection rules; they are
described in Other libraries.
Asking questions
An interactive question is acceptable only when a terminal is attached, and its text goes to standard error so that standard
output remains data. IO#noecho hides what is typed (passwords, tokens), and gets returns nil at end of input, which has to
be handled. Prefer an option or an environment variable for every value a prompt can supply, so that scripts and CI never need
to prompt (see TTY, Colour & Interactivity).
# frozen_string_literal: true
require "io/console"
# Prompts go to standard error so that standard output stays clean for piping.
def ask(question, secret: false, input: $stdin, output: $stderr)
return nil unless input.tty? # never block a script that has no person attached
output.print "#{question} "
answer = secret ? input.noecho(&:gets) : input.gets
output.puts if secret # the newline the terminal did not echo
answer&.chomp
end
name = ask("Borrower name?")
token = ask("Token (hidden)?", secret: true)
if name.nil?
warn "no terminal attached; pass --borrower instead"
exit 2
end
puts "borrower=#{name} token_length=#{token&.length}"
Without a terminal (run as ruby -w prompt.rb < /dev/null):
no terminal attached; pass --borrower instead
status: 2
With a terminal, typing Ana and then a hidden token (captured through a pseudo-terminal; the first two lines are the input
as the terminal echoed it before the program started, and the token itself is never printed back by the program):
Ana
secret
Borrower name? Token (hidden)?
borrower=Ana token_length=6
Source: IO#noecho
For line editing, history and completion (a REPL or a shell inside the program), use Reline, the pure-Ruby readline that also
powers IRB. Reline.readline(prompt, add_hist) is the entry point:
# frozen_string_literal: true
require "reline"
# A tiny read-eval loop with line editing and history; Ctrl-D (end of input) leaves it.
while (line = Reline.readline("shelf> ", true))
command = line.strip
next if command.empty?
puts "you typed: #{command}"
break if command == "quit"
end
puts "bye"
An interactive session looks like this (the terminal also shows editing control sequences, which are omitted here):
shelf> list
you typed: list
shelf> quit
you typed: quit
bye
Source: Reline
require "readline" still works in Ruby 4.0: since the C extension was retired in 3.3 the readline gem is a thin wrapper over
Reline, and in 4.0 both are bundled gems, which means that under Bundler they have to be in the Gemfile (see
RubyGems and Bundler). New code should require reline directly.
Environment and configuration
ENV is a hash-like object of the process environment (see
Processes, Signals and Shell). A program that has settings
resolves each from, in decreasing priority: an option on the command line, an environment variable, a configuration file, and a
built-in default. Doing it in that order (and saying so in --help) is what users expect; the tool-configuration
and secrets guidance is in Configuration & Auth.
-
Read optional settings with
ENV.fetch("NAME", default)or a block, and required ones withENV.fetch("NAME"), which raisesKeyErrorwith the name when missing.ENV["NAME"]returnsnilfor an unset variable. Environment values are strings, so an empty value (NAME=) is a truthy""in Ruby, which is whyENV["NAME"] || defaultdoes not apply a default for it. -
Follow the XDG Base Directory convention on Linux and macOS: data in
XDG_DATA_HOME(default~/.local/share), configuration inXDG_CONFIG_HOME(default~/.config), caches inXDG_CACHE_HOME(default~/.cache), under a directory named after the program.Dir.homegives the home directory;File.joinbuilds the path. -
Never print secrets, and do not accept them as command-line arguments: every process on the machine can read another process’s arguments (
ps), and shell history keeps them. Read them from the environment, a file with restrictive permissions, or a hidden prompt. -
Environment variables set inside a Ruby program are inherited by child processes it starts and nothing else.
Building the shelf command-line program
The seven-line exe/shelf of the running scenario already uses OptionParser: it understands --search TERM and --version,
prints the one hard-coded book and, given a bad option, ends with an uncaught OptionParser::InvalidOption and a backtrace.
#!/usr/bin/env ruby
# frozen_string_literal: true
require "optparse"
require "shelf"
options = { search: nil }
OptionParser.new do |opts|
opts.banner = "Usage: shelf [options]"
opts.on("-sTERM", "--search=TERM", "Search titles") { |t| options[:search] = t }
opts.on("-v", "--version", "Show version") do
puts Shelf::VERSION
exit
end
end.parse!
catalog = Shelf::Catalog.new
catalog.add(Shelf::Book.new(Shelf::Isbn.new("978-0-306-40615-7"), "ruby basics", Shelf::Author.new("A. Writer")))
term = options[:search]
catalog.select { |b| term.nil? || b.title.include?(term) }.each { |b| puts b }
Run as ruby -w -Ilib exe/shelf, exe/shelf -s ruby, exe/shelf -v and exe/shelf --nope, it prints ruby basics by A. Writer
twice, then 0.1.0, and for the unknown option it ends with this line and a status of 1:
exe/shelf:15:in '<main>': invalid option: --nope (OptionParser::InvalidOption)
Source: OptionParser
The version below keeps the executable tiny, moves everything into a Shelf::CLI class whose run method returns an integer, and
splits storage and colour into small classes. The executable translates the two process-level events (Interrupt,
Errno::EPIPE) and passes the return value to exit:
#!/usr/bin/env ruby
# frozen_string_literal: true
require "shelf/cli"
begin
exit Shelf::CLI.run(ARGV)
rescue Interrupt
warn "" # finish the line the terminal echoed ^C on
exit 130 # 128 + SIGINT, what a shell reports for a process killed by Ctrl-C
rescue Errno::EPIPE
exit 0 # the reader of our output (for example head) went away
end
The command-line class. It receives its streams and environment as arguments, parses global options with order! so that the
command name is the first operand, hands each command its own parser, and converts errors into a message and a status:
# frozen_string_literal: true
require "json"
require "optparse"
require "shelf"
require "shelf/json_file"
require "shelf/painter"
module Shelf
# The command-line front end. #run returns an exit status instead of calling exit,
# so a test can run it in-process with fake streams.
class CLI
EX_USAGE = 64 # from BSD sysexits.h: the command was used incorrectly
EX_FAILURE = 1
BANNER = <<~TEXT
Usage: shelf [options] COMMAND [ARGS]
Commands: list, search TERM, add ISBN TITLE AUTHOR, import [FILE]
TEXT
Settings = Struct.new(:store, :format, :color)
def self.run(argv, **) = new(**).run(argv)
def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr, env: ENV)
@stdin = stdin
@stdout = stdout
@stderr = stderr
@env = env
@settings = Settings.new(JsonFile.default_path(env), :text, :auto)
end
def run(argv)
argv = argv.dup
catch(:done) do
parser.order!(argv) # stop at the first operand: the command name
dispatch(argv)
end
rescue OptionParser::ParseError => e
usage_error(e.message)
rescue ArgumentError, KeyError, JSON::ParserError, SystemCallError => e
failure(e.message)
end
private
def dispatch(argv)
command = argv.shift
case command
when "list" then show(books.sort_by(&:title))
when "search" then search(argv)
when "add" then add(argv)
when "import" then import(argv)
when nil then usage_error("missing command")
else usage_error("unknown command: #{command}")
end
end
def parser
OptionParser.new do |opts|
opts.program_name = "shelf"
opts.version = Shelf::VERSION
opts.banner = BANNER
opts.separator ""
global_options(opts)
opts.on_tail("-h", "--help", "Show this help") { finish(opts.help) }
opts.on_tail("--version", "Show the version") { finish(opts.ver) }
end
end
def finish(text)
@stdout.puts text
throw :done, 0
end
def global_options(opts)
opts.on("--store=PATH", "JSON file holding the books (env: SHELF_STORE)") { |v| @settings.store = v }
opts.on("-f", "--format=FORMAT", %i[text json], "text (default) or json") { |v| @settings.format = v }
opts.on("--color=WHEN", %i[auto always never], "auto (default), always or never") { |v| @settings.color = v }
end
def search(argv)
limit = nil
OptionParser.new("Usage: shelf search [options] TERM") do |opts|
opts.on("-n", "--limit=N", Integer, "Show at most N books") { |v| limit = v }
end.parse!(argv)
return usage_error("search needs a TERM") if argv.empty?
found = books.select { |book| book.title.downcase.include?(argv.first.downcase) }
show(limit ? found.first(limit) : found)
end
def add(argv)
return usage_error("add needs ISBN TITLE AUTHOR") unless argv.size == 3
isbn, title, author = argv
store.save(store.load.add(Book.new(Isbn.new(isbn), title, Author.new(author))))
0
end
def import(argv)
source = argv.empty? ? @stdin.read : File.read(argv.first)
catalog = store.load
JsonFile.books_from_json(source).each { |book| catalog.add(book) }
store.save(catalog)
0
end
def show(found)
if @settings.format == :json
@stdout.puts JSON.pretty_generate(found.map { |book| JsonFile.book_to_h(book) })
else
found.each { |book| @stdout.puts book }
end
found.empty? ? 1 : 0 # like grep: 1 means "nothing found"
end
def usage_error(message)
failure(message)
@stderr.puts "Try 'shelf --help' for usage."
EX_USAGE
end
def failure(message)
@stderr.puts "#{painter.paint('shelf:', :red)} #{message}"
EX_FAILURE
end
def painter = Painter.new(@stderr, mode: @settings.color, env: @env)
def store = JsonFile.new(@settings.store)
def books = store.load.to_a
end
end
Source: OptionParser#order!
The collaborators: a JSON file as the store (the real shelf offers JSON as one of its duck-typed stores), and the Painter
shown earlier.
# frozen_string_literal: true
require "fileutils"
require "json"
require "shelf"
module Shelf
# Keeps a catalogue in a JSON file: the persistence the command-line program needs.
class JsonFile
def self.default_path(env = ENV)
env.fetch("SHELF_STORE") do
data_home = env.fetch("XDG_DATA_HOME") { File.join(Dir.home, ".local", "share") }
File.join(data_home, "shelf", "books.json")
end
end
def self.books_from_json(text)
JSON.parse(text, symbolize_names: true).map do |row|
Book.new(Isbn.new(row.fetch(:isbn)), row.fetch(:title), Author.new(row.fetch(:author)))
end
end
def self.book_to_h(book) = { isbn: book.isbn.to_s, title: book.title, author: book.author.name }
def initialize(path) = @path = path
def load
catalog = Catalog.new(store: Store::Memory.new)
books_from_file.each { |book| catalog.add(book) }
catalog
end
def save(catalog)
FileUtils.mkdir_p(File.dirname(@path))
File.write(@path, JSON.pretty_generate(catalog.map { |book| self.class.book_to_h(book) }))
end
private
def books_from_file
File.exist?(@path) ? self.class.books_from_json(File.read(@path)) : []
end
end
end
Source: JSON
A session (the books are kept in the file named by SHELF_STORE; [exit n] is the status of each command; ^[ is the escape
character that begins the red colour):
$ shelf list
[exit 1]
$ shelf add 978-0-306-40615-7 Ruby Basics A. Writer
[exit 0]
$ shelf add 978-0-306-40615-8 Broken A. Writer
shelf: invalid ISBN: 978-0-306-40615-8
[exit 1]
$ shelf add 978-0-306-40615-7 Only two
shelf: add needs ISBN TITLE AUTHOR
Try 'shelf --help' for usage.
[exit 64]
$ shelf import ../ex/import.json
[exit 0]
$ shelf list
Learning Ruby by M. Fitzgerald
Ruby Basics by A. Writer
The C Programming Language by B. Kernighan
[exit 0]
$ shelf search ruby
Ruby Basics by A. Writer
Learning Ruby by M. Fitzgerald
[exit 0]
$ shelf --format json search --limit 1 ruby
[
{
"isbn": "9780306406157",
"title": "Ruby Basics",
"author": "A. Writer"
}
]
[exit 0]
$ shelf search nothing-here
[exit 1]
$ shelf --format xml list
shelf: invalid argument: --format xml
Try 'shelf --help' for usage.
[exit 64]
$ shelf frobnicate
shelf: unknown command: frobnicate
Try 'shelf --help' for usage.
[exit 64]
$ shelf
shelf: missing command
Try 'shelf --help' for usage.
[exit 64]
$ shelf --color always --bogus
^[[31mshelf:^[[0m invalid option: --bogus
Try 'shelf --help' for usage.
[exit 64]
$ shelf --store /dev/null/x.json add 978-0-306-40615-7 t a
shelf: File exists @ dir_s_mkdir - /dev/null
[exit 1]
$ shelf --version
shelf 0.1.0
[exit 0]
$ shelf import ../ex/bad.json
shelf: expected object key, got 'bad' at line 1 column 2
[exit 1]
Source: OptionParser tutorial
Choices in this code that are worth copying:
-
runreturns an integer and never callsexit, which keeps it testable and makesexitappear once, inexe/shelf. -
Streams, environment and standard input are constructor arguments with the real ones as defaults; a test passes
StringIOobjects and a plain hash. -
catch(:done)/throw :done, 0leaves from inside the--helpand--versionblocks withoutexit(which would raiseSystemExitthrough the parser). -
The error path distinguishes usage mistakes (
OptionParser::ParseError, status 64) from runtime failures (ArgumentErrorfrom an invalid ISBN,KeyError,JSON::ParserErrorandSystemCallError, status 1). RescuingStandardErrorwholesale would hide programming errors as friendly messages; list the failures that are the user’s to fix. -
"Nothing found" returns 1, as
grepdoes, and still prints nothing: scripts testif shelf search x; then. -
Output that a program reads (
list,search) goes to standard output with--format jsonfor machines (the guidance for machine-readable output is in Machine-Readable Output); messages go to standard error. -
--helpis generated from the declarations, so the text and the behaviour cannot drift apart (see Self-Describing Help).
Testing a command-line program
The structure above allows two kinds of test. In-process tests call Shelf::CLI.run with fake streams: they are fast, they
see the return value directly, and they can cover every branch. A small number of out-of-process tests run the real executable
with Open3.capture3 (see Processes, Signals and Shell), which
proves that the shebang, the load path, the real streams and the exit status work together.
# frozen_string_literal: true
require "open3"
require "stringio"
require "tmpdir"
require_relative "test_helper"
require "shelf/cli"
class CliTest < Minitest::Test
ISBN = "978-0-306-40615-7"
def setup
@dir = Dir.mktmpdir("shelf-test")
@store = File.join(@dir, "books.json")
end
def teardown = FileUtils.remove_entry(@dir)
# In-process: fast, and the streams are plain StringIO objects.
def run_cli(*argv, stdin: "")
out = StringIO.new
err = StringIO.new
status = Shelf::CLI.run(["--store", @store, *argv], stdin: StringIO.new(stdin), stdout: out, stderr: err, env: {})
[status, out.string, err.string]
end
def test_add_then_search
assert_equal 0, run_cli("add", ISBN, "Ruby Basics", "A. Writer").first
status, out, = run_cli("search", "ruby")
assert_equal 0, status
assert_equal "Ruby Basics by A. Writer\n", out
end
def test_unknown_command_is_a_usage_error
status, out, err = run_cli("frobnicate")
assert_equal Shelf::CLI::EX_USAGE, status
assert_empty out
assert_includes err, "unknown command: frobnicate"
end
def test_nothing_found_returns_one
assert_equal 1, run_cli("search", "missing").first
end
# Out of process: the real executable, real exit status, real streams.
def test_exe_reports_the_version
exe = File.expand_path("../exe/shelf", __dir__)
out, err, status = Open3.capture3(RbConfig.ruby, "-I#{File.expand_path('../lib', __dir__)}", exe, "--version")
assert_predicate status, :success?
assert_equal "shelf #{Shelf::VERSION}\n", out
assert_empty err
end
end
Run with ruby -w -Ilib -Itest test/cli_test.rb: 4 runs, 14 assertions, 0 failures. The framework and its assertions are in
Testing with Minitest; the language-independent testing advice for
command-line programs is in Testing CLIs.
Source: Open3
Other libraries
OptionParser is enough for many programs. Beyond it:
-
Thor is the best-known framework for programs with subcommands, per-command options and generated help (Bundler and Rails generators are Thor programs). dry-cli, GLI and commander are alternatives with a registry of commands; Slop and optimist are option parsers with a terser DSL; TTY is a family of gems (
tty-prompt,tty-table,tty-spinner,tty-option) for the interactive side; Rainbow and Pastel add colour; HighLine asks questions. They are installed from RubyGems like any other gem (see RubyGems and Bundler) and were not run for this page. -
Rake and Thor tasks are other ways to expose commands (
rake -Tlists tasks); see Creating and Publishing Gems, and Rake. -
Packaging the executable as a gem gives users
gem install shelfand ashelfon theirPATH(the gem’sexe/directory is wrapped by RubyGems); the other distribution routes are covered in Packaging & Distribution.
Where older material is outdated
Tutorials and books written before Ruby 3.4 contain advice that has aged:
-
getoptlongwas the C-style option parser of Ruby 1.x. It became a bundled gem in Ruby 3.4 (it is no longer a default gem, so under Bundler it needs aGemfileentry), andOptionParserhas superseded it for twenty years. -
Readline(require "readline") used to be a C extension linked to GNU readline or libedit. It was retired in 3.3 in favour ofReline, and in Ruby 4.0 thereadlinewrapper andrelineare bundled gems, not default gems. -
OpenStructwas a common way to hold parsed options (options = OpenStruct.new). It is a bundled gem in Ruby 4.0, slow, and hides typos (an unknown attribute silently returnsnil). AStruct, aDataor a plain hash withinto:is better. -
Kernel#open("|command")started a subprocess from a string; Ruby 4.0 removed that behaviour (it was prone to command injection). UseOpen3orsystemwith an argument array. -
Error messages in old output samples quote the method name with a backtick and an apostrophe and end with
for nil:NilClass; since Ruby 3.4 the quotes are straight and the receiver is described as inundefined method 'x' for nil. Test expectations that match message text need the new format. -
"Ruby parses and runs the script line by line" is not true of a program file: Ruby parses the whole file before running it (with Prism, the default parser since 3.4), so a syntax error anywhere stops the program before it prints anything, and
ruby -c script.rbchecks the syntax without running it.
Pitfalls checklist
| Pitfall | Remedy |
|---|---|
|
Use |
Output ends with a Ruby backtrace for a mistyped option |
Rescue |
|
Return a status; call |
|
Rescue |
|
It skips |
Colour codes in files and pipes |
Decide once with |
Prompting when there is no terminal |
Check |
Messages on standard output |
Diagnostics, prompts and progress go to standard error. |
|
Rescue it at the top level and end quietly. |
Unrelated errors shown as friendly messages |
Rescue the specific failures the user can fix. |
Abbreviated options in scripts |
Use full names; consider |
|
Use |
Secrets as arguments |
Read from the environment, a protected file or a hidden prompt. |
Handler that logs or locks in |
Set a flag or write to a pipe, and act in the main loop. |
Locale-dependent arguments |
Run with a UTF-8 locale; arguments are in |
References
-
IO (
tty?,sync,winsize,noecho,getch) -
Building CLIs for AI Agents: CLI design guidance used throughout this page