Command line application with Ruby

The goal of this article is to show you how you can create your command line application using the Ruby programming language and make it available via Homebrew, so anyone can install it quickly and use in seconds.

The article consists of two main parts: building the command line application and making it available via Homebrew.

Building command-line application

Our goal is to build a get_joke application that will render a random joke about Chuck Norris. The command will also be able to parse the following arguments:

  • random - random jokes that should be displayed. By default, one joke is shown, but if you are prepared to read more, you can pass the -r or --random argument with the number of jokes you would like to see
  • first_name - the first name you would like to see instead of Chuck. It should be possible to pass the custom first name with -f or --first-name argument
  • last_name - the last name you would like to see instead of Norris. It should be possible to pass the custom last name with -l or --last-name argument
  • help - the help command that displays the list of available commands along with the short description. The command is rendered when the -h or --help argument is passed. The command should not display the joke when this argument is passed.

As the data source, we will use the http://api.icndb.com/jokes/random endpoint, which provides a nice and simple API for getting jokes about Chuck.

The program will consist of three parts:

  • Options parsing - we have to parse options passed to the get_joke command. We will achieve it with the OptionParser class, which is a part of the Ruby standard library
  • Request URL creation - depending on the arguments passed, we have to build a URL that we will call to get the joke or jokes
  • Request response parsing - once we will perform the request, we have to take care of parsing the response and displaying jokes in the console

We won't be using any custom Ruby gems, only code available out of the box with the Ruby.

Creating executable file

The very first step is to create an executable file. Create the project directory:

mkdir get_joke
cd get_joke/

and our executable file along with the proper permissions:

touch bin/get_joke
chmod +x bin/get_joke

Creating the program

We have to add the following shebang line to our script:

#!/usr/bin/env ruby

This line tells the shell what interpreter should be used to process the rest of the file. It's a preferred approach to the hardcoded interpreter's path to Ruby as it's more portable. We can now execute our program, and we shouldn't see any errors or output:

./bin/get_joke

Parsing arguments passed to the command

As I mentioned before, we will use the OptionParser class, a standard Ruby class for parsing command-line arguments. You can check the documentation https://ruby-doc.org/stdlib-2.7.0/libdoc/optparse/rdoc/OptionParser.html

Let's add support for the first argument - random :

#!/usr/bin/env ruby

require 'optparse'

options = {}

parser = OptionParser.new do |parser|
  parser.on("-r", "--random RANDOM_JOKES_COUNT", "Render n random jokes")
end

parser.parse!(into: options)

puts options.inspect

As you can see, the on method from OptionParser takes three arguments:

  • shortcut argument name, which is -r in our case
  • full argument name which is --random in our case
  • the description for the argument

You can now execute the command with -r argument to see how it's parsed:

./bin/get_joke -r 4
# => { random: 4 }

We can add --first-name and --last-name arguments the same way:

#!/usr/bin/env ruby

require 'optparse'

options = {}

parser = OptionParser.new do |parser|
  parser.on("-f", "--first-name FIRST_NAME", "Replacement for Chuck's first name")
  parser.on("-l", "--last-name LAST_NAME", "Replacement for Chuck's last name")
  parser.on("-r", "--random RANDOM_JOKES_COUNT", "Render n random jokes")
end

parser.parse!(into: options)

puts options.inspect

and execute our command to see how arguments are parsed:

./bin/get_joke --random 4 --first-name John -l Doe
# => { random: 4, :'first-name' => 'John', :'last-name' => 'Doe' }

The last step is to add support for the --help arugment:

parser = OptionParser.new do |parser|
  parser.on("-f", "--first-name FIRST_NAME", "Replacement for Chuck's first name")
  parser.on("-l", "--last-name LAST_NAME", "Replacement for Chuck's last name")
  parser.on("-r", "--random RANDOM_JOKES_COUNT", "Render n random jokes")

  parser.on("-h", "--help", "Prints this help") do
    puts parser
    exit
  end
end

The command will print a nice help output each time -h or --help argument will be passed:

./bin/get_joke --help

# Usage: get_joke [options]
#    -f, --first-name FIRST_NAME      Replacement for Chuck's first name
#    -l, --last-name LAST_NAME        Replacement for Chuck's last name
#    -r, --random RANDOM_JOKES_COUNT  Render n random jokes
#    -h, --help                       Prints this help

The arguments parsing part is done, so we can now begin preparing the API URL to reflect the argument's values passed to our command.

Preparing API URL with values passed as arguments

As I mentioned before, we will use http://api.icndb.com/jokes/random endpoint, which provides the jokes about Chuck Norris. The following combination of the API URL is available:

  • Multiple random jokes - https://api.icndb.com/jokes/random/4 - it will provide four random jokes about Chuck Norris
  • Jokes with the replaced first name - http://api.icndb.com/jokes/random?firstName=name - the value from firstName will replace "Chuck" string in jokes
  • Jokes with replaced last name - http://api.icndb.com/jokes/random?lastName=name - the value from lastName param will replace "Norris" string in jokes

The above variants can be combined to request five random jokes where Chuck Norris's name will be replaced with John Doe.

Here is the part of the code responsible for preparing the proper request URL:

require 'uri'
require 'rack'

base_url = "http://api.icndb.com/jokes/random"
base_url += "/#{options.fetch(:random)}" if options.key?(:random)
uri = URI(base_url)

query = {
  'firstName' => options[:'first-name'],
  'lastName' => options[:'last-name']
}.delete_if { |key, value| value.nil? }

uri.query = Rack::Utils.build_query(query) unless query.empty?

puts uri.to_s

The part consists of two steps:

  1. Building the base URL - when the random argument is not passed, one random joke is returned by the default. When the argument is passed, we add it to the end of the path
  2. Building query - we build a simple query and reject params where the value is not given. With a little help of Rack::Utils class, we created a query part of the URL

We can test it to see how it's working:

./bin/get_joke
# => http://api.icndb.com/jokes/random

./bin/get_joke -r 4
# => http://api.icndb.com/jokes/random/4

./bin/get_joke --random 4 --first-name John -l Doe
# => http://api.icndb.com/jokes/random/4?firstName=John&lastName=Doe

The second part is done so we can perform the request to the API and parse the response to render jokes in the console.

Parsing API response and rendering jokes

The goal is to use only code that is available out of the box in Ruby, so to perform the request, we will use the Net::HTTP class and get_response method. The response is in JSON format, so we have to parse it before doing anything else:

require 'net/http'
require 'json'

response = Net::HTTP.get_response(uri).body
parsed_response = JSON.load(response)

If the type attribute from the response equals success, we can render jokes. Otherwise, something went wrong, and it won't be funny. We should have in mind that the format of the response is different depending on the number of jokes we request.

When one joke is requested, the joke is available as a hash. When multiple jokes are requested, the response contains an array of jokes:

require 'net/http'
require 'json'

response = Net::HTTP.get_response(uri).body
parsed_response = JSON.load(response)

if parsed_response['type'] == 'success'
  value = parsed_response['value']
  value = [value] if value.is_a?(Hash)

  value.each_with_index do |joke, index|
    puts ''
    puts "Joke ##{index + 1}: #{joke['joke']}"
  end
  puts ''
else
  puts 'Something went wrong, please try again'
end

We can now put all parts into one and test it:

#!/usr/bin/env ruby

require 'optparse'
require 'net/http'
require 'uri'
require 'json'
require 'rack'

options = {}

parser = OptionParser.new do |parser|
  parser.on("-f", "--first-name FIRST_NAME", "Replacement for Chuck's first name")
  parser.on("-l", "--last-name LAST_NAME", "Replacement for Chuck's last name")
  parser.on("-r", "--random RANDOM_JOKES_COUNT", "Render n random jokes")

  parser.on("-h", "--help", "Prints this help") do
    puts parser
    exit
  end
end

parser.parse!(into: options)

base_url = "http://api.icndb.com/jokes/random"
base_url += "/#{options.fetch(:random)}" if options.key?(:random)
uri = URI(base_url)
query = {
  'firstName' => options[:'first-name'],
  'lastName' => options[:'last-name']
}.delete_if { |key, value| value.nil? }
uri.query = Rack::Utils.build_query(query) unless query.empty?

response = Net::HTTP.get_response(uri).body
parsed_response = JSON.load(response)

if parsed_response['type'] == 'success'
  value = parsed_response['value']
  value = [value] if value.is_a?(Hash)

  value.each_with_index do |joke, index|
    puts ''
    puts "Joke ##{index + 1}: #{joke['joke']}"
  end
  puts ''
else
  puts 'Something went wrong, please try again'
end

Let's have some fun:

./bin/get_joke

# => Joke #1: Chuck Norris originally wrote the first dictionary. The definition for each word is as follows - A swift roundhouse kick to the face.

let's have some more fun with a custom name:

./bin/get_joke -r 2 -f John

# => Joke #1: There is no theory of evolution, just a list of creatures John Norris allows to live.
# =>
# => Joke #2: When John Norris calls 1-900 numbers, he doesn't get charged. He holds up the phone and money falls out.

Our little program is now ready so that we can show it to the world.

Making the program available via Homebrew

Homebrew is a free and open-source software package management system for macOS and Linux. Thanks to Github's extensive use, we can easily publish our packages and make them available for other users.

Creating a Github repository

As I mentioned above, it's easier to publish our package when our code is hosted on Github. In this step, we will prepare the repository, which will be used by Homebrew to install packages in the system.

Prefix your repository with homebrew- so it can be easily added to the Homebrew. In my case, my username is pdabrowski6, and I added a repository called homebrew-get_joke, so the URL is the following: https://github.com/pdabrowski6/homebrew-get_joke

Add your repository and note the username and repository name as we would need them in the next step.

Adding Homebrew formula

Quote: Homebrew Formulae is an online package browser for Homebrew – the macOS (and Linux) package manager.

To add a new formula, add the Formula directory and create the get_joke.rb file inside:

mkdir Formula
touch Formula/get_joke.rb

The next step is to create a straightforward formula definition. In our case, it consists of the following elements:

  • Description
  • Homepage - URL address to the homepage of the package
  • Version - version number so Homebrew knows when to update the package after installation
  • URL - URL to the zipped repository that contains source code
  • install method - the installation process of our package

Taking into account the above elements, the complete formula definition can look as follows:

class GetJoke < Formula
  desc "render a random joke about Chuck Norris"
  homepage "https://github.com/github_username/github_repository"
  version "0.1"

  url "https://github.com/github_username/github_repository/archive/main.zip", :using => :curl

  def install
    bin.install "bin/get_joke"
  end
end

You can now push all files to the Github repository you created in the previous step.

Installing the package

To let know Homebrew that we would like to use formulas from our package, we have to use the tap command:

brew tap pdabrowski6/get_joke

As you can see, I used my GitHub username and the repository name , but without the homebrew prefix - thanks to the prefix, the tool knows that it's a package.

You can now install the package the same way you install other official packages:

brew install get_joke

The package was installed in your system; you can now use it:

get_joke
# => Joke #1: Some people ask for a Kleenex when they sneeze, Chuck Norris asks for a body bag.