├── .gitignore ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin └── cli ├── lib └── cli.rb └── spec ├── cli_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /Gemfile.lock 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rake' 4 | gem 'rspec', '~> 3.0' 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Hal Brodigan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | 'Software'), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ruby-cli-boilerplate 2 | 3 | This repository contains a boilerplate Ruby CLI class that can be used to 4 | implement a basic zero-dependency CLI for other Ruby libraries. 5 | 6 | ## Features 7 | 8 | * Zero-dependencies, making it ideal for adding a CLI to small libraries. 9 | * Correctly handles `Ctrl^C` and broken pipe exceptions. 10 | * Catches any other exceptions and prints a bug report. 11 | * Defines the CLI as a class, making it easy to test. 12 | * Comes with boilerplate RSpec tests. 13 | 14 | ## Other CLI libraries 15 | 16 | If you want to build a CLI for a large Ruby app or framework, and 17 | don't mind adding an extra dependency for a CLI library/framework, I recommend 18 | the following Ruby CLI libraries: 19 | 20 | * [cmdparse](https://cmdparse.gettalong.org/) 21 | * [dry-cli](https://dry-rb.org/gems/dry-cli/) 22 | * [command_kit](https://github.com/postmodern/command_kit.rb#readme) 23 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rspec/core/rake_task' 2 | RSpec::Core::RakeTask.new 3 | task :test => :spec 4 | task :default => :spec 5 | -------------------------------------------------------------------------------- /bin/cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | lib_dir = File.expand_path(File.join(__dir__,'..','lib')) 4 | $LOAD_PATH << lib_dir unless $LOAD_PATH.include?(lib_dir) 5 | 6 | require 'cli' 7 | exit CLI.run(ARGV) 8 | -------------------------------------------------------------------------------- /lib/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'optparse' 3 | 4 | class CLI 5 | 6 | # The CLI name. 7 | PROGRAM_NAME = "cli" 8 | 9 | # The CLI version 10 | VERSION = '0.1.0' 11 | 12 | # The URL to report bugs to. 13 | BUG_REPORT_URL = "https://github.com/FIXME/FIXME/issues/new" 14 | 15 | # The CLI's option parser. 16 | # 17 | # @return [OptionParser] 18 | attr_reader :option_parser 19 | 20 | # 21 | # Initializes the CLI. 22 | # 23 | def initialize 24 | @option_parser = option_parser 25 | 26 | # FIXME: initialize additional variables here 27 | end 28 | 29 | # 30 | # Initializes and runs the CLI. 31 | # 32 | # @param [Array] argv 33 | # Command-line arguments. 34 | # 35 | # @return [Integer] 36 | # The exit status of the CLI. 37 | # 38 | def self.run(argv=ARGV) 39 | new().run(argv) 40 | rescue Interrupt 41 | # https://tldp.org/LDP/abs/html/exitcodes.html 42 | return 130 43 | rescue Errno::EPIPE 44 | # STDOUT pipe broken 45 | return 0 46 | end 47 | 48 | # 49 | # Runs the CLI. 50 | # 51 | # @param [Array] argv 52 | # Command-line arguments. 53 | # 54 | # @return [Integer] 55 | # The return status code. 56 | # 57 | def run(argv=ARGV) 58 | argv = begin 59 | @option_parser.parse(argv) 60 | rescue OptionParser::ParseError => error 61 | print_error(error.message) 62 | return -1 63 | end 64 | 65 | # FIXME: add CLI logic here 66 | do_stuff 67 | rescue => error 68 | print_backtrace(error) 69 | return -1 70 | end 71 | 72 | def do_stuff 73 | end 74 | 75 | # 76 | # The option parser. 77 | # 78 | # @return [OptionParser] 79 | # 80 | def option_parser 81 | OptionParser.new do |opts| 82 | opts.banner = "usage: #{PROGRAM_NAME} [options] ARG ..." 83 | 84 | opts.separator "" 85 | opts.separator "Options:" 86 | 87 | # FIXME: add additional options here 88 | 89 | opts.on('-V','--version','Print the version') do 90 | puts "#{PROGRAM_NAME} #{VERSION}" 91 | exit 92 | end 93 | 94 | opts.on('-h','--help','Print the help output') do 95 | puts opts 96 | exit 97 | end 98 | end 99 | end 100 | 101 | # 102 | # Prints an error message to stderr. 103 | # 104 | # @param [String] error 105 | # The error message. 106 | # 107 | def print_error(error) 108 | $stderr.puts "#{PROGRAM_NAME}: #{error}" 109 | end 110 | 111 | # 112 | # Prints a backtrace to stderr. 113 | # 114 | # @param [Exception] exception 115 | # The exception. 116 | # 117 | def print_backtrace(exception) 118 | $stderr.puts "Oops! Looks like you've found a bug!" 119 | $stderr.puts "Please report the following text to: #{BUG_REPORT_URL}" 120 | $stderr.puts 121 | $stderr.puts "```" 122 | $stderr.puts "#{exception.full_message}" 123 | $stderr.puts "```" 124 | end 125 | 126 | end 127 | -------------------------------------------------------------------------------- /spec/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'cli' 3 | 4 | describe CLI do 5 | describe "#initialize" do 6 | it "must initialize #option_parser" do 7 | expect(subject.option_parser).to be_kind_of(OptionParser) 8 | end 9 | 10 | # FIXME: add additional #initialize specs here 11 | end 12 | 13 | describe "#print_error" do 14 | let(:error) { "error!" } 15 | 16 | it "must print the program name and the error message to stderr" do 17 | expect { 18 | subject.print_error(error) 19 | }.to output("#{described_class::PROGRAM_NAME}: #{error}#{$/}").to_stderr 20 | end 21 | end 22 | 23 | describe "#print_backtrace" do 24 | let(:exception) { RuntimeError.new("error!") } 25 | 26 | it "must print the program name and the error message to stderr" do 27 | expect { 28 | subject.print_backtrace(exception) 29 | }.to output( 30 | %r{Oops! Looks like you've found a bug! 31 | Please report the following text to: #{Regexp.escape(described_class::BUG_REPORT_URL)} 32 | 33 | ```}m 34 | ).to_stderr 35 | end 36 | end 37 | 38 | describe "#option_parser" do 39 | it do 40 | expect(subject.option_parser).to be_kind_of(OptionParser) 41 | end 42 | 43 | describe "#parse" do 44 | # FIXME: add additional option specs here 45 | 46 | %w[-V --version].each do |flag| 47 | context "when given #{flag}" do 48 | let(:argv) { [flag] } 49 | 50 | it "must print the CLI's version" do 51 | expect(subject).to receive(:exit) 52 | 53 | expect { 54 | subject.option_parser.parse(argv) 55 | }.to output("#{described_class::PROGRAM_NAME} #{described_class::VERSION}#{$/}").to_stdout 56 | end 57 | end 58 | end 59 | 60 | %w[-h --help].each do |flag| 61 | context "when given #{flag}" do 62 | let(:argv) { [flag] } 63 | 64 | it "must print the option parser --help output" do 65 | expect(subject).to receive(:exit) 66 | 67 | expect { 68 | subject.option_parser.parse(argv) 69 | }.to output("#{subject.option_parser}").to_stdout 70 | end 71 | end 72 | end 73 | end 74 | end 75 | 76 | describe ".run" do 77 | subject { described_class } 78 | 79 | context "when Interrupt is raised" do 80 | before do 81 | expect_any_instance_of(described_class).to receive(:run).and_raise(Interrupt) 82 | end 83 | 84 | it "must exit with 130" do 85 | expect(subject.run([])).to eq(130) 86 | end 87 | end 88 | 89 | context "when Errno::EPIPE is raised" do 90 | before do 91 | expect_any_instance_of(described_class).to receive(:run).and_raise(Errno::EPIPE) 92 | end 93 | 94 | it "must exit with 0" do 95 | expect(subject.run([])).to eq(0) 96 | end 97 | end 98 | end 99 | 100 | describe "#run" do 101 | # FIXME: add additional specs here 102 | 103 | context "when an invalid option is given" do 104 | let(:opt) { '--foo' } 105 | 106 | it "must print '#{described_class::PROGRAM_NAME}: invalid option ...' to $stderr and exit with -1" do 107 | expect { 108 | expect(subject.run([opt])).to eq(-1) 109 | }.to output("#{described_class::PROGRAM_NAME}: invalid option: #{opt}#{$/}").to_stderr 110 | end 111 | end 112 | 113 | context "when another type of Exception is raised" do 114 | let(:exception) { RuntimeError.new("error!") } 115 | 116 | before do 117 | expect(subject).to receive(:do_stuff).and_raise(exception) 118 | end 119 | 120 | it "must print a backtrace and exit with -1" do 121 | expect { 122 | expect(subject.run([])).to eq(-1) 123 | }.to output( 124 | %r{Oops! Looks like you've found a bug! 125 | Please report the following text to: #{Regexp.escape(described_class::BUG_REPORT_URL)} 126 | 127 | ```}m 128 | ).to_stderr 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | --------------------------------------------------------------------------------