/command_kit.rb

A Ruby toolkit for building complete and robust CLI commands.

Primary LanguageRubyMIT LicenseMIT

command_kit

Build Status Code Climate Gem Version

Description

A modular Ruby toolkit for building clean, correct, and robust CLI commands as plain-old Ruby classes.

Features

  • Simple - Commands are plain-old ruby classes, with options and arguments declared as attributes. All features are Ruby modules that can be included into command classes.
  • Correct - CommandKit behaves like a standard UNIX command.
    • Safely handles Ctrl^C / SIGINT interrupts and exits with 130.
    • Safely handles broken pipes (aka mycmd | head).
    • Respects common environment variables (ex: TERM=dumb and NO_COLOR).
    • Uses OptionParser for POSIX option parsing.
    • Disables ANSI color when output is redirected to a file or when NO_COLOR is set.
  • Complete - Provides many additional CLI features.
    • OS detection.
    • Terminal size detection.
    • ANSI coloring support.
    • Interactive input.
    • Rich text printing support (fields, lists, and tables).
    • Subcommands (explicit or lazy-loaded) and command aliases.
    • Displaying man pages for --help/help.
    • Using the pager (aka less).
    • XDG directories (aka ~/.config/, ~/.local/share/, ~/.cache/).
    • Exception handling / Bug reporting.
  • Testable - Since commands are plain-old Ruby classes, it's easy to initialize them and call #main or #run.

Anti-Features

  • No additional runtime dependencies.
  • Does not implement it's own option parser.
  • Not named after a comic-book Superhero.

Requirements

Install

$ gem install command_kit

gemspec

gem.add_dependency 'command_kit', '~> 0.3'

Gemfile

gem 'command_kit', '~> 0.3'

Examples

lib/foo/cli/my_cmd.rb

require 'command_kit'

module Foo
  module CLI
    class MyCmd < CommandKit::Command

      usage '[OPTIONS] [-o OUTPUT] FILE'

      option :count, short: '-c',
                     value: {
                       type: Integer,
                       default: 1
                     },
                     desc: "Number of times"

      option :output, short: '-o',
                      value: {
                        type: String,
                        usage: 'FILE'
                      },
                      desc: "Optional output file"

      option :verbose, short: '-v', desc: "Increase verbose level" do
        @verbose += 1
      end

      argument :file, required: true,
                      usage: 'FILE',
                      desc: "Input file"

      examples [
        '-o path/to/output.txt path/to/input.txt',
        '-v -c 2 -o path/to/output.txt path/to/input.txt',
      ]

      description 'Example command'

      def initialize(**kwargs)
        super(**kwargs)

        @verbose = 0
      end

      def run(file)
        puts "count=#{options[:count].inspect}"
        puts "output=#{options[:output].inspect}"
        puts "file=#{file.inspect}"
        puts "verbose=#{@verbose.inspect}"
      end

    end
  end
end

bin/my_cmd

#!/usr/bin/env ruby

require_relative 'lib/foo/cli/my_cmd'

Foo::CLI::MyCmd.start

--help

Usage: my_cmd [OPTIONS] [-o OUTPUT] FILE

Options:
    -c, --count INT                  Number of times (Default: 1)
    -o, --output FILE                Optional output file
    -v, --verbose                    Increase verbose level
    -h, --help                       Print help information

Arguments:
    FILE                             Input file

Examples:
    my_cmd -o path/to/output.txt path/to/input.txt
    my_cmd -v -c 2 -o path/to/output.txt path/to/input.txt

Example command

Testing

RSpec

require 'spec_helper'
require 'stringio'
require 'foo/cli/my_cmd'

describe Foo::CLI::MyCmd do
  let(:stdin)  { StringIO.new }
  let(:stdout) { StringIO.new }
  let(:stderr) { StringIO.new }
  let(:env)    { ENV }

  subject do
    described_class.new(
      stdin:   stdin,
      stdout:  stdout,
      stderr:  stderr,
      env:     env
    )
  end

  # testing with raw options/arguments
  describe "#main" do
    context "when executed with no arguments" do
      it "must exit with -1" do
        expect(subject.main([])).to eq(-1)
      end
    end

    context "when executed with -o OUTPUT" do
      let(:file)   { ... }
      let(:output) { ... }

      before { subject.main(["-o", output, file]) }

      it "must create the output file" do
        ...
      end
    end
  end
end

Reference

Alternatives

Special Thanks

Special thanks to everyone who answered my questions and gave feedback on Twitter.

Copyright

Copyright (c) 2021-2024 Hal Brodigan

See {file:LICENSE.txt} for details.