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

Banner image