rubyguides

Ruby Thor CLI: Building Professional Command-Line Tools

If you’ve ever used Bundler, Rails, or Rake, you’ve interacted with Thor—Ruby’s most popular CLI building library. Thor powers some of the most widely-used Ruby tools, and for good reason: it makes building command-line interfaces almost effortless.

In this guide, you’ll learn how to create professional CLI tools with Thor. We’ll build a real example—a deployment CLI—that demonstrates commands, options, subcommands, and interactive prompts. By the end, you’ll have the skills to build CLI tools that feel as polished as the ones you use daily.

Before you begin

Thor works best when you already know the command you want to expose and you want a clean way to present it to the user. It sits between plain Ruby code and the finished command-line interface, which makes it a good fit for tools that need structure without a lot of boilerplate. That is why so many Ruby projects use it for generators, deploy scripts, and project utilities.

The real value of Thor is not just less typing. It is the way the library turns your Ruby methods into a discoverable interface. Help text, options, subcommands, and argument parsing all come from the same place, so the code stays close to the command it implements. That makes it easier to maintain when the CLI grows from a single command into a small toolset.

Why Thor?

Before diving in, let’s understand why Thor is the go-to choice for Ruby CLI development.

Thor provides three key benefits that would otherwise require significant boilerplate:

  • Automatic argument parsing — Convert CLI arguments into method parameters
  • Built-in help generation--help works automatically
  • Clean DSL — Define commands using simple Ruby method definitions

Compare this to parsing options manually or using Ruby’s built-in OptionParser. Thor reduces hundreds of lines of code to a few declarative statements.

That difference matters once a CLI has more than one or two commands. The help text, argument parsing, and command dispatching stay in one place, which keeps the tool easier to extend and easier to teach to teammates.

Installation

Thor is distributed as a gem and requires no external dependencies:

gem install thor

Or add it to your Gemfile if you’re building a gem:

Thor commands gain their power from how options and arguments compose together. The first code block shows one piece of the CLI interface, and the second demonstrates how additional features like type checking, default values, and help text enrich the user experience. Building a polished CLI means thinking about each flag and argument as part of a discoverable interface that users can explore with —help.

# In your Gemfile
gem 'thor', '~> 1.3'

your first Thor CLI

Let’s start with the simplest possible CLI. Create a file called hello.rb with a Thor class that exposes a single hello command. The desc method documents the command for the auto-generated help output:

Thor commands gain their power from how options and arguments compose together. The first code block shows one piece of the CLI interface, and the second demonstrates how additional features like type checking, default values, and help text enrich the user experience. Building a polished CLI means thinking about each flag and argument as part of a discoverable interface that users can explore with —help.

require 'thor'

class HelloCLI < Thor
  desc "hello NAME", "Greet a person by name"
  def hello(name)
    puts "Hello, #{name}!"
  end
end

HelloCLI.start(ARGV)

Run the script by passing hello and a name as command-line arguments. Thor uses the first argument as the command name and the rest as method arguments:

Thor commands gain their power from how options and arguments compose together. The first code block shows one piece of the CLI interface, and the second demonstrates how additional features like type checking, default values, and help text enrich the user experience. Building a polished CLI means thinking about each flag and argument as part of a discoverable interface that users can explore with —help.

ruby hello.rb hello World
# => Hello, World!

ruby hello.rb hello --help
# => Usage:
#   hello.rb hello NAME
#
# Greet a person by name

That’s it: you have a working CLI with argument parsing and automatic help. The desc method documents your command, and Thor uses that documentation to generate help text.

The nice part is that the command body stays ordinary Ruby. You can validate input, read configuration, and call services without leaving the language you already know.

building a real deployment CLI

Now let’s build something more practical: a deployment CLI with multiple commands, options, and subcommands.

Project Structure

A well-organized CLI typically looks like this:

my_cli/
├── bin/
│   └── deploy              # Executable entry point
├── lib/
│   ├── my_cli/
│   │   └── commands.rb    # Thor classes
│   └── my_cli.rb          # Main require
└── Gemfile

the executable

Create bin/deploy:

Thor commands gain their power from how options and arguments compose together. The first code block shows one piece of the CLI interface, and the second demonstrates how additional features like type checking, default values, and help text enrich the user experience. Building a polished CLI means thinking about each flag and argument as part of a discoverable interface that users can explore with —help.

#!/usr/bin/env ruby
require_relative '../lib/my_cli'

MyCLI::Commands.start(ARGV)

Make the file executable so it can be run directly from the terminal. Without this step, you would need to prefix every command with ruby:

Thor commands gain their power from how options and arguments compose together. The first code block shows one piece of the CLI interface, and the second demonstrates how additional features like type checking, default values, and help text enrich the user experience. Building a polished CLI means thinking about each flag and argument as part of a discoverable interface that users can explore with —help.

chmod +x bin/deploy

defining commands

Create lib/my_cli/commands.rb:

Thor commands gain their power from how options and arguments compose together. The first code block shows one piece of the CLI interface, and the second demonstrates how additional features like type checking, default values, and help text enrich the user experience. Building a polished CLI means thinking about each flag and argument as part of a discoverable interface that users can explore with —help.

require 'thor'

module MyCLI
  class Commands < Thor
    desc "deploy ENVIRONMENT", "Deploy the application to an environment"
    option :force, type: :boolean, default: false, 
             desc: "Skip confirmation prompts"
    option :tag, type: :string, 
             desc: "Git tag to deploy"
    long_desc <<-LONGDESC
      Deploys the application to the specified environment (staging, production).
      
      Examples:
      
      $ deploy staging
      $ deploy production --tag v1.2.3
      $ deploy production --force
    LONGDESC
    
    def deploy(environment)
      validate_environment!(environment)
      
      tag = options[:tag] || fetch_latest_tag
      puts "Deploying #{tag} to #{environment}..."
      
      unless options[:force]
        confirm_deploy(environment, tag)
      end
      
      run_deployment(environment, tag)
      puts "Deployment complete!"
    end
    
    desc "rollback [ENVIRONMENT]", "Rollback to the previous release"
    option :steps, type: :numeric, default: 1,
             desc: "Number of releases to rollback"
    def rollback(environment = "production")
      puts "Rolling back #{options[:steps]} release(s) in #{environment}..."
      # Rollback logic here
    end
    
    desc "status", "Show deployment status"
    def status
      puts "Production:  v1.2.3 (deployed 2 hours ago)"
      puts "Staging:    v1.2.3-rc1 (deployed 5 minutes ago)"
    end
    
    private
    
    def validate_environment!(env)
      unless %w[staging production].include?(env)
        puts "Error: Invalid environment. Use 'staging' or 'production'."
        exit 1
      end
    end
    
    def fetch_latest_tag
      # In reality, fetch from git
      `git describe --tags --abbrev=0`.strip
    end
    
    def confirm_deploy(env, tag)
      print "Deploy #{tag} to #{env}? (y/n) "
      answer = STDIN.gets.chomp
      exit unless answer.downcase == 'y'
    end
    
    def run_deployment(env, tag)
      # Actual deployment logic
      sleep 1  # Simulate deployment time
    end
  end
end

This example demonstrates several Thor features:

  • Required argumentsenvironment in deploy is positional and required
  • Options with types--force is a boolean, --tag is a string
  • Default values--force defaults to false
  • Long descriptions — Provide detailed documentation accessible via --help
  • Private methods — Methods starting with _ aren’t exposed as commands
  • Exit codes — Use exit 1 for errors

When the CLI grows, those features help keep the user experience predictable. A well-named command, clear options, and consistent exit codes matter just as much as the Ruby code behind them.

running the CLI

# Deploy to staging
./bin/deploy staging

# Deploy to production with a specific tag
./bin/deploy production --tag v1.2.3

# Force deploy without confirmation
./bin/deploy production --tag v1.2.3 --force

# View help
./bin/deploy help
./bin/deploy help deploy

Subcommands

As your CLI grows, you may want to organize commands into groups. Thor supports this through subcommands. Let’s add a logs command group:

Thor commands gain their power from how options and arguments compose together. The first code block shows one piece of the CLI interface, and the second demonstrates how additional features like type checking, default values, and help text enrich the user experience. Building a polished CLI means thinking about each flag and argument as part of a discoverable interface that users can explore with —help.

require 'thor'

module MyCLI
  class Commands < Thor
    desc "deploy ENVIRONMENT", "Deploy the application"
    def deploy(environment)
      # ...
    end
    
    desc "rollback [ENVIRONMENT]", "Rollback deployments"
    def rollback(environment = "production")
      # ...
    end
  end
  
  class Logs < Thor
    desc "logs ENVIRONMENT", "View application logs"
    option :lines, type: :numeric, default: 100,
             desc: "Number of lines to show"
    option :follow, type: :boolean, default: false,
             desc: "Stream logs in real-time"
    def logs(environment)
      puts "Showing #{options[:lines]} log lines from #{environment}"
      # Log streaming logic
    end
    
    desc "search PATTERN", "Search logs for a pattern"
    def search(pattern)
      puts "Searching logs for: #{pattern}"
    end
  end
end

Register subcommands in your main CLI:

Thor commands gain their power from how options and arguments compose together. The first code block shows one piece of the CLI interface, and the second demonstrates how additional features like type checking, default values, and help text enrich the user experience. Building a polished CLI means thinking about each flag and argument as part of a discoverable interface that users can explore with —help.

require 'thor'

module MyCLI
  class Commands < Thor
    desc "deploy ENVIRONMENT", "Deploy the application"
    def deploy(environment)
      # ...
    end
    
    # Register the Logs subcommand
    subcommand "logs", Logs
  end
end

Now your CLI supports:

Thor commands gain their power from how options and arguments compose together. The first code block shows one piece of the CLI interface, and the second demonstrates how additional features like type checking, default values, and help text enrich the user experience. Building a polished CLI means thinking about each flag and argument as part of a discoverable interface that users can explore with —help.

./bin/deploy logs production --lines 500
./bin/deploy logs production --follow
./bin/deploy logs search "ERROR"

interactive prompts

For sensitive operations, you might want interactive confirmation. Thor works well with the highline gem for this, which provides methods like agree and ask for getting user input:

require 'thor'
require 'highline/import'

class DeployCLI < Thor
  desc "destroy ENVIRONMENT", "Permanently destroy environment data"
  def destroy(environment)
    say "Warning: This will permanently delete all data in #{environment}!", :red
    
    unless agree("Are you sure? ") { |q| q.default = 'n' }
      say "Aborted.", :yellow
      exit 0
    end
    
    type = agree("Type '#{environment}' to confirm: ") { |q| q.validate = /#{environment}/ }
    # Proceed with destruction
  end
end

global options

Sometimes you want options available across all commands. Use class_options at the class level instead of repeating option inside each method. These options are inherited by every command in the class:

Thor commands gain their power from how options and arguments compose together. The first code block shows one piece of the CLI interface, and the second demonstrates how additional features like type checking, default values, and help text enrich the user experience. Building a polished CLI means thinking about each flag and argument as part of a discoverable interface that users can explore with —help.

class GlobalCLI < Thor
  class_option :verbose, type: :boolean, default: false,
               desc: "Enable verbose output"
  class_option :config, type: :string,
               desc: "Path to config file"
  
  desc "deploy ENVIRONMENT", "Deploy application"
  def deploy(environment)
    if options[:verbose]
      puts "Verbose mode enabled"
      puts "Config: #{options[:config]}"
    end
    # ...
  end
  
  desc "rollback ENVIRONMENT", "Rollback deployment"
  def rollback(environment)
    # Options are available here too
  end
end

Now --verbose and --config work with any command in the class. The options are available through options[:verbose] and options[:config] inside every method:

Thor commands gain their power from how options and arguments compose together. The first code block shows one piece of the CLI interface, and the second demonstrates how additional features like type checking, default values, and help text enrich the user experience. Building a polished CLI means thinking about each flag and argument as part of a discoverable interface that users can explore with —help.

./deploy deploy production --verbose
./deploy rollback staging --config ~/.deploy.yml

Best Practices

Follow these patterns to build CLI tools that users love:

1. Always provide help

Use desc and long_desc to document every command. Thor generates --help automatically:

The first code block establishes a foundational Ruby concept, and the second builds on it by introducing a related technique or showing a different angle. Reading these examples in sequence helps you build a layered understanding of how Ruby features work together in practice. The progression from basic to more advanced usage mirrors how you would naturally encounter these patterns when reading or writing real Ruby code.

desc "deploy ENVIRONMENT", "Deploy to an environment"
long_desc <<-LONGDESC
  Deploys your application using the configured deployment strategy.
  
  Supported environments:
  - staging: Testing environment
  - production: Live environment
LONGDESC

2. Use exit codes correctly

Return 0 for success, non-zero for errors:

Methods and classes are the building blocks of Ruby programs. The first example introduces a pattern, and the second shows a variation that handles a different use case. Learning to recognise these variations helps you read Ruby code more fluently and choose the right pattern for the problem you are solving. Each variation trades off simplicity, flexibility, and explicitness.

def deploy(environment)
  begin
    # Deployment logic
  rescue => e
    puts "Error: #{e.message}"
    exit 1
  end
end

3. Handle missing arguments

Thor automatically validates required arguments, but you can customize behavior:

Methods and classes are the building blocks of Ruby programs. The first example introduces a pattern, and the second shows a variation that handles a different use case. Learning to recognise these variations helps you read Ruby code more fluently and choose the right pattern for the problem you are solving. Each variation trades off simplicity, flexibility, and explicitness.

def deploy(environment = nil)
  if environment.nil?
    puts "Error: ENVIRONMENT is required"
    puts "Usage: deploy ENVIRONMENT"
    exit 1
  end
  # ...
end

4. Test Your CLI

Use Thor’s built-in testing support:

Thor commands gain their power from how options and arguments compose together. The first code block shows one piece of the CLI interface, and the second demonstrates how additional features like type checking, default values, and help text enrich the user experience. Building a polished CLI means thinking about each flag and argument as part of a discoverable interface that users can explore with —help.

require 'thor/spec'

describe MyCLI::Commands do
  include Thor::Spec::Fixtures
  
  it "deploys to the specified environment" do
    capture_io { subject.deploy("staging") }.should include("Deploying")
  end
end

FAQ

How do I handle environment variables in Thor?

Access them directly in your commands:

Thor commands gain their power from how options and arguments compose together. The first code block shows one piece of the CLI interface, and the second demonstrates how additional features like type checking, default values, and help text enrich the user experience. Building a polished CLI means thinking about each flag and argument as part of a discoverable interface that users can explore with —help.

def deploy(environment)
  api_key = ENV['DEPLOY_API_KEY']
  unless api_key
    puts "Error: Set DEPLOY_API_KEY environment variable"
    exit 1
  end
end

can i use thor with rails?

Yes! Many Rails gems use Thor for generators and rake tasks. Add it to your Gemfile and use it just like in any Ruby project.

how do I add a version command?

Map the --version and -v flags to a version method using the map method. This lets users check the CLI version without typing a separate command:

class CLI < Thor
  map ["--version", "-v"] => :version
  
  desc "version", "Show version"
  def version
    puts "MyCLI v1.2.3"
  end
end

What’s the difference between Thor and Rake?

Thor is designed for CLI applications—you define commands that accept arguments. Rake is for running tasks, typically within a project. Rails uses Thor for its CLI (generators, migrations) and Rake for project tasks.

Next steps

If you want to turn command-line tasks into scheduled automation, continue with Ruby Rake Tasks. Rake is a good companion to Thor when you need project-level build and maintenance tasks alongside a user-facing CLI.

If you want to compare Thor with a different automation style, move next to Automating Tasks with Rake. Rake is a better fit when you want project tasks instead of interactive commands, while Thor shines when users need a polished CLI with options and subcommands.

Conclusion

Thor transforms CLI development from a chore into a pleasure. With just a few lines of Ruby, you get argument parsing, help generation, subcommands, and a clean DSL—all the ingredients for professional command-line tools.

The deployment CLI we built demonstrates patterns you can apply to any project: required and optional arguments, boolean and string options, subcommand groups, and proper exit codes. Start with these foundations, and you’ll build CLI tools that feel as polished as Bundler or Rails.

Next Steps

Ready to continue your Ruby for DevOps journey? The next tutorial in this series covers Automating Tasks with Rake—Ruby’s built-in task management tool. You’ll learn how to create reusable automation tasks, build task dependencies, and organize your DevOps workflows.

After mastering Rake, explore other tutorials in the series:

  • Ruby Gem Development — Package your CLI as a distributable gem
  • Ruby Testing — Write comprehensive tests for your tools