Building CLI Tools with Thor
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 —
--helpworks 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 arguments —
environmentindeployis positional and required - Options with types —
--forceis a boolean,--tagis a string - Default values —
--forcedefaults tofalse - Long descriptions — Provide detailed documentation accessible via
--help - Private methods — Methods starting with
_aren’t exposed as commands - Exit codes — Use
exit 1for 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