The Nine Facets of an Awesome Command-Line App
April 01, 2012 📬 Get My Weekly Newsletter ☞
When creating the outline for my book (now officially published and in print!), I decided to organize it around the nine facets of an awesome command-line app. Each chapter focuses on one of these facets. They state that an awesome command-line app should:
- have a clear and concise purpose
- be easy to use
- be helpful
- play well with others
- delight casual users
- make configuration easy for advanced users
- install and distribute painlessly
- be well-tested and as bug free as possible
- be easy to maintain
In this post, I’ll illustrate each of these facets (along with a test of the tenth chapter on color and formatting), via a code walkthrough of a simple command-line app I created for work.
LivingSocial (where I work) processes thousands of credit card transactions per day, across a highly distributed, asynchronous system. When things go wrong, the log files are the first place I look to find answers. This means that grep
is my go-to tool for analysis. Even though grep
can highlight search terms in output, with long and complex log lines, it can be hard to pick out just what I’m looking for. I needed a tool to just highlight text, but not actually “grep out” non-matching lines.
To the command-line!
So, in just a few short hours, hl was born. I wrote it using TDD, and, even though it’s barely 100 lines of code, it hits all the notes of an awesome command-line app (if I do say so myself :). Let’s go through all nine of our “facets of an awesome command-line app” and see what the fuss is about.
Have a Clear & Concise Purpose
The best way to have a clear & concise purpose is to do one thing, and one thing only. hl
highlights search terms in any output to assist with visual scanning of output. It doesn’t highlight multiple terms, and it doesn’t remove non-matching lines. It just highlights terms. One thing, and one thing only.
Be Easy to Use
This is a big topic, but here’s an example of using hl
:
$ grep 987876736 my_logs.log | hl credit_card_token
hl
does what it’s asked, by default, without a lot of fuss, just like any other UNIX command. It has options, but you never
need to worry about them in most cases. Of course, if you are curious about those options, that leads to our next facet.
Be Helpful
hl
is based on methadone, which is a proxy to OptionParser, which is the tool to use for parsing the command-line in Ruby. It’s very powerful, and generates a canonical, documented UI for your app:
$ bin/hl --help
Usage: hl [options] [search_term] [filename]
Highlight terms in output without grepping out lines
v1.0.0
Options:
-c, --color COLOR Color to use for highlighting
(red|green|yellow|blue|magenta|cyan|white)
(default: yellow)
-b, --[no-]bright Use bright colors
-n, --[no-]inverse Inverse highlight
-u, --[no-]underline Underline highlight
-p, --regexp PATTERN Search term as explicit option
-i, --[no-]ignore-case Ignore case in match
--version Show help/version info
Default values can be placed in the HL_OPTS environment variable
Note how much OptionParser
gives us:
- Ability to describe our app, its version, and basic invocation syntax
- Nicely formatted list of options and descriptions
- Ability to accept “negatable” options (we’ll talk about that in a second)
Further, I’ve gone to the trouble to make sure that --color
clearly indicates the acceptable values as well as the default. Finally, I’ve made sure that all options are available in short-form (for easy typing on the command line) and long-form (for clarity when scripting and configuring our app).
Here’s the code that makes this happen (if you aren’t familiar with methadone, the method on
behaves almost exactly like the on
method in OptionParser
):
#!/usr/bin/env ruby
require 'optparse'
require 'methadone'
require 'hl'
class App
include Methadone::Main
include Methadone::CLILogging
main do |keyword,*filenames|
# main logic here
end
description "Highlight terms in output without grepping out lines"
options[:color] = 'yellow'
colors = ['red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
on("-c COLOR", "--color","Color to use for highlighting",colors,"(#{colors.join('|')})")
on("--[no-]bright", "-b", "Use bright colors")
on("--[no-]inverse", "-n", "Inverse highlight")
on("--[no-]underline", "-u", "Underline highlight")
on("--regexp PATTERN", "-p", "Search term as explicit option")
on("--[no-]ignore-case","-i", "Ignore case in match")
arg :search_term, :optional
arg :filename, :optional
version Hl::VERSION
defaults_from_env_var 'HL_OPTS'
go!
end
Methods like arg
, version
, and description
are helpers from methadone (see the intro for more), but note how little code it takes just to make a great and polished UI.
The second part of a helpful app is to include more detailed documentation. For a command-line app, this is expected to be in the form of a man page. If you installed hl
with RubyGems, try this:
$ gem man hl
You should see a nicely formatted man page (which also happens to be the README
for the github project)! Creating a man page is extremely simple thanks to ronn. ronn
converts Markdown to troff, the format used by the man system. Just add this to your Rakefile:
require 'methadone'
require 'fileutils'
include Methadone::SH
include FileUtils
task :man do
sh 'ronn --markdown --roff man/hl.1.ronn'
mv 'man/hl.1.markdown','README.md'
end
And, your gemspec just needs:
s.add_development_dependency('ronn')
s.add_dependency('gem-man')
You’ll also need to include the generated file man/hl.1
in your files
in your gemspec, but if you’re using the gemspec created by Bundler, this happens automatically as long as the file is in source control.
That’s it. Now your app has a great UI and a man page, and all you had to do was drop a few lines of code and write a short Markdown file (which you’d write anyway, since you are making a README, right?).
In addition to being helpful to humans, awesome command-line apps should be helpful to other commands.
Play well with others
An app that “plays well with others” on the command line, basically means that it acts as a filter. Text comes in, gets processed, the processed text goes out. The expectation is that text from any other “well playing” program can be input into our program, and that our program’s output can be piped into another program as input.
Since the purpose of our app is to add ANSI escape codes to the output for assistance with human visual scanning, we can’t claim that our output plays well with others; it’s not designed to. But, we can still play well with the output from other apps.
We saw that hl
was designed to take input from a tool like grep
. hl
can also highlight terms from any number of files given to it on the command line. You can do this transparently in Ruby using the awesome ARGF, however Methadone doesn’t support ARGF (a sad fact I learned while writing this app, and something I’ll address in the near future), so here’s how did it (a few comments added to indicate what’s going on):
# filenames is a possibly empty list of strings
files = if filenames.empty?
[STDIN]
else
filenames.map { |_| File.open(_) }
end
# files is now an Array of open IO objects
begin
# highlighting code
ensure
# we close the files since we didn't open them in "block" form; closing STDIN is OK to do
# since we know our app will soon exit
files && files.map(&:close)
end
Again, ARGF handles this transparently, but the point is, we want the standard input and a provided list of files to be treated the same by our program, and this is how I did it.
Since our app is similar in concept to grep, I thought it would be nice if users familiar with grep could be instantly familiar
with hl
.
Delight Casual Users
This is a “level up” from “being easy to use”. The idea behind the term “delight” is to provide a level of polish and attention to detail that your users will appreciate if they’re observant, but hopefully not even notice, because your app “just works”.
Since hl
, like grep
, is used for filtering and examining text files, I chose my command-line options to match grep
’s where i could. Initially, I had the short-form of --inverse
as -i
. When I later added the ability to do a case-insensitive match, I realized that -i
is the option to grep
for “case-insensitive”. I quickly changed --inverse
to have -n
as its short-form, and made -i
and --ignore-case
the options for case-insensitivity. These are the same values that grep
uses, so a user who might subconciously type hl -i
expecting a case-insensitive match will get it.
Further, I allowed the user to specify the search term either as a command-line argument, or as the argument to -p
or --regexp
, which are the option names grep
uses. It’s a basic principle of design that things that are the same should be exactly the same, so I used grep
as my guide when hl
implemented similar features.
Of course, power users love to customize things.
Make Configuration Easy
In the book, I talk about using YAML as a configuration format for an .rc
file. This can be very useful for complex apps, but another technique that’s handy is to allow an environment variable to hold default options. grep
does this via GREP_OPTS
and if you were paying attenion, you noticed this line in bin/hl
:
defaults_from_env_var 'HL_OPTS'
This tells methadone to look at the environment variable HL_OPTS
(as well as the command line) for any options. These options are placed first in ARGV
, essentially like so:
String(ENV[@env_var]).split(/\s+/).each do |arg|
::ARGV.unshift(arg)
end
(Note the use of String
to make sure that nil
gets turned into the empty string, saving us an if
statement). Methadone does this before parsing ARGV
. Using unshift
means that any options the user specifies will come after those in HL_OPTS
and therefore take precendence:
$ export HL_OPTS=--color=cyan
$ grep foo some_log.txt | hl --color=magenta
This is the same as
$ grep foo some_log.txt | hl --color-cyan --color=magenta
This is also why I’ve provided the “negatable” forms. Suppose you generally wanted inverse:
$ export HL_OPTS=--inverse
If you wanted to run hl
without inverse, but there was no negatable option, the only way to turn it off would be to unset the environment variable. With the negatable forms, it’s simple:
$ grep foo some_log.txt | hl --no-inverse
Since the user’s command-line options take precedence, things work out, but you can still configure your defaults.
Finally, I’d recommend that you use the long-form options in your configuration. In other words, if you prefer bright and inverted highlights, do this:
$ export HL_OPTS='--inverse --bright'
As opposed to
$ export HL_OPTS=-nb
The second form is more compact, but your configuration is going to be read more than written, and, 6 months from now when you are going through your .bashrc
, you’re going to appreciate seeing things spelled out; you’ll know instantly what the configuration does and don’t have to wonder about what -n
means.
Distribute Painlessly
RubyGems:
$ gem install hl
$ hl --help
That is all.
Be well-tested
I wrote hl
entirely using TDD and entirely using aruba. Here’s a sampling:
Scenario: Highlights with case insensitivity
Given a file named "test_file" with the word "FOO bar foo" in it
When I successfully run `hl -i foo ../../test_file`
Then the entire contents of "test_file" should be output
But the word "foo" should be highlighted in yellow
And the word "FOO" should be highlighted in yellow
It was very easy to do this, although aruba could use a man page for easier reference. I had to jump into its source too many times to get reminded of the syntax of the steps it provides. Aruba also strips out ANSI escape sequences, which made testing hl
a bit tricky. There appears to be an option to prevent this, but I couldn’t get it to work, so I just used Aruba’s internal API:
Then /^the word "([^"]*)" should be highlighted in (.*$)$/ do |keyword,color|
# #color is provided by rainbow, which we'll talk about in a bit
expected = keyword.color(color.to_sym)
# assert_partial_output and all_stdout are provided by aruba
assert_partial_output(expected,all_stdout)
end
I still recommend aruba and cucumber, as it forces you to think about how users will use your app first, not how to implement it. In fact, my initial implementation was a big hacky mess of stuff inside the main
block. Once the tests were in place, I refactored it to be a lot cleaner.
Be Easy to Maintain
As I just mentioned, I was able to use my tests to refactor my code. As such, the main block of hl
is pretty simple:
main do |keyword,*filenames|
if options[:regexp]
Array(filenames).unshift(keyword)
keyword = options[:regexp]
end
exit_now! 'search term or --regexp/-p required' if keyword.nil?
keyword = keyword.dup
highlighter = Hl::Highlighter.new(options)
puts highlighter.highlight(filenames,keyword)
end
This is the sort of logic you want in your main
block:
- Handling the keyword-from-argument and keyword-from-command-line-option case
- Simple error checking
- Duping the keyword (since it comes in frozen)
- Calling our
Highlighter
class to do the real work
We defer all non-UI logic to the Highlighter
class. I decided to make each instance of the class able to highlight any files repeatedly based on a configuration, so the constructor takes in the formatting options, and the method highlight
takes the list of filenames and the search term.
The actual highlighting is made possible via lots of list comprehension:
files.map { |_| _.readlines}.flatten.map { |_| highlight_matches(regexp,_) }.join("")
If you aren’t comfortable with this use of chained calls, it can be very powerful. What this does is:
- Map each file to an array of its contents as lines.
[foo,bar]
becomes[ ['first line of foo\n','second line of foo\n'],['first line of bar\n'],['second line of bar\n']]
- Flatten that array of arrays to just one list of all lines of all files. Our example array becomes:
[ 'first line of foo\n','second line of foo\n','first line of bar\n','second line of bar\n']
- map those lines to the lines with the search term highlighted. Supposing we wanted to highlight the word “line”, our array becomes:
[ 'first \e[33mline\e[0m of foo\n','second \e[33mline\e[0m of foo\n','first \e[33mline\e[0m of bar\n','second \e[33mline\e[0m of bar\n']
- join them all together into one big string
"first \e[33mline\e[0m of foo\nsecond \e[33mline\e[0m of foo\nfirst \e[33mline\e[0m of bar\nsecond \e[33mline\e[0m of bar\n"
Granted, this approach will probably have trouble with extremely large input, but hl
was designed to work with the output of grep
, so hopefully we won’t have too much (I’ve already decided I need it to work with tail
).
Breaking the rules
Color and formatting are not typically associated with awesome command-line apps; too much of it makes an app hard to use with other apps. But, the whole purpose of hl
is to colorize output, so for that, I used rainbow, which is a pretty
simple enhancement to String
that allows coloring and formatting. We can see it in action in the highlight_string
method of Highlighter
:
def highlight_string(string)
string = string.color(@options['color'].to_sym)
string = string.inverse if @options[:inverse]
string = string.bright if @options[:bright]
string = string.underline if @options[:underline]
string
end
Each method called on string
is a method provided by Rainbow. These methods return a new string with the appropriate ANSI escape codes added.
In Conclusion
Hopefully, you’ve seen that it’s really not that hard to make an awesome command-line app. I was able to write hl
in just a few hours, using TDD and the end result is a highly polished, well-documented, easily installable and maintainable piece of software that will be a part of my command-line arsenal for quite a while. You can do this, too. There’s a lot more detail and in-depth explanations in my book, which you should buy right now :)