Creating generators and executables with Thor
Read in 7 minutes
Thor is an amazing library for creating generators. It gives you methods for creating and copying files and directories, defining symbolic links, read remote files, and more. And is the perfect companion for gems that need to generate a project structure, just like Rails.
The first thing you need is defining the generator class.
require 'thor'
require 'thor/group'
class MyGem::Generator < Thor::Group
include Thor::Actions
desc 'Generate a new filesystem structure'
end
The Thor::Group
class is perfect for generators because allows you to execute all actions at once. To define the actions of your generator, you must include the Thor::Actions
module.
require 'thor'
require 'thor/group'
class MyGem::Generator < Thor::Group
include Thor::Actions
desc 'Generate a new filesystem structure'
def self.source_root
File.dirname(__FILE__) + '/../../templates'
end
def create_config_file
copy_file 'config.yml', 'config/mygem.yml'
end
def create_git_files
copy_file 'gitignore', '.gitignore'
create_file 'images/.gitkeep'
create_file 'text/.gitkeep'
end
def create_output_directory
empty_directory 'output'
end
end
As you can see, we’re defining the MyGem::Generator.source_root
, which is your generator templates directory.
To execute this generator, you have to instantiate the MyGem::Generator
class.
generator = MyGem::Generator.new
generator.destination_root = '/some/path'
generator.invoke_all
We’re defining the destination through the generator.destination_root
property; it’s optional and will use the current directory by default.
Creating an executable
You can create your CLI using the OptionParser standard library, although it can be really hard to manage the complexity for more sofisticated interfaces. But fear not; Thor can help you with that too.
To define a new CLI with Thor, create a class that inherits from the Thor
class.
require 'thor'
class MyGem::Cli < Thor
end
Your executable file needs to call the MyGem::Cli.start
method. You can pass an array that represents the ARGV
, specially good when writing tests.
#!/usr/bin/env ruby
require 'mygem'
MyGem::Cli.start
Your executable file must have execution permission; you can do it by running the chmod
.
chmod +x bin/mygem
Now you can execute ./bin/mygem
.
$ ./bin/mygem
Commands:
mygem help [COMMAND] # Describe available commands or one specific command
Our script doesn’t do much yet; let’s add an option for displaying the program version. To define a new command switch, you can use the Thor.map
method.
module MyGem
VERSION = '0.1.0'
class Cli < Thor
desc 'version', 'Display version'
map %w[-v --version] => :version
def version
say "MyGem #{VERSION}"
end
end
end
If you run ./bin/mygem
again you’ll see that we now have a version
command available. This happens because all public methods are considered as commands; define a private method if you don’t want to make it available as a command.
$ ./bin/mygem
Commands:
mygem help [COMMAND] # Describe available commands or one specific command
mygem version # Display MyGem version
Now you use one of the following options to display the program version.
$ ./bin/mygem -v
MyGem 0.1.0
$ ./bin/mygem --version
MyGem 0.1.0
$ ./bin/mygem version
MyGem 0.1.0
To create a new generator, you’ll probably want something like mygem new <some path>
. This means that you need a positional argument, so just define a method that receives an argument.
require 'thor'
module MyGem
VERSION = '0.1.0'
class Cli < Thor
desc 'version', 'Display MyGem version'
map %w[-v --version] => :version
def version
say "MyGem #{VERSION}"
end
desc 'new PATH', 'Create a new static website'
def new(path)
path = File.expand_path(path)
say "Creating site at #{path}"
end
end
end
Now you can execute the new
command. Notice that Thor validates the argument presence for you, returning an error message when the argument is missing.
$ ./bin/mygem new foo
Creating site at /Users/fnando/Projects/samples/thor-bin-sample/foo
$ ./bin/mygem new
ERROR: "mygem new" was called with no arguments
Usage: "mygem new PATH"
You may need to accept some options for initializing your project. Rails does this all the time, like rails new myapp -d postgresql
. To define a new option for your command, use the Thor.option
method.
require 'thor'
module MyGem
VERSION = '0.1.0'
class Cli < Thor
desc 'version', 'Display MyGem version'
map %w[-v --version] => :version
def version
say "MyGem #{VERSION}"
end
desc 'new PATH', 'Create a new static website'
option :javascript_engine, :default => 'babeljs', :aliases => '-j'
def new(path)
path = File.expand_path(path)
say "Creating site at #{path}"
say options
end
end
end
In this example, we can define the JavaScript engine by using --javascript-engine
or its -j
alias. Thor is smart and will automatically convert the option name to a hyphenated version.
$ ./bin/mygem new foo
Creating site at /Users/fnando/Projects/samples/thor-bin-sample/foo
{"javascript_engine"=>"babeljs"}
$ ./bin/mygem new foo --javascript-engine coffeescript
Creating site at /Users/fnando/Projects/samples/thor-bin-sample/foo
{"javascript_engine"=>"coffeescript"}
$ ./bin/mygem new foo -j coffeescript
Creating site at /Users/fnando/Projects/samples/thor-bin-sample/foo
{"javascript_engine"=>"coffeescript"}
The Thor.option
accepts other parameters, like type coercion and if it’s a required option.
option :file, :type => :array, :aliases => :files
option :force, :type => :boolean, :default => false
option :database, :required => true
By default Thor won’t validate the options you’re passing to the executable. So when you combine a positional argument with an invalid option, you’ll end up with a unexpected behavior.
$ ./bin/mygem new -h
Creating site at /Users/fnando/Projects/samples/thor-bin-sample/-h
{"javascript_engine"=>"babeljs"}
Did you see that the directory name is marked as -h
? Thor thinks that -h
is the positional argument, instead of an invalid option. You can reject invalid options by calling the Thor.check_unknown_options!
method.
require 'thor'
module MyGem
VERSION = '0.1.0'
class Cli < Thor
check_unknown_options!
desc 'version', 'Display MyGem version'
map %w[-v --version] => :version
def version
say "MyGem #{VERSION}"
end
desc 'new PATH', 'Create a new static website'
option :javascript_engine, :default => 'babeljs', :aliases => '-j'
def new(path)
path = File.expand_path(path)
say "Creating site at #{path}"
say options
end
end
end
Now, invalid arguments are rejected and an error message is displayed.
$ ./bin/mygem new -h
Unknown switches '-h'
Another common task is having global options like --verbose
, which can be applied to all commands. You can use the Thor.class_option
for this.
require 'thor'
module MyGem
VERSION = '0.1.0'
class Cli < Thor
check_unknown_options!
desc 'version', 'Display MyGem version'
map %w[-v --version] => :version
class_option 'verbose', :type => :boolean, :default => false
def version
say "MyGem #{VERSION}"
end
desc 'new PATH', 'Create a new static website'
option :javascript_engine, :default => 'babeljs', :aliases => '-j'
def new(path)
path = File.expand_path(path)
say "Creating site at #{path}"
say options
end
end
end
With this option you can set the --verbose
in every command and the Thor#options
method will be populated with global and local options.
$ ./bin/mygem new foo --verbose
Creating site at /Users/fnando/Projects/samples/thor-bin-sample/foo
{"verbose"=>true, "javascript_engine"=>"babeljs"}
Eventually you’ll have to validate the arguments you’re receiving. You may even need to interrupt the execution, returning an error message. You can thrown exception with Thor::Error
class, specifying the error message you want to display.
require 'thor'
module MyGem
VERSION = '0.1.0'
class Cli < Thor
check_unknown_options!
desc 'version', 'Display MyGem version'
map %w[-v --version] => :version
class_option 'verbose', :type => :boolean, :default => false
def version
say "MyGem #{VERSION}"
end
desc 'new PATH', 'Create a new static website'
option :javascript_engine, :default => 'babeljs', :aliases => '-j'
def new(path)
path = File.expand_path(path)
raise Error, "ERROR: #{path} already exists." if File.exist?(path)
say "Creating site at #{path}"
say options
end
end
end
We’re returning an error message when the output directory already exists. The problem, in this case, is that the exit code of that failing command will be 0
, which means “success” in the *nix world.
$ ./bin/mygem new cli.rb --verbose
ERROR: /Users/fnando/Projects/samples/thor-bin-sample/cli.rb already exists.
$ echo $?
0
You can fix this by defining the MyGem::Cli.exit_on_failure?
method and returning true
.
require 'thor'
module MyGem
VERSION = '0.1.0'
class Cli < Thor
check_unknown_options!
def self.exit_on_failure?
true
end
desc 'version', 'Display MyGem version'
map %w[-v --version] => :version
class_option 'verbose', :type => :boolean, :default => false
def version
say "MyGem #{VERSION}"
end
desc 'new PATH', 'Create a new static website'
option :javascript_engine, :default => 'babeljs', :aliases => '-j'
def new(path)
path = File.expand_path(path)
raise Error, "ERROR: #{path} already exists." if File.exist?(path)
say "Creating site at #{path}"
say options
end
end
end
Now the same execution will return the exit code as 1
.
$ ./bin/mygem new cli.rb --verbose
ERROR: /Users/fnando/Projects/samples/thor-bin-sample/cli.rb already exists.
$ echo $?
1
You can even use colored output for better visualization; just use the set_color
method.
require 'thor'
module MyGem
VERSION = '0.1.0'
class Cli < Thor
check_unknown_options!
def self.exit_on_failure?
true
end
desc 'version', 'Display MyGem version'
map %w[-v --version] => :version
class_option 'verbose', :type => :boolean, :default => false
def version
say "MyGem #{VERSION}"
end
desc 'new PATH', 'Create a new static website'
option :javascript_engine, :default => 'babeljs', :aliases => '-j'
def new(path)
path = File.expand_path(path)
raise Error, set_color("ERROR: #{path} already exists.", :red) if File.exist?(path)
say "Creating site at #{path}"
say options
end
end
end
Now the same error message would be displayed like this:
Wrapping up
I really like Thor for creating CLIs and generators. It has all the features I need and is used by large projects such as Ruby on Rails.
To get more information about Thor, check out the documentation. The project’s wiki also have some useful information.
Finally, remember that creating a gem is still the best delivery method; the executable will be available on your $PATH
and is easy to install it with gem install mygem
.