class Toys::CLI

A Toys-based CLI.

This is the entry point for command line execution. It includes the set of tool definitions (and/or information on how to load them from the file system), configuration parameters such as logging and error handling, and a method to call to invoke a command.

This is the class to instantiate to create a Toys-based command line executable. For example:

#!/usr/bin/env ruby
require "toys-core"
cli = Toys::CLI.new
cli.add_config_block do
  def run
    puts "Hello, world!"
  end
end
exit(cli.run(*ARGV))

The currently running CLI is also available at runtime, and can be used by tools that want to invoke other tools. For example:

# My .toys.rb
tool "foo" do
  def run
    puts "in foo"
  end
end
tool "bar" do
  def run
    puts "in bar"
    cli.run "foo"
  end
end

Attributes

base_level[R]

The initial logger level in this CLI, used as the level for verbosity 0. May be `nil`, indicating it will use the initial logger setting. @return [Integer,nil]

completion[R]

The overall completion strategy for this CLI. @return [Toys::Completion::Base,Proc]

executable_name[R]

The effective executable name used for usage text in this CLI. @return [String]

extra_delimiters[R]

The string of tool name delimiter characters (besides space). @return [String]

loader[R]

The current loader for this CLI. @return [Toys::Loader]

logger[R]

The global logger, if any. @return [Logger,nil]

logger_factory[R]

The logger factory. @return [Proc]

Public Class Methods

new( executable_name: nil, middleware_stack: nil, extra_delimiters: "", config_dir_name: nil, config_file_name: nil, index_file_name: nil, preload_file_name: nil, preload_dir_name: nil, data_dir_name: nil, lib_dir_name: nil, mixin_lookup: nil, middleware_lookup: nil, template_lookup: nil, logger_factory: nil, logger: nil, base_level: nil, error_handler: nil, completion: nil ) click to toggle source

Create a CLI.

Most configuration parameters (besides tool definitions and tool lookup paths) are set as options passed to the constructor. These options fall roughly into four categories:

*  Options affecting output behavior:
    *  `logger`: A global logger for all tools to use
    *  `logger_factory`: A proc that returns a logger to use
    *  `base_level`: The default log level
    *  `error_handler`: Callback for handling exceptions
    *  `executable_name`: The name of the executable
*  Options affecting tool specification
    *  `extra_delimibers`: Tool name delimiters besides space
    *  `completion`: Tab completion handler
*  Options affecting tool definition
    *  `middleware_stack`: The middleware applied to all tools
    *  `mixin_lookup`: Where to find well-known mixins
    *  `middleware_lookup`: Where to find well-known middleware
    *  `template_lookup`: Where to find well-known templates
*  Options affecting tool files and directories
    *  `config_dir_name`: Directory name containing tool files
    *  `config_file_name`: File name for tools
    *  `index_file_name`: Name of index files in tool directories
    *  `preload_file_name`: Name of preload files in tool directories
    *  `preload_dir_name`: Name of preload directories in tool directories
    *  `data_dir_name`: Name of data directories in tool directories

@param logger [Logger] A global logger to use for all tools. This may be

set if the CLI will call at most one tool at a time. However, it will
behave incorrectly if CLI might run multiple tools at the same time
with different verbosity settings (since the logger cannot have
multiple level settings simultaneously). In that case, do not set a
global logger, but use the `logger_factory` parameter instead.

@param logger_factory [Proc] A proc that takes a {Toys::ToolDefinition}

as an argument, and returns a `Logger` to use when running that tool.
Optional. If not provided (and no global logger is set), CLI will use
a default factory that writes generates loggers writing formatted
output to `STDERR`, as defined by {Toys::CLI.default_logger_factory}.

@param base_level [Integer] The logger level that should correspond

to zero verbosity.
Optional. If not provided, defaults to the current level of the
logger (which is often `Logger::WARN`).

@param error_handler [Proc,nil] A proc that is called when an error is

caught. The proc should take a {Toys::ContextualError} argument and
report the error. It should return an exit code (normally nonzero).
Optional. If not provided, defaults to an instance of
{Toys::CLI::DefaultErrorHandler}, which displays an error message to
`STDERR`.

@param executable_name [String] The executable name displayed in help

text. Optional. Defaults to the ruby program name.

@param extra_delimiters [String] A string containing characters that can

function as delimiters in a tool name. Defaults to empty. Allowed
characters are period, colon, and slash.

@param completion [Toys::Completion::Base] A specifier for shell tab

completion for the CLI as a whole.
Optional. If not provided, defaults to an instance of
{Toys::CLI::DefaultCompletion}, which delegates completion to the
relevant tool.

@param middleware_stack [Array<Toys::Middleware::Spec>] An array of

middleware that will be used by default for all tools.
Optional. If not provided, uses a default set of middleware defined
in {Toys::CLI.default_middleware_stack}. To include no middleware,
pass the empty array explicitly.

@param mixin_lookup [Toys::ModuleLookup] A lookup for well-known mixin

modules (i.e. with symbol names).
Optional. If not provided, defaults to the set of standard mixins
provided by toys-core, as defined by
{Toys::CLI.default_mixin_lookup}. If you explicitly want no standard
mixins, pass an empty instance of {Toys::ModuleLookup}.

@param middleware_lookup [Toys::ModuleLookup] A lookup for well-known

middleware classes.
Optional. If not provided, defaults to the set of standard middleware
classes provided by toys-core, as defined by
{Toys::CLI.default_middleware_lookup}. If you explicitly want no
standard middleware, pass an empty instance of
{Toys::ModuleLookup}.

@param template_lookup [Toys::ModuleLookup] A lookup for well-known

template classes.
Optional. If not provided, defaults to the set of standard template
classes provided by toys core, as defined by
{Toys::CLI.default_template_lookup}. If you explicitly want no
standard tenokates, pass an empty instance of {Toys::ModuleLookup}.

@param config_dir_name [String] A directory with this name that appears

in the loader path, is treated as a configuration directory whose
contents are loaded into the toys configuration.
Optional. If not provided, toplevel configuration directories are
disabled.
Note: the standard toys executable sets this to `".toys"`.

@param config_file_name [String] A file with this name that appears in

the loader path, is treated as a toplevel configuration file whose
contents are loaded into the toys configuration. This does not
include "index" configuration files located within a configuration
directory.
Optional. If not provided, toplevel configuration files are disabled.
Note: the standard toys executable sets this to `".toys.rb"`.

@param index_file_name [String] A file with this name that appears in any

configuration directory is loaded first as a standalone configuration
file. This does not include "toplevel" configuration files outside
configuration directories.
Optional. If not provided, index configuration files are disabled.
Note: the standard toys executable sets this to `".toys.rb"`.

@param preload_file_name [String] A file with this name that appears

in any configuration directory is preloaded using `require` before
any tools in that configuration directory are defined. A preload file
includes normal Ruby code, rather than Toys DSL definitions. The
preload file is loaded before any files in a preload directory.
Optional. If not provided, preload files are disabled.
Note: the standard toys executable sets this to `".preload.rb"`.

@param preload_dir_name [String] A directory with this name that appears

in any configuration directory is searched for Ruby files, which are
preloaded using `require` before any tools in that configuration
directory are defined. Files in a preload directory include normal
Ruby code, rather than Toys DSL definitions. Files in a preload
directory are loaded after any standalone preload file.
Optional. If not provided, preload directories are disabled.
Note: the standard toys executable sets this to `".preload"`.

@param data_dir_name [String] A directory with this name that appears in

any configuration directory is added to the data directory search
path for any tool file in that directory.
Optional. If not provided, data directories are disabled.
Note: the standard toys executable sets this to `".data"`.

@param lib_dir_name [String] A directory with this name that appears in

any configuration directory is added to the Ruby load path when
executing any tool file in that directory.
Optional. If not provided, lib directories are disabled.
Note: the standard toys executable sets this to `".lib"`.
# File lib/toys/cli.rb, line 178
def initialize( # rubocop:disable Metrics/MethodLength
  executable_name: nil,
  middleware_stack: nil,
  extra_delimiters: "",
  config_dir_name: nil,
  config_file_name: nil,
  index_file_name: nil,
  preload_file_name: nil,
  preload_dir_name: nil,
  data_dir_name: nil,
  lib_dir_name: nil,
  mixin_lookup: nil,
  middleware_lookup: nil,
  template_lookup: nil,
  logger_factory: nil,
  logger: nil,
  base_level: nil,
  error_handler: nil,
  completion: nil
)
  @executable_name = executable_name || ::File.basename($PROGRAM_NAME)
  @middleware_stack = middleware_stack || CLI.default_middleware_stack
  @mixin_lookup = mixin_lookup || CLI.default_mixin_lookup
  @middleware_lookup = middleware_lookup || CLI.default_middleware_lookup
  @template_lookup = template_lookup || CLI.default_template_lookup
  @error_handler = error_handler || DefaultErrorHandler.new
  @completion = completion || DefaultCompletion.new
  @logger = logger
  @logger_factory = logger ? proc { logger } : logger_factory || CLI.default_logger_factory
  @base_level = base_level
  @extra_delimiters = extra_delimiters
  @config_dir_name = config_dir_name
  @config_file_name = config_file_name
  @index_file_name = index_file_name
  @preload_file_name = preload_file_name
  @preload_dir_name = preload_dir_name
  @data_dir_name = data_dir_name
  @lib_dir_name = lib_dir_name
  @loader = Loader.new(
    index_file_name: @index_file_name,
    preload_dir_name: @preload_dir_name,
    preload_file_name: @preload_file_name,
    data_dir_name: @data_dir_name,
    lib_dir_name: @lib_dir_name,
    middleware_stack: @middleware_stack,
    extra_delimiters: @extra_delimiters,
    mixin_lookup: @mixin_lookup,
    template_lookup: @template_lookup,
    middleware_lookup: @middleware_lookup
  )
end

Private Class Methods

default_logger_factory() click to toggle source

Returns a logger factory that generates loggers that write to stderr. All loggers generated by this factory share a single {Toys::Utils::Terminal}, so log entries may interleave but will not interrupt one another.

@return [Proc]

# File lib/toys/cli.rb, line 715
def default_logger_factory
  require "toys/utils/terminal"
  shared_terminal = Utils::Terminal.new(output: $stderr)
  proc do
    logger = ::Logger.new(shared_terminal)
    logger.formatter = proc do |severity, time, _progname, msg|
      msg_str =
        case msg
        when ::String
          msg
        when ::Exception
          "#{msg.message} (#{msg.class})\n" << (msg.backtrace || []).join("\n")
        else
          msg.inspect
        end
      format_log(shared_terminal, time, severity, msg_str)
    end
    logger.level = ::Logger::WARN
    logger
  end
end
default_middleware_lookup() click to toggle source

Returns a default ModuleLookup for middleware that points at the StandardMiddleware module.

@return [Toys::ModuleLookup]

# File lib/toys/cli.rb, line 694
def default_middleware_lookup
  ModuleLookup.new.add_path("toys/standard_middleware")
end
default_middleware_stack() click to toggle source

Returns a default set of middleware that may be used as a starting point for a typical CLI. This set includes the following in order:

  • {Toys::StandardMiddleware::SetDefaultDescriptions} providing defaults for description fields.

  • {Toys::StandardMiddleware::ShowHelp} adding the `–help` flag and providing default behavior for namespaces.

  • {Toys::StandardMiddleware::HandleUsageErrors}

  • {Toys::StandardMiddleware::AddVerbosityFlags} adding the `–verbose` and `–quiet` flags for managing the logger level.

@return [Array<Toys::Middleware::Spec>]

# File lib/toys/cli.rb, line 669
def default_middleware_stack
  [
    Middleware.spec(:set_default_descriptions),
    Middleware.spec(:show_help, help_flags: true, fallback_execution: true),
    Middleware.spec(:handle_usage_errors),
    Middleware.spec(:add_verbosity_flags),
  ]
end
default_mixin_lookup() click to toggle source

Returns a default ModuleLookup for mixins that points at the StandardMixins module.

@return [Toys::ModuleLookup]

# File lib/toys/cli.rb, line 684
def default_mixin_lookup
  ModuleLookup.new.add_path("toys/standard_mixins")
end
default_template_lookup() click to toggle source

Returns a default empty ModuleLookup for templates.

@return [Toys::ModuleLookup]

# File lib/toys/cli.rb, line 703
def default_template_lookup
  ModuleLookup.new
end
format_log(terminal, time, severity, msg) click to toggle source
# File lib/toys/cli.rb, line 739
def format_log(terminal, time, severity, msg)
  timestr = time.strftime("%Y-%m-%d %H:%M:%S")
  header = format("[%<time>s %<sev>5s]", time: timestr, sev: severity)
  styled_header =
    case severity
    when "FATAL"
      terminal.apply_styles(header, :bright_magenta, :bold, :underline)
    when "ERROR"
      terminal.apply_styles(header, :bright_red, :bold)
    when "WARN"
      terminal.apply_styles(header, :bright_yellow)
    when "INFO"
      terminal.apply_styles(header, :bright_cyan)
    when "DEBUG"
      terminal.apply_styles(header, :white)
    else
      header
    end
  "#{styled_header}  #{msg}\n"
end

Public Instance Methods

add_config_block(high_priority: false, source_name: nil, context_directory: nil, &block) click to toggle source

Add a configuration block to the loader.

This is used to create tools “inline”, and is useful for simple command line executables based on Toys.

@param high_priority [Boolean] Add the config at the head of the priority

list rather than the tail.

@param source_name [String] The source name that will be shown in

documentation for tools defined in this block. If omitted, a default
unique string will be generated.

@param block [Proc] The block of configuration, executed in the context

of the tool DSL {Toys::DSL::Tool}.

@param context_directory [String,nil] The context directory for tools

loaded from this block. You can pass a directory path as a string, or
`nil` to denote no context. Defaults to `nil`.

@return [self]

# File lib/toys/cli.rb, line 361
def add_config_block(high_priority: false,
                     source_name: nil,
                     context_directory: nil,
                     &block)
  @loader.add_block(high_priority: high_priority,
                    source_name: source_name,
                    context_directory: context_directory,
                    &block)
  self
end
add_config_path(path, high_priority: false, source_name: nil, context_directory: :parent) click to toggle source

Add a specific configuration file or directory to the loader.

This is generally used to load a static or “built-in” set of tools, either for a standalone command line executable based on Toys, or to provide a “default” set of tools for a dynamic executable. For example, the main Toys executable uses this to load the builtin tools from its “builtins” directory.

@param path [String] A path to add. May reference a single Toys file or

a Toys directory.

@param high_priority [Boolean] Add the config at the head of the priority

list rather than the tail.

@param source_name [String] A custom name for the root source. Optional. @param context_directory [String,nil,:path,:parent] The context directory

for tools loaded from this path. You can pass a directory path as a
string, `:path` to denote the given path, `:parent` to denote the
given path's parent directory, or `nil` to denote no context.
Defaults to `:parent`.

@return [self]

# File lib/toys/cli.rb, line 332
def add_config_path(path,
                    high_priority: false,
                    source_name: nil,
                    context_directory: :parent)
  @loader.add_path(path,
                   high_priority: high_priority,
                   source_name: source_name,
                   context_directory: context_directory)
  self
end
add_search_path(search_path, high_priority: false, context_directory: :path) click to toggle source

Checks the given directory path. If it contains a config file and/or config directory, those are added to the loader.

The main Toys executable uses this method to load tools from directories in the `TOYS_PATH`.

@param search_path [String] A path to search for configs. @param high_priority [Boolean] Add the configs at the head of the

priority list rather than the tail.

@param context_directory [String,nil,:path,:parent] The context directory

for tools loaded from this path. You can pass a directory path as a
string, `:path` to denote the given path, `:parent` to denote the
given path's parent directory, or `nil` to denote no context.
Defaults to `:path`.

@return [self]

# File lib/toys/cli.rb, line 389
def add_search_path(search_path,
                    high_priority: false,
                    context_directory: :path)
  paths = []
  if @config_file_name
    file_path = ::File.join(search_path, @config_file_name)
    paths << @config_file_name if !::File.directory?(file_path) && ::File.readable?(file_path)
  end
  if @config_dir_name
    dir_path = ::File.join(search_path, @config_dir_name)
    paths << @config_dir_name if ::File.directory?(dir_path) && ::File.readable?(dir_path)
  end
  @loader.add_path_set(search_path, paths,
                       high_priority: high_priority,
                       context_directory: context_directory)
  self
end
add_search_path_hierarchy(start: nil, terminate: [], high_priority: false) click to toggle source

Walk up the directory hierarchy from the given start location, and add to the loader any config files and directories found.

The main Toys executable uses this method to load tools from the current directory and its ancestors.

@param start [String] The first directory to add. Defaults to the current

working directory.

@param terminate [Array<String>] Optional list of directories that should

terminate the search. If the walk up the directory tree encounters
one of these directories, the search is halted without checking the
terminating directory.

@param high_priority [Boolean] Add the configs at the head of the

priority list rather than the tail.

@return [self]

# File lib/toys/cli.rb, line 424
def add_search_path_hierarchy(start: nil, terminate: [], high_priority: false)
  path = start || ::Dir.pwd
  paths = []
  loop do
    break if terminate.include?(path)
    paths << path
    next_path = ::File.dirname(path)
    break if next_path == path
    path = next_path
  end
  paths.reverse! if high_priority
  paths.each do |p|
    add_search_path(p, high_priority: high_priority)
  end
  self
end
child(**opts) { |cli| ... } click to toggle source

Make a clone with the same settings but no no config blocks and no paths in the loader. This is sometimes useful for calling another tool that has to be loaded from a different configuration.

@param opts [keywords] Any configuration arguments that should be

modified from the original. See {#initialize} for a list of
recognized keywords.

@return [Toys::CLI] @yieldparam cli [Toys::CLI] If you pass a block, the new CLI is yielded

to it so you can add paths and make other modifications.
# File lib/toys/cli.rb, line 242
def child(**opts)
  args = {
    executable_name: @executable_name,
    config_dir_name: @config_dir_name,
    config_file_name: @config_file_name,
    index_file_name: @index_file_name,
    preload_dir_name: @preload_dir_name,
    preload_file_name: @preload_file_name,
    data_dir_name: @data_dir_name,
    lib_dir_name: @lib_dir_name,
    middleware_stack: @middleware_stack,
    extra_delimiters: @extra_delimiters,
    mixin_lookup: @mixin_lookup,
    middleware_lookup: @middleware_lookup,
    template_lookup: @template_lookup,
    logger: @logger,
    logger_factory: @logger_factory,
    base_level: @base_level,
    error_handler: @error_handler,
    completion: @completion,
  }.merge(opts)
  cli = CLI.new(**args)
  yield cli if block_given?
  cli
end
run(*args, verbosity: 0, delegated_from: nil) click to toggle source

Run the CLI with the given command line arguments. Handles exceptions using the error handler.

@param args [String…] Command line arguments specifying which tool to

run and what arguments to pass to it. You may pass either a single
array of strings, or a series of string arguments.

@param verbosity [Integer] Initial verbosity. Default is 0.

@return [Integer] The resulting process status code (i.e. 0 for success).

# File lib/toys/cli.rb, line 452
def run(*args, verbosity: 0, delegated_from: nil)
  tool, remaining = ContextualError.capture("Error finding tool definition") do
    @loader.lookup(args.flatten)
  end
  ContextualError.capture_path(
    "Error during tool execution!", tool.source_info&.source_path,
    tool_name: tool.full_name, tool_args: remaining
  ) do
    default_data = {
      Context::Key::VERBOSITY => verbosity,
      Context::Key::DELEGATED_FROM => delegated_from,
    }
    run_tool(tool, remaining, default_data)
  end
rescue ContextualError, ::Interrupt => e
  @error_handler.call(e).to_i
end

Private Instance Methods

execute_tool_in_context(context, tool) click to toggle source
# File lib/toys/cli.rb, line 502
def execute_tool_in_context(context, tool)
  executor = proc do
    begin
      if !context[Context::Key::USAGE_ERRORS].empty?
        handle_usage_errors(context, tool)
      elsif !tool.runnable?
        raise NotRunnableError, "No implementation for tool #{tool.display_name.inspect}"
      else
        context.run
      end
    rescue ::Interrupt => e
      raise e unless tool.handles_interrupts?
      handle_interrupt(context, tool.interrupt_handler, e)
    end
  end
  tool.built_middleware.reverse_each do |middleware|
    executor = make_executor(middleware, context, executor)
  end
  catch(:result) do
    executor.call
    0
  end
end
handle_interrupt(context, handler, exception) click to toggle source
# File lib/toys/cli.rb, line 538
def handle_interrupt(context, handler, exception)
  handler = context.method(handler).to_proc if handler.is_a?(::Symbol)
  if handler.arity.zero?
    context.instance_exec(&handler)
  else
    context.instance_exec(exception, &handler)
  end
rescue ::Interrupt => e
  raise e if e.equal?(exception)
  handle_interrupt(context, handler, e)
end
handle_usage_errors(context, tool) click to toggle source
# File lib/toys/cli.rb, line 526
def handle_usage_errors(context, tool)
  usage_errors = context[Context::Key::USAGE_ERRORS]
  handler = tool.usage_error_handler
  raise ArgParsingError, usage_errors if handler.nil?
  handler = context.method(handler).to_proc if handler.is_a?(::Symbol)
  if handler.arity.zero?
    context.instance_exec(&handler)
  else
    context.instance_exec(usage_errors, &handler)
  end
end
make_executor(middleware, context, next_executor) click to toggle source
# File lib/toys/cli.rb, line 550
def make_executor(middleware, context, next_executor)
  if middleware.respond_to?(:run)
    proc { middleware.run(context, &next_executor) }
  else
    next_executor
  end
end
run_tool(tool, args, default_data) click to toggle source

Run the given tool with the given arguments. Does not handle exceptions.

@param tool [Toys::ToolDefinition] The tool to run. @param args [Array<String>] Command line arguments passed to the tool. @param default_data [Hash] Initial tool context data. @return [Integer] The resulting status code

# File lib/toys/cli.rb, line 481
def run_tool(tool, args, default_data)
  arg_parser = ArgParser.new(self, tool,
                             default_data: default_data,
                             require_exact_flag_match: tool.exact_flag_match_required?)
  arg_parser.parse(args).finish
  context = tool.tool_class.new(arg_parser.data)
  tool.source_info&.apply_lib_paths
  tool.run_initializers(context)

  cur_logger = context[Context::Key::LOGGER]
  if cur_logger
    original_level = cur_logger.level
    cur_logger.level = (base_level || original_level) - context[Context::Key::VERBOSITY].to_i
  end
  begin
    execute_tool_in_context(context, tool)
  ensure
    cur_logger.level = original_level if cur_logger
  end
end