cyberangles guide

Building Command-Line Tools with Ruby: A Comprehensive Guide

Ruby’s design philosophy—*"optimize for developer happiness"*—makes it ideal for CLI development. Here’s why: - **Readable Syntax**: Ruby’s English-like code reduces cognitive load, making CLI logic easy to write and maintain. - **Rich Standard Library**: Built-in libraries like `OptionParser` (for argument parsing), `File` (file handling), and `JSON` (data serialization) eliminate the need for external dependencies for basic tools. - **Vibrant Gem Ecosystem**: Gems like `thor` (subcommand support), `colorize` (colored output), and `terminal-table` (formatted tables) extend Ruby’s capabilities for advanced CLIs. - **Cross-Platform Compatibility**: Ruby runs on macOS, Linux, and Windows, ensuring your CLI works everywhere.

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

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.

  1. Create a new file hello.rb:

    #!/usr/bin/env ruby  
    # hello.rb  
    
    puts "Hello, World!"  
  2. Shebang Line: The first line #!/usr/bin/env ruby tells the system to run the script with Ruby.

  3. Make the script executable:

    chmod +x hello.rb  
  4. 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 colorize  
    require 'colorize'  
    puts "Success!".green  
    puts "Warning!".yellow  
    puts "Error!".red  
  • terminal-table: Create formatted tables.

    gem install terminal-table  
    require '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-progressbar  
    require '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

  1. Create a project directory:

    mkdir ruby-todo-cli && cd ruby-todo-cli  
  2. 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.

  1. Create a gemspec file (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  
  2. Link the script to the gem’s bin directory:

    mkdir -p bin  
    ln -s ../todo.rb bin/todo  
  3. Build and install the gem locally:

    gem build todo_cli.gemspec  
    gem install todo_cli-0.1.0.gem  
  4. 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.

  1. Install Thor:

    gem install thor  
  2. 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 --help option and descriptive error messages.
    • Use colors sparingly (ensure readability for colorblind users).
    • Add a --version flag.
  • 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

Happy coding, and may your CLIs be user-friendly and robust! 🛠️