Thor

A popular Ruby library for building command-line tools. Used by Rails, designed to be easy to use, test, and extend.

rubyclicommand-linegenerator

GitHub Overview

rails/thor

Thor is a toolkit for building powerful command-line interfaces.

Stars5,179
Watchers69
Forks551
Created:May 7, 2008
Language:Ruby
License:MIT License

Topics

None

Star History

rails/thor Star History
Data as of: 7/25/2025, 02:06 AM

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 applications
  • rails console - Starting the Rails console
  • rails server - Starting the development server
  • rails routes - Displaying route listings
  • rails 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 and at_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