A real CLI with OptionParser
The parser
OptionParser is in the standard library and does the part of a CLI that is boring: the flags, the help and the error for an unknown option. The command itself is a case on the first argument:
#!/usr/bin/env ruby
require "json"
require "optparse"
FILE = "todos.json"
OPTIONS = {}
parser = OptionParser.new do |o|
o.banner = "Usage: todo [options] COMMAND [args]"
o.on("-f", "--file PATH", "todo file (default: todos.json)") { |v| OPTIONS[:file] = v }
o.on("-h", "--help", "show this help") { puts o; exit }
end
begin
parser.parse!
rescue OptionParser::ParseError => e
abort("#{e.message}\n#{parser.help}")
end
command = ARGV.shift or abort(parser.help)
parse! removes the flags from ARGV and leaves the command and its arguments. The rescue turns a bad flag into a message and a help text instead of a stack trace, which is the difference between a tool and a script.
Subcommands
Each subcommand reads its own arguments from what is left. The file is JSON, loaded at the start and written after a change:
path = OPTIONS[:file] || FILE
todos = File.exist?(path) ? JSON.parse(File.read(path)) : []
save = -> { File.write(path, JSON.pretty_generate(todos)) }
case command
when "add"
title = ARGV.shift or abort("todo add: missing title")
todos << { "id" => todos.size + 1, "title" => title, "done" => false }
save.call
puts "added: #{title}"
when "list"
todos.each { |t| puts format("%d [%s] %s", t["id"], t["done"] ? "x" : " ", t["title"]) }
when "done"
id = Integer(ARGV.shift) rescue abort("todo done: id must be a number")
todo = todos.find { |t| t["id"] == id } or abort("todo done: no todo #{id}")
todo["done"] = true
save.call
puts "done: #{todo["title"]}"
else
abort("todo: unknown command #{command.inspect}")
end
abort writes to stderr and exits with status 1, which is exactly the contract a shell and a CI job expect. Every branch that cannot continue calls it with a message that names the command and what was wrong.
Running it
ruby todo.rb --help
Usage: todo [options] COMMAND [args] -f, --file PATH todo file (default: todos.json) -h, --help show this help
ruby todo.rb add "write the lesson"
added: write the lesson
ruby todo.rb list
1 [ ] write the lesson
ruby todo.rb done 1
done: write the lesson
ruby todo.rb list
1 [x] write the lesson
Errors and exit codes
The three failures a user meets, each with the message and the status:
ruby todo.rb --nope
invalid option: --nope Usage: todo [options] COMMAND [args] -f, --file PATH todo file (default: todos.json) -h, --help show this help
ruby todo.rb bogus
todo: unknown command "bogus"
ruby todo.rb add
todo add: missing title
Each of them exits 1; success exits 0. A shell reads the number, not the message, so a command that prints an error and exits 0 breaks a pipeline in the quietest possible way.
What to watch for
The order matters: parse the flags before taking the command, or
todo --help is read as a command called
--help. The error above is what that bug looks
like.
A subcommand named after a flag is a name collision waiting to happen.
Keep the commands as plain words and let every flag start with a dash,
and the parser can always tell them apart.
Next: Configuration and environment
Article author: Arthur Isaev