Create a command-line gem from scratch with Thor (part two)
This is the last in a two-part post on creating a Ruby cli gem from scratch. Part one found here.
Picking up from our last post, we want to start working on our user interface. In this case we want a cli that looks something like this:
#-> what would you like to do?
1 - Build a matrix
2 - Generate a random matrix
#-> now that we have a matrix, would you like to spiralize it?
1 - Spiralize it
2 - Stare at it
Thor
We're going to use Thor, which is a toolkit for building command-line tools and applications. In fact, Bundler
itself was written using Thor
. Let's add it to the bottom of our gemspec
and re-install:
# spiralizer.gemspec
spec.add_dependency "thor"
---
# from cmd line:
> bundle install
Now, we just need to require it in and start using it!
require 'thor'
...
But, this is also a good time to start separating our code a bit, so we don't let things get to messy. So, let's create a file called lib/spiralizer/cli.rb
, and move our require
statement into this file. Then we will stub out a class under the Spiralizer
namespace and inherit from Thor
:
require 'thor'
require 'spiralizer'
class Spiralizer::CLI < Thor
end
Back in our main module we will need to require in this new file:
require "spiralizer/version"
require "spiralizer/cli"
module Spiralizer
...
end
Our cli is gem that users can install on their systems. We want them to have an executable that will take a simple command to spin up prompt we defined earlier:
Say the user enters spiralizer go
for example. We would want output similar to what we envisioned above:
$ >spiralizer go
#-> what would you like to do?
1 - Build a matrix
2 - Generate a random matrix
Thor gives us some easy-to-use tooling to get this thing going in no time. We'll get started by defining our go
method, and use the desc
macro annotation get some magic documentation:
require 'thor'
require 'spiralizer'
class Spiralizer::CLI < Thor
desc 'go', 'starts a prompt that brings users to spiral heaven'
def go
say "What would you like to do? (choose a number)\n 1 - Build a matrix\n 2 - Generate a random matrix"
action = ask '> '
say "\nyou picked #{action}"
exit!
end
end
Thor
provides some intuitive methods like say
to output something to the user, and ask
to prompt the user for input. They work just as expected. Notice that you can assign the result of ask
to a variable.
To start playing around with it, we just need to create a new directory called exe
(alternatively, we could have scaffolded our gem with a --exe
option but here we are). It should be a sibling directory of lib
and bin
. cd exe && touch spiralizer
to create a file. Then, give it executable permissions: > chmod +x exe/spiralizer
, and dump this inside:
#!/usr/bin/env ruby
require 'spiralizer/cli'
Spiralizer::CLI.start
And let's give 'er a whirl:
> bundle exec exe/spiralizer
Commands:
spiralizer go # starts a prompt that brings users to spiral heaven
spiralizer help [COMMAND] # Describe available commands or one specific command
Notice the helpful output Thor gives us. We have two commands available: go
and help
. Let's try them out:
> bundle exec exe/spiralizer help
Commands:
spiralizer go # starts a prompt that brings users to spiral heaven
spiralizer help [COMMAND] # Describe available commands or one specific command
> bundle exec exe/spiralizer go
What would you like to do? (choose a number)
1 - Build a matrix
2 - Generate a random matrix
> 1
you picked 1
So, help
is the default action, and we can see our new go
command is registered properly. Right now our prompt gives us two options, let's add functionality for our second option. When a user enters '2'
we want to spit out a random matrix. We can do a quick and dirty version of this and just hard code some matrix range/dimension pairs that we will feed to our matrix factory. Let's make a constant called MATRICES
that itself is a matrix:
MATRICES = [
['A-L', '4x3'],
['M-X', '3x4'],
['1-8', '2x4'],
['1-144', '12x12'],
['C-J', '4x2'],
['A-Z', '13x2']
].freeze
We now need a semi-random way to pick one of these: range_response, dimensions = MATRICES[rand(6)]
. This will give us a range value as a string like 'C-j' and dimensions like 4x2
. We'll then split that range string up, so we can feed it into our factory:
range = range_response.split('-')
matrix = Spiralizer::Matrix.the_matrix(range: (range.first..range.last), dimensions: dimensions)
Thor
wants you to put helper methods for your class in a no_commands
block, so let's create one of those
and put all this together so its somewhat presentable. Here's what we have so far:
class Spiralizer::CLI < Thor
attr_reader :matrix
MATRICES = [
['A-L', '4x3'],
['M-X', '3x4'],
['1-8', '2x4'],
['1-144', '12x12'],
['C-J', '4x2'],
['A-Z', '13x2']
].freeze
desc 'go', 'starts a prompt that brings users to spiral heaven'
def go
say "What would you like to do? (choose a number)\n 1 - Build a matrix\n 2 - Generate a random matrix"
action = ask '> '
say "\n"
range, dimensions = random_range_and_dimensions
@matrix = Spiralizer::Matrix.the_matrix(range: range, dimensions: dimensions)
output_matrix
end
no_commands do
def random_range_and_dimensions
say "\ngenerating..."
sleep 1
range_response, dimensions = MATRICES[rand(6)]
range = range_response.split('-')
return (range.first..range.last), dimensions
end
def output_matrix
say "\nHere is your M A T R I X\n------------------------"
matrix.each { |inner| say inner.join("\t") }
exit!
end
end
end
You'll notice that any input we give the prompt at this point will just generate a random matrix from our list. We're making progress!
> bundle exec exe/spiralizer go
What would you like to do? (choose a number)
1 - Build a matrix
2 - Generate a random matrix
> 2
generating...
Here is your M A T R I X
------------------------
1 2
3 4
5 6
7 8
Next, let's hook up a follow up prompt that asks us if we want to spiralize the matrix. Update your output_matrix
method:
def output_matrix
say "\nHere is your M A T R I X\n------------------------"
matrix.each { |arr| say arr.join("\t") }
say "\nWould you like to spiralize it? (choose a number)\n 1 - Yes\n 2 - No"
action = ask '> '
say "\n"
if action == '1'
say Spiralizer::Spiralize.new(matrix: matrix).perform
end
exit!
end
We are now prompted for more input after the matrix has been generated! Pretty neat.
> bundle exec exe/spiralizer go
What would you like to do? (choose a number)
1 - Build a matrix
2 - Generate a random matrix
> 2
generating...
Here is your M A T R I X
------------------------
M N O
P Q R
S T U
V W X
Would you like to spiralize it? (choose a number)
1 - Yes
2 - No
> 1
m n o r u x w v s p q t
This is looking good but we still need to handle our first option to allow users to create their own matrix. Let's add a method to handle that input now:
def user_range_and_dimensions
range_response = ask("Please provide range. Acceptable format: (A-L) or (1-6)\n> ")
values = range_response.split('-')
dimensions = ask("Please provide dimensions. Acceptable format: (4x3) or (3x2)\n> ")
return (values.first..values.last), dimensions
end
We want this to trigger when the user chooses '1'
from our introduction prompt so we need to re-work our go
method:
def go
say "What would you like to do? (choose a number)\n 1 - Build a matrix\n 2 - Generate a random matrix"
action = ask '> '
say "\n"
case action
when '1' then range, dimensions = user_range_and_dimensions
when '2' then range, dimensions = random_range_and_dimensions
else exit!
end
range, dimensions = random_range_and_dimensions
@matrix = Spiralizer::Matrix.the_matrix(range: range, dimensions: dimensions)
output_matrix
rescue Spiralizer::InvalidInput => e
say "\nUh oh! #{e.message}"
ensure
say "\nexiting..."
exit!
end
You should be getting the hang of how to use Thor
. Our class is need of some cleanup, but with some refactoring we end up with something like this:
require 'thor'
require 'spiralizer'
class Spiralizer::CLI < Thor
MATRICES = [
['A-L', '4x3'],
['M-X', '3x4'],
['1-8', '2x4'],
['1-144', '12x12'],
['C-J', '4x2'],
['A-Z', '13x2']
].freeze
attr_reader :action, :matrix
desc 'go', 'starts a prompt that brings users to spiral heaven'
def go
clear_screen!
intro_prompt
build_matrix
output_matrix
action_prompt
spiralize!
rescue Spiralizer::InvalidInput => e
say "\nUh oh! #{e.message}"
ensure
quit_softly
end
no_commands do
def intro_prompt
say "What would you like to do? (choose a number)\n 1 - Build a matrix\n 2 - Generate a random matrix"
@action = ask '> '
end
def build_matrix
say "\n"
case action
when '1' then range, dimensions = user_range_and_dimensions
when '2' then range, dimensions = random_range_and_dimensions
else quit_softly
end
@matrix = Spiralizer::Matrix.the_matrix(range: range, dimensions: dimensions)
end
def user_range_and_dimensions
range_response = ask("Please provide range. Acceptable format: (A-L) or (1-6)\n> ")
values = range_response.split('-')
dimensions = ask("Please provide dimensions. Acceptable format: (4x3) or (3x2)\n> ")
return (values.first..values.last), dimensions
end
def random_range_and_dimensions
say "\ngenerating..."
pause_for_effect
range_response, dimensions = MATRICES[rand(6)]
range = range_response.split('-')
return (range.first..range.last), dimensions
end
def action_prompt
say "\nWhat would you like to do with it? (choose a number)\n 1 - Spiralize it\n 2 - Look at it"
@action = ask '> '
end
def output_matrix
say "\nHere is your M A T R I X\n------------------------"
matrix.each { |inner| say inner.join("\t") }
end
def spiralize!
say "\n"
say Spiralizer::Spiralize.new(matrix: matrix).perform if action == '1'
end
def clear_screen!
return system 'cls' if Gem.win_platform?
system 'clear'
end
def pause_for_effect
sleep 0.5
end
def quit_softly
say "\nexiting..."
pause_for_effect
exit!
end
end
end
Now, let's try it all out:
What would you like to do? (choose a number)
1 - Build a matrix
2 - Generate a random matrix
> 1
Please provide range. Acceptable format: (A-L) or (1-6)
> 1-6
Please provide dimensions. Acceptable format: (4x3) or (3x2)
> 2x3
Here is your M A T R I X
------------------------
1 2
3 4
5 6
What would you like to do with it? (choose a number)
1 - Spiralize it
2 - Look at it
> 1
1 2 4 6 5 3
exiting...
Our little gem is complete! There is a lot of cleanup we can and should do, but this is where this blog post ends.
Feel free to add new functionality and experiment further with Thor. I've gone ahead and added a Crisscross
class for
example in my repo.
Oh! One more thing, if yoy run bundle exec rake install
bundler will install this gem on your system as a global executable so you can then call it like this:
> spiralizer go
If you ever need to uninstall it for some insane reason just run gem uninstall spiralizer
.
You can find a finalized version of the code here.
Well, there you have it. We've built a cli gem from scratch using Bundler
and Thor
. I hope this post serves you well and until next time!