YRKShellScriptHelper: Fun Shell Scripts Using Ruby
Let’s be honest here: No one has ever had any fun writing shell scripts more
than 3 lines long. First of all, you always are starting from scratch. And
to make the script have a nice “user experience", you end up adding so
much extra junk and special cases that you end up with a horrible complex
mess of code, which you may or may not be able to interpret when you need to
update the script 3 months later. And to do any complex string manipulation
or pattern matching you have to rely on shell utilities (grep, awk, sed)
which may or may not work like you expect on another system (egrep? gawk?).
And to top it off, I can never remember that [[ -e ${WACKY} ]] bash;
syntax);
Meanwhile, I have been using (and enjoying) Ruby more and more the past few years to write little scripts. So, I thought it might make a good replacement for bash for the purpose of writing shell scripts. The advantages are obvious: sane syntax, sane string manipulation, sane error handling. Basically just sanity all around. Just imagine!
But, it does have its downsides. Ruby is nice, but to call a shell command
you have to use system()
, which can get a bit tedious. So you would end up
with some pretty verbose scripts to do simple things. But hey, this is Ruby;
I’m pretty sure the whole point is to hack up some ugliness, hide it away
somewhere, and then write nice, clean code everywhere else. So that’s what I
did. I’m not badass enough yet with Ruby to make everything perfect, but I
did my best. And would love to have help making it even
better.
I call it YRKShellScript (yes, the Cocoa naming conventions are deeply ingrained in my head) or yrk-shell-script-helper, if you really prefer. It’s a Ruby class, which helps you have fun writing shell scripts. Yes, fun. Sound good? Read on to learn more about it…
Feature Summary (to entice you to read on)
- easy access to all of Ruby’s logic/flow control, string manipulation, etc.
- pretty output with powerful control of what to show
- a dry-run mode
- an interactive mode
- easily add options and set defaults
- easily validate arguments (and separate them from options)
- more fun than you’ve ever had writing shell scripts
What it’s NOT (so I quickly dash any false hopes)
- this is not a ruby shell (for that, try rush)
- this does not generate bash scripts from a Ruby DSL (Domain-Specific Language) (for that, try ShellScriptBuilder for Capistrano)
- this does not provide fun for non-nerds (for that, try something sparkly)
To jump right into some code, here is what a simple shell script looks like using my helper class.
who_likes_to_rock_the_party.rb
#!/usr/bin/env ruby -w
require 'yrk-shell-script-helper.rb'
script = YRKShellScript.new
script.run do
script.echo script.arguments.first
case script.arguments.first.downcase
when /babies/
script.echo "shake their booties"
when /hotties/
if script.arguments[1] == "naughty"
script.echo "shake your boobies"
else
script.error "Naughtiness required, but not present."
end
when "new zealand"
script.echo "Likes to rock the party!"
else
script.cmd "sleep 10"
script.echo "That was a pretty boring party"
end
script.echo
end
That’s a little tastes of how much nicer it is to do flow control and string manipulation with Ruby as opposed to Bash. Now, here is what happens when we run it a few times:
$ ./who_likes_to_rock_the_party.rb babies
# shake their booties
$ ./who_likes_to_rock_the_party.rb hotties
! Naughtiness required, but not present.
$ ./who_likes_to_rock_the_party.rb hotties naughty
# shake your boobies
$ ./who_likes_to_rock_the_party.rb me
$ sleep 10
# That was a pretty boring party
$ ./who_likes_to_rock_the_party.rb "New Zealand"
# Likes to rock the party!
Isn’t that fun! Admittedly that isn’t a terribly useful script, and doesn’t
even run many shell commands. But, we’ll get there. For now, some first
things to notice are that you just do script.cmd to run a command in the
shell, and you can do script.echo to output some text in a consistent manner
(the output is nicely colored in the terminal, but I didn’t spend the time
yet to fix my stylesheets to match here). I put hash comment markers before
any output, so you can easily differnetiate between output from the script
and output from a command in the script. (Sidenote: I got the idea from git
status
. I noticed that my eyes are accustomed to looking for comment
markers, and I bet yours are too.) There is also script.header and
script.error to show things slightly differently (all of these prefixes and
colors are customizable.) Finally, those following along closely probably
noticed that it showed the command it was going to run before it ran it.
Thats nice, but that could get annoying at times. Fortunately there are a
bunch of options for controlling the output. I’ll go into detail later, but
for that impatient among you:
$ ./who_likes_to_rock_the_party.rb me --verbosity=echos
# That wasn't much of a party
But, there is still some work to be done. What happens if we do this:
$ ./who_likes_to_rock_the_party.rb
./who_likes_to_rock_the_party.rb:8: undefined method `downcase' for nil:NilClass (NoMethodError)
from /Users/kelan/nix/yrk-shell-script-helper.rb:247:in `call'
from /Users/kelan/nix/yrk-shell-script-helper.rb:247:in `run'
from ./who_likes_to_rock_the_party.rb:5
Whoops. That’s decidedly not fun. What can we do about that?
Well, there are a few methods you can call before calling #run
, which let
you add options, set options’ default values, process standard input, and
finally validate arguments. You pass a block to the appropriate method, and
it will get called at the right time. Ruby’s blocks made this pretty clean
and easy. Again, let’s just look at some code.
crazy.rb
#!/usr/bin/env ruby -w
require 'yrk-shell-script-helper.rb'
script = YRKShellScript.new
# Do validation of the arguments
script.check_arguments do
# we want to require at least 1 argument
if script.arguments.length < 1
raise ArgumentsNotValid, "You need at least 1 argument to get this crazy"
end
end
script.run do
script.echo "woooooooooooooooo!"
end
And when we run it
$ ./crazy.rb
! Arguments not valid: You need at least 1 argument to get this crazy
$ ./crazy.rb one
# woooooooooooooooo!
A few notes
- You need to access the script’s values through the accessors to the
instance you create. See how I called
script.arguments.length
andscript.error
. - In the check_arguemnts block, you need to raise the ArgumentsNotValid exception if you want to not accept the arguments. You can optionally pass an error message.
And, I’ve already given some hints about the next fun feature: options! I
mostly just took advantage of Ruby’s OptionParser class, which is pretty good.
But, I added some handy default options, mostly used to control output, and a
few other cool things. These are the heart of what this is all about for me.
When writing shell scripts, I always want quick, easy ways to control the
output and help me test it, but it’s always so tedious with bash that you
usually do a hold-your-breath-and-hope-for-the-best move when running it for
the first time. Or put an exit 0
in the middle of your script, run it, move
it down, repeat (over and over) until you’re finally running the whole thing.
Neither of these are very fun.
So, now you can do a --dry-run
to have it only print the commands it would
run, but not actually run them (though, you can force commands to be run even
in this mode… see the :force => true
parameter to #cmd
). You can also
do --debug
to have it print out a bunch of junk, and even --interactive
to
have the script stop before each command and ask you if it should run it. And
this is all for free! Furthermore, you can add your own options, and set the
default values by passing a block to the #add_options
and
#set_options_default_values
methods, respectively.
One area that is particularly under-perfected at this stage is the
#get_input
method. The idea is to have an easy way to prompt the user for
input, and do some basic validation of their response. As of now, you give it
an array of possible responses (single-letter only), and the first one is the
default if they just press return. It’s not great, but it’s better than
nothing. It’s worked ok for me so far, but could definitely be fancier.
Consider this part a work-in-progress. One nice feature it does have is an
optional timeout (after which it just going with the default response).
get_input.rb
#!/usr/bin/env ruby -w
require 'yrk-shell-script-helper.rb'
script = YRKShellScript.new
script.run do
response = script.get_input("Enter something.", ["y", "n"], 15)
script.echo "you entered #{response}"
end
And run
$ ./get_input.rb
Enter something. (timeout in 15 secs.)
[Y/n]? n
# you entered n
Full Demo
As I was writing this stuff, I made a demo script to play with all the features. It’s also on the github page, and should be generously commented. I don’t think it’s necessary to go over it here, as you’ve already seen the highlights. But it’s a good place to get started if you want to play around at home.
Some other notes
- You can chain commands together in a single
#cmd
, if you want (as inscript.cmd "git co master && git pull
“). But they are run as a single command. - I don’t have any full-blown official documentation yet. I think it’s self-explanatory for now. And, the code is readily accessible on github. This has all been in a pretty heavy state of churn, up to and during me writing this post. Hopefully things will settle down now and I can try to work on some more formal docs.
- Since each
#cmd
gets run in a separate instance of the shell, you can’t do cd and have it “stick”. To get around this I added the#cd
method, which works exactly as you would expect. - Being the hardcore hacker than I am, I usually a terminal with a dark background. I picked the colors to look best there. But, if you don’t like them, it’s easy to change the “Output Decoration" variables at the top of the script.
- I’m releasing all this code under the BSD 3-clause license. So, please feel free to use this for whatever you want. But, I would like to know what you think, and especially if you find this at all useful.
Known issues/Limiations
- You don’t inherit the outer shell’s environment variables, nor do you
automatically source
~/.bash_profile
(or~/.bashrc
). I wish you did, at least optionally, but I couldn’t get it to work. Any tips would be appreciated. - I use this on Mac OS X 10.5 Leopard and also on a few flavors of linux (mostly Fedora 9). But, I’m not sure if the dependencies and paths are all set up for simple drop in usage anywhere. If you have any problems or improvements, please let me know.
- One bit of ugliness is the fact that I sometimes want to capture all output
from the commands. So I first tried
IO#popen
, but that doesn’t let you capture stderr, which I wanted to show if there is an error. So I moved to usingOpen3#popen3
, which is part of the standard library. However, that wasn’t properly setting the exit status of the subprocess in$?
, and I read here that it’s a known bug in Ruby 1.8.5 (which apparently persists in 1.8.6 on Mac OS X Leopard). So, based on Nate’s helpful suggestion, I moved to using Open4. However, since that’s a custom gem, I first check if therequire 'open4'
succeeds, and then conditionally use#popen3
or#popen4
based on that knowledge. It’s not fun, but is at least hidden in theYRKShellScript
class and user scripts never have to think about it (except that they can’t correctly detect if a command fails when running in a mode that captures command output). If anybody can think of any other ways around this, I’d be happy to improve the situation.
Summary
I imagine this stuff is more useful for non-trivial scripts that need a fair
bit of flow-control logic, string manipulations, and/or a nice interface.
Bash one-liners are still great, and my .bash_profile
is definitely a
testament to that. But, I’ve seen some pretty hairy shell scripts written in
bash, and would have much rather dealt with them in Ruby. I expect I’m not
alone in this.
I am finishing up some example scripts (which I use to help my daily workflow with git, and even help me keep all my text note files synchronized across multiple computers). That’s going to be a whole ‘nother post, which I hope to get up soon. Stay tuned.