kelan.io

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)

What it’s NOT (so I quickly dash any false hopes)

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

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

Known issues/Limiations

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.