CLI Tools Development in Ruby
Learn to build powerful command-line applications in Ruby using OptionParser, Thor, and other tools. Create professional CLI tools with argument parsing, help systems, subcommands, and interactive features.
Introduction to CLI Development
Ruby provides excellent tools for building command-line interfaces, from simple scripts with OptionParser to complex applications with Thor or custom frameworks.
Basic CLI with OptionParser
#!/usr/bin/env ruby
require 'optparse'
require 'ostruct'
# Simple CLI tool with OptionParser
class MyCLI
def self.parse(args)
options = OpenStruct.new
options.verbose = false
options.output_file = nil
options.format = 'text'
opt_parser = OptionParser.new do |opts|
opts.banner = "Usage: mycli [options] files..."
opts.separator ""
opts.separator "Specific options:"
# Boolean flag
opts.on("-v", "--[no-]verbose", "Run verbosely") do |v|
options.verbose = v
end
# Argument with value
opts.on("-o", "--output FILE", "Write output to FILE") do |file|
options.output_file = file
end
# Choice from list
opts.on("-f", "--format FORMAT",
%w[text json xml],
"Output format (text, json, xml)") do |format|
options.format = format
end
# Multiple values
opts.on("-e", "--exclude PATTERN",
"Exclude files matching pattern (can be used multiple times)") do |pattern|
(options.exclude ||= []) << pattern
end
opts.separator ""
opts.separator "Common options:"
# Help
opts.on_tail("-h", "--help", "Show this message") do
puts opts
exit
end
# Version
opts.on_tail("--version", "Show version") do
puts "MyCLI version 1.0.0"
exit
end
end
opt_parser.parse!(args)
options
end
def self.run(args)
options = parse(args)
puts "Processing files: #{args}" if options.verbose
puts "Output format: #{options.format}"
puts "Output file: #{options.output_file}" if options.output_file
puts "Exclude patterns: #{options.exclude}" if options.exclude
# Process remaining arguments (files)
if args.empty?
puts "No files specified"
exit 1
end
process_files(args, options)
end
def self.process_files(files, options)
files.each do |file|
next if excluded?(file, options.exclude)
puts "Processing #{file}..." if options.verbose
begin
content = File.read(file)
output = format_content(content, options.format)
if options.output_file
File.write(options.output_file, output)
else
puts output
end
rescue => e
puts "Error processing #{file}: #{e.message}"
end
end
end
def self.excluded?(file, patterns)
return false unless patterns
patterns.any? { |pattern| File.fnmatch(pattern, file) }
end
def self.format_content(content, format)
case format
when 'json'
require 'json'
{ content: content, lines: content.lines.count }.to_json
when 'xml'
"#{content} "
else
content
end
end
end
# Run if called directly
if __FILE__ == $0
MyCLI.run(ARGV)
end
Advanced OptionParser Features
Implement sophisticated argument parsing with validation, custom types, and complex option handling.
Advanced OptionParser Patterns
require 'optparse'
require 'date'
require 'uri'
class AdvancedCLI
def self.parse_options
options = {
config_file: 'config.yml',
threads: 4,
timeout: 30,
date_range: nil,
urls: [],
log_level: :info,
dry_run: false
}
OptionParser.new do |opts|
opts.banner = "Usage: advanced-cli [options]"
# Custom type conversion
opts.on("--threads COUNT", Integer, "Number of threads (1-32)") do |count|
if count < 1 || count > 32
raise OptionParser::InvalidArgument, "threads must be 1-32"
end
options[:threads] = count
end
# Custom validation
opts.on("--timeout SECONDS", Float, "Timeout in seconds") do |timeout|
raise OptionParser::InvalidArgument, "timeout must be positive" if timeout <= 0
options[:timeout] = timeout
end
# Date parsing
opts.on("--since DATE", "Process items since date (YYYY-MM-DD)") do |date_str|
begin
options[:since] = Date.parse(date_str)
rescue Date::Error
raise OptionParser::InvalidArgument, "invalid date format"
end
end
# URL validation
opts.on("--url URL", "Add URL to process") do |url_str|
begin
uri = URI.parse(url_str)
raise URI::InvalidURIError unless uri.scheme =~ /^https?$/
options[:urls] << uri
rescue URI::InvalidURIError
raise OptionParser::InvalidArgument, "invalid URL: #{url_str}"
end
end
# Symbol choices
opts.on("--log-level LEVEL",
[:debug, :info, :warn, :error],
"Log level (debug, info, warn, error)") do |level|
options[:log_level] = level
end
# File existence validation
opts.on("-c", "--config FILE", "Configuration file") do |file|
unless File.exist?(file)
raise OptionParser::InvalidArgument, "config file not found: #{file}"
end
options[:config_file] = file
end
# Custom parsing with regex
opts.on("--match PATTERN", "Regex pattern to match") do |pattern|
begin
options[:pattern] = Regexp.new(pattern)
rescue RegexpError => e
raise OptionParser::InvalidArgument, "invalid regex: #{e.message}"
end
end
# Key-value pairs
opts.on("--set KEY=VALUE", "Set configuration key") do |pair|
key, value = pair.split('=', 2)
raise OptionParser::InvalidArgument, "invalid format" unless value
(options[:config_overrides] ||= {})[key] = value
end
opts.on("--dry-run", "Show what would be done without executing") do
options[:dry_run] = true
end
opts.on_tail("-h", "--help", "Show this help") do
puts opts
exit
end
end.parse!
# Post-parsing validation
validate_options(options)
options
end
def self.validate_options(options)
# Cross-option validation
if options[:since] && options[:since] > Date.today
raise "Since date cannot be in the future"
end
if options[:urls].empty? && !options[:config_file]
raise "Either URLs or config file must be specified"
end
# Load and validate config file
if File.exist?(options[:config_file])
config = YAML.load_file(options[:config_file])
options[:config] = config
# Validate config structure
required_keys = %w[database redis]
missing_keys = required_keys - config.keys
unless missing_keys.empty?
raise "Config file missing required keys: #{missing_keys.join(', ')}"
end
end
rescue => e
puts "Configuration error: #{e.message}"
exit 1
end
def self.run
options = parse_options
puts "Starting with options: #{options.inspect}"
# Use the parsed options
setup_logging(options[:log_level])
process_urls(options[:urls], options)
rescue OptionParser::InvalidOption, OptionParser::InvalidArgument => e
puts "Error: #{e.message}"
puts "Use --help for usage information"
exit 1
end
private
def self.setup_logging(level)
puts "Setting log level to #{level}"
end
def self.process_urls(urls, options)
urls.each do |url|
if options[:dry_run]
puts "Would process: #{url}"
else
puts "Processing: #{url}"
# Actual processing logic
end
end
end
end
Thor Framework for Complex CLIs
Use Thor to build sophisticated command-line applications with subcommands, generators, and advanced features.
Building with Thor
#!/usr/bin/env ruby
require 'thor'
require 'fileutils'
require 'json'
# Thor-based CLI application
class MyAppCLI < Thor
# Global options
class_option :verbose,
type: :boolean,
aliases: '-v',
desc: 'Verbose output'
class_option :config,
type: :string,
aliases: '-c',
default: 'config.yml',
desc: 'Configuration file'
desc "init [NAME]", "Initialize a new project"
method_option :template,
type: :string,
default: 'basic',
desc: 'Project template to use'
method_option :force,
type: :boolean,
default: false,
desc: 'Overwrite existing files'
def init(name = 'my_project')
say "Initializing project: #{name}", :green
# Create project directory
if Dir.exist?(name) && !options[:force]
say "Directory #{name} already exists. Use --force to overwrite.", :red
exit 1
end
FileUtils.mkdir_p(name)
# Create project structure based on template
create_project_structure(name, options[:template])
inside(name) do
create_file 'README.md', readme_content(name)
create_file 'config.yml', config_content
create_file '.gitignore', gitignore_content
if yes?("Initialize git repository?")
run 'git init'
run 'git add .'
run 'git commit -m "Initial commit"'
end
end
say "Project #{name} created successfully!", :green
end
desc "generate TYPE NAME", "Generate files of specified type"
method_option :test,
type: :boolean,
default: true,
desc: 'Generate corresponding test file'
def generate(type, name)
case type.downcase
when 'model'
generate_model(name)
when 'controller'
generate_controller(name)
when 'service'
generate_service(name)
else
say "Unknown generator type: #{type}", :red
say "Available types: model, controller, service"
end
end
desc "build", "Build the application"
method_option :environment,
type: :string,
default: 'development',
aliases: '-e',
desc: 'Build environment'
method_option :optimize,
type: :boolean,
default: false,
desc: 'Enable optimizations'
def build
say "Building application for #{options[:environment]} environment", :blue
# Load configuration
config = load_config
# Build steps
with_spinner("Compiling assets") do
compile_assets(config, options[:environment])
end
with_spinner("Running tests") do
run_tests unless options[:skip_tests]
end
with_spinner("Creating package") do
create_package(options[:optimize])
end
say "Build completed successfully!", :green
end
desc "deploy ENVIRONMENT", "Deploy to specified environment"
method_option :version,
type: :string,
desc: 'Specific version to deploy'
method_option :dry_run,
type: :boolean,
default: false,
desc: 'Show what would be deployed without executing'
def deploy(environment)
config = load_config
unless config['environments']&.key?(environment)
say "Unknown environment: #{environment}", :red
say "Available environments: #{config['environments']&.keys&.join(', ')}"
exit 1
end
if options[:dry_run]
say "DRY RUN: Would deploy to #{environment}", :yellow
show_deployment_plan(environment, config)
else
confirm_deployment(environment) if environment == 'production'
perform_deployment(environment, config)
end
end
desc "status", "Show application status"
def status
config = load_config
table = []
table << ['Component', 'Status', 'Version']
table << :separator
# Check various components
components = check_components(config)
components.each do |component, info|
status_color = info[:healthy] ? :green : :red
table << [component, set_color(info[:status], status_color), info[:version] || 'N/A']
end
print_table(table)
end
desc "config COMMAND", "Manage configuration"
subcommand "config", ConfigCLI
private
def load_config
config_file = options[:config]
unless File.exist?(config_file)
say "Configuration file not found: #{config_file}", :red
exit 1
end
YAML.load_file(config_file)
rescue => e
say "Error loading configuration: #{e.message}", :red
exit 1
end
def create_project_structure(name, template)
case template
when 'basic'
inside(name) do
empty_directory 'lib'
empty_directory 'spec'
empty_directory 'bin'
empty_directory 'config'
end
when 'web'
inside(name) do
empty_directory 'app'
empty_directory 'app/controllers'
empty_directory 'app/models'
empty_directory 'app/views'
empty_directory 'config'
empty_directory 'public'
empty_directory 'spec'
end
end
end
def generate_model(name)
class_name = name.split('_').map(&:capitalize).join
file_name = "lib/#{name}.rb"
create_file file_name, model_template(class_name)
if options[:test]
create_file "spec/#{name}_spec.rb", model_spec_template(class_name, name)
end
say "Generated model: #{class_name}", :green
end
def generate_controller(name)
class_name = "#{name.split('_').map(&:capitalize).join}Controller"
file_name = "app/controllers/#{name}_controller.rb"
create_file file_name, controller_template(class_name)
if options[:test]
create_file "spec/controllers/#{name}_controller_spec.rb",
controller_spec_template(class_name, name)
end
say "Generated controller: #{class_name}", :green
end
def with_spinner(message)
print "#{message}... "
yield
say "✓", :green
rescue => e
say "✗", :red
say "Error: #{e.message}", :red
exit 1
end
def confirm_deployment(environment)
unless yes?("Deploy to #{environment}? This cannot be undone! (y/N)")
say "Deployment cancelled", :yellow
exit 0
end
end
def model_template(class_name)
<<~RUBY
class #{class_name}
def initialize(attributes = {})
@attributes = attributes
end
attr_reader :attributes
end
RUBY
end
def model_spec_template(class_name, file_name)
<<~RUBY
require 'spec_helper'
require_relative '../lib/#{file_name}'
RSpec.describe #{class_name} do
describe '#initialize' do
it 'sets attributes' do
instance = #{class_name}.new(name: 'Test')
expect(instance.attributes).to eq(name: 'Test')
end
end
end
RUBY
end
end
# Subcommand for configuration management
class ConfigCLI < Thor
desc "show [KEY]", "Show configuration"
def show(key = nil)
config = YAML.load_file(parent_options[:config])
if key
value = config.dig(*key.split('.'))
if value
puts YAML.dump({ key => value })
else
say "Key not found: #{key}", :red
end
else
puts YAML.dump(config)
end
end
desc "set KEY VALUE", "Set configuration value"
def set(key, value)
config_file = parent_options[:config]
config = File.exist?(config_file) ? YAML.load_file(config_file) : {}
# Navigate to nested key
keys = key.split('.')
target = keys[0..-2].reduce(config) { |h, k| h[k] ||= {} }
target[keys.last] = parse_value(value)
File.write(config_file, YAML.dump(config))
say "Set #{key} = #{value}", :green
end
desc "delete KEY", "Delete configuration key"
def delete(key)
config_file = parent_options[:config]
config = YAML.load_file(config_file)
keys = key.split('.')
target = keys[0..-2].reduce(config) { |h, k| h[k] }
if target&.key?(keys.last)
target.delete(keys.last)
File.write(config_file, YAML.dump(config))
say "Deleted #{key}", :green
else
say "Key not found: #{key}", :red
end
end
private
def parse_value(value)
case value
when 'true' then true
when 'false' then false
when /^\d+$/ then value.to_i
when /^\d+\.\d+$/ then value.to_f
else value
end
end
end
# Run the CLI
MyAppCLI.start(ARGV) if __FILE__ == $0
Interactive CLI Features
Build interactive command-line applications with user input, progress bars, menus, and rich output formatting.
Interactive Elements and UI
require 'io/console'
require 'tty-prompt'
require 'tty-progressbar'
require 'tty-table'
require 'colorize'
require 'pastel'
class InteractiveCLI
def initialize
@prompt = TTY::Prompt.new
@pastel = Pastel.new
end
def run
show_welcome
main_menu
end
private
def show_welcome
puts @pastel.bright_blue.bold("=" * 60)
puts @pastel.bright_blue.bold("🚀 Welcome to Interactive CLI Tool")
puts @pastel.bright_blue.bold("=" * 60)
puts
end
def main_menu
loop do
choice = @prompt.select("What would you like to do?") do |menu|
menu.choice "📂 File Operations", :files
menu.choice "🗄️ Database Operations", :database
menu.choice "🔧 System Utilities", :system
menu.choice "⚙️ Configuration", :config
menu.choice "📊 Reports", :reports
menu.choice "❌ Exit", :exit
end
case choice
when :files
file_operations_menu
when :database
database_operations_menu
when :system
system_utilities_menu
when :config
configuration_menu
when :reports
reports_menu
when :exit
puts @pastel.green("Goodbye! 👋")
break
end
end
end
def file_operations_menu
choice = @prompt.select("File Operations:") do |menu|
menu.choice "📁 List files", :list
menu.choice "🔍 Search files", :search
menu.choice "📋 Copy files", :copy
menu.choice "🗑️ Delete files", :delete
menu.choice "⬅️ Back to main menu", :back
end
case choice
when :list
list_files
when :search
search_files
when :copy
copy_files
when :delete
delete_files
when :back
return
end
# Return to file menu after operation
file_operations_menu unless choice == :back
end
def list_files
directory = @prompt.ask("Enter directory path:", default: ".")
unless Dir.exist?(directory)
puts @pastel.red("❌ Directory not found: #{directory}")
return
end
files = Dir.entries(directory).reject { |f| f.start_with?('.') }
if files.empty?
puts @pastel.yellow("📭 No files found in #{directory}")
return
end
# Create table with file information
table_data = files.map do |file|
path = File.join(directory, file)
stat = File.stat(path)
[
File.directory?(path) ? "📁" : "📄",
file,
human_file_size(stat.size),
stat.mtime.strftime("%Y-%m-%d %H:%M")
]
end
table = TTY::Table.new(
header: ['Type', 'Name', 'Size', 'Modified'],
rows: table_data
)
puts table.render(:unicode, padding: [0, 1])
end
def search_files
pattern = @prompt.ask("Enter search pattern (glob format):")
directory = @prompt.ask("Search in directory:", default: ".")
files = Dir.glob(File.join(directory, "**", pattern))
if files.empty?
puts @pastel.yellow("🔍 No files found matching pattern: #{pattern}")
return
end
puts @pastel.green("Found #{files.size} files:")
files.each_with_index do |file, index|
puts "#{index + 1}. #{file}"
end
if @prompt.yes?("Open a file?")
file_index = @prompt.ask("Enter file number:", convert: :int) - 1
if file_index >= 0 && file_index < files.size
system("${EDITOR:-nano} '#{files[file_index]}'")
end
end
end
def copy_files
source = @prompt.ask("Source pattern:")
destination = @prompt.ask("Destination directory:")
files = Dir.glob(source)
if files.empty?
puts @pastel.red("❌ No files found matching: #{source}")
return
end
FileUtils.mkdir_p(destination) unless Dir.exist?(destination)
# Progress bar for copying
progressbar = TTY::Progressbar.new(
"Copying [:bar] :percent :current/:total files",
total: files.size,
bar_format: :block
)
files.each do |file|
FileUtils.cp(file, destination)
progressbar.advance
sleep(0.1) # Simulate work for demo
end
puts @pastel.green("✅ Copied #{files.size} files to #{destination}")
end
def delete_files
pattern = @prompt.ask("Files to delete (pattern):")
files = Dir.glob(pattern)
if files.empty?
puts @pastel.yellow("🔍 No files found matching: #{pattern}")
return
end
puts @pastel.yellow("Files to be deleted:")
files.each { |file| puts " #{file}" }
if @prompt.yes?(@pastel.red.bold("⚠️ Are you sure you want to delete these files?"))
confirmation = @prompt.ask("Type 'DELETE' to confirm:")
if confirmation == 'DELETE'
files.each { |file| File.delete(file) }
puts @pastel.green("✅ Deleted #{files.size} files")
else
puts @pastel.yellow("🚫 Deletion cancelled")
end
end
end
def database_operations_menu
# Multi-select example
operations = @prompt.multi_select("Select database operations:") do |menu|
menu.choice "🔄 Backup database", :backup
menu.choice "📊 Run migrations", :migrate
menu.choice "🌱 Seed data", :seed
menu.choice "🔍 Query data", :query
menu.choice "📈 Generate report", :report
end
return if operations.empty?
operations.each do |op|
case op
when :backup
perform_backup
when :migrate
run_migrations
when :seed
seed_database
when :query
interactive_query
when :report
generate_db_report
end
end
end
def perform_backup
databases = ["production", "staging", "development"]
database = @prompt.select("Select database:", databases)
backup_types = {
"Full backup" => :full,
"Schema only" => :schema,
"Data only" => :data
}
backup_type = @prompt.select("Backup type:", backup_types)
puts @pastel.blue("🔄 Starting #{backup_type} backup of #{database} database...")
# Simulate backup with progress
progressbar = TTY::Progressbar.new(
"Backing up [:bar] :percent",
total: 100,
bar_format: :crate
)
100.times do
progressbar.advance
sleep(0.05)
end
filename = "backup_#{database}_#{Time.now.strftime('%Y%m%d_%H%M%S')}.sql"
puts @pastel.green("✅ Backup completed: #{filename}")
end
def interactive_query
puts @pastel.blue("Interactive Database Query")
puts @pastel.dim("Enter SQL queries (type 'exit' to quit)")
loop do
query = @prompt.ask("SQL>", value: "")
break if query.downcase == 'exit'
# Simulate query execution
puts @pastel.green("Executing: #{query}")
# Mock results
if query.downcase.include?('select')
table = TTY::Table.new(
header: ['ID', 'Name', 'Email', 'Created'],
rows: [
['1', 'John Doe', 'john@example.com', '2024-01-15'],
['2', 'Jane Smith', 'jane@example.com', '2024-01-16']
]
)
puts table.render(:unicode)
else
puts @pastel.green("Query executed successfully (2 rows affected)")
end
end
end
def configuration_menu
# Mask input for sensitive data
database_password = @prompt.mask("Database password:")
api_key = @prompt.mask("API key:")
# Slider for numeric values
max_connections = @prompt.slider("Max database connections:",
min: 1, max: 100, step: 5, default: 20)
# Enum selection
log_level = @prompt.select("Log level:") do |menu|
menu.choice "Debug", :debug
menu.choice "Info", :info
menu.choice "Warning", :warn
menu.choice "Error", :error
end
config = {
database_password: database_password,
api_key: api_key,
max_connections: max_connections,
log_level: log_level
}
puts @pastel.green("Configuration saved:")
puts config.reject { |k, v| k.to_s.include?('password') || k.to_s.include?('key') }
end
def reports_menu
report_types = {
"📊 System Status" => :system_status,
"👥 User Activity" => :user_activity,
"💾 Storage Usage" => :storage,
"🔧 Performance Metrics" => :performance
}
report_type = @prompt.select("Select report type:", report_types)
case report_type
when :system_status
generate_system_status_report
when :user_activity
generate_user_activity_report
when :storage
generate_storage_report
when :performance
generate_performance_report
end
end
def generate_system_status_report
puts @pastel.blue.bold("📊 System Status Report")
puts @pastel.dim("Generated at: #{Time.now}")
puts
# System information table
system_info = [
['CPU Usage', '45%', '🟢 Normal'],
['Memory Usage', '67%', '🟡 Warning'],
['Disk Usage', '23%', '🟢 Normal'],
['Network I/O', '1.2 MB/s', '🟢 Normal'],
['Active Processes', '342', '🟢 Normal']
]
table = TTY::Table.new(
header: ['Component', 'Value', 'Status'],
rows: system_info
)
puts table.render(:unicode, padding: [0, 1])
# Services status
puts
puts @pastel.blue.bold("Services Status:")
services = [
['Web Server', '🟢 Running', 'Port 3000'],
['Database', '🟢 Running', 'PostgreSQL 13'],
['Redis', '🟢 Running', 'Version 6.2'],
['Background Jobs', '🟡 Warning', '5 failed jobs']
]
services_table = TTY::Table.new(
header: ['Service', 'Status', 'Details'],
rows: services
)
puts services_table.render(:unicode, padding: [0, 1])
end
def human_file_size(size)
units = %w[B KB MB GB TB]
unit_index = 0
while size >= 1024 && unit_index < units.length - 1
size /= 1024.0
unit_index += 1
end
if unit_index == 0
"#{size} #{units[unit_index]}"
else
"#{size.round(1)} #{units[unit_index]}"
end
end
end
# Enhanced CLI with argument parsing
class EnhancedCLI
def self.run(args)
if args.empty?
InteractiveCLI.new.run
else
# Command-line mode
command = args.shift
case command
when 'status'
show_status
when 'backup'
perform_backup(args)
when 'deploy'
deploy_application(args)
else
puts "Unknown command: #{command}"
show_help
end
end
end
def self.show_help
puts <<~HELP
Usage: enhanced-cli [command] [options]
Commands:
status Show system status
backup [database] Backup database
deploy [env] Deploy to environment
Run without arguments for interactive mode.
HELP
end
end
# Run the application
if __FILE__ == $0
EnhancedCLI.run(ARGV)
end
CLI Testing and Packaging
Test your CLI applications thoroughly and package them for distribution as gems or executables.
Testing CLI Applications
# spec/cli_spec.rb
require 'rspec'
require 'open3'
require 'tempfile'
describe 'CLI Application' do
let(:cli_path) { File.expand_path('../bin/mycli', __dir__) }
describe 'help command' do
it 'shows help information' do
stdout, stderr, status = Open3.capture3("#{cli_path} --help")
expect(status).to be_success
expect(stdout).to include('Usage:')
expect(stdout).to include('Options:')
expect(stderr).to be_empty
end
end
describe 'version command' do
it 'shows version number' do
stdout, stderr, status = Open3.capture3("#{cli_path} --version")
expect(status).to be_success
expect(stdout).to match(/\d+\.\d+\.\d+/)
expect(stderr).to be_empty
end
end
describe 'file processing' do
let(:test_file) { Tempfile.new(['test', '.txt']) }
before do
test_file.write("Hello, World!\nSecond line\n")
test_file.close
end
after do
test_file.unlink
end
it 'processes files correctly' do
stdout, stderr, status = Open3.capture3(
"#{cli_path} --format json #{test_file.path}"
)
expect(status).to be_success
expect(stderr).to be_empty
output = JSON.parse(stdout)
expect(output['lines']).to eq(2)
expect(output['content']).to include('Hello, World!')
end
it 'handles missing files gracefully' do
stdout, stderr, status = Open3.capture3(
"#{cli_path} nonexistent_file.txt"
)
expect(status).not_to be_success
expect(stderr).to include('No such file')
end
end
describe 'option validation' do
it 'validates format option' do
stdout, stderr, status = Open3.capture3(
"#{cli_path} --format invalid_format file.txt"
)
expect(status).not_to be_success
expect(stderr).to include('invalid argument')
end
it 'requires at least one file' do
stdout, stderr, status = Open3.capture3("#{cli_path}")
expect(status).not_to be_success
expect(stdout).to include('No files specified')
end
end
describe 'integration with external tools' do
it 'handles large files efficiently' do
large_file = Tempfile.new(['large', '.txt'])
# Create a 1MB file
1000.times { large_file.write("A" * 1024) }
large_file.close
start_time = Time.now
stdout, stderr, status = Open3.capture3(
"#{cli_path} --format text #{large_file.path}"
)
end_time = Time.now
expect(status).to be_success
expect(end_time - start_time).to be < 5.0 # Should complete within 5 seconds
large_file.unlink
end
end
end
# Integration testing helper
class CLITestHelper
def self.run_cli(command, options = {})
env = options[:env] || {}
input = options[:input]
timeout = options[:timeout] || 30
Open3.popen3(env, command) do |stdin, stdout, stderr, wait_thread|
if input
stdin.write(input)
stdin.close
end
# Read output with timeout
output = { stdout: '', stderr: '', status: nil }
begin
Timeout.timeout(timeout) do
output[:stdout] = stdout.read
output[:stderr] = stderr.read
output[:status] = wait_thread.value
end
rescue Timeout::Error
Process.kill('TERM', wait_thread.pid)
output[:stderr] = 'Command timed out'
output[:status] = OpenStruct.new(success?: false, exitstatus: 124)
end
output
end
end
def self.with_temp_config(config_content)
config_file = Tempfile.new(['config', '.yml'])
config_file.write(config_content)
config_file.close
yield config_file.path
ensure
config_file&.unlink
end
def self.capture_interactive_session(commands)
# Helper for testing interactive CLIs
input = commands.join("\n") + "\n"
run_cli('bin/interactive_cli', input: input)
end
end
# Example usage in tests
describe 'Interactive CLI' do
it 'handles user interaction correctly' do
result = CLITestHelper.capture_interactive_session([
'1', # Select first menu option
'test', # Enter test data
'y', # Confirm action
'q' # Quit
])
expect(result[:status]).to be_success
expect(result[:stdout]).to include('Action completed')
end
it 'respects configuration files' do
config = YAML.dump({
'database' => { 'host' => 'localhost' },
'api' => { 'key' => 'test-key' }
})
CLITestHelper.with_temp_config(config) do |config_path|
result = CLITestHelper.run_cli(
"bin/mycli --config #{config_path} status"
)
expect(result[:status]).to be_success
expect(result[:stdout]).to include('localhost')
end
end
end
Best Practices
CLI Development Best Practices
- Follow UNIX conventions for option naming and behavior
- Provide comprehensive help with examples and clear descriptions
- Handle errors gracefully with meaningful error messages
- Support both interactive and non-interactive modes
- Validate input early and provide clear feedback
- Use progress indicators for long-running operations
- Make commands composable and scriptable
- Test thoroughly including edge cases and error conditions
💡 Pro Tips
- Use gems like
tty-prompt,tty-progressbar, andcolorizefor rich interactions - Consider creating both a gem and a standalone executable for distribution
- Use
Bundler.with_clean_envwhen executing external commands - Implement signal handling for graceful shutdown on Ctrl+C
- Add shell completion scripts for better user experience
🚀 Next Steps
Test your development tools knowledge with our comprehensive quiz covering Rake, Guard, and CLI development techniques.