Hanami::CLI

General purpose Command Line Interface (CLI) framework for Ruby.

:warning: This is a general framework for Ruby (aka thor gem replacement), NOT the implementation of the hanami CLI commands :warning:

Status

Gem Version TravisCI CircleCI Test Coverage Depfu Inline Docs

Contact

Rubies

Hanami::CLI supports Ruby (MRI) 2.3+, JRuby 9.1.5.0+

Installation

Add this line to your application's Gemfile:

gem 'hanami-cli'

And then execute:

$ bundle

Or install it yourself as:

$ gem install hanami-cli

Table of Contents

Features

Registration

For a given command name, you can register a corresponding command object (aka command).

Example: for foo hi command name there is the corresponding Foo::CLI::Hello command object.

#!/usr/bin/env ruby
require "bundler/setup"
require "hanami/cli"

module Foo
  module CLI
    module Commands
      extend Hanami::CLI::Registry

      class Hello < Hanami::CLI::Command
        def call(*)
        end
      end
    end
  end
end

class Version < Hanami::CLI::Command
  def call(*)
  end
end

Foo::CLI::Commands.register "hi", Foo::CLI::Commands::Hello
Foo::CLI::Commands.register "v",  Version

Hanami::CLI.new(Foo::CLI::Commands).call

Please note: there is NOT a convention between the command name and the command object class. The manual registration assigns a command object to a command name.

Commands as objects

A command is a subclass of Hanami::CLI::Command and it MUST respond to #call(*).

Subcommands

There is nothing special in subcommands: they are just command objects registered under a nested command name.

#!/usr/bin/env ruby
require "bundler/setup"
require "hanami/cli"

module Foo
  module CLI
    module Commands
      extend Hanami::CLI::Registry

      module Generate
        class Configuration < Hanami::CLI::Command
          def call(*)
          end
        end
      end
    end
  end
end

Foo::CLI::Commands.register "generate configuration", Foo::CLI::Commands::Generate::Configuration

Hanami::CLI.new(Foo::CLI::Commands).call

Arguments

An argument is a token passed after the command name. For instance, given the foo greet command, when an user types foo greet Luca, then Luca is considered an argument. A command can accept none or many arguments. An argument can be declared as required.

#!/usr/bin/env ruby
require "bundler/setup"
require "hanami/cli"

module Foo
  module CLI
    module Commands
      extend Hanami::CLI::Registry

      class Greet < Hanami::CLI::Command
        argument :name, required: true, desc: "The name of the person to greet"
        argument :age, desc: "The age of the person to greet"

        def call(name:, age: nil, **)
          result = "Hello, #{name}."
          result = "#{result} You are #{age} years old." unless age.nil?

          puts result
        end
      end

      register "greet", Greet
    end
  end
end

Hanami::CLI.new(Foo::CLI::Commands).call
% foo greet Luca
Hello, Luca.
% foo greet Luca 35
Hello, Luca. You are 35 years old.
% foo greet
ERROR: "foo greet" was called with no arguments
Usage: "foo greet NAME"

Option

An option is a named argument that is passed after the command name and the arguments.

For instance, given the foo request command, when an user types foo request --mode=http2, then --mode=http2 is considered an option. A command can accept none or many options.

#!/usr/bin/env ruby
require "bundler/setup"
require "hanami/cli"

module Foo
  module CLI
    module Commands
      extend Hanami::CLI::Registry

      class Request < Hanami::CLI::Command
        option :mode, default: "http", values: %w[http http2], desc: "The request mode"

        def call(**options)
          puts "Performing a request (mode: #{options.fetch(:mode)})"
        end
      end

      register "request", Request
    end
  end
end

Hanami::CLI.new(Foo::CLI::Commands).call
% foo request
Performing a request (mode: http)
% foo request --mode=http2
Performing a request (mode: http2)
% foo request --mode=unknown
Error: "request" was called with arguments "--mode=unknown"

Variadic arguments

Sometimes we need extra arguments because those will be forwarded to a sub-command like ssh, docker or cat.

By using -- (double dash, aka hypen), the user indicates the end of the arguments and options belonging to the main command, and the beginning of the variadic arguments that can be forwarded to the sub-command. These extra arguments are included as :args in the keyword arguments available for each command.

#!/usr/bin/env ruby
require "bundler/setup"
require "hanami/cli"

module Foo
  module CLI
    module Commands
      extend Hanami::CLI::Registry

      class Runner < Hanami::CLI::Command
        argument :image, required: true, desc: "Docker image"

        def call(image:, args: [], **)
          puts `docker run -it --rm #{image} #{args.join(" ")}`
        end
      end

      register "run", Runner
    end
  end
end

Hanami::CLI.new(Foo::CLI::Commands).call
% foo run ruby:latest -- ruby -v
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-linux]

The user separates via -- the arguments for foo and the command has to be run by the Docker container. In this specific case, ruby:latest corresponds to the image mandatory argument for foo, whereas ruby -v is the variadic argument that is passed to Docker via args.

Installation

Add this line to your application's Gemfile:

gem "hanami-cli"

And then execute:

$ bundle

Or install it yourself as:

$ gem install hanami-cli

Usage

Imagine to build a CLI executable foo for your Ruby project.

#!/usr/bin/env ruby
require "bundler/setup"
require "hanami/cli"

module Foo
  module CLI
    module Commands
      extend Hanami::CLI::Registry

      class Version < Hanami::CLI::Command
        desc "Print version"

        def call(*)
          puts "1.0.0"
        end
      end

      class Echo < Hanami::CLI::Command
        desc "Print input"

        argument :input, desc: "Input to print"

        example [
          "             # Prints 'wuh?'",
          "hello, folks # Prints 'hello, folks'"
        ]

        def call(input: nil, **)
          if input.nil?
            puts "wuh?"
          else
            puts input
          end
        end
      end

      class Start < Hanami::CLI::Command
        desc "Start Foo machinery"

        argument :root, required: true, desc: "Root directory"

        example [
          "path/to/root # Start Foo at root directory"
        ]

        def call(root:, **)
          puts "started - root: #{root}"
        end
      end

      class Stop < Hanami::CLI::Command
        desc "Stop Foo machinery"

        option :graceful, type: :boolean, default: true, desc: "Graceful stop"

        def call(**options)
          puts "stopped - graceful: #{options.fetch(:graceful)}"
        end
      end

      class Exec < Hanami::CLI::Command
        desc "Execute a task"

        argument :task, type: :string, required: true,  desc: "Task to be executed"
        argument :dirs, type: :array,  required: false, desc: "Optional directories"

        def call(task:, dirs: [], **)
          puts "exec - task: #{task}, dirs: #{dirs.inspect}"
        end
      end

      module Generate
        class Configuration < Hanami::CLI::Command
          desc "Generate configuration"

          option :apps, type: :array, default: [], desc: "Generate configuration for specific apps"

          def call(apps:, **)
            puts "generated configuration for apps: #{apps.inspect}"
          end
        end

        class Test < Hanami::CLI::Command
          desc "Generate tests"

          option :framework, default: "minitest", values: %w[minitest rspec]

          def call(framework:, **)
            puts "generated tests - framework: #{framework}"
          end
        end
      end

      register "version", Version, aliases: ["v", "-v", "--version"]
      register "echo",    Echo
      register "start",   Start
      register "stop",    Stop
      register "exec",    Exec

      register "generate", aliases: ["g"] do |prefix|
        prefix.register "config", Generate::Configuration
        prefix.register "test",   Generate::Test
      end
    end
  end
end

Hanami::CLI.new(Foo::CLI::Commands).call

Let's have a look at the command line usage.

Available commands

% foo
Commands:
  foo echo [INPUT]                       # Print input
  foo exec TASK [DIRS]                   # Execute a task
  foo generate [SUBCOMMAND]
  foo start ROOT                         # Start Foo machinery
  foo stop                               # Stop Foo machinery
  foo version                            # Print version

Help

% foo echo --help
Command:
  foo echo

Usage:
  foo echo [INPUT]

Description:
  Print input

Arguments:
  INPUT                 # Input to print

Options:
  --help, -h                        # Print this help

Examples:
  foo echo              # Prints 'wuh?'
  foo echo hello, folks # Prints 'hello, folks'

Optional arguments

% foo echo
wuh?

% foo echo hello
hello

Required arguments

% foo start .
started - root: .
% foo start
ERROR: "foo start" was called with no arguments
Usage: "foo start ROOT"

Array arguments

Captures all the remaining arguments in a single array. Please note that array argument must be used as last argument as it works as a "catch-all".

% foo exec test
exec - task: test, dirs: []
% foo exec test spec/bookshelf/entities spec/bookshelf/repositories
exec - task: test, dirs: ["spec/bookshelf/entities", "spec/bookshelf/repositories"]

Options

% foo generate test
generated tests - framework: minitest
% foo generate test --framework=rspec
generated tests - framework: rspec
% foo generate test --framework=unknown
Error: "test" was called with arguments "--framework=unknown"

Boolean options

% foo stop
stopped - graceful: true
% foo stop --no-graceful
stopped - graceful: false

Array options

% foo generate config --apps=web,api
generated configuration for apps: ["web", "api"]

Subcommands

% foo generate
Commands:
  foo generate config           # Generate configuration
  foo generate test             # Generate tests

Aliases

% foo version
1.0.0
% foo v
1.0.0
% foo -v
1.0.0
% foo --version
1.0.0

Subcommand aliases

% foo g config
generated configuration for apps: []

Callbacks

Third party gems can register before and after callbacks to enhance a command.

From the foo gem we have a command hello.

#!/usr/bin/env ruby
require "hanami/cli"

module Foo
  module CLI
    module Commands
      extend Hanami::CLI::Registry

      class Hello < Hanami::CLI::Command
        argument :name, required: true

        def call(name:, **)
          puts "hello #{name}"
        end
      end
    end
  end
end

Foo::CLI::Commands.register "hello", Foo::CLI::Commands::Hello

cli = Hanami::CLI.new(Foo::CLI::Commands)
cli.call

The foo-bar gem enhances hello command with callbacks:

Foo::CLI::Commands.before("hello") { |args| puts "debug: #{args.inspect}" } # syntax 1
Foo::CLI::Commands.after "hello", &->(args) { puts "bye, #{args.fetch(:name)}" } # syntax 2
% foo hello Anton
debug: {:name=>"Anton"}
hello Anton
bye, Anton

Development

After checking out the repo, run bin/setup to install dependencies. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/hanami/cli.

Alternatives

Copyright © 2017 Luca Guidi – Released under MIT License