Let's Build a CLI

The Weekly Iteration

This video is only a short sample, but you can access the full version and all our other great content by subscribing.

Video

Notes

What are Command Line Utilities

To start, when we say "CLI" or "command line utility," we're talking about a program that we can run at the command line by simply typing the command name. git is a great example of the sort of commands we could write (although ours will of course be a good bit simpler).

Getting Started

To begin, we'll work with a very simple bash script with the following contents

echo 'hello world'

We can then run this very basic shell script by specifying the sh interpreter (similar to using ruby script.rb):

$ sh script.sh

This is a great start, but our goal is be able to run this without specifying the interpreter, so we can try that as follows (for now we'll need to specify the current path with ./)

$ ./script.sh
zsh: permission denied: ./script.sh

Here we hit our first wall since file can not be executed directly.

Mark it as executable

By default, the text files we create are not executable and we have to explicitly mark them as executable if we want to use them without always needing to specify the language / interpreter to use. We can mark our script as executable using the chmod command:

$ chmod +x script.sh

And now we can directly run our script with ./script.sh.

Add a shebang line

Shell is fine, but it is far from my favorite language to write in. Instead, I'd rather write in a language like Ruby. We can rename our script file to script.rb and update the contents to:

puts 'hello world'

but now we've broken our script. If we try to run it based on its new executable mode, we'll get an error:

$ ./script.rb
./script.rb: line 2: puts: command not found

This error occurs because the shell, by default, is running our script with the sh (shell) interpreter. It doesn't much care about the file extension being "rb", that's for humans and text editors.

Instead, we can specify the interpreter to use, ruby in this case, using a "shebang" line. This is a special comment that must be the first line in our file, and specifies the interpreter to use. The contents of our script.rb file are now:

#!/usr/bin/env ruby

puts 'hello world'

Here, we've added the "shebang" line to specify that we want to run via ruby. In fact, we're going a bit further and using /usr/bin/env which is a command to which we pass the argument ruby, and this command will then provide a specific version of ruby. This is useful if we're working with multiple rubies via rbenv, chruby, or other tool to manage multiple version of ruby.

Now, we can run our script directly without needing to re-specify ruby, but we can even go a bit further and remove the ".rb" extension:

$ ./script.rb
hello world

$ mv script.rb script

$ ./script
hello world

These all work, but just to go crazy, we can actually change our shebang line to specify many different interpreters, even node!

#!/usr/bin/env node

console.log('hello world from JavaScript')
$ ./script
hello world from JavaScript

Add it to your path PATH

The final step in building up our command line script is to free ourselves from having to know the location of the script, and instead be able to run it from anywhere.

In order to do this, we can hook into a special shell variable called $PATH. The $PATH is an ordered list of directories, separated by :s, that the shell will search through to find executables.

As an example, here is the (abbreviated) value of $PATH on my system, splitting to show one directory per line:

$ echo $PATH | tr \: \\n
/Users/christoomey/.gem/ruby/2.1.5/bin
/Users/christoomey/.rubies/ruby-2.1.5/lib/ruby/gems/2.1.0/bin
/Users/christoomey/.rubies/ruby-2.1.5/bin
/Users/christoomey/Library/Haskell/bin
/Users/christoomey/bin
/usr/local/bin
/usr/bin
/bin
/sbin
/opt/X11/bin
/usr/local/go/bin

In this case, I can take our script file and move it into my ~/bin/ directory which is on my path, and from there I can run the script from anywhere!

$ mv script ~/bin/

$ script
hello world

You can add a directory like ~/bin to your path by adding a snippet like the following to your shell startup file (~/.bashrc, ~/.zshrc, etc):

export PATH="$HOME/bin:$PATH"

For a more in depth overview of $PATH setting, check out this great Unix StackExchange answer, or take a peek at how the thoughtbot dotfiles work with the PATH.

Example Use Cases

Now that we know how to build these command line scripts and make them available everywhere, the question becomes, "What can we do with them?" It turns out we can do a whole lot.

Custom General Purpose Utilities

The first and likely largest category these sort of command line scripts fall into are general purpose helper scripts. A few examples are:

  • tat - a script to automate creating (or switching to) tmux sessions based on the current directory's name
  • bin/setup a setup script in Upcase (all suspenders apps are generated with one).
  • bin/deploy similar to bin/setup, this script automates deploying Upcase.

Custom Git Subcommands

One specific use case for command line scripts is to automate Git workflows. git will automatically look for executable scripts on your $PATH that match the command name, so running git churn will search for a script named git-churn that is marked as executable and available on your path.

As an example, I have the script called git-shalector in my ~/bin directory which I can then run as:

$ git shalector

git-shalector is written in Bash, but like with all our command line scripts, we can choose any interpreted language to use, so git-publish is written in Ruby.

Custom Bundler Subcommands

Similar to Git, Ruby's Bundler has the same sort of path lookup for subcommand magic built in. The thoughtbot dotfiles includes a custom bundler-search subcommand that uses bundle and ag (the_silver_searcher) to search for a pattern in all of your active gems.

$ bundle search 'semantic_form_for'

Tips for Building CLIs

Using Proper Exit codes

Often we want to chain together a number of commands using &&, knowing that if any command in the sequence fails, the whole sequence will stop. In order to support this, we need to return a specific exit code from our script.

0 as the exit code means "success" and the next command will run, any other value means "failure" and that no further commands in the && sequence should run. Also, we can always check $? as it will contain the exit code of the previous command:

$ true && echo hello
hello

$ false && echo hello
# no output as `false` returned non-zero exit code

$ echo $?
1

By default our scripts will exit with 0 as the status code indicating success, but we can explicitly set the exit code to indicate failure as needed:

# new script file called 'hello'
puts 'hello world'
exit 1

With the addition of exit 1, this command now is viewed as failing, so if we chain it:

$ ./hello && echo after
hello world

Outputting Error Text

This is a bit more advanced, but since this often confuses newer users, we wanted to cover how to work with error text. By default, all output is sent to the standard output, aka stdout, stream. In the event of an error, we often want to alert the user by presenting some sort of message, but we don't want to muck up the normal output of the script.

Luckily, there is a second output stream known as standard error or stderr, and we can send any error text to it.

In bash this would done by echoing and redirecting to 2, which is the numeric way to reference stderr:

echo 'this is an error' >&2

Similarly, in Ruby we have a special IO global we can use called $stderr:

$stderr.puts 'this is an error'

In some cases, we just want to silence both the output and error messages. We can do this by redirecting stdout and stderr. Given we have the following contents in our hello script file:

#!/usr/bin/env ruby

puts 'hello world'
$stderr.puts 'errroorrrrrr'
exit 1

We can run the script, redirecting the output to the file output.txt, but allowing the error message to be displayed:

$ ./hello > output.txt
errroorrrrrr

$ cat output.txt
hello world

Similarly, we can combine stderr into stdout and redirect both into our file:

$ ./hello > output.txt 2>&1

$ cat output.txt
errroorrrrrr
hello world

Lastly, we can explicitly ignore the error output by redirecting stderr to /dev/null which is essentially a black hole:

$ ./hello > output.txt 2>/dev/null

$ cat output.txt
hello world
×

15 Full Courses, 100+ Screencasts & New Content Weekly