rubycli 0.1.1
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 +7 -0
- data/CHANGELOG.md +8 -0
- data/LICENSE +21 -0
- data/README.ja.md +404 -0
- data/README.md +399 -0
- data/exe/rubycli +6 -0
- data/lib/rubycli/argument_parser.rb +343 -0
- data/lib/rubycli/cli.rb +341 -0
- data/lib/rubycli/command_line.rb +116 -0
- data/lib/rubycli/documentation_registry.rb +836 -0
- data/lib/rubycli/environment.rb +77 -0
- data/lib/rubycli/eval_coercer.rb +42 -0
- data/lib/rubycli/help_renderer.rb +298 -0
- data/lib/rubycli/json_coercer.rb +32 -0
- data/lib/rubycli/result_emitter.rb +41 -0
- data/lib/rubycli/type_utils.rb +128 -0
- data/lib/rubycli/types.rb +16 -0
- data/lib/rubycli/version.rb +5 -0
- data/lib/rubycli.rb +406 -0
- metadata +65 -0
data/lib/rubycli.rb
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
feature_path = File.expand_path(__FILE__)
|
|
6
|
+
$LOADED_FEATURES << feature_path unless $LOADED_FEATURES.include?(feature_path)
|
|
7
|
+
|
|
8
|
+
require_relative 'rubycli/version'
|
|
9
|
+
require_relative 'rubycli/environment'
|
|
10
|
+
require_relative 'rubycli/types'
|
|
11
|
+
require_relative 'rubycli/type_utils'
|
|
12
|
+
require_relative 'rubycli/documentation_registry'
|
|
13
|
+
require_relative 'rubycli/json_coercer'
|
|
14
|
+
require_relative 'rubycli/eval_coercer'
|
|
15
|
+
require_relative 'rubycli/argument_parser'
|
|
16
|
+
require_relative 'rubycli/help_renderer'
|
|
17
|
+
require_relative 'rubycli/result_emitter'
|
|
18
|
+
require_relative 'rubycli/cli'
|
|
19
|
+
require_relative 'rubycli/command_line'
|
|
20
|
+
|
|
21
|
+
module Rubycli
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
class CommandNotFoundError < Error; end
|
|
24
|
+
class ArgumentError < Error; end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def environment
|
|
28
|
+
@environment ||= Environment.new(env: ENV, argv: ARGV)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def documentation_registry
|
|
32
|
+
@documentation_registry ||= DocumentationRegistry.new(environment: environment)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def json_coercer
|
|
36
|
+
@json_coercer ||= JsonCoercer.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def eval_coercer
|
|
40
|
+
@eval_coercer ||= EvalCoercer.new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def argument_parser
|
|
44
|
+
@argument_parser ||= ArgumentParser.new(
|
|
45
|
+
environment: environment,
|
|
46
|
+
documentation_registry: documentation_registry,
|
|
47
|
+
json_coercer: json_coercer,
|
|
48
|
+
debug_logger: method(:debug_log)
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def help_renderer
|
|
53
|
+
@help_renderer ||= HelpRenderer.new(documentation_registry: documentation_registry)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def result_emitter
|
|
57
|
+
@result_emitter ||= ResultEmitter.new(environment: environment)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cli
|
|
61
|
+
@cli ||= CLI.new(
|
|
62
|
+
environment: environment,
|
|
63
|
+
argument_parser: argument_parser,
|
|
64
|
+
documentation_registry: documentation_registry,
|
|
65
|
+
help_renderer: help_renderer,
|
|
66
|
+
result_emitter: result_emitter
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def run(target, args = ARGV, cli_mode = true)
|
|
71
|
+
cli.run(target, args.dup, cli_mode)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse_arguments(args, method = nil)
|
|
75
|
+
argument_parser.parse(args.dup, method)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def available_commands(target)
|
|
79
|
+
cli.available_commands(target)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def find_method(target, command)
|
|
83
|
+
cli.find_method(target, command)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def usage_for_method(command, method)
|
|
87
|
+
cli.usage_for_method(command, method)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def method_description(method)
|
|
91
|
+
cli.method_description(method)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def print_help(target)
|
|
95
|
+
catalog = cli.command_catalog_for(target)
|
|
96
|
+
help_renderer.print_help(target, catalog)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def call_target(target_callable, pos_args, kw_args)
|
|
100
|
+
debug_log "Calling target with pos_args: #{pos_args.inspect}, kw_args: #{kw_args.inspect}"
|
|
101
|
+
kw_args.empty? ? target_callable.call(*pos_args) : target_callable.call(*pos_args, **kw_args)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def debug_log(message)
|
|
105
|
+
puts "[DEBUG] #{message}" if environment.debug?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def json_mode?
|
|
109
|
+
json_coercer.json_mode?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def with_json_mode(enabled = true)
|
|
113
|
+
if enabled && eval_mode?
|
|
114
|
+
raise Rubycli::ArgumentError, '--json-args and --eval-args cannot be used together'
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
json_coercer.with_json_mode(enabled) { yield }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def coerce_json_value(value)
|
|
121
|
+
json_coercer.coerce_json_value(value)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def eval_mode?
|
|
125
|
+
eval_coercer.eval_mode?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def with_eval_mode(enabled = true)
|
|
129
|
+
if enabled && json_mode?
|
|
130
|
+
raise Rubycli::ArgumentError, '--json-args and --eval-args cannot be used together'
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
eval_coercer.with_eval_mode(enabled) { yield }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def coerce_eval_value(value)
|
|
137
|
+
eval_coercer.coerce_eval_value(value)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def apply_argument_coercions(pos_args, kw_args)
|
|
141
|
+
if json_mode? && eval_mode?
|
|
142
|
+
raise Rubycli::ArgumentError, '--json-args and --eval-args cannot be used together'
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
if json_mode?
|
|
146
|
+
pos_args.map! { |value| coerce_json_value(value) }
|
|
147
|
+
kw_args.keys.each do |key|
|
|
148
|
+
kw_args[key] = coerce_json_value(kw_args[key])
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
return unless eval_mode?
|
|
153
|
+
|
|
154
|
+
pos_args.map! { |value| coerce_eval_value(value) }
|
|
155
|
+
kw_args.keys.each do |key|
|
|
156
|
+
kw_args[key] = coerce_eval_value(kw_args[key])
|
|
157
|
+
end
|
|
158
|
+
rescue ArgumentError => e
|
|
159
|
+
raise Rubycli::ArgumentError, e.message
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def apply_json_coercion(pos_args, kw_args)
|
|
163
|
+
apply_argument_coercions(pos_args, kw_args)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
module Runner
|
|
168
|
+
class Error < Rubycli::Error; end
|
|
169
|
+
class PreScriptError < Error; end
|
|
170
|
+
|
|
171
|
+
module_function
|
|
172
|
+
|
|
173
|
+
def execute(
|
|
174
|
+
target_path,
|
|
175
|
+
class_name = nil,
|
|
176
|
+
cli_args = nil,
|
|
177
|
+
new: false,
|
|
178
|
+
json: false,
|
|
179
|
+
eval_args: false,
|
|
180
|
+
pre_scripts: []
|
|
181
|
+
)
|
|
182
|
+
raise ArgumentError, 'target_path must be specified' if target_path.nil? || target_path.empty?
|
|
183
|
+
original_program_name = $PROGRAM_NAME
|
|
184
|
+
if json && eval_args
|
|
185
|
+
raise Error, '--json-args and --eval-args cannot be used together'
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
full_path = find_target_path(target_path)
|
|
189
|
+
load full_path
|
|
190
|
+
$PROGRAM_NAME = File.basename(full_path)
|
|
191
|
+
defined_constants = constants_defined_in_file(full_path)
|
|
192
|
+
|
|
193
|
+
constant_name = class_name || infer_class_name(full_path)
|
|
194
|
+
target = constantize(
|
|
195
|
+
constant_name,
|
|
196
|
+
defined_constants: defined_constants,
|
|
197
|
+
full_path: full_path
|
|
198
|
+
)
|
|
199
|
+
runner_target = new ? instantiate_target(target) : target
|
|
200
|
+
runner_target = apply_pre_scripts(pre_scripts, target, runner_target)
|
|
201
|
+
|
|
202
|
+
original_argv = ARGV.dup
|
|
203
|
+
ARGV.replace(Array(cli_args).dup)
|
|
204
|
+
run_with_modes(runner_target, json: json, eval_args: eval_args)
|
|
205
|
+
ensure
|
|
206
|
+
$PROGRAM_NAME = original_program_name if original_program_name
|
|
207
|
+
ARGV.replace(original_argv) if original_argv
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def apply_pre_scripts(sources, base_target, initial_target)
|
|
211
|
+
Array(sources).reduce(initial_target) do |current_target, source|
|
|
212
|
+
result = evaluate_pre_script(source, base_target, current_target)
|
|
213
|
+
result.nil? ? current_target : result
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def evaluate_pre_script(source, base_target, current_target)
|
|
218
|
+
code, context = read_pre_script_code(source)
|
|
219
|
+
pre_binding = Object.new.instance_eval { binding }
|
|
220
|
+
pre_binding.local_variable_set(:target, base_target)
|
|
221
|
+
pre_binding.local_variable_set(:current, current_target)
|
|
222
|
+
pre_binding.local_variable_set(:instance, current_target)
|
|
223
|
+
|
|
224
|
+
Rubycli.with_eval_mode(true) do
|
|
225
|
+
pre_binding.eval(code, context)
|
|
226
|
+
end
|
|
227
|
+
rescue Errno::ENOENT
|
|
228
|
+
raise PreScriptError, "Pre-script file not found: #{context}"
|
|
229
|
+
rescue StandardError => e
|
|
230
|
+
raise PreScriptError, "Failed to evaluate pre-script (#{context}): #{e.message}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def read_pre_script_code(source)
|
|
234
|
+
value = source[:value]
|
|
235
|
+
inline_context = source[:context] || '(inline pre-script)'
|
|
236
|
+
|
|
237
|
+
if !value.nil? && File.file?(value)
|
|
238
|
+
[File.read(value), File.expand_path(value)]
|
|
239
|
+
else
|
|
240
|
+
[String(value), inline_context]
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def find_target_path(path)
|
|
245
|
+
if File.file?(path)
|
|
246
|
+
File.expand_path(path)
|
|
247
|
+
elsif File.file?("#{path}.rb")
|
|
248
|
+
File.expand_path("#{path}.rb")
|
|
249
|
+
else
|
|
250
|
+
raise Error, "File not found: #{path}"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def infer_class_name(path)
|
|
255
|
+
base = File.basename(path, '.rb')
|
|
256
|
+
base_const = camelize(base)
|
|
257
|
+
detect_constant_for_file(path, base_const) || base_const
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def camelize(name)
|
|
261
|
+
name.split(/[^a-zA-Z0-9]+/).reject(&:empty?).map { |part|
|
|
262
|
+
part[0].upcase + part[1..].downcase
|
|
263
|
+
}.join
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def constantize(name, defined_constants: nil, full_path: nil)
|
|
267
|
+
parts = name.to_s.split('::').reject(&:empty?)
|
|
268
|
+
raise Error, "Unable to resolve class/module name: #{name.inspect}" if parts.empty?
|
|
269
|
+
|
|
270
|
+
parts.reduce(Object) do |context, const_name|
|
|
271
|
+
context.const_get(const_name)
|
|
272
|
+
end
|
|
273
|
+
rescue NameError
|
|
274
|
+
message = build_missing_constant_message(name, defined_constants, full_path)
|
|
275
|
+
raise Error.new(message), cause: nil
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def instantiate_target(target)
|
|
279
|
+
case target
|
|
280
|
+
when Class
|
|
281
|
+
target.new
|
|
282
|
+
when Module
|
|
283
|
+
Object.new.extend(target)
|
|
284
|
+
else
|
|
285
|
+
target
|
|
286
|
+
end
|
|
287
|
+
rescue ArgumentError => e
|
|
288
|
+
raise Error, "Failed to instantiate target: #{e.message}"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def run_with_modes(target, json:, eval_args:)
|
|
292
|
+
runner = proc { Rubycli.run(target) }
|
|
293
|
+
|
|
294
|
+
if json
|
|
295
|
+
Rubycli.with_json_mode(true, &runner)
|
|
296
|
+
elsif eval_args
|
|
297
|
+
Rubycli.with_eval_mode(true, &runner)
|
|
298
|
+
else
|
|
299
|
+
runner.call
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def detect_constant_for_file(path, base_const)
|
|
304
|
+
full_path = File.expand_path(path)
|
|
305
|
+
candidates = runtime_constant_candidates(full_path, base_const)
|
|
306
|
+
candidates.find { |name| constant_defined?(name) }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def constants_defined_in_file(path)
|
|
310
|
+
return [] unless Module.method_defined?(:const_source_location)
|
|
311
|
+
|
|
312
|
+
normalized = File.expand_path(path)
|
|
313
|
+
ObjectSpace.each_object(Module).each_with_object([]) do |mod, memo|
|
|
314
|
+
mod_name = module_name_for(mod)
|
|
315
|
+
next if mod_name.nil?
|
|
316
|
+
|
|
317
|
+
safe_module_constants(mod).each do |const_name|
|
|
318
|
+
location = safe_const_source_location(mod, const_name)
|
|
319
|
+
next unless location && location[0]
|
|
320
|
+
next unless File.expand_path(location[0]) == normalized
|
|
321
|
+
|
|
322
|
+
memo << qualified_constant_name(mod_name, const_name.to_s)
|
|
323
|
+
end
|
|
324
|
+
end.uniq.sort_by { |name| [name.count('::'), name] }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def runtime_constant_candidates(full_path, base_const)
|
|
328
|
+
return [] unless Module.method_defined?(:const_source_location)
|
|
329
|
+
|
|
330
|
+
normalized = File.expand_path(full_path)
|
|
331
|
+
ObjectSpace.each_object(Module).each_with_object([]) do |mod, memo|
|
|
332
|
+
mod_name = module_name_for(mod)
|
|
333
|
+
next unless mod_name
|
|
334
|
+
next unless safe_const_defined?(mod, base_const)
|
|
335
|
+
|
|
336
|
+
location = safe_const_source_location(mod, base_const)
|
|
337
|
+
next unless location && File.expand_path(location[0]) == normalized
|
|
338
|
+
|
|
339
|
+
memo << qualified_constant_name(mod_name, base_const)
|
|
340
|
+
end.uniq.sort_by { |name| [-name.count('::'), name] }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def module_name_for(mod)
|
|
344
|
+
return '' if mod.equal?(Object)
|
|
345
|
+
|
|
346
|
+
name = mod.name
|
|
347
|
+
return nil if name.nil? || name.start_with?('#<')
|
|
348
|
+
|
|
349
|
+
name
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def qualified_constant_name(mod_name, base_const)
|
|
353
|
+
mod_name.empty? ? base_const : "#{mod_name}::#{base_const}"
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def safe_module_constants(mod)
|
|
357
|
+
mod.constants(false)
|
|
358
|
+
rescue NameError
|
|
359
|
+
[]
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def safe_const_defined?(mod, const_name)
|
|
363
|
+
mod.const_defined?(const_name, false)
|
|
364
|
+
rescue NameError
|
|
365
|
+
false
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def safe_const_source_location(mod, const_name)
|
|
369
|
+
return nil unless mod.respond_to?(:const_source_location)
|
|
370
|
+
|
|
371
|
+
mod.const_source_location(const_name, false)
|
|
372
|
+
rescue NameError
|
|
373
|
+
nil
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def constant_defined?(name)
|
|
377
|
+
parts = name.split('::').reject(&:empty?)
|
|
378
|
+
context = Object
|
|
379
|
+
|
|
380
|
+
parts.each do |const_name|
|
|
381
|
+
return false unless context.const_defined?(const_name, false)
|
|
382
|
+
|
|
383
|
+
context = context.const_get(const_name)
|
|
384
|
+
end
|
|
385
|
+
true
|
|
386
|
+
rescue NameError
|
|
387
|
+
false
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def build_missing_constant_message(name, defined_constants, full_path)
|
|
391
|
+
lines = ["Could not find definition: #{name}"]
|
|
392
|
+
lines << " Loaded file: #{File.expand_path(full_path)}" if full_path
|
|
393
|
+
|
|
394
|
+
if defined_constants && !defined_constants.empty?
|
|
395
|
+
sample = defined_constants.first(5)
|
|
396
|
+
suffix = defined_constants.size > sample.size ? " ... (#{defined_constants.size} total)" : ''
|
|
397
|
+
lines << " Constants found in this file: #{sample.join(', ')}#{suffix}"
|
|
398
|
+
else
|
|
399
|
+
lines << " Rubycli could not detect any publicly exposable constants in this file."
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
lines << " Ensure the CLASS_OR_MODULE argument is correct when invoking the CLI."
|
|
403
|
+
lines.join("\n")
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rubycli
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- inakaegg
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2025-11-01 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Rubycli turns plain Ruby classes and modules into command-line interfaces
|
|
13
|
+
by reading their documentation comments, inspired by Python Fire but tailored for
|
|
14
|
+
Ruby tooling.
|
|
15
|
+
email:
|
|
16
|
+
- 52376271+inakaegg@users.noreply.github.com
|
|
17
|
+
executables:
|
|
18
|
+
- rubycli
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- LICENSE
|
|
24
|
+
- README.ja.md
|
|
25
|
+
- README.md
|
|
26
|
+
- exe/rubycli
|
|
27
|
+
- lib/rubycli.rb
|
|
28
|
+
- lib/rubycli/argument_parser.rb
|
|
29
|
+
- lib/rubycli/cli.rb
|
|
30
|
+
- lib/rubycli/command_line.rb
|
|
31
|
+
- lib/rubycli/documentation_registry.rb
|
|
32
|
+
- lib/rubycli/environment.rb
|
|
33
|
+
- lib/rubycli/eval_coercer.rb
|
|
34
|
+
- lib/rubycli/help_renderer.rb
|
|
35
|
+
- lib/rubycli/json_coercer.rb
|
|
36
|
+
- lib/rubycli/result_emitter.rb
|
|
37
|
+
- lib/rubycli/type_utils.rb
|
|
38
|
+
- lib/rubycli/types.rb
|
|
39
|
+
- lib/rubycli/version.rb
|
|
40
|
+
homepage: https://github.com/inakaegg/rubycli
|
|
41
|
+
licenses:
|
|
42
|
+
- MIT
|
|
43
|
+
metadata:
|
|
44
|
+
homepage_uri: https://github.com/inakaegg/rubycli
|
|
45
|
+
documentation_uri: https://github.com/inakaegg/rubycli#readme
|
|
46
|
+
changelog_uri: https://github.com/inakaegg/rubycli/releases
|
|
47
|
+
bug_tracker_uri: https://github.com/inakaegg/rubycli/issues
|
|
48
|
+
rdoc_options: []
|
|
49
|
+
require_paths:
|
|
50
|
+
- lib
|
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '3.0'
|
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
requirements: []
|
|
62
|
+
rubygems_version: 3.6.2
|
|
63
|
+
specification_version: 4
|
|
64
|
+
summary: Doc-comment driven CLI wrapper for Ruby classes and modules.
|
|
65
|
+
test_files: []
|