Introduction to GLI

December 02, 2013 📬 Get My Weekly Newsletter

Sitepoint recently published Introduction to Thor and, to be honest, I don’t think Thor is a great tool for writing command-line apps. Thor is a great for writing Rails generators (likely the only reasonable tool), but I wrote GLI specifically because I wanted a tool tailor-made to write awesome command-line apps.

With the re-release of my book, which uses GLI to demonstrate how to build amazing command-line apps in Ruby, I thought I’d mimic Sitepoint’s post with a GLI version, and let you decide for yourself.

What is GLI?

GLI is a Ruby library designed to make writing a “command-based” application (which I call a “command suite”) very easy. It’s designed to make the simple things simple, but to not hide anything from the developer.

I won’t go back to getopt, but a fairly common way to create a command suite application is to use OptionParser to get command line options, and then parse ARGV directly to figure out the “command”:

include "optparse"

options[:file] = "~/.todo"

opts = OptionParser.new do |opts|
  # declare a new options
  opts.on(
      "-f FILE","--file", # it can be -f or --file and requires an argument
      "Location of the todo list file (default ~/.todo)") do |file|
    options[:file] = file # when the user specifies it, save the argument in options[:file]
  end

  opts.on(
      "-l","--long",
      "List todo elements in long form") do |long|
    options[:long] = long
  end

  opts.on(
      "-a","--all",
      "List all todos, not just ones that haven't been completed") do |all|
    options[:all] = all
  end
end

opts.parse!  # parse the options, modifying ARGV

command = ARGV.shift

case command do
when 'new': 
  # Add a new todo to options[:file]
when 'done'
  # complete a todo and rewrite options[:file]
when 'list'
  # use options[:all] and options[:long] to output
  # the todo list in options[:file]
else
  # Print help
end

There are a few problems with this:

  • The --all and --long options are only relevant to the list command
  • There’s no explicit documentation of the commands - we have to hope that the generic help will tell us what they do
  • The option handling code is very duplicative and boilerplate
  • Making this robust is tricky - if the user passes wrong options, we’ll get a bad message

These are problems solvable by a framework more sophisticated than OptionParser

First Steps with GLI

Typically, a new GLI app is generated for you by the gli command-line app:

> gem install gli
Fetching: gli-2.8.1.gem (100%)
Successfully installed gli-2.8.1
> gli init todo new done list
> cd todo
> bundle install
> bundle exec bin/todo help
NAME
    todo - Describe your application here

SYNOPSIS
    todo [global options] command [command options] [arguments...]

VERSION
    0.0.1

GLOBAL OPTIONS
    -f, --flagname=The name of the argument - Describe some flag here (default: the default)
    --help                                  - Show this message
    -s, --[no-]switch                       - Describe some switch here
    --version                               - Display the program version

COMMANDS
    done - Describe done here
    help - Shows a list of commands or help for one command
    list - Describe list here
    new  - Describe new here

OK, so what happened? We haven’t written any Ruby code, but we ran some commands, and had to use bundler.

GLI makes a few assumptions about how you want to work:

  • You want a canoncially set-up Ruby project structure
  • You want to write tests
  • You want to distribute via RubyGems

None of these are requirements for GLI, so you could just as easily gem install gli and get to work. The reason we are using Bundler is because bin/todo does not hack the load path to load our files in lib. At runtime, RubyGems will configure the load path for our users, so everything in lib will be available. In development, we don’t have that, so we use Bundler, which does the same thing. You could also do RUBYLIB=lib bin/todo help if you prefer.

Back to our app, you’ll notice that we have an application that produces a pretty decent help system already, so what does the code look like?

#!/usr/bin/env ruby
require 'gli'
require 'todo'

include GLI::App

program_desc 'Describe your application here'

version Todo::VERSION

desc 'Describe some switch here'
switch [:s,:switch]

desc 'Describe some flag here'
default_value 'the default'
arg_name 'The name of the argument'
flag [:f,:flagname]

desc 'Describe new here'
arg_name 'Describe arguments to new here'
command :new do |c|
  c.desc 'Describe a switch to new'
  c.switch :s

  c.desc 'Describe a flag to new'
  c.default_value 'default'
  c.flag :f
  c.action do |global_options,options,args|
    puts "new command ran"
  end
end

desc 'Describe done here'
arg_name 'Describe arguments to done here'
command :done do |c|
  c.action do |global_options,options,args|
    puts "done command ran"
  end
end

desc 'Describe list here'
arg_name 'Describe arguments to list here'
command :list do |c|
  c.action do |global_options,options,args|
    puts "list command ran"
  end
end

pre do |global,command,options,args|
  true
end

post do |global,command,options,args|
end

on_error do |exception|
  true
end

exit run(ARGV)

Since we specified new done list on the command line to gli init, it went ahead and created command blocks for us. Notice that each command block is configured in the style of rake - we describe the command, document its arguments, and declare that it exists. You’ll notice that each command has a generic puts in it, so we can see how our new app works right now:

> bundle exec bin/todo list
list command ran

> bundle exec bin/todo done
done command ran

We can also get help for particular commands already:

> bundle exec bin/todo help list
NAME
    list - Describe list here

SYNOPSIS
    todo [global options] list Describe arguments to list here

Not bad for having written absolutely no code!

Filling it in

Let’s replace the boilerplate with what we need for our todo list app.

desc 'Location of todo file'
default_value '~/.todo'
arg_name 'path_to_file'
flag [:f,:file]

desc 'Create a new todo item'
arg_name 'text_of_todo'
command :new do |c|
  c.action do |global_options,options,args|
    todo = args.join(' ')
    # Add todo to the file at global_options[:file]
  end
end

desc 'Complete a todo'
arg_name 'todo_id'
command :done do |c|
  c.action do |global_options,options,args|
    id = args.shift
    # Locate id in global_options[:file] and mark it completed
  end
end

desc 'List todo items'
command :list do |c|
  c.desc 'Use long format'
  c.switch [:l,:long]

  c.desc 'Show all items, even uncompleted ones'
  c.switch [:a,:all]

  c.action do |global_options,options,args|
    # Read todos from global_options[:file]
    # and then use options[:long] and
    # options[:all] to figure out what
    # to display
  end
end

Basically, we’ve just replaced boilerplate text with our app-, command-, and option-specific help text. We also removed the example flags and switches and replaced them with the ones we’ll actually need.

Notice that we specified --file outside of any command block, thus making it a global flag. This is because all commands need access to the todo file. Note also that the options --long and --all, which are specified inside the list command block, will only be available for the list command.

> bundle exec bin/todo help 
NAME
    todo - Describe your application here

SYNOPSIS
    todo [global options] command [command options] [arguments...]

VERSION
    0.0.1

GLOBAL OPTIONS
    -f, --file=path_to_file - Location of todo file (default: ~/.todo)
    --help                  - Show this message
    --version               - Display the program version

COMMANDS
    done - Complete a todo
    help - Shows a list of commands or help for one command
    list - List todo items
    new  - Create a new todo item

> bundle exec bin/todo help new
NAME
    new - Create a new todo item

SYNOPSIS
    todo [global options] new text_of_todo

> bundle exec bin/todo help list
NAME
    list - List todo items

SYNOPSIS
    todo [global options] list [command options] 

COMMAND OPTIONS
    -a, --[no-]all  - Show all items, even uncompleted ones
    -l, --[no-]long - Use long format

Notice how we see the documentation relevant to the command, and not in one global space? Handy.

What I like about this design is that, although it’s not “object-oriented”, it’s obvious and clear. A command-line interface isn’t OO, it’s declarative and command-oriented, so it makes sense to me that we describe our UI in the same way.

Also notice the structure of the command line. In a Thor app, all command-line options must come at the end of the command line. In a GLI app, the position of the switches determines their interpretation.

> bin/todo -f ~/.todo.txt -l list
error: Unknown options -l
> bin/todo list -l -f ~/.todo.txt
error: Unknown option -f
> bin/todo -f ~/.todo.txt list -l
# lists in long form from ~/.todo.txt

This creates namespaces for our options, which allows the creation of a rich user interface, if needed. I borrowed this design from git (and, in fact, GLI stands for “Git-Like Interface”).

Our application code would likely not live inside this file, but instead be delegated to classes located under lib, designed and unit tested as you would in any application. The file generated by gli init is already primed to look there.

Digging Deeper

This example only scratches the surface. Let’s go over a few different handy features for managing our command suite.

Powerful option parsing

It’s usually good practice for switches (options that take no arguments) to have both a positive and “negative” version. For example, we’d want to be able to use --no-long or --long, as appropriate. You can see from our help output that GLI supports this by default. If the user specifies --no-all on the command line, options[:all] will be false.

GLI makes this work because it’s using OptionParser underneath. This opens up some other powerful features.

Suppose we want to give our new todo items a “category” and that we want to require the category to be one of “chore”, “feature”, or “bug”. The naive approach would be to examine options[:category] inside our action block and raise an error if it’s not one of the three allowed values, GLI, via OptionParser, provides this for us:

command :new do |c|
  c.desc "The category of the new todo"
  c.default_value 'chore'
  c.flag :category, must_match: %w(chore feature bug)

  # ...

  c.action do |global_options, options, args|
    # options[:category] will always be one of chore, feature, or bug
  end
end

must_match takes a wide variety of values, including an Array, Hash, or Regexp.

Flags also accept the option :type that can be used to do a type conversion. OptionParser has some conversions built-in, but we could do very sophisticated things if we wanted to:

accept(Todo::Type) do |string|
  Todo.const_get(string.capitalize)
end

command :new do |c|
  c.desc "The type of the new todo"
  c.default_value 'chore'
  c.flag :type, must_match: %w(chore feature bug), type: Todo::Type

  # ...

  c.action do |global_options, options, args|
    # options[:type] will always be Todo::Chore, Todo::Feature, or Todo::Bug
  end
end

Nice!

Default Values

You’ve probably noticed default_value being used. This not only documents in our help text what the default of a flag is, but it’s also the default value in global_options or options. You don’t have to manage it yourself.

Aliases

By default, GLI will identify a command based on the shortest unambiguous string. In our case, bundle exec bin/todo n would be recognized as the “new” command, because no other command starts with “n”.

We can also provide explicit aliases by passing an array to command, much as we did with our flags and switches:

command [:list,:show] do |c|

  # ...

end

Global Hooks

If we were to fill in the three actions with actual code, you’d see that they all have some need to access the to-do list. We might create a class like TodoList and use it like so:

c.command :list do 
  c.action do |global_options,options,args|
    todo_list = TodoList.load(global_options[:file])
    todo_list.tasks.each do |todo|
      puts todo
    end
  end
end

c.command :done do
  c.action do |global_options,options,args|
    todo_list = TodoList.load(global_options[:file])
    todo_list.complete!(args.shift)
    todo_list.save!(global_options[:file])
  end
end

c.command :new do
  c.action do |global_options,options,args|
    todo_list = TodoList.load(global_options[:file])
    todo_list.add(args.join(' ')
    todo_list.save!(global_options[:file])
  end
end

This can get repetitive. Although we have a way to specify that all commands have the flag --file, it would be nice if we could globally translate that filename into a real object and have it managed outside our commands.

That’s where pre and post come in:

pre do |global_options,command,options,args|
  $todo_list = TodoList.load(global_options[:file])
  true
end

post do |global_options,command,options,args|
  $todo_list.save!(global_options[:file])
end

c.command :list do 
  c.action do |global_options,options,args|
    $todo_list.tasks.each do |todo|
      puts todo
    end
  end
end

c.command :done do
  c.action do |global_options,options,args|
    $todo_list.complete!(args.shift)
  end
end

c.command :new do
  c.action do |global_options,options,args|
    $todo_list.add(args.join(' ')
  end
end

Here, pre receives the parsed command and options. The pre block’s code will execute before the contents of our action block. post, too, receives this information and runs after our action block. Our todo list app commands always have access to the parsed todo list file, and can be sure that any changes they make will be saved to disk after.

Subcommands

GLI allows infinitely nested subcommands. For example, if we wanted to have our list command work a bit differently, such as todo list done or todo list inprogress, we can model done and inprogress as subcommands:

desc "List todo items"
command :list do |list|

  list.desc "Show only completed items"
  list.command :done do |done|
    done.action do |global_options,options,args|
      $todo_list.completed.each do |todo|
        puts todo
      end
    end
  end

  list.desc "Show only in-progress items"
  list.command :inprogress do |done|
    done.action do |global_options,options,args|
      $todo_list.in_progress.each do |todo|
        puts todo
      end
    end
  end
end

Subcommands have their own “option space”, so you can create a very sophisticated UI if you need to.

Conclusion

I’ve tried a lot of command-line libraries for Ruby and GLI is the most featureful, compact, and powerful one I’ve seen—I created it to be that way. The thing I like about it is that simple applications have simple source code, but if you need more complex features, they are there for you. The “shape” of your binfile mimics the shape of your app. The bootstrapping from gli init also sets you up to have a properly organized, easily distributable application—all hallmarks of an awesome command-line app.

“Build Awesome Command-Line Applications in Ruby 2” is on sale now (and is a free upgrade for purchasers of the first version). It covers the generic aspects of command-line development with Ruby, using GLI to demonstrate how to do it with command suites. It’s also a much deeper dive on OptionParser, which is a powerful tool you should learn for writing non-command-based command-line apps. The appendix covers Thor, Main, and Methadone as well.