ergane 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f27bb356ea374b87bf0a12ad73bc945e518dee66aa18797b5f9b06a197de896
4
- data.tar.gz: 2de58095dd7014c321d13fcac71f9d16019db72cd6feb0896bafea5f9ae19d6e
3
+ metadata.gz: 5bac11b2362d538571dc708483d4cbc83ee2a66e51983c5e9c312e5a1a98d3f3
4
+ data.tar.gz: e27be5089a4034d7a67d26b17f4b5572d723a6ed38f3c29275d2089d6ead65cd
5
5
  SHA512:
6
- metadata.gz: 3a3431e9dd63b745dc3765a83c98c0d3ed3d60837373d12b69371f1df05e5718be8b2fd5007808e4383ba8d77c0fd6997ba7dc867e317b5b598fc80921429161
7
- data.tar.gz: fa7972b5a4019cff455ca2f0631181cb3ce49192a56ee01eb549c22827fbc8d41b77cc86d089178d04e2b63474626faab18dd3acbe45be12aedaec5a37254955
6
+ metadata.gz: e136d486d292c5bac857f0cd8a950189d562aef43f76320b65c672bcc11f98af37658cac4b201a607c8ba103f73d84a94b5bfeafefa4044ccbd98a62707240ad
7
+ data.tar.gz: c36af86e50cb12b0cdd270744cea2f4c3a3f88570c8bd305bcd976f8466ffa8e6d34a827df3619b317b2c86fcc30d73fbcecc4b10d7a9b4eed250d028baf9913
data/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
+ # CHANGELOG
2
+
1
3
  ## [Unreleased]
2
4
 
3
- ## [0.1.0] - 2023-04-26
5
+ ## [0.2.0] - 2026-05-25
6
+
7
+ ### Changed
8
+ - **Unknown subcommands on a command group now raise `CommandNotFound`** (with a did-you-mean suggestion) and exit non-zero, instead of silently printing help. Scoped to command groups — leaf commands still treat unmatched tokens as positional arguments.
9
+ - **Positional `argument` declarations are now enforced.** Required-ness is derived from the command's `run` signature — a required parameter (`run(name)`) makes the argument required, while an optional one (`run(name = nil)`) or a splat (`run(*)`) makes it optional; an explicit `required:` on the `argument` overrides the signature. A missing required argument raises `MissingArgument`; an absent optional argument takes its `default:`; values are coerced to the declared `type:` (`String` is identity, `Integer`/`Float` via Kernel conversion raising `InvalidOption` on bad input). Previously these keywords affected only help text.
10
+
11
+ ### Added
12
+ - `Ergane::DSL::Macros.dsl_value` — a class-level getter/setter accessor generator used by the DSL.
13
+
14
+ ### Fixed
15
+ - `PathRegistry#abbreviate` now expands its input before matching (mirroring `#register`), so abbreviation is consistent across platforms (notably Windows, where un-expanded inputs failed to match drive-qualified prefixes) and accepts `~`-relative input.
16
+ - `OptionParser#order_recognized!` no longer drops the trailing tokens of a multi-token unknown option, and preserves argument order.
17
+ - `String#blank?` is guarded against external definitions (e.g. ActiveSupport) instead of unconditionally overriding them.
18
+ - Tool-rooted abstract intermediate commands are no longer stranded in the tool's registry when marked abstract.
19
+
20
+ ### Internal
21
+ - Command registration unified into a single `register!` path (removed the duplicate `define_singleton_method`'d `inherited`/`inherited_command_name_set` hooks).
22
+ - `HelpFormatter` renders through a shared `section` helper with a per-render color cycler (no module-level mutable state).
23
+ - `Util::Debug` is no longer packaged in the gem (dev-only tooling).
24
+
25
+ ## [0.1.0] - 2026-05-25
26
+
27
+ First release of the rewritten framework. A near-complete rewrite of the
28
+ original `0.0.1` proof of concept.
29
+
30
+ ### Added
31
+ - Dual DSL: class-based and block-based command definitions, both producing the same command tree
32
+ - Zeitwerk autoloading
33
+ - Recursive subcommand resolution via Runner
34
+ - Tool base class with auto-created command base (`MyTool::Command`)
35
+ - Custom `command_class` for shared command behavior
36
+ - Abstract commands for grouping shared options
37
+ - Colorized help output with box-drawing characters
38
+ - Did-you-mean suggestions for unknown commands (Levenshtein)
39
+ - `--help` and `--version` flag handling
40
+ - Options, flags, and positional arguments, with optional values for options
41
+ - Path abbreviation registry (`Ergane.paths` / `PathRegistry`): collapses registered prefixes (default `$HOME` → `~`), longest-prefix-wins and boundary-safe; commands call `abbreviate_path`
42
+ - Interactive output helpers: `Ergane::Formatter.confirm?` and `Ergane::Formatter.time_ago`
43
+ - Core extensions (`blank?`, `present?`, `try`, `underscore`, `demodulize`, `Array.wrap`, `Hash#&`)
44
+ - `OptionParser#order_recognized!` for multi-level flag passthrough
45
+
46
+ ## [0.0.1] - 2023-05-08
4
47
 
5
48
  - Initial release
data/LICENSE CHANGED
@@ -1,21 +1,22 @@
1
- The MIT License (MIT)
1
+ Copyright (c) 2026 Twilight Coders, LLC
2
2
 
3
- Copyright (c) 2023 Dale Stevens
3
+ MIT License
4
4
 
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
11
12
 
12
- The above copyright notice and this permission notice shall be included in
13
- all copies or substantial portions of the Software.
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
14
15
 
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
- THE SOFTWARE.
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md CHANGED
@@ -1,18 +1,279 @@
1
+ <img src="https://github.com/TwilightCoders/ergane/blob/main/media/ergane.png?raw=true" alt="Athena Ergane" width="400" style="float: right"/>
2
+
1
3
  # Ergane
2
- **The patron goddess of craftsmen and artisans**
3
4
 
4
- Library for creating lightweight, yet powerful CLI tools in Ruby.
5
- Emphasis and priority on load speed and flexibility.
5
+ [![Version](https://img.shields.io/gem/v/ergane.svg)](https://rubygems.org/gems/ergane)
6
+ [![CI](https://github.com/TwilightCoders/ergane/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/TwilightCoders/ergane/actions/workflows/ci.yml)
7
+ [![Code Coverage](https://qlty.sh/badges/43c39f23-168e-475f-a9fd-1754eaca83e6/coverage.svg)](https://qlty.sh/gh/TwilightCoders/projects/ergane)
8
+ [![Maintainability](https://qlty.sh/badges/43c39f23-168e-475f-a9fd-1754eaca83e6/maintainability.svg)](https://qlty.sh/gh/TwilightCoders/projects/ergane)
9
+
10
+ A lightweight, powerful CLI framework for Ruby. Define commands using class inheritance or block DSL — both produce the same command tree.
11
+
12
+ An alternative to other similar utilities with a cleaner, more Ruby-native design.
13
+
14
+ Named after [Athena Ergane](https://en.wikipedia.org/wiki/Athena#Ergane), patron of craftsmen and toolmakers.
6
15
 
7
16
  ## Requirements
8
- Ruby 2.7+
9
17
 
10
- May work with older versions.
18
+ Ruby 3.1+
19
+
20
+ ## Installation
21
+
22
+ ```ruby
23
+ gem "ergane"
24
+ ```
25
+
26
+ ## Quick Start
27
+
28
+ ```ruby
29
+ require "ergane"
30
+
31
+ class MyCLI < Ergane::Tool
32
+ tool_name :mycli
33
+ version "1.0.0"
34
+ description "My awesome CLI tool"
35
+
36
+ command :greet do
37
+ description "Say hello"
38
+ option :name, String, short: :n, default: "world"
39
+
40
+ run { puts "Hello #{options[:name]}!" }
41
+ end
42
+ end
43
+
44
+ MyCLI.start(ARGV)
45
+ ```
46
+
47
+ ```
48
+ $ mycli greet -n 'Johnny Appleseed'
49
+ Hello Johnny Appleseed!
50
+
51
+ $ mycli --help
52
+ My awesome CLI tool
53
+
54
+ Version: 1.0.0
55
+
56
+ Usage: mycli [options] [subcommand]
57
+
58
+ Subcommands:
59
+ greet Say hello
60
+
61
+ $ mycli greet --help
62
+ Say hello
63
+
64
+ Usage: greet [options]
65
+
66
+ Options:
67
+ -n, --name=VALUE (default: world)
68
+ ```
69
+
70
+ ## Defining Commands
71
+
72
+ Ergane supports two equivalent styles for defining commands. Both produce the same underlying `Command` subclass.
73
+
74
+ ### Block-based DSL
75
+
76
+ Best for quick, self-contained commands:
77
+
78
+ ```ruby
79
+ class MyCLI < Ergane::Tool
80
+ tool_name :mycli
81
+ version "1.0.0"
82
+
83
+ command :deploy do
84
+ description "Deploy the application"
85
+ option :env, String, short: :e, default: "staging"
86
+ flag :force, short: :f, description: "Skip confirmation"
87
+
88
+ run do |*targets|
89
+ puts "Deploying #{targets.join(', ')} to #{options[:env]}"
90
+ puts "Force mode!" if options[:force]
91
+ end
92
+ end
93
+ end
94
+ ```
95
+
96
+ ### Class-based
97
+
98
+ Best for complex commands that need helper methods, mixins, or testing in isolation.
99
+
100
+ Defining a Tool automatically creates a `MyCLI::Command` base class for your commands to inherit from:
101
+
102
+ ```ruby
103
+ class Deploy < MyCLI::Command
104
+ self.command_name = :deploy
105
+ description "Deploy the application"
106
+ option :env, String, short: :e, default: "staging"
107
+ flag :force, short: :f, description: "Skip confirmation"
108
+
109
+ def run(*targets)
110
+ puts "Deploying #{targets.join(', ')} to #{options[:env]}"
111
+ puts "Force mode!" if options[:force]
112
+ end
113
+
114
+ private
115
+
116
+ def confirm?
117
+ return true if options[:force]
118
+ # ...
119
+ end
120
+ end
121
+ ```
122
+
123
+ ### Nested Subcommands
124
+
125
+ Both styles support nesting to arbitrary depth:
126
+
127
+ ```ruby
128
+ class MyCLI < Ergane::Tool
129
+ tool_name :mycli
130
+ version "1.0.0"
131
+
132
+ command :server do
133
+ description "Server management"
134
+
135
+ command :start do
136
+ description "Start the server"
137
+ option :port, String, short: :p, default: "3000"
138
+
139
+ run { puts "Starting on port #{options[:port]}" }
140
+ end
141
+
142
+ command :stop do
143
+ description "Stop the server"
144
+ run { puts "Stopping server" }
145
+ end
146
+ end
147
+ end
148
+ ```
149
+
150
+ ```
151
+ $ mycli server start -p 8080
152
+ Starting on port 8080
153
+ ```
154
+
155
+ ## Options and Flags
156
+
157
+ ```ruby
158
+ # Typed option (requires a value)
159
+ option :env, String, short: :e, description: "Target environment", default: "staging"
160
+
161
+ # Boolean flag (no value, defaults to false)
162
+ flag :verbose, short: :v, description: "Enable verbose output"
163
+
164
+ # Positional argument
165
+ argument :target, description: "Deploy target"
166
+ ```
167
+
168
+ Options are accessed via the `options` hash:
169
+
170
+ ```ruby
171
+ run do |*args|
172
+ puts options[:env] # => "staging"
173
+ puts options[:verbose] # => false
174
+ end
175
+ ```
176
+
177
+ ### Positional Arguments
178
+
179
+ An argument's required-ness is derived from the command's `run` signature — a required parameter makes it required, while an optional parameter or a splat makes it optional:
180
+
181
+ ```ruby
182
+ def run(source, destination = nil, *rest)
183
+ # source is required; destination is optional
184
+ end
185
+ ```
186
+
187
+ Pass `required:` on the `argument` to override the signature, `type:` to coerce the value (a non-`String` type is converted, raising on failure), and `default:` for an absent optional argument. A missing required argument raises `Ergane::MissingArgument`.
188
+
189
+ ## Loading Commands from Files
190
+
191
+ For larger CLIs, organize commands in separate files:
192
+
193
+ ```ruby
194
+ class MyCLI < Ergane::Tool
195
+ tool_name :mycli
196
+ version "1.0.0"
197
+
198
+ load_commands(File.expand_path("commands/**/*.rb", __dir__))
199
+ end
200
+ ```
201
+
202
+ Each file defines a Command subclass that auto-registers via Ruby's `inherited` hook:
203
+
204
+ ```ruby
205
+ # commands/deploy.rb
206
+ class Deploy < MyCLI::Command
207
+ self.command_name = :deploy
208
+ description "Deploy the application"
209
+
210
+ def run(*targets)
211
+ # ...
212
+ end
213
+ end
214
+ ```
215
+
216
+ ## Custom Command Base
217
+
218
+ For shared behavior across all commands, provide your own base class:
219
+
220
+ ```ruby
221
+ class MyBaseCommand < Ergane::Command
222
+ self.abstract_class = true
223
+ flag :verbose, short: :v, description: "Verbose output"
224
+
225
+ def log(msg)
226
+ puts msg if options[:verbose]
227
+ end
228
+ end
229
+
230
+ class MyCLI < Ergane::Tool
231
+ tool_name :mycli
232
+ version "1.0.0"
233
+ command_class MyBaseCommand
234
+ end
235
+
236
+ class Deploy < MyBaseCommand
237
+ self.command_name = :deploy
238
+ description "Deploy the application"
239
+
240
+ def run(*targets)
241
+ log "Deploying #{targets.join(', ')}"
242
+ end
243
+ end
244
+ ```
245
+
246
+ ## Abstract Commands
247
+
248
+ Group related commands with shared options:
249
+
250
+ ```ruby
251
+ class DatabaseCommand < MyCLI::Command
252
+ self.abstract_class = true
253
+ option :database, String, short: :d, default: "primary"
254
+ end
255
+
256
+ class Migrate < DatabaseCommand
257
+ self.command_name = :migrate
258
+ description "Run migrations"
259
+
260
+ def run
261
+ puts "Migrating #{options[:database]}"
262
+ end
263
+ end
264
+ ```
265
+
266
+ ## Development
267
+
268
+ After checking out the repo, run `bundle` to install dependencies. Then, run `bundle exec rspec` to run the tests.
11
269
 
12
270
  ## Contributing
13
271
 
14
- 1. Fork it ( https://github.com/TwilightCoders/ergane/fork )
15
- 2. Create your feature branch (`git checkout -b my-new-feature`)
16
- 3. Commit your changes (`git commit -am 'Add some feature'`)
17
- 4. Push to the branch (`git push origin my-new-feature`)
18
- 5. Create a new Pull Request
272
+ Bug reports and pull requests are welcome on GitHub at
273
+ <https://github.com/TwilightCoders/ergane>. This project is intended to be a safe,
274
+ welcoming space for collaboration, and contributors are expected to adhere to the
275
+ [Contributor Covenant](http://contributor-covenant.org) code of conduct.
276
+
277
+ ## License
278
+
279
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ergane
4
+ class ArgumentDefinition
5
+ attr_reader :name, :type, :description, :required, :default
6
+
7
+ # +required+ defaults to nil, meaning "derive from the run signature";
8
+ # pass true/false to force it.
9
+ def initialize(name, type = String, description: nil, required: nil, default: nil)
10
+ @name = name.to_sym
11
+ @type = type
12
+ @description = description
13
+ @required = required
14
+ @default = default
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ergane
4
+ class Command
5
+ include Concerns::Inheritance
6
+ include Concerns::OptionHandling
7
+ extend DSL::CommandDSL
8
+
9
+ self.abstract_class = true
10
+
11
+ class << self
12
+ def command_name=(name)
13
+ @command_name = name&.to_sym
14
+ register!
15
+ end
16
+
17
+ def command_name
18
+ @command_name || derive_command_name
19
+ end
20
+
21
+ def terms
22
+ [command_name, *aliases].compact.uniq
23
+ end
24
+
25
+ def subcommands
26
+ @subcommands ||= {}
27
+ end
28
+
29
+ # Effective required-ness of the positional argument at +index+. An
30
+ # explicit DSL `required:` (true/false) wins; otherwise it's derived from
31
+ # the run method's matching positional parameter: a required parameter
32
+ # (`run(name)`) means required, while an optional one (`run(name = nil)`)
33
+ # or a splat (`run(*)`) means optional.
34
+ def argument_required?(index)
35
+ defn = argument_definitions[index]
36
+ return false unless defn
37
+ return defn.required unless defn.required.nil?
38
+
39
+ param = run_positional_parameters[index]
40
+ param ? param.first == :req : false
41
+ end
42
+
43
+ def inherited(subclass)
44
+ super
45
+ subclass.instance_variable_set(:@option_definitions, option_definitions.dup)
46
+ subclass.instance_variable_set(:@argument_definitions, argument_definitions.dup)
47
+ subclass.instance_variable_set(:@subcommands, {})
48
+ subclass.send(:register!)
49
+ end
50
+
51
+ private
52
+
53
+ # The run method's positional parameters (required/optional), in order,
54
+ # which line up with declared arguments. Excludes splat/keyword params.
55
+ def run_positional_parameters
56
+ instance_method(:run).parameters.select { |kind, _| kind == :req || kind == :opt }
57
+ end
58
+
59
+ def derive_command_name
60
+ return nil if self == Command || abstract_class?
61
+ base = name&.demodulize
62
+ return nil unless base
63
+ base.sub(/Command$/, "").underscore.to_sym
64
+ end
65
+
66
+ # The registry this command belongs in: a tool's abstract command base
67
+ # registers under the tool itself; a concrete parent registers under
68
+ # that parent. A command rooted directly on Command, or under an
69
+ # abstract non-tool parent, registers nowhere.
70
+ def registration_target
71
+ parent = superclass
72
+ if parent.respond_to?(:tool) && parent.abstract_class?
73
+ parent.tool
74
+ elsif parent != Command && !parent.abstract_class?
75
+ parent
76
+ end
77
+ end
78
+
79
+ # Registers (or re-registers) this command in its target registry under
80
+ # its current command_name, removing any prior registration when the
81
+ # name changes or the command becomes abstract. Idempotent — safe to
82
+ # call from both .inherited and command_name=.
83
+ def register!
84
+ target = registration_target
85
+ return unless target
86
+
87
+ name = abstract_class? ? nil : command_name
88
+
89
+ target.subcommands.delete(@registered_as) if @registered_as && @registered_as != name
90
+ if name
91
+ target.subcommands[name] = self
92
+ @registered_as = name
93
+ else
94
+ @registered_as = nil
95
+ end
96
+ end
97
+ end
98
+
99
+ attr_reader :options
100
+
101
+ def abbreviate_path(path)
102
+ Ergane.paths.abbreviate(path)
103
+ end
104
+
105
+ def initialize(argv = [])
106
+ @options = self.class.build_default_options
107
+ @argv = process_arguments(parse_options(argv.dup))
108
+ end
109
+
110
+ def args
111
+ @argv
112
+ end
113
+
114
+ def run(*run_args)
115
+ if self.class.subcommands.any?
116
+ $stdout.puts HelpFormatter.new(self.class).format
117
+ else
118
+ raise AbstractCommand, "#{self.class.name}#run is not implemented"
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ # Validates and coerces positional args against the command's argument
125
+ # definitions: missing required args raise, absent optional args take
126
+ # their default, and present args are coerced by type. Extra positionals
127
+ # beyond the declared arguments pass through untouched (e.g. for run(*)).
128
+ def process_arguments(argv)
129
+ definitions = self.class.argument_definitions
130
+ return argv if definitions.empty?
131
+
132
+ declared = definitions.each_with_index.map do |defn, i|
133
+ if i < argv.length
134
+ coerce_argument(argv[i], defn)
135
+ elsif self.class.argument_required?(i)
136
+ raise MissingArgument, "Missing required argument: <#{defn.name}>"
137
+ else
138
+ defn.default
139
+ end
140
+ end
141
+ declared + argv.drop(definitions.length)
142
+ end
143
+
144
+ def coerce_argument(value, defn)
145
+ type = defn.type
146
+ return value if type.nil? || type == String
147
+
148
+ if type == Integer
149
+ Integer(value)
150
+ elsif type == Float
151
+ Float(value)
152
+ else
153
+ value
154
+ end
155
+ rescue ArgumentError
156
+ raise InvalidOption, "Invalid value for <#{defn.name}>: #{value.inspect} (expected #{type})"
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ergane
4
+ module Concerns
5
+ module Inheritance
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def abstract_class=(value)
12
+ @abstract_class = value
13
+ # Re-run registration so the command leaves (or rejoins) its
14
+ # registry to match its new abstract state.
15
+ register! if self < Ergane::Command && respond_to?(:register!, true)
16
+ end
17
+
18
+ def abstract_class?
19
+ @abstract_class == true
20
+ end
21
+
22
+ # Returns the class descending directly from Ergane::Command, or
23
+ # an abstract class, if any, in the inheritance hierarchy.
24
+ #
25
+ # If A extends Command, A.base_class returns A.
26
+ # If B < A through some hierarchy, B.base_class returns A.
27
+ # If A is abstract, both B.base_class and C.base_class return B.
28
+ def base_class
29
+ unless self < Ergane::Command
30
+ raise Ergane::Error, "#{name} doesn't belong in a hierarchy descending from Ergane::Command"
31
+ end
32
+
33
+ if superclass == Ergane::Command || superclass.abstract_class?
34
+ self
35
+ else
36
+ superclass.base_class
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ergane
4
+ module Concerns
5
+ module OptionHandling
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def option_definitions
12
+ @option_definitions ||= {}
13
+ end
14
+
15
+ def argument_definitions
16
+ @argument_definitions ||= []
17
+ end
18
+
19
+ def build_default_options
20
+ option_definitions.each_with_object({}) do |(name, defn), hash|
21
+ hash[name] = defn.default_value
22
+ end
23
+ end
24
+
25
+ def build_option_parser(store)
26
+ ::OptionParser.new do |parser|
27
+ option_definitions.each_value do |defn|
28
+ defn.attach(parser, store)
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def parse_options(argv)
37
+ parser = self.class.build_option_parser(@options)
38
+ parser.order_recognized!(argv)
39
+ argv
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Array
4
+ def self.wrap(object)
5
+ if object.nil?
6
+ []
7
+ elsif object.respond_to?(:to_ary)
8
+ object.to_ary || [object]
9
+ else
10
+ [object]
11
+ end
12
+ end unless respond_to?(:wrap)
13
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hash
4
+ # Hash intersection by keys. Values come from the receiver.
5
+ # {a: 1, b: 2} & {a: 10, c: 3} # => {a: 1}
6
+ def &(other)
7
+ shared = keys & other.keys
8
+ shared.each_with_object({}) { |k, h| h[k] = self[k] }
9
+ end unless method_defined?(:&)
10
+ end