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 +4 -4
- data/CHANGELOG.md +44 -1
- data/LICENSE +18 -17
- data/README.md +271 -10
- data/lib/ergane/argument_definition.rb +17 -0
- data/lib/ergane/command.rb +159 -0
- data/lib/ergane/concerns/inheritance.rb +42 -0
- data/lib/ergane/concerns/option_handling.rb +43 -0
- data/lib/ergane/core_ext/array.rb +13 -0
- data/lib/ergane/core_ext/hash.rb +10 -0
- data/lib/ergane/core_ext/object.rb +47 -0
- data/lib/ergane/core_ext/option_parser.rb +18 -0
- data/lib/ergane/core_ext/string.rb +22 -0
- data/lib/ergane/dsl/block_dsl.rb +29 -0
- data/lib/ergane/dsl/command_dsl.rb +50 -0
- data/lib/ergane/dsl/macros.rb +23 -0
- data/lib/ergane/errors.rb +51 -0
- data/lib/ergane/formatter.rb +29 -0
- data/lib/ergane/help_formatter.rb +104 -0
- data/lib/ergane/option_definition.rb +49 -0
- data/lib/ergane/path_registry.rb +50 -0
- data/lib/ergane/runner.rb +64 -0
- data/lib/ergane/tool.rb +49 -95
- data/lib/ergane/util/formatting.rb +31 -0
- data/lib/ergane/version.rb +3 -1
- data/lib/ergane.rb +32 -77
- metadata +68 -72
- data/app/commands/ergane/console.rb +0 -22
- data/bin/ergane +0 -24
- data/lib/core_ext/option_parser.rb +0 -15
- data/lib/ergane/command_definition.rb +0 -176
- data/lib/ergane/debug.rb +0 -75
- data/lib/ergane/helpers/hashall.rb +0 -8
- data/lib/ergane/switch_definition.rb +0 -49
- data/lib/ergane/util.rb +0 -77
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5bac11b2362d538571dc708483d4cbc83ee2a66e51983c5e9c312e5a1a98d3f3
|
|
4
|
+
data.tar.gz: e27be5089a4034d7a67d26b17f4b5572d723a6ed38f3c29275d2089d6ead65cd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
1
|
+
Copyright (c) 2026 Twilight Coders, LLC
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MIT License
|
|
4
4
|
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
-
of this software and associated documentation files (the
|
|
7
|
-
in the Software without restriction, including
|
|
8
|
-
to use, copy, modify, merge, publish,
|
|
9
|
-
copies of the Software, and to
|
|
10
|
-
furnished to do so, subject to
|
|
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
|
|
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,
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
5
|
-
|
|
5
|
+
[](https://rubygems.org/gems/ergane)
|
|
6
|
+
[](https://github.com/TwilightCoders/ergane/actions/workflows/ci.yml)
|
|
7
|
+
[](https://qlty.sh/gh/TwilightCoders/projects/ergane)
|
|
8
|
+
[](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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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,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
|