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!