7 Ruby Gems That Transform Your Command-Line Scripts Into Professional CLI Tools
Discover 7 essential Ruby gems for building polished CLI tools. From OptionParser to TTY::Prompt, learn which gem fits your project with real code examples.
I’ve been writing Ruby scripts for years, and I still remember my first command-line tool—a messy collection of ARGV checks and puts statements that only I could understand. Over time, I learned that building a proper CLI doesn’t mean reinventing the wheel. Ruby has a rich ecosystem of gems that turn a few lines of code into a polished, user-friendly interface. In this article, I’ll walk you through seven gems I’ve used to build everything from simple file utilities to complex deployment tools. I’ll keep things simple, because when you’re starting, you don’t need jargon—you need working code.
Let me show you what I’ve learned, one gem at a time.
The built-in starter: OptionParser
Before I ever installed a third-party gem, I used OptionParser. It’s part of Ruby’s standard library, so you already have it. The thing is, most beginners ignore it because they think parsing arguments is hard. It’s not. OptionParser gives you a clean way to define flags, switches, and help text without writing endless if statements.
Here’s how I started a simple file size calculator:
require 'optparse'
require 'fileutils'
options = {}
OptionParser.new do |opts|
opts.banner = "Usage: sizer.rb [options]"
opts.on("-f", "--file FILE", "Path to file") do |f|
options[:file] = f
end
opts.on("-u", "--unit UNIT", [:b, :kb, :mb], "Unit: b, kb, mb") do |u|
options[:unit] = u
end
opts.on("-h", "--help", "Prints help") do
puts opts
exit
end
end.parse!
if options[:file]
size = File.size(options[:file])
case options[:unit]
when :kb then size /= 1024.0
when :mb then size /= 1024.0 / 1024.0
end
puts "Size: #{size} #{options[:unit] || 'bytes'}"
else
puts "No file specified. Use -h for help."
end
That script gave me a structure I could reuse. I added an --all flag to loop through directories. OptionParser handles --no- prefixes automatically—try --no-color if you define a boolean flag. It’s simple but powerful for short scripts. The downside? When your tool grows beyond ten flags, you start wanting something that organizes tasks better. That’s when I moved to Thor.
Thor: tasks and templates
Thor is the workhorse of Ruby CLI gems. Rails uses it for generators. It’s built around tasks—think of them as subcommands. If you’ve ever typed thor help generate, you know what I mean. Thor also brings templates, file manipulation, and nice error handling.
My first Thor app was a project scaffold. I wanted to create a folder with a Gemfile, README.md, and a lib directory. Here’s the core:
require 'thor'
require 'fileutils'
class Scaffold < Thor
desc "new PROJECT_NAME", "Create a new Ruby project"
def new(name)
dir = "./#{name}"
FileUtils.mkdir_p("#{dir}/lib")
FileUtils.mkdir_p("#{dir}/spec")
write_gemfile(dir, name)
write_readme(dir, name)
puts "Project #{name} created!"
end
desc "add COMPONENT", "Add a component to existing project"
def add(component)
FileUtils.mkdir_p("./lib/#{component}")
puts "Added #{component} component."
end
private
def write_gemfile(dir, name)
File.write("#{dir}/Gemfile", <<~RUBY)
source 'https://rubygems.org'
gem '#{name}'
RUBY
end
def write_readme(dir, name)
File.write("#{dir}/README.md", "# #{name}\n\nMy amazing project.")
end
end
Scaffold.start(ARGV)
Run ruby scaffold.rb new my_app and it generates the structure. Thor picks up --help automatically. What I love most is how you can define group options and shared options—like a --dry-run flag for every task. For example:
class_option :verbose, type: :boolean, default: false, desc: "Enable verbose output"
def new(name)
puts "Verbose mode on" if options[:verbose]
# ... rest of code
end
Thor also has a map command to alias tasks. You can say map '-v' => :version to make -v show version. It’s not the simplest gem to learn, but once you get past the desc and method_option syntax, you’ll never go back to raw ARGV.
Commander: help text made pretty
I discovered Commander when I needed a CLI that looked professional out of the box. It gives you a global program object where you define version, description, and commands. The generated help output is beautiful—colored, aligned, and easy to read.
Here’s a git-like tool I built with Commander:
require 'commander'
require 'json'
module Gitlike
class Application
include Commander::Methods
def run
program :name, 'gitlike'
program :version, '0.1.0'
program :description, 'A toy version control tool'
command :init do |c|
c.syntax = 'gitlike init'
c.description = 'Initialize a new repository'
c.action do |args, options|
Dir.mkdir('.gitlike') unless Dir.exist?('.gitlike')
File.write('.gitlike/config.json', JSON.pretty_generate({created: Time.now}))
puts "Initialized empty Gitlike repository"
end
end
command :status do |c|
c.syntax = 'gitlike status'
c.description = 'Show working tree status'
c.action do |args, options|
if Dir.exist?('.gitlike')
puts "Repository exists. (Real status would check file hashes here.)"
else
puts "Not a gitlike repository"
end
end
end
default_command :help
run!
end
end
end
Gitlike::Application.new.run
Commander uses run! to parse ARGV and dispatch. The method command blocks let me define options per command. I added a global option --json to output results as JSON by checking options.json inside each action. The gem also has a global_option method. One thing I often do is set up a --verbose flag and then redirect $stdout to a log file if needed. Commander handles the rest.
The only catch is that Commander is less maintained than Thor. But for small tools that need quick polish, it’s fantastic.
GLI: for apps with nested subcommands
GLI stands for “Git-Like Interface.” That exactly describes what it builds. If you have commands that need deeper nesting—like myapp config set key value—GLI gives you a structure for that. It also generates a skeleton when you run gli init.
I used GLI to build a note-taking tool with categories:
require 'gli'
include GLI::App
program_desc 'A simple note taker'
flag [:category, :c], default_value: 'default'
command :add do |c|
c.desc 'Add a new note'
c.arg_name 'TITLE'
c.action do |global_options, options, args|
title = args.join(' ')
cat = global_options[:category]
File.open("#{cat}_notes.txt", 'a') { |f| f.puts "#{Time.now}: #{title}" }
puts "Note added to #{cat}."
end
end
command :list do |c|
c.desc 'List all notes'
c.action do |global_options, options, args|
cat = global_options[:category]
if File.exist?("#{cat}_notes.txt")
puts File.read("#{cat}_notes.txt")
else
puts "No notes in #{cat}."
end
end
end
exit run(ARGV)
GLI’s flag and switch methods are straightforward. The run method returns an exit status. I like that you can define a pre block that runs before every command—handy for checking config files or auth tokens. For example:
pre do |global, command, options, args|
unless File.exist?('config.yml')
puts "Config missing. Run 'setup' first."
exit 1
end
end
GLI has a steeper learning curve because of its DSL. But once you internalize that command blocks are just method definitions, it clicks.
Slop: the minimalist’s friend
Sometimes you don’t need a full framework. You just need to parse flags without ceremony. That’s Slop. It’s a single file, no dependencies, and you can wrap your entire script in a few lines.
I wrote a quick file renamer with Slop:
require 'slop'
opts = Slop.parse do |o|
o.banner = "Usage: rename.rb [options]"
o.string '-p', '--pattern', 'Search pattern (regex)'
o.string '-r', '--replacement', 'Replacement string'
o.boolean '-d', '--dry-run', 'Show what would change'
o.on '--help' do
puts o
exit
end
end
Dir.glob('*').each do |file|
new_name = file.gsub(Regexp.new(opts[:pattern]), opts[:replacement])
next if new_name == file
if opts.dry_run?
puts "Would rename #{file} to #{new_name}"
else
File.rename(file, new_name)
puts "Renamed #{file} to #{new_name}"
end
end
Slop’s parse returns an object where you access values with opts[:pattern] or opts.pattern. It supports automatic type casting—opts.integer '-n' returns a number. I love how Slop doesn’t force a structure. You can embed it inside a loop or a class.
The downside? Slop doesn’t generate help text as neatly as Commander. And it has no built-in support for subcommands. But for one-off scripts that you want to share with coworkers, it’s perfect.
HighLine: asking questions with style
Command-line interactions shouldn’t be ugly. HighLine makes it easy to ask for input, display lists, and colorize output. It’s not a parser—it’s a user interaction gem. I pair it with any of the above parsers.
Here’s a simple contact saver using HighLine:
require 'highline/import'
require 'json'
contacts = []
loop do
name = ask("Name? ") { |q| q.validate = /\A\w+\z/ }
phone = ask("Phone? ") { |q| q.validate = /\A\d{10}\z/ }
contacts << { name: name, phone: phone }
break unless agree("Add another? (y/n) ")
end
File.write('contacts.json', JSON.pretty_generate(contacts))
puts "Saved #{contacts.length} contacts."
ask can take a block where you set validation rules, default values, and even completions. There’s also choose for menus:
choice = choose("Select an action:", "View", "Edit", "Delete")
HighLine handles ANSI colors with say("<%= color('Success', GREEN) %>"). I use it to highlight errors in red. The gem also reads passwords without echoing.
One trick I learned: use HighLine.new to create an instance instead of importing everything into global namespace. That keeps your code clean:
cli = HighLine.new
name = cli.ask("Name?")
HighLine doesn’t parse command-line arguments—you still need OptionParser for that. But for interactive prompts during a command, it’s unmatched.
TTY::Prompt: modern interactive arrays
The TTY toolkit is a collection of gems. The one I use most is tty-prompt. It gives you beautiful, interactive prompts with keyboard navigation. Think of it as HighLine’s younger, more modern sibling.
I built a configuration wizard with TTY::Prompt:
require 'tty-prompt'
require 'yaml'
prompt = TTY::Prompt.new
project_type = prompt.select("Choose project type?", %w(gem rails script))
language = prompt.select("Primary language?", %w(ruby javascript python), default: 1)
name = prompt.ask("Project name?", required: true) { |q| q.validate(/\A[a-z_]+\z/, "Use snake_case") }
use_docker = prompt.yes?("Add Docker support?")
config = {
type: project_type,
lang: language,
name: name,
docker: use_docker
}
File.write('project_config.yml', config.to_yaml)
puts "Config saved."
The select menu lets you move with arrow keys. multi_select allows picking multiple options. ask has validators, masks for passwords, and autocomplete. The gem also includes slider, keypress, and collect for multi-step forms.
I combine TTY::Prompt with Thor. Inside a Thor task, I call TTY::Prompt.new.ask(...) to get interactive input. The visual feedback—like checking items—makes the tool feel professional.
One thing to note: TTY::Prompt depends on pastel and tty-cursor for colors and cursor movement. It works on Windows too, but you might need the win32console gem on older Ruby versions.
Choosing the right gem for the job
I don’t use the same gem for every project. My rule of thumb:
If my tool has only three flags and no subcommands, I use OptionParser. It’s built-in, zero dependencies, and my code remains readable.
If I need a set of tasks that share options and maybe generate files, I reach for Thor. It’s the Swiss Army knife.
If I want a beautiful help page without much code, Commander gets the job done in half the lines.
If my command structure is deeply nested, like a git clone, I use GLI. Its pre and post hooks let me centralize validation.
If I’m writing a throwaway script for a team, Slop keeps it small. I don’t want to burden them with a list of gems.
If I need to ask users for input during execution, HighLine gives me validation and colors with minimal setup.
If I want a modern, interactive wizard, TTY::Prompt is the way. It’s my go-to for installation scripts or setup tools.
You can mix them. For example, use Thor for the outer structure and TTY::Prompt for interactive prompts inside each task. I’ve done that many times. The gems coexist because they don’t step on each other’s toes—one handles parsing, the other handles I/O.
My personal workflow
I often start a new CLI with a single file that uses OptionParser. Then I realize I need subcommands, so I refactor to Thor. Once I add interactive input, I bring in TTY::Prompt. I keep the code organized into a lib folder with a main runner class.
Here’s a template I use:
# bin/myapp
#!/usr/bin/env ruby
require_relative '../lib/myapp/cli'
MyApp::CLI.new.run
Inside lib/myapp/cli.rb:
require 'thor'
require 'tty-prompt'
require_relative 'commands'
module MyApp
class CLI < Thor
desc "setup", "Run setup wizard"
def setup
Commands::Setup.new.run
end
desc "status", "Show current status"
method_option :json, type: :boolean, desc: "Output as JSON"
def status
Commands::Status.new(options).run
end
desc "version", "Show version"
def version
puts "MyApp #{MyApp::VERSION}"
end
end
end
Then Commands::Setup uses TTY::Prompt, and Commands::Status uses the options hash. This separation keeps each file under 50 lines.
Beyond the seven
There are other gems I’ve tried: Slop by Lee Jarvis, Methadone (which wraps OptionParser with logging), Commander by TJ Holowaychuk (now maintained by others), and Ruby's standard library RDoc for documentation. I also use Pastel for colors directly when I don’t need a full prompt library.
But the seven I covered here—OptionParser, Thor, Commander, GLI, Slop, HighLine, and TTY::Prompt—cover 99% of CLI scenarios. You can build anything from a file converter to a cloud orchestrator.
Final thoughts
Building a command-line application in Ruby doesn’t have to be complicated. Start small, with one gem. Add another when you need it. Write code that other people can run without reading a manual. That’s the whole point of a CLI—it’s a tool that does one thing well.
I still go back to my first awful script from time to time. It had no help, no flags, and if you typed the wrong argument, it just crashed. Now I look at my projects and see clean YAML configurations, colorful prompts, and error messages that actually help. The gems made that possible. But more than that, understanding how they work—deep down, they all parse strings and call methods—gave me the confidence to write tools that my teammates actually use.
You can do it too. Pick one gem today. Write a script that prints “Hello, World” with an option for different languages. Then add a subcommand. Then make it interactive. Before you know it, you’ll have a CLI that feels like it was built by a professional.
I promise, it’s easier than it looks. And the feeling of typing myapp do-something --fast and seeing it work is worth every line of code.