Thor
A popular Ruby library for building command-line tools. Used by Rails, designed to be easy to use, test, and extend.
GitHub Overview
rails/thor
Thor is a toolkit for building powerful command-line interfaces.
Topics
Star History
Framework
Thor
Overview
Thor is a toolkit for building powerful command-line interfaces in Ruby. It's officially used by the Ruby on Rails project and provides self-documentation, option parsing, and command execution capabilities. Commands can be defined using Rake-like syntax, with concise handling of arguments and options. Supporting Ruby 2.6.0 and later, Thor is a proven library adopted by many prominent Ruby projects including Bundler, Vagrant, and Rails itself.
Details
Thor is a simple and efficient tool that removes the pain of parsing command line options, writing "USAGE:" banners, and handling command execution. It can also be used as an alternative to the Rake build tool, particularly excelling in code generation and task automation.
Adoption in Ruby on Rails
Thor has been used for code generation in Rails v3 and later, with commands like the following implemented using Thor:
rails new
- Creating new Rails applicationsrails console
- Starting the Rails consolerails server
- Starting the development serverrails routes
- Displaying route listingsrails generate
- Running generators
Key Features
- Automatic Documentation Generation: Automatically generates help messages from command and option descriptions
- Method-based Command Definition: Public methods in Ruby classes automatically become commands
- Option Management: Type-safe option processing (string, hash, array, numeric, boolean)
- Exclusive Options: Advanced option control with
exclusive
andat_least_one
- Command Aliases: Command aliasing with the
map
method - Default Commands: Implicit command execution with
default_command
- Subcommands: Hierarchical command structure with the
register
method - Thor::Group: Special class for sequential execution tasks and generators
- Thor::Actions: Helper methods for file operations and user interaction
Latest Updates (2024)
- Ruby 2.6.0+ Support: Compatible with the latest Ruby versions
- Continuous Maintenance: Ongoing bug fixes and feature improvements
- Ecosystem Stability: Continued stable development as a core tool in the Rails community
Thor vs Rake
Thor differs from Rake in the following ways:
- CLI-focused: Optimized for CLI application development
- Argument Processing: Natural handling of positional arguments and options
- Help Generation: Automatic documentation generation
- Type Safety: Type checking functionality for options
- User Interaction: Built-in interactive features
Pros and Cons
Pros
- Rails Integration: Fully integrated as an official Rails tool
- Proven Track Record: Used by major projects like Bundler, Vagrant, and Rails
- Intuitive API: Natural Ruby-like syntax for defining commands
- Automatic Documentation: Automatic help generation with the
desc
method - Type-safe Options: Safe option processing with type specification
- File Operation Support: Rich file operation helpers with Thor::Actions
- User Interaction: Built-in interaction methods like
ask
,yes?
,no?
- Testing Support: Easy testing of CLI applications
- Ruby Standard: De facto standard in the Ruby ecosystem
Cons
- Ruby Dependency: Cannot be used outside of Ruby projects
- Learning Curve: Time required to understand all features due to its rich functionality
- Design Philosophy: Designed for system tools, application user input is discouraged
- Performance: Ruby startup time may impact small commands
Key Links
Code Examples
Basic Command Definition
#!/usr/bin/env ruby
require 'thor'
class MyCLI < Thor
desc "hello NAME", "Say hello to NAME"
def hello(name)
puts "Hello, #{name}!"
end
desc "goodbye NAME", "Say goodbye to NAME"
method_option :polite, type: :boolean, default: false, aliases: "-p", desc: "Polite greeting"
def goodbye(name)
if options[:polite]
puts "Goodbye, #{name}"
else
puts "See ya, #{name}!"
end
end
end
MyCLI.start(ARGV) if __FILE__ == $0
Detailed Options and Arguments Example
#!/usr/bin/env ruby
require 'thor'
class FileManager < Thor
desc "process FILES", "Process multiple files"
method_option :format, type: :string, default: "txt", aliases: "-f", desc: "Output format"
method_option :verbose, type: :boolean, default: false, aliases: "-v", desc: "Verbose output"
method_option :exclude, type: :array, default: [], aliases: "-e", desc: "Exclude patterns"
method_option :config, type: :hash, default: {}, aliases: "-c", desc: "Configuration options"
method_option :count, type: :numeric, default: 1, aliases: "-n", desc: "Processing count"
def process(*files)
puts "Output format: #{options[:format]}"
puts "Verbose mode: #{options[:verbose] ? 'enabled' : 'disabled'}"
puts "Exclude patterns: #{options[:exclude].join(', ')}" unless options[:exclude].empty?
puts "Configuration: #{options[:config]}" unless options[:config].empty?
puts "Processing count: #{options[:count]}"
files.each do |file|
puts "Processing: #{file}"
puts " Detailed info..." if options[:verbose]
end
end
end
FileManager.start(ARGV) if __FILE__ == $0
Subcommands Example
#!/usr/bin/env ruby
require 'thor'
# User management subcommand
class UserCLI < Thor
desc "create NAME EMAIL", "Create a new user"
method_option :admin, type: :boolean, default: false, desc: "Admin privileges"
def create(name, email)
puts "Creating user..."
puts " Name: #{name}"
puts " Email: #{email}"
puts " Role: #{options[:admin] ? 'Admin' : 'Regular User'}"
end
desc "delete USER_ID", "Delete a user"
method_option :force, type: :boolean, default: false, aliases: "-f", desc: "Delete without confirmation"
def delete(user_id)
unless options[:force]
exit unless yes?("Delete user #{user_id}? [y/N]")
end
puts "User #{user_id} deleted"
end
desc "list", "Display user list"
method_option :format, type: :string, default: "table", desc: "Display format (table/json)"
def list
users = [
{ id: 1, name: "John Doe", email: "[email protected]" },
{ id: 2, name: "Jane Smith", email: "[email protected]" }
]
case options[:format]
when "json"
require 'json'
puts JSON.pretty_generate(users)
else
puts "ID\tName\t\tEmail"
puts "-" * 40
users.each do |user|
puts "#{user[:id]}\t#{user[:name]}\t#{user[:email]}"
end
end
end
end
# Project management subcommand
class ProjectCLI < Thor
desc "init NAME", "Initialize a new project"
method_option :template, type: :string, default: "basic", aliases: "-t", desc: "Project template"
def init(name)
puts "Initializing project '#{name}'..."
puts "Template: #{options[:template]}"
empty_directory(name)
create_file("#{name}/README.md", "# #{name}\n\nA new project.")
create_file("#{name}/Gemfile", "source 'https://rubygems.org'\n\n# gems here")
end
desc "build", "Build the project"
method_option :environment, type: :string, default: "development", aliases: "-e", desc: "Environment"
def build
puts "Building in #{options[:environment]} environment..."
puts "Build complete"
end
end
# Main CLI
class MainCLI < Thor
desc "user SUBCOMMAND ...ARGS", "User management"
subcommand "user", UserCLI
desc "project SUBCOMMAND ...ARGS", "Project management"
subcommand "project", ProjectCLI
desc "version", "Show version information"
def version
puts "MyApp v1.0.0"
end
# Set default command
default_command :help
end
MainCLI.start(ARGV) if __FILE__ == $0
Thor::Group Generator Example
#!/usr/bin/env ruby
require 'thor'
class AppGenerator < Thor::Group
include Thor::Actions
# Generator description
desc "Generate application scaffold"
# Argument definition
argument :name, type: :string, desc: "Application name"
# Option definitions
class_option :database, type: :string, default: "sqlite", desc: "Database type"
class_option :testing, type: :string, default: "rspec", desc: "Testing framework"
class_option :skip_git, type: :boolean, default: false, desc: "Skip Git initialization"
# Template file source path
def self.source_root
File.dirname(__FILE__) + "/templates"
end
# The following methods are executed sequentially
def create_app_directory
say "Creating application directory...", :green
empty_directory(name)
end
def create_config_files
say "Creating configuration files...", :green
template("config.rb.tt", "#{name}/config/application.rb")
template("database.yml.tt", "#{name}/config/database.yml")
end
def create_app_files
say "Creating application files...", :green
create_file "#{name}/app/models/.keep"
create_file "#{name}/app/controllers/.keep"
create_file "#{name}/app/views/.keep"
end
def create_test_files
say "Creating test files...", :green
case options[:testing]
when "rspec"
template("spec_helper.rb.tt", "#{name}/spec/spec_helper.rb")
create_file "#{name}/spec/models/.keep"
when "minitest"
create_file "#{name}/test/test_helper.rb"
create_file "#{name}/test/models/.keep"
end
end
def create_gemfile
say "Creating Gemfile...", :green
template("Gemfile.tt", "#{name}/Gemfile")
end
def init_git
return if options[:skip_git]
say "Initializing Git repository...", :green
inside(name) do
run("git init")
create_file(".gitignore", "/tmp\n/log/*.log\n")
run("git add .")
run("git commit -m 'Initial commit'")
end
end
def show_readme
say "\n" + "="*50, :green
say "Application '#{name}' created successfully!", :green
say "="*50, :green
say ""
say "Next steps:"
say " cd #{name}"
say " bundle install"
say " # Start developing"
say ""
end
end
# Usage: ruby generator.rb myapp --database=postgresql --testing=rspec
AppGenerator.start(ARGV) if __FILE__ == $0
Thor::Actions File Operations
#!/usr/bin/env ruby
require 'thor'
class FileOperations < Thor
include Thor::Actions
desc "setup PROJECT_NAME", "Set up a project"
method_option :force, type: :boolean, default: false, aliases: "-f", desc: "Overwrite existing files"
method_option :pretend, type: :boolean, default: false, aliases: "-p", desc: "Don't actually execute (dry run)"
def setup(project_name)
self.destination_root = project_name
# Create directories
empty_directory("src")
empty_directory("tests")
empty_directory("docs")
# Create files
create_file("README.md", "# #{project_name}\n\nA new project.")
create_file("src/main.rb", "#!/usr/bin/env ruby\n\nputs 'Hello, #{project_name}!'")
# Create files from templates
template("config.rb.erb", "config/#{project_name}.rb", {
project_name: project_name,
created_at: Time.now
})
# Copy files
copy_file("templates/gitignore", ".gitignore")
# Operations inside directory
inside("src") do
create_file("utils.rb", "# Utility classes")
end
# Execute commands
run("chmod +x src/main.rb")
# Ask user
if yes?("Initialize Git repository?")
run("git init")
run("git add .")
run("git commit -m 'Initial commit'")
end
# Show results
say("Project #{project_name} setup complete!", :green)
end
desc "interactive", "Interactive setup"
def interactive
# Various question methods
name = ask("Enter project name:")
database = ask("Select database:", limited_to: %w[sqlite postgresql mysql])
use_git = yes?("Use Git?")
if use_git
git_remote = ask("Enter remote repository URL (optional):")
end
# Confirmation
say("\nConfiguration:", :blue)
say(" Project name: #{name}")
say(" Database: #{database}")
say(" Use Git: #{use_git ? 'Yes' : 'No'}")
say(" Remote URL: #{git_remote}") if git_remote && !git_remote.empty?
if yes?("\nCreate project with these settings?")
say("Creating project...", :green)
# Setup process...
say("Done!", :green)
else
say("Cancelled", :red)
end
end
private
def self.source_root
File.dirname(__FILE__) + "/templates"
end
end
FileOperations.start(ARGV) if __FILE__ == $0
Exclusive and Required Options
#!/usr/bin/env ruby
require 'thor'
class AdvancedOptions < Thor
desc "deploy", "Deploy the application"
# Exclusive options (only one can be specified)
method_option :staging, type: :boolean, desc: "Deploy to staging"
method_option :production, type: :boolean, desc: "Deploy to production"
# At least one required option
method_option :version, type: :string, desc: "Version to deploy"
method_option :branch, type: :string, desc: "Branch to deploy"
# Other options
method_option :force, type: :boolean, default: false, desc: "Force deployment"
method_option :dry_run, type: :boolean, default: false, desc: "Dry run"
def deploy
# Check exclusive options
if options[:staging] && options[:production]
say("Error: --staging and --production cannot be specified together", :red)
exit(1)
end
unless options[:staging] || options[:production]
say("Error: Either --staging or --production must be specified", :red)
exit(1)
end
# Check at least one required option
unless options[:version] || options[:branch]
say("Error: Either --version or --branch must be specified", :red)
exit(1)
end
# Execute deployment
env = options[:staging] ? "staging" : "production"
target = options[:version] || options[:branch]
say("Deploying #{target} to #{env} environment...", :green)
if options[:dry_run]
say("(Dry run mode - not actually deployed)", :yellow)
end
if options[:force]
say("Force deployment enabled", :yellow)
end
say("Deployment complete!", :green) unless options[:dry_run]
end
end
AdvancedOptions.start(ARGV) if __FILE__ == $0
Error Handling and Testing
#!/usr/bin/env ruby
require 'thor'
class MyTool < Thor
desc "risky_operation FILE", "Perform a risky operation"
method_option :backup, type: :boolean, default: true, desc: "Create backup"
def risky_operation(file)
begin
unless File.exist?(file)
say("Error: File '#{file}' not found", :red)
exit(1)
end
if options[:backup]
backup_file = "#{file}.backup.#{Time.now.to_i}"
FileUtils.cp(file, backup_file)
say("Created backup: #{backup_file}", :blue)
end
# Some operation...
say("Operation complete", :green)
rescue => e
say("Error occurred: #{e.message}", :red)
exit(1)
end
end
# Private methods don't become commands
private
def validate_file(file)
File.exist?(file) && File.readable?(file)
end
end
# Test helper
if $0 == __FILE__
if ARGV.include?("--test")
require 'minitest/autorun'
class MyToolTest < Minitest::Test
def setup
@cli = MyTool.new
end
def test_help_output
output = capture_output { MyTool.start(["help"]) }
assert_includes output, "risky_operation"
end
private
def capture_output
old_stdout = $stdout
$stdout = StringIO.new
yield
$stdout.string
ensure
$stdout = old_stdout
end
end
else
MyTool.start(ARGV)
end
end