Building CLI Tools with Thor

· 7 min read · Updated March 7, 2026 · intermediate
ruby cli thor command-line devops tooling

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.

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.

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:

# 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:

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 it:

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.

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:

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

MyCLI::Commands.start(ARGV)

Make it executable:

chmod +x bin/deploy

Defining Commands

Create lib/my_cli/commands.rb:

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

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:

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:

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:

./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 other gems for this:

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:

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:

./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:

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:

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:

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:

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:

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 version 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.

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