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.

flowchart TD A["shell: shelf --format json search ruby"] --> B["Ruby starts exe/shelf with ARGV and ENV"] B --> C["OptionParser reads global options, then the command name"] C -->|"bad option or unknown command"| E["message on stderr, status 64"] C -->|"valid"| D["command runs: reads the JSON store, writes results to stdout"] D -->|"found something"| F["status 0"] D -->|"nothing found"| G["status 1"] D -->|"IO or data error"| H["message on stderr, status 1"] B -.->|"Ctrl-C"| I["Interrupt: exit 130"] F --> Z["exit hands the status to the shell"] G --> Z E --> Z H --> Z I --> Z

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.

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", ""]

Points that surprise newcomers:

  • Everything is a string: 3 is "3", and an empty argument ("") is kept. Ruby does not interpret --limit; options are only conventions until a parser reads them.

  • Options of the ruby executable itself must come before the script name. Anything after the script name belongs to the script: ruby a.rb -v gives the script ["-v"] (checked with ruby -w), not Ruby’s version flag.

  • A lone -- after the script name is not removed by Ruby, so ruby a.rb — x -w gives ["--", "x", "-w"]. Handling -- is the option parser’s job. Only ruby -e 'p ARGV' -- -x y (where -- ends Ruby’s own options) hides it.

  • The strings are in the locale encoding: Encoding.default_external follows LANG/LC_ALL, and a shell with LANG=C produces US-ASCII arguments, which then fail to compare with UTF-8 text. Set a UTF-8 locale (LANG=C.UTF-8 or en_US.UTF-8), or pass -E UTF-8, and see Encodings and Unicode.

  • ruby -s script.rb -debug -name=value is an old switch-parsing feature that defines $debug and $name; it works, but OptionParser replaces 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, p and pp write to $stdout; warn writes to $stderr. p returns its argument; puts returns nil.

  • $stdout.sync is false when the output is a file or a pipe (Ruby buffers) and $stderr.sync is always true. When a program prints progress while another process reads the pipe, set $stdout.sync = true or call $stdout.flush. On a terminal output is flushed at each newline.

  • warn is not a plain $stderr.puts: it is silenced by -W0 ($VERBOSE = nil), it accepts several messages, uplevel: (prefixes the caller’s location) and category:, and Warning.warn can be overridden. Fatal diagnostics that must always appear go through abort or $stderr.puts; with -W0 the first prints and warn "hidden" does not (checked on 4.0.7).

  • Redirecting $stdout to a StringIO captures output in a test; restore it in an ensure. A better design passes the stream as an argument, as the shelf program below does.

  • Ruby sets SIGPIPE to ignored at startup, so writing to a closed pipe raises Errno::EPIPE. A program that is not handling it dies silently with the status a shell reports as 141 (128 plus SIGPIPE, 13) when the reader quits early, for example shelf 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

exit or exit(true)

Raises SystemExit; ensure blocks and at_exit handlers run.

0

exit(false)

Raises SystemExit with status 1.

1

exit(n)

Raises SystemExit with status n; only the low 8 bits survive (exit 256 is 0, exit(-1) is 255).

n modulo 256

abort("message")

Prints the message to $stderr, then raises SystemExit with status 1. Without a message it prints nothing.

1

exit! or exit!(n)

Ends the process at once: no ensure, no at_exit handlers, no SystemExit. The default status is 1 (false); exit!(true) is 0.

1 or n

Uncaught exception

Ruby prints the message and backtrace to $stderr, runs at_exit handlers and exits.

1

Uncaught Interrupt (Ctrl-C)

Prints the error; the process then ends as if killed by SIGINT.

130 in a shell

Killed by SIGTERM

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.

# 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")
# 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).

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 exit(false), abort and an uncaught exception; also what grep returns for "no match", which shelf search copies.

2

Misuse of a shell builtin in Bash and the value many Unix tools use for a usage error; Ruby’s own ruby executable uses 1 for a bad option.

64

EX_USAGE from BSD sysexits.h: the command was used incorrectly. The shelf program uses it for bad options and unknown commands.

65 to 78

Other sysexits.h values (data format error, cannot open input, service unavailable, configuration error, and so on).

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 SIGINT (Ctrl-C), 141 is SIGPIPE, 143 is SIGTERM. A program that handles Ctrl-C itself should return 130.

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>'

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

"-v"

A short flag. Clusters work: -vq is -v -q, and -nx is -n -x.

"--verbose"

A long flag.

"-v", "--verbose"

Both names for the same option; any number of names may be given.

"--[no-]color"

Defines --color (true) and --no-color (false). The block receives true or false.

"-nN", "-n N", "--limit=N", "--limit N"

An option with a required argument; the word is only a placeholder shown in the help. -n5, -n 5, --limit=5 and --limit 5 all work.

"-o [FILE]", "--out [FILE]"

An option with an optional argument. The block receives nil when no argument follows, and the argument is never taken from a following token that looks like an option.

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 Integer

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 1..100

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

String

Anything (the default).

Integer

Decimal, 0x hexadecimal, 0b binary and leading-zero octal, with underscores: 0x1f is 31, 1_000 is 1000, and 010 is 8. Not 1.5.

OptionParser::DecimalInteger

Decimal digits only, so 010 is 10 and 0x10 is rejected.

OptionParser::OctalInteger

Octal, or hexadecimal and binary with a prefix.

Float

1e3, .5, 0.75.

Numeric

An integer, a float or a rational such as 1/3.

OptionParser::DecimalNumeric

Decimal digits, a float or a rational, with no octal interpretation.

TrueClass and FalseClass

yes, true, +, no, false, - (and the usual abbreviations) for an option declared with an argument, such as --t=B. A flag declared without an argument takes none (--ok=yes raises NeedlessArgument).

Array

A comma-separated list, delivered as an array of strings: --tag a,b.

Regexp

A regular expression written like /x/i.

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

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

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 3 works.

  • 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 OptionParser of its own.

  • If the environment variable POSIXLY_CORRECT exists (any value), permute behaves like order, 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"}

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

OptionParser::InvalidOption

An option is not defined (--bogus).

OptionParser::MissingArgument

An option that needs an argument is last, or is followed by another option.

OptionParser::InvalidArgument

The argument fails its conversion, enumeration, pattern or range.

OptionParser::NeedlessArgument

An argument was given to an option that takes none (--color=maybe, --ok=yes).

OptionParser::AmbiguousOption

An abbreviation matches several options.

OptionParser::AmbiguousArgument

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

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#lock raises ThreadError ("can’t be called from trap context") and so do most blocking synchronization calls. A Logger call inside a handler fails for the same reason: it prints log writing failed. can't be called from trap context instead of the message (checked on 4.0.7).

  • KILL and STOP cannot be trapped. Signal.trap("KILL") raises Errno::EINVAL.

  • trap returns the previous handler (the string "DEFAULT", "IGNORE", "SYSTEM_DEFAULT", "EXIT" or a Proc), 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 has INT, TERM and HUP at the default (raise or exit) and PIPE ignored.

  • Signal.list maps names to numbers (they are platform-specific: USR1 is 30 on the macOS machine used here and 10 on Linux) and Signal.signame(n) goes the other way.

  • Windows supports only a few signals (Ctrl-C is INT); a program meant to be portable should rely on Interrupt and exit.

# 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

$stdout.tty? is false

No colour, no progress bars, no cursor movement; stable, parseable output.

$stdin.tty? is false

Never prompt; read from the pipe, or fail with a clear message naming the option that supplies the value.

NO_COLOR is set to any value

No colour, even on a terminal (https://no-color.org).

TERM is dumb

No colour or cursor control.

--color=never, --color=always

Explicit flags override detection (auto is the default).

# 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 with ENV.fetch("NAME"), which raises KeyError with the name when missing. ENV["NAME"] returns nil for an unset variable. Environment values are strings, so an empty value (NAME=) is a truthy "" in Ruby, which is why ENV["NAME"] || default does 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 in XDG_CONFIG_HOME (default ~/.config), caches in XDG_CACHE_HOME (default ~/.cache), under a directory named after the program. Dir.home gives the home directory; File.join builds 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

Source: Interrupt and Errno

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

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]

Choices in this code that are worth copying:

  • run returns an integer and never calls exit, which keeps it testable and makes exit appear once, in exe/shelf.

  • Streams, environment and standard input are constructor arguments with the real ones as defaults; a test passes StringIO objects and a plain hash.

  • catch(:done) / throw :done, 0 leaves from inside the --help and --version blocks without exit (which would raise SystemExit through the parser).

  • The error path distinguishes usage mistakes (OptionParser::ParseError, status 64) from runtime failures (ArgumentError from an invalid ISBN, KeyError, JSON::ParserError and SystemCallError, status 1). Rescuing StandardError wholesale would hide programming errors as friendly messages; list the failures that are the user’s to fix.

  • "Nothing found" returns 1, as grep does, and still prints nothing: scripts test if shelf search x; then.

  • Output that a program reads (list, search) goes to standard output with --format json for machines (the guidance for machine-readable output is in Machine-Readable Output); messages go to standard error.

  • --help is 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 -T lists tasks); see Creating and Publishing Gems, and Rake.

  • Packaging the executable as a gem gives users gem install shelf and a shelf on their PATH (the gem’s exe/ 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:

  • getoptlong was 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 a Gemfile entry), and OptionParser has 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 of Reline, and in Ruby 4.0 the readline wrapper and reline are bundled gems, not default gems.

  • OpenStruct was 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 returns nil). A Struct, a Data or a plain hash with into: is better.

  • Kernel#open("|command") started a subprocess from a string; Ruby 4.0 removed that behaviour (it was prone to command injection). Use Open3 or system with 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 in undefined 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.rb checks the syntax without running it.

Pitfalls checklist

Pitfall Remedy

gets reads the files in ARGV, not the terminal

Use $stdin.gets for standard input; use ARGF on purpose.

Output ends with a Ruby backtrace for a mistyped option

Rescue OptionParser::ParseError, print the message, return a usage status.

exit inside library code

Return a status; call exit once, in exe/.

rescue Exception swallowing exit and Ctrl-C

Rescue StandardError; rescue Interrupt and SystemExit explicitly and only at the top.

exit! used for a normal exit

It skips ensure and at_exit (temporary files stay, buffers are not flushed); reserve it for a forked child.

Colour codes in files and pipes

Decide once with tty?, NO_COLOR, TERM and --color.

Prompting when there is no terminal

Check $stdin.tty? and fail with the name of the option that provides the value.

Messages on standard output

Diagnostics, prompts and progress go to standard error.

Errno::EPIPE when the reader of a pipe quits early

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 require_exact = true.

Integer option accepting 010 as 8

Use OptionParser::DecimalInteger when leading zeros are decimal.

Secrets as arguments

Read from the environment, a protected file or a hidden prompt.

Handler that logs or locks in trap

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 Encoding.default_external.