Adding Thor Commands to Rails
While Rails provides great support for adding your own Rake tasks, it does not provide documented support for adding Thor commands which I find to be better for tasks that require sophisticated interaction with the user. Let's dive into the Rails internals and see how they setup their Thor commands. We can then use their conventions for creating our own Thor commands.
Rails Command Classes
Rails keeps its Thor commands in the railties gem, with each command having its own folder. Let's dive right in and take a look at the rails secrets
command.
# rails/railties/lib/rails/commands/secrets
require "active_support"
require "rails/secrets"
module Rails
module Command
class SecretsCommand < Rails::Command::Base # :nodoc:
no_commands do
def help
say "Usage:\n #{self.class.banner}"
say ""
say self.class.desc
end
end
def setup
require "rails/generators"
require "rails/generators/rails/encrypted_secrets/encrypted_secrets_generator"
Rails::Generators::EncryptedSecretsGenerator.start
end
def edit
if ENV["EDITOR"].to_s.empty?
say "No $EDITOR to open decrypted secrets in. Assign one like this:"
say ""
say %(EDITOR="mate --wait" bin/rails secrets:edit)
say ""
say "For editors that fork and exit immediately, it's important to pass a wait flag,"
say "otherwise the secrets will be saved immediately with no chance to edit."
return
end
require_application_and_environment!
Rails::Secrets.read_for_editing do |tmp_path|
say "Waiting for secrets file to be saved. Abort with Ctrl-C."
system("\$EDITOR #{tmp_path}")
end
say "New secrets encrypted and saved."
rescue Interrupt
say "Aborted changing encrypted secrets: nothing saved."
rescue Rails::Secrets::MissingKeyError => error
say error.message
end
end
end
end
If you're familiar with Thor you can immediately see the similarities of the SecretsCommand
class and a typical Thor class. For example, the methods #setup
and #edit
are the commands that correspond to rails secrets:edit
and rails secrets:setup
. There is also the no_commands
block which is part of the Thor API and allows you to define methods that Thor will not turn into commands. However, despite seeing Thor methods, we notice that the SecretsCommand
inherits from Rails::Command::Base
, and not Thor. Let's take a look at this (abbreviated) base class.
# rails/railties/lib/rails/command/base
# Shortened ...
module Rails
module Command
class Base < Thor
class Error < Thor::Error # :nodoc:
end
include Actions
class << self
# Shortened ...
# Tries to get the description from a USAGE file one folder above the command
# root.
def desc(usage = nil, description = nil, options = {})
if usage
super
else
@desc ||= ERB.new(File.read(usage_path)).result(binding) if usage_path
end
end
# Convenience method to get the namespace from the class name. It's the
# same as Thor default except that the Command at the end of the class
# is removed.
def namespace(name = nil)
if name
super
else
@namespace ||= super.chomp("_command").sub(/:command:/, ":")
end
end
# Convenience method to hide this command from the available ones when
# running rails command.
def hide_command!
Rails::Command.hidden_commands << self
end
# Shortened ...
def printing_commands
namespaced_commands
end
def executable
"bin/rails #{command_name}"
end
# Use Rails' default banner.
def banner(*)
"#{executable} #{arguments.map(&:usage).join(' ')} [options]".squish!
end
# ...
# Default file root to place extra files a command might need, placed
# one folder above the command file.
#
# For a `Rails::Command::TestCommand` placed in `rails/command/test_command.rb`
# would return `rails/test`.
def default_command_root
path = File.expand_path(File.join("../commands", command_root_namespace), __dir__)
path if File.exist?(path)
end
private
# Shortened ...
end
# Shortened ...
end
end
Ah, there's Thor. It's being inherited by the base class. You can see that Rails has added a few methods to the base class. With these added and overridden methods, you can begin to see that Rails has conventionalized how its commands are created. For example, Thor's .desc
method is overridden so that the description can be stored in a separate file. There are also methods like .banner
, .hide_command!
and .executable
that Rails adds to manage how commands are displayed via the rails
command.
Rails Command Conventions
Now that we've taken a look on how the command classes are made up let's dive into the conventions that Rails uses to organize its commands so we can create our own. We'll demonstrate by recreating commands that Exposition uses to list and create users. Our first step will be to create a file lib/commands/exposition/exposition_command.rb
in our Rails engine or app.
The exact path isn't required. For example, you can place the file in say app/commands/exposition/exposition_command.rb
. However, what is important is that the command file ends in "_command.rb" as this is how Rails knows to load the file as a command file. Once the file is created, let's go ahead and create our commands:
# lib/commands/exposition/exposition_command.rb
module Exposition
module Command
class ExpositionCommand < Rails::Command::Base
namespace 'exposition'
desc 'create_user', 'Creates an admin user'
def create_user
require_application_and_environment!
name = ask("What is your name?")
email = ask("What is your email address?")
password = ask("Please choose a password.", echo: false)
say("\n")
password_confirmation = ask("Please confirm your password.", echo: false)
::Exposition::User.create!(
name: name, email: email, password: password,
password_confirmation: password_confirmation
)
end
desc "list_users", "Lists all the admin users"
def list_users
require_application_and_environment!
users = Exposition::User.all
users = users.map do |user|
[user.name, user.email]
end
say("Exposition users")
print_table(users)
end
end
end
end
Let's walk through this command class. The first thing to point out is that we've included the command in the same namespace as our Rails engine, "Exposition". By placing our command in the Exposition
module, Rails will use this name to namespace our commands if a namespace isn't explicitly specified. You can explicitly define your own namespace by using the .namespace
method. Now when you type in the rails
command without any arguments you'll see our namespaced commands.
Exposition:
exposition:create_user
exposition:list_users
You'll notice that like other Rails commands, our command class inherits from Rails::Command::Base
. This is a requirement as Rails learns and adds the command to its list of commands when it inherits from Rails::Command::Base
.
Next, you'll notice the method #require_application_and_environment!
. This method will load your application giving you access to your models and the database.
The rest of the command is composed of standard Rails and Thor methods.
Now you can add Thor commands to your Rails engines as long as the conventions are followed.
If you want to do the same for a standard Rails application, you'll have to add either lib
or app
, depending on where you're putting your commands, to the $LOAD_PATH. You can simply add the following line to your bin/rails
file:
$LOAD_PATH.unshift(File.expand_path('../app', __dir__))
The file should look like:
# bin/rails
#!/usr/bin/env ruby
APP_PATH = File.expand_path('../config/application', __dir__)
# use lib instead of app if your commands are in your lib directory
$LOAD_PATH.unshift(File.expand_path('../app', __dir__))
require 'pry'
require_relative '../config/boot'
require 'rails/commands'
As always, if you have any questions or find corrections tweet at me!