Command-line tools (CLIs) are the workhorses of developer workflows, automating repetitive tasks, simplifying complex operations, and integrating seamlessly with scripts and pipelines. Ruby, with its elegant syntax, rich standard library, and vibrant gem ecosystem, is an excellent choice for building robust, user-friendly CLIs. In this guide, we’ll walk through creating CLI tools in Ruby, from simple scripts to full-featured applications, with practical examples and best practices.
Table of Contents
- Introduction: Why Ruby for CLIs?
- Getting Started: Your First Ruby CLI Tool
- Parsing Command-Line Arguments
- User Input & Output
- Error Handling & Validation
- Building a Real-World Example: Todo List CLI
- Packaging & Distributing Your CLI Tool
- Advanced Topics
- Best Practices for Ruby CLIs
- References
Getting Started: Your First Ruby CLI Tool
Setting Up Your Environment
First, ensure Ruby is installed. Check with:
ruby -v
# Output: ruby 3.2.2 (2023-03-30 revision e51014f9c0) [x86_64-linux]
If not installed, use rbenv or RVM for version management, or download from the official Ruby site.
Hello World: A Minimal CLI Tool
Let’s create a simple “Hello World” tool to understand the basics.
-
Create a new file
hello.rb:#!/usr/bin/env ruby # hello.rb puts "Hello, World!" -
Shebang Line: The first line
#!/usr/bin/env rubytells the system to run the script with Ruby. -
Make the script executable:
chmod +x hello.rb -
Run it:
./hello.rb # Output: Hello, World!
That’s it! You’ve built your first CLI tool. Let’s add functionality.
Parsing Command-Line Arguments
Most CLIs accept arguments (e.g., git commit -m "Fix bug"). Ruby provides two primary ways to handle arguments: ARGV (simple) and OptionParser (advanced).
Basic Argument Handling with ARGV
ARGV is an array containing command-line arguments passed to the script. For example:
# greeting.rb
#!/usr/bin/env ruby
name = ARGV[0] || "World"
puts "Hello, #{name}!"
Run it with:
./greeting.rb Alice
# Output: Hello, Alice!
./greeting.rb
# Output: Hello, World!
ARGV works for simple cases but lacks features like flag parsing (e.g., --name), type validation, or help messages. For that, use OptionParser.
Advanced Parsing with OptionParser
OptionParser (part of Ruby’s standard library) simplifies parsing flags, options, and arguments. Let’s build a tool that accepts a name, custom greeting, and verbose mode.
#!/usr/bin/env ruby
# advanced_greeting.rb
require 'optparse'
options = {
name: "World",
greeting: "Hello",
verbose: false
}
# Define options
parser = OptionParser.new do |opts|
opts.banner = "Usage: advanced_greeting.rb [options]"
opts.on("-n", "--name NAME", "Your name (default: World)") do |name|
options[:name] = name
end
opts.on("-g", "--greeting GREETING", "Custom greeting (default: Hello)") do |greeting|
options[:greeting] = greeting
end
opts.on("-v", "--[no-]verbose", "Run verbosely") do |v|
options[:verbose] = v
end
opts.on_tail("-h", "--help", "Show this message") do
puts opts
exit
end
end
# Parse arguments
begin
parser.parse!(ARGV)
rescue OptionParser::InvalidOption => e
puts e.message
puts parser
exit 1
end
# Generate output
message = "#{options[:greeting]}, #{options[:name]}!"
puts message
# Verbose mode: print extra details
if options[:verbose]
puts "\n[Verbose] Options used:"
options.each { |key, value| puts " #{key}: #{value}" }
end
Test it:
# Basic usage
./advanced_greeting.rb
# Output: Hello, World!
# Custom name and greeting
./advanced_greeting.rb -n Alice -g Hi
# Output: Hi, Alice!
# Verbose mode
./advanced_greeting.rb --name Bob --verbose
# Output:
# Hello, Bob!
# [Verbose] Options used:
# name: Bob
# greeting: Hello
# verbose: true
# Help message
./advanced_greeting.rb -h
# Output: Usage: advanced_greeting.rb [options]
# -n, --name NAME Your name (default: World)
# -g, --greeting GREETING Custom greeting (default: Hello)
# -v, --[no-]verbose Run verbosely
# -h, --help Show this message
OptionParser automatically handles errors (e.g., unknown flags) and generates help text—critical for user-friendly CLIs.
User Input & Output
CLIs often interact with users via input (e.g., prompts) and output (e.g., messages, tables).
Reading Input from Users
Use gets.chomp to read input from stdin. For sensitive input (e.g., passwords), use IO.console.getpass (hides input).
#!/usr/bin/env ruby
# user_input.rb
puts "What's your name?"
name = gets.chomp
puts "Hello, #{name}!"
# Sensitive input (e.g., password)
puts "Enter password:"
password = IO.console.getpass
puts "Password length: #{password.length} characters"
Formatting Output (Colors, Tables, Progress Bars)
Enhance user experience with styled output. Use these gems:
-
colorize: Add colors to text.gem install colorizerequire 'colorize' puts "Success!".green puts "Warning!".yellow puts "Error!".red -
terminal-table: Create formatted tables.gem install terminal-tablerequire 'terminal-table' table = Terminal::Table.new do |t| t.headings = ['Name', 'Age'] t.add_row ['Alice', 30] t.add_row ['Bob', 25] end puts table # Output: # +-------+-----+ # | Name | Age | # +-------+-----+ # | Alice | 30 | # | Bob | 25 | # +-------+-----+ -
tty-progressbar: Show progress bars for long-running tasks.gem install tty-progressbarrequire 'tty-progressbar' bar = TTY::ProgressBar.new("Downloading [:bar]", total: 100) 100.times { bar.advance; sleep 0.05 }
Error Handling & Validation
Robust CLIs validate inputs and handle errors gracefully. Use begin/rescue blocks and custom checks.
Example: A tool that reads a file and requires a valid path:
#!/usr/bin/env ruby
# file_reader.rb
require 'optparse'
options = { file: nil }
parser = OptionParser.new do |opts|
opts.on("-f", "--file FILE", "Path to file") { |f| options[:file] = f }
opts.on_tail("-h", "--help", "Show help") { puts opts; exit }
end
begin
parser.parse!(ARGV)
rescue OptionParser::InvalidOption => e
puts "Error: #{e.message}".red
exit 1
end
# Validate file exists
unless options[:file]
puts "Error: --file is required".red
exit 1
end
unless File.exist?(options[:file])
puts "Error: File '#{options[:file]}' not found".red
exit 1
end
# Read and print file content
begin
content = File.read(options[:file])
puts "File content:\n#{content}"
rescue Errno::EACCES
puts "Error: Permission denied to read '#{options[:file]}'".red
exit 1
end
Building a Real-World Example: Todo List CLI
Let’s build a practical todo list CLI with features to add, list, delete, and clear tasks. We’ll use JSON for storage and OptionParser for arguments.
Project Setup
-
Create a project directory:
mkdir ruby-todo-cli && cd ruby-todo-cli -
Create the main script
todo.rb:#!/usr/bin/env ruby # todo.rb require 'optparse' require 'json' require 'colorize' # Path to store tasks (JSON file in home directory) TODO_FILE = File.expand_path("~/.todo.json")
Core Features Implementation
1. Load/Save Tasks
Add methods to load existing tasks from ~/.todo.json (or create an empty list) and save tasks back.
# Load tasks from JSON file
def load_tasks
return [] unless File.exist?(TODO_FILE)
JSON.parse(File.read(TODO_FILE))
rescue JSON::ParserError
puts "Warning: Corrupt todo file. Starting fresh.".yellow
[]
end
# Save tasks to JSON file
def save_tasks(tasks)
File.write(TODO_FILE, JSON.pretty_generate(tasks))
end
2. Add a Task
Add an --add option to create a new task.
options = { add: nil, list: false, delete: nil, clear: false }
parser = OptionParser.new do |opts|
opts.banner = "Usage: todo.rb [options]"
opts.on("-a", "--add TASK", "Add a new task") { |t| options[:add] = t }
opts.on("-l", "--list", "List all tasks") { options[:list] = true }
opts.on("-d", "--delete INDEX", Integer, "Delete task by index (1-based)") { |i| options[:delete] = i }
opts.on("-c", "--clear", "Clear all tasks") { options[:clear] = true }
opts.on_tail("-h", "--help", "Show this message") { puts opts; exit }
end
parser.parse!
3. List Tasks
Format and display tasks with indices.
def list_tasks(tasks)
if tasks.empty?
puts "No tasks. Add one with --add 'Task'!".green
return
end
puts "Your Tasks:".blue
tasks.each_with_index do |task, index|
puts "#{index + 1}. #{task}" # 1-based index
end
end
4. Delete a Task
Allow deleting tasks by their 1-based index.
def delete_task(tasks, index)
if index < 1 || index > tasks.size
puts "Error: Invalid index. Use --list to see valid indices.".red
exit 1
end
deleted = tasks.delete_at(index - 1) # Convert to 0-based
puts "Deleted task: '#{deleted}'".green
end
5. Clear All Tasks
Add a --clear option to wipe all tasks (with confirmation).
def clear_tasks(tasks)
if tasks.empty?
puts "No tasks to clear!".yellow
return
end
print "Are you sure? (y/N) ".yellow
response = gets.chomp.downcase
unless response == 'y'
puts "Clear canceled.".green
return
end
tasks.clear
puts "All tasks cleared!".green
end
6. Main Logic
Tie it all together:
# Main execution
tasks = load_tasks
case
when options[:add]
tasks << options[:add]
save_tasks(tasks)
puts "Added task: '#{options[:add]}'".green
when options[:list]
list_tasks(tasks)
when options[:delete]
delete_task(tasks, options[:delete])
save_tasks(tasks)
when options[:clear]
clear_tasks(tasks)
save_tasks(tasks)
else
puts "Error: No command provided. Use --help for options.".red
exit 1
end
Testing the Todo CLI
Make the script executable and test:
chmod +x todo.rb
# Add a task
./todo.rb --add "Buy groceries"
# Output: Added task: 'Buy groceries'
# Add another task
./todo.rb -a "Finish blog post"
# List tasks
./todo.rb -l
# Output:
# Your Tasks:
# 1. Buy groceries
# 2. Finish blog post
# Delete task 1
./todo.rb -d 1
# Output: Deleted task: 'Buy groceries'
# Clear tasks
./todo.rb --clear
# Output: Are you sure? (y/N) y
# All tasks cleared!
Packaging & Distributing Your CLI Tool
To share your CLI, package it as a Ruby gem.
-
Create a
gemspecfile (todo_cli.gemspec):Gem::Specification.new do |spec| spec.name = "todo_cli" spec.version = "0.1.0" spec.authors = ["Your Name"] spec.email = ["[email protected]"] spec.summary = "A simple todo list CLI" spec.homepage = "https://github.com/yourusername/todo-cli" spec.license = "MIT" spec.files = ["todo.rb"] spec.executables = ["todo"] # Name of the executable spec.required_ruby_version = ">= 3.0.0" end -
Link the script to the gem’s
bindirectory:mkdir -p bin ln -s ../todo.rb bin/todo -
Build and install the gem locally:
gem build todo_cli.gemspec gem install todo_cli-0.1.0.gem -
Now run it globally:
todo --add "Test gem" todo -l
To distribute publicly, push to RubyGems.org.
Advanced Topics
Subcommands with Thor
For CLIs with many commands (e.g., git add, git commit), use the thor gem, which simplifies subcommand management.
-
Install Thor:
gem install thor -
Example Thor-based CLI (
thor_todo.rb):#!/usr/bin/env ruby require 'thor' require 'json' class TodoCLI < Thor desc "add TASK", "Add a new task" def add(task) tasks = load_tasks tasks << task save_tasks(tasks) puts "Added: #{task}" end desc "list", "List all tasks" def list tasks = load_tasks tasks.each_with_index { |t, i| puts "#{i+1}. #{t}" } end private def load_tasks File.exist?("~/.todo.json") ? JSON.parse(File.read("~/.todo.json")) : [] end def save_tasks(tasks) File.write("~/.todo.json", JSON.pretty_generate(tasks)) end end TodoCLI.start(ARGV)
Run with:
./thor_todo.rb add "Learn Thor"
./thor_todo.rb list
Configuration Files
Let users customize your CLI with YAML/JSON config files (e.g., ~/.todo_config.yaml).
# Load config from YAML
def load_config
config_path = File.expand_path("~/.todo_config.yaml")
return {} unless File.exist?(config_path)
YAML.load_file(config_path)
end
# Example config: ~/.todo_config.yaml
# default_priority: high
Testing CLI Tools
Test CLI behavior with minitest or rspec. Use capture_io to test output:
require 'minitest/autorun'
require_relative 'todo'
class TestTodoCLI < Minitest::Test
def test_add_task
# Mock TODO_FILE to avoid overwriting real data
todo_file = "test_todo.json"
original_file = TODO_FILE
Object.const_set(:TODO_FILE, todo_file)
`./todo.rb --add "Test task"`
tasks = JSON.parse(File.read(todo_file))
assert_includes tasks, "Test task"
Object.const_set(:TODO_FILE, original_file) # Restore
File.delete(todo_file)
end
end
Best Practices for Ruby CLIs
-
User Experience:
- Provide a
--helpoption and descriptive error messages. - Use colors sparingly (ensure readability for colorblind users).
- Add a
--versionflag.
- Provide a
-
Maintainability:
- Split logic into methods/classes (avoid monolithic scripts).
- Document with comments and a
README.md.
-
Robustness:
- Test edge cases (empty inputs, invalid files).
- Validate all user inputs.
-
Performance:
- Avoid unnecessary dependencies.
- Lazy-load heavy libraries (e.g.,
require 'json'only when needed).
References
- Ruby OptionParser Documentation
- Thor Gem
- RubyGems Guides
- TTY Toolkit (for advanced terminal UI)
- The Art of Command Line
Happy coding, and may your CLIs be user-friendly and robust! 🛠️