command_kit.rb icon indicating copy to clipboard operation
command_kit.rb copied to clipboard

A Ruby toolkit for building complete and robust CLI commands.

command_kit

Build Status Code Climate Gem Version

Description

A 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).
    • Uses OptionParser for POSIX option parsing.
    • Disables ANSI color when output is redirected to a file.
  • Complete - Provides many additional CLI features.
    • OS detection.
    • Terminal size detection.
    • ANSI coloring support.
    • Interactive input.
    • 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/).
  • 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

$LOAD_PATH.unshift(File.expand_path('../../lib',__FILE__))
require '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-2022 Hal Brodigan

See {file:LICENSE.txt} for details.