Skip to content

Commit

Permalink
Use Thor as the basis for generated CLI (#148)
Browse files Browse the repository at this point in the history
* Hide stack trace unless VERBOSE is set
* Mention Thor in the README
  • Loading branch information
mattbrictson authored Jul 23, 2023
1 parent 5333a6e commit e63618c
Show file tree
Hide file tree
Showing 7 changed files with 94 additions and 4 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This template is based on `bundle gem` with some notable improvements:
- GitHub Actions configuration
- Minitest, with minitest-reporters for nicely formatted test output
- Rubocop with a good set of configuration
- CLI scaffolding, built on top of Thor (optional)
- [release-drafter](https://github.com/apps/release-drafter) GitHub Action for automating release notes
- A `rake bump` task to keep your Ruby and Bundler dependencies up to date
- A nice README with badges ready to go (see below)
Expand Down
3 changes: 3 additions & 0 deletions example.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ Gem::Specification.new do |spec|
spec.bindir = "exe"
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]

# Runtime dependencies
spec.add_dependency "thor", "~> 1.2"
end
2 changes: 1 addition & 1 deletion exe/example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env ruby

require "example"
Example::CLI.new.call(ARGV)
Example::CLI.start(ARGV)
1 change: 1 addition & 0 deletions lib/example.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
module Example
autoload :CLI, "example/cli"
autoload :VERSION, "example/version"
autoload :ThorExt, "example/thor_ext"
end
12 changes: 10 additions & 2 deletions lib/example/cli.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
require "thor"

module Example
class CLI
def call(_argv)
class CLI < Thor
extend ThorExt::Start

map %w[-v --version] => "version"

desc "version", "Display example version", hide: true
def version
say "example/#{VERSION} #{RUBY_DESCRIPTION}"
end
end
end
69 changes: 69 additions & 0 deletions lib/example/thor_ext.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
module Example
module ThorExt
# Configures Thor to behave more like a typical CLI, with better help and error handling.
#
# - Passing -h or --help to a command will show help for that command.
# - Unrecognized options will be treated as errors (instead of being silently ignored).
# - Error messages will be printed in red to stderr, without stack trace.
# - Full stack traces can be enabled by setting the VERBOSE environment variable.
# - Errors will cause Thor to exit with a non-zero status.
#
# To take advantage of this behavior, your CLI should subclass Thor and extend this module.
#
# class CLI < Thor
# extend ThorExt::Start
# end
#
# Start your CLI with:
#
# CLI.start
#
# In tests, prevent Kernel.exit from being called when an error occurs, like this:
#
# CLI.start(args, exit_on_failure: false)
#
module Start
def self.extended(base)
super
base.check_unknown_options!
end

def start(given_args=ARGV, config={})
config[:shell] ||= Thor::Base.shell.new
handle_help_switches(given_args) do |args|
dispatch(nil, args, nil, config)
end
rescue StandardError => e
handle_exception_on_start(e, config)
end

private

def handle_help_switches(given_args)
yield(given_args.dup)
rescue Thor::UnknownArgumentError => e
retry_with_args = []

if given_args.first == "help"
retry_with_args = ["help"] if given_args.length > 1
elsif (e.unknown & %w[-h --help]).any?
retry_with_args = ["help", (given_args - e.unknown).first]
end
raise unless retry_with_args.any?

yield(retry_with_args)
end

def handle_exception_on_start(error, config)
return if error.is_a?(Errno::EPIPE)
raise if ENV["VERBOSE"] || !config.fetch(:exit_on_failure, true)

message = error.message.to_s
message.prepend("[#{error.class}] ") if message.empty? || !error.is_a?(Thor::Error)

config[:shell]&.say_error(message, :red)
exit(false)
end
end
end
end
10 changes: 9 additions & 1 deletion rename_template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,19 @@ def main # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
ensure_executable "exe/#{gem_name}"

replace_in_file "lib/example/cli.rb",
"example" => as_path(gem_name),
"Example" => as_module(gem_name)

git "mv", "lib/example/cli.rb", "lib/#{as_path(gem_name)}/cli.rb"
reindent_module "lib/#{as_path(gem_name)}/cli.rb"

replace_in_file "lib/example/thor_ext.rb", "Example" => as_module(gem_name)
git "mv", "lib/example/thor_ext.rb", "lib/#{as_path(gem_name)}/thor_ext.rb"
reindent_module "lib/#{as_path(gem_name)}/thor_ext.rb"
else
git "rm", "exe/example", "lib/example/cli.rb"
git "rm", "exe/example", "lib/example/cli.rb", "lib/example/thor_ext.rb"
replace_in_file "example.gemspec", 'spec.add_dependency "thor"' => '# spec.add_dependency "thor"'
remove_line "lib/example.rb", /autoload :ThorExt/
remove_line "lib/example.rb", /autoload :CLI/
end

Expand Down Expand Up @@ -113,6 +120,7 @@ def main # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
'require "example"' => %Q(require "#{as_path(gem_name)}")

git "rm", "rename_template.rb"
Dir.unlink("lib/example") if Dir.empty?("lib/example")

puts <<~MESSAGE
Expand Down

0 comments on commit e63618c

Please sign in to comment.