By Thor's Hammer!

For the past few months, I've become more and more disillusioned by the current state of Ruby's scripting support. Sure, we have optparse, and a gamut of other solutions, but there's no full-stack package for writing robust binaries.

Enter thor.

The idea behind thor initially came from my work on a textmate binary (more on that later today), which would manage installed Textmate bundles. Sure, there's the getbundle bundle, and you can manually get bundles from subversion, but I wanted a single binary that handled all of that.

While I was building it, I decided to map the commands that people could enter to a class and its methods. I created a small file that I included with the textmate binary called class_cli, and created some syntax for mapping things, like so (note that this is not the final syntax):

class MyApp  
  include CLI

  desc "list [list]", "list some items"
  def list(list = "stuff")
    puts list.split(/,\s*/).join("\n")
  end
end

MyApp.start  

Assuming the binary is called app, you could then do app list "one,two,three", which would print:

one  
two  
three  

In the case of the textmate binary, I created methods for list, install, installed, and uninstall.

This worked nicely, so I wanted to extract it out for use in other tools. I started the Hermes project on github, but while drinking with Chris Wanstrath, I was convinced that the name Thor would be more appealing. Oh, the things that alcohol can do. I was also convinced of a couple other things (again, in a slightly inebriated state):

  • rake and sake needed to be replaced for scripts, not as a replacement for make
  • nobody was going to use hermesthor if they need extra boilerplate like MyApp.start

Long story short, Chris convinced me to make Thor a full-fledged scripting solution. So I did.

Thor, as it exists today, has two components:

  • The Thor superclass, which works exactly like CLI/Hermes, except that you inherit from it instead of including it (class MyApp < Thor).
  • The Thor runner, which can run Thortasks that are in a local directory or installed from a remote location

The thor runner allows you to make files like:

# module: random

class Amazing < Thor  
  desc "describe NAME", "say that someone is amazing"
  method_options :forcefully => :boolean
  def describe(name, opts)
    ret = "#{name} is amazing"
    puts opts["forcefully"] ? ret.upcase : ret
  end

  desc "hello", "say hello"
  def hello
    puts "Hello"
  end
end  

If you call the file .thor or Thorfile and place it in your current directory, any directory above you, or tasks/.thor, you can then invoke the thorfile in any of the following ways:

$ thor -T
Tasks  
-----
amazing:describe NAME [--forcefully]   say that someone is amazing  
amazing:hello                          say hello

$ thor amazing:hello
Hello

$ thor amazing:describe "This blog reader"
This blog reader is amazing

$ thor amazing:describe "This blog reader" --forcefully
THIS BLOG READER IS AMAZING  

You can also install local tasks or remote tasks to your system thor cache and make them available anywhere:

$ thor install task.thor
Your Thorfile contains:  
# module: random

class Amazing < Thor  
  desc "describe NAME", "say that someone is amazing"
  method_options :forcefully => :boolean
  def describe(name, opts)
    ret = "#{name} is amazing"
    puts opts["forcefully"] ? ret.upcase : ret
  end

  desc "hello", "say hello"
  def hello
    puts "Hello"
  end
end  
Do you wish to continue [y/N]? y  
Storing thor file in your system repository

$ thor installed
Name      Modules  
----      -------
random    amazing

Tasks  
-----
amazing:describe NAME [--forcefully]   say that someone is amazing  
amazing:hello                          say hello

$ thor amazing:hello
Hello

... same as above ...

You can also specify a URL instead of a file name; like sake, thor uses open-uri to get the files. You uninstall or update thor modules based on the short name that was provided; if # module: name exists at the top of the file, thor will use that by default. You can also use thor install task.thor --as myshortname. If you don't provide a short name, thor will ask for one.

Later, you can do thor update shortname and thor will remember where you got the module from and try to update it. thor uninstall shortname will remove the module from your list of installed modules.

Of course, thor -T (or thor list) will list local tasks and system-wide tasks in the resulting list, so you don't need a separate tool to track your thortasks, and a local task can be made into a system task trivially.

Finally, thor itself is self-hosting; the thor runner uses the Thor superclass. As a result, I added some more features to the superclass as I built the runner:

  • You can map short names to their full name: map "-T" => :list, which is why thor -T and thor list are identical
  • You can provide additional options that get passed in as a Hash (method_options :as => :required). The available option types are :required, :optional, and :boolean. The resulting hash is passed in as the final parameter to your method and the pretty-printed help automatically includes them in the usage screen.

Take a look at the thor repository on github, and specifically, the thor runner for more information on how it all fits together.