rubycli 0.1.6 → 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.
data/lib/rubycli.rb CHANGED
@@ -2,9 +2,6 @@
2
2
 
3
3
  require 'json'
4
4
 
5
- feature_path = File.expand_path(__FILE__)
6
- $LOADED_FEATURES << feature_path unless $LOADED_FEATURES.include?(feature_path)
7
-
8
5
  require_relative 'rubycli/version'
9
6
  require_relative 'rubycli/environment'
10
7
  require_relative 'rubycli/types'
@@ -127,8 +124,8 @@ module Rubycli
127
124
  argument_mode_controller.json_mode?
128
125
  end
129
126
 
130
- def with_json_mode(enabled = true)
131
- argument_mode_controller.with_json_mode(enabled) { yield }
127
+ def with_json_mode(enabled = true, &block)
128
+ argument_mode_controller.with_json_mode(enabled, &block)
132
129
  end
133
130
 
134
131
  def coerce_json_value(value)
@@ -143,8 +140,8 @@ module Rubycli
143
140
  eval_coercer.eval_lax_mode?
144
141
  end
145
142
 
146
- def with_eval_mode(enabled = true, **options)
147
- argument_mode_controller.with_eval_mode(enabled, **options) { yield }
143
+ def with_eval_mode(enabled = true, **options, &block)
144
+ argument_mode_controller.with_eval_mode(enabled, **options, &block)
148
145
  end
149
146
 
150
147
  def coerce_eval_value(value)
@@ -159,397 +156,7 @@ module Rubycli
159
156
  apply_argument_coercions(pos_args, kw_args)
160
157
  end
161
158
  end
162
-
163
- module Runner
164
- class Error < Rubycli::Error; end
165
- class PreScriptError < Error; end
166
-
167
- ConstantCandidate = Struct.new(
168
- :name,
169
- :constant,
170
- :class_methods,
171
- :instance_methods,
172
- keyword_init: true
173
- ) do
174
- def callable?(instantiate: false)
175
- return true if class_methods.any?
176
-
177
- instantiate && instance_methods.any?
178
- end
179
-
180
- def matches?(base_name)
181
- name.split('::').last == base_name
182
- end
183
-
184
- def instance_only?
185
- instance_methods.any? && class_methods.empty?
186
- end
187
-
188
- def summary
189
- parts = []
190
- parts << "class: #{format_methods(class_methods)}" if class_methods.any?
191
- parts << "instance: #{format_methods(instance_methods)}" if instance_methods.any?
192
- parts << 'no CLI methods' if parts.empty?
193
- parts.join(' | ')
194
- end
195
-
196
- private
197
-
198
- def format_methods(methods)
199
- list = methods.first(3).map(&:to_s)
200
- list << '...' if methods.size > 3
201
- list.join(', ')
202
- end
203
- end
204
-
205
- module_function
206
-
207
- def execute(
208
- target_path,
209
- class_name = nil,
210
- cli_args = nil,
211
- new: false,
212
- json: false,
213
- eval_args: false,
214
- eval_lax: false,
215
- pre_scripts: [],
216
- constant_mode: nil
217
- )
218
- raise ArgumentError, 'target_path must be specified' if target_path.nil? || target_path.empty?
219
- if json && eval_args
220
- raise Error, '--json-args cannot be combined with --eval-args or --eval-lax'
221
- end
222
-
223
- runner_target, full_path = prepare_runner_target(
224
- target_path,
225
- class_name,
226
- new: new,
227
- pre_scripts: pre_scripts,
228
- constant_mode: constant_mode
229
- )
230
-
231
- original_program_name = $PROGRAM_NAME
232
- original_argv = nil
233
- $PROGRAM_NAME = File.basename(full_path)
234
- original_argv = ARGV.dup
235
- ARGV.replace(Array(cli_args).dup)
236
- run_with_modes(runner_target, json: json, eval_args: eval_args, eval_lax: eval_lax)
237
- ensure
238
- $PROGRAM_NAME = original_program_name if original_program_name
239
- ARGV.replace(original_argv) if original_argv
240
- end
241
-
242
- def check(
243
- target_path,
244
- class_name = nil,
245
- new: false,
246
- pre_scripts: [],
247
- constant_mode: nil
248
- )
249
- raise ArgumentError, 'target_path must be specified' if target_path.nil? || target_path.empty?
250
- previous_doc_check = Rubycli.environment.doc_check_mode?
251
- Rubycli.environment.clear_documentation_issues!
252
- Rubycli.environment.enable_doc_check!
253
-
254
- runner_target, full_path = prepare_runner_target(
255
- target_path,
256
- class_name,
257
- new: new,
258
- pre_scripts: pre_scripts,
259
- constant_mode: constant_mode
260
- )
261
-
262
- original_program_name = $PROGRAM_NAME
263
- $PROGRAM_NAME = File.basename(full_path)
264
-
265
- catalog = Rubycli.cli.command_catalog_for(runner_target)
266
- Array(catalog&.entries).each do |entry|
267
- method_obj = entry&.method
268
- Rubycli.documentation_registry.metadata_for(method_obj) if method_obj
269
- end
270
-
271
- if catalog&.entries&.empty? && runner_target.respond_to?(:call)
272
- method_obj = runner_target.method(:call) rescue nil
273
- Rubycli.documentation_registry.metadata_for(method_obj) if method_obj
274
- end
275
-
276
- issues = Rubycli.environment.documentation_issues
277
- if issues.empty?
278
- puts 'rubycli documentation OK'
279
- 0
280
- else
281
- warn "[ERROR] rubycli documentation check failed (#{issues.size} issue#{issues.size == 1 ? '' : 's'})"
282
- 1
283
- end
284
- ensure
285
- Rubycli.environment.disable_doc_check! unless previous_doc_check
286
- $PROGRAM_NAME = original_program_name if original_program_name
287
- end
288
-
289
- def apply_pre_scripts(sources, base_target, initial_target)
290
- Array(sources).reduce(initial_target) do |current_target, source|
291
- result = evaluate_pre_script(source, base_target, current_target)
292
- result.nil? ? current_target : result
293
- end
294
- end
295
-
296
- def evaluate_pre_script(source, base_target, current_target)
297
- code, context = read_pre_script_code(source)
298
- pre_binding = Object.new.instance_eval { binding }
299
- pre_binding.local_variable_set(:target, base_target)
300
- pre_binding.local_variable_set(:current, current_target)
301
- pre_binding.local_variable_set(:instance, current_target)
302
-
303
- Rubycli.with_eval_mode(true) do
304
- pre_binding.eval(code, context)
305
- end
306
- rescue Errno::ENOENT
307
- raise PreScriptError, "Pre-script file not found: #{context}"
308
- rescue StandardError => e
309
- raise PreScriptError, "Failed to evaluate pre-script (#{context}): #{e.message}"
310
- end
311
-
312
- def read_pre_script_code(source)
313
- value = source[:value]
314
- inline_context = source[:context] || '(inline pre-script)'
315
-
316
- if !value.nil? && File.file?(value)
317
- [File.read(value), File.expand_path(value)]
318
- else
319
- [String(value), inline_context]
320
- end
321
- end
322
-
323
- def find_target_path(path)
324
- if File.file?(path)
325
- File.expand_path(path)
326
- elsif File.file?("#{path}.rb")
327
- File.expand_path("#{path}.rb")
328
- else
329
- raise Error, "File not found: #{path}"
330
- end
331
- end
332
-
333
- def camelize(name)
334
- name.split(/[^a-zA-Z0-9]+/).reject(&:empty?).map { |part|
335
- part[0].upcase + part[1..].downcase
336
- }.join
337
- end
338
-
339
- def constantize(name, defined_constants: nil, full_path: nil)
340
- parts = name.to_s.split('::').reject(&:empty?)
341
- raise Error, "Unable to resolve class/module name: #{name.inspect}" if parts.empty?
342
-
343
- parts.reduce(Object) do |context, const_name|
344
- context.const_get(const_name)
345
- end
346
- rescue NameError
347
- message = build_missing_constant_message(name, defined_constants, full_path)
348
- raise Error.new(message), cause: nil
349
- end
350
-
351
- def instantiate_target(target)
352
- case target
353
- when Class
354
- target.new
355
- when Module
356
- Object.new.extend(target)
357
- else
358
- target
359
- end
360
- rescue ArgumentError => e
361
- raise Error, "Failed to instantiate target: #{e.message}"
362
- end
363
-
364
- def run_with_modes(target, json:, eval_args:, eval_lax:)
365
- runner = proc { Rubycli.run(target) }
366
-
367
- if json
368
- Rubycli.with_json_mode(true, &runner)
369
- elsif eval_args
370
- Rubycli.with_eval_mode(true, lax: eval_lax, &runner)
371
- else
372
- runner.call
373
- end
374
- end
375
-
376
- def prepare_runner_target(
377
- target_path,
378
- class_name,
379
- new: false,
380
- pre_scripts: [],
381
- constant_mode: nil
382
- )
383
- full_path = find_target_path(target_path)
384
- capture = Rubycli.constant_capture
385
- capture.capture(full_path) { load full_path }
386
- constant_mode ||= Rubycli.environment.constant_resolution_mode
387
- candidates = build_constant_candidates(full_path, capture.constants_for(full_path))
388
- defined_constants = candidates.map(&:name)
389
-
390
- target = if class_name
391
- constantize(
392
- class_name,
393
- defined_constants: defined_constants,
394
- full_path: full_path
395
- )
396
- else
397
- select_constant_candidate(
398
- full_path,
399
- camelize(File.basename(full_path, '.rb')),
400
- candidates,
401
- constant_mode,
402
- instantiate: new
403
- )
404
- end
405
-
406
- runner_target = new ? instantiate_target(target) : target
407
- runner_target = apply_pre_scripts(pre_scripts, target, runner_target)
408
- [runner_target, full_path]
409
- end
410
-
411
- def build_constant_candidates(path, constant_names)
412
- normalized = normalize_path(path)
413
- Array(constant_names).each_with_object([]) do |const_name, memo|
414
- constant = safe_constant_lookup(const_name)
415
- next unless constant.is_a?(Module)
416
-
417
- class_methods = collect_defined_methods(constant.singleton_class, normalized)
418
- instance_methods = collect_defined_methods(constant, normalized)
419
-
420
- memo << ConstantCandidate.new(
421
- name: const_name,
422
- constant: constant,
423
- class_methods: class_methods,
424
- instance_methods: instance_methods
425
- )
426
- end
427
- end
428
-
429
- def collect_defined_methods(owner, normalized_path)
430
- owner.public_instance_methods(false).each_with_object([]) do |method_name, memo|
431
- method_object = owner.instance_method(method_name)
432
- location = method_object.source_location
433
- next unless location && normalize_path(location[0]) == normalized_path
434
-
435
- memo << method_name
436
- end
437
- rescue TypeError
438
- []
439
- end
440
-
441
- def safe_constant_lookup(name)
442
- parts = name.split('::').reject(&:empty?)
443
- context = Object
444
-
445
- parts.each do |const_name|
446
- return nil unless context.const_defined?(const_name, false)
447
-
448
- context = context.const_get(const_name)
449
- end
450
-
451
- context
452
- rescue NameError
453
- nil
454
- end
455
-
456
- def select_constant_candidate(path, base_const, candidates, constant_mode, instantiate: false)
457
- if candidates.empty?
458
- raise Error, build_missing_constant_message(
459
- base_const,
460
- [],
461
- path,
462
- details: 'Rubycli could not detect any constants in this file.'
463
- )
464
- end
465
-
466
- matching = candidates.find { |candidate| candidate.matches?(base_const) }
467
- if matching
468
- return matching.constant if matching.callable?(instantiate: instantiate)
469
-
470
- detail = if matching.instance_only?
471
- "#{matching.name} only defines instance methods in this file. Run with --new to instantiate before invoking CLI commands."
472
- else
473
- "#{matching.name} does not define any CLI-callable methods in this file. Add a public class or instance method defined in this file."
474
- end
475
- raise Error, build_missing_constant_message(
476
- base_const,
477
- candidates.map(&:name),
478
- path,
479
- details: detail
480
- )
481
- end
482
-
483
- callable = candidates.select { |candidate| candidate.callable?(instantiate: instantiate) }
484
- if callable.empty?
485
- raise Error, build_missing_constant_message(
486
- base_const,
487
- candidates.map(&:name),
488
- path,
489
- details: 'Rubycli detected constants in this file, but none define CLI-callable methods. Add a public class or instance method defined in this file.'
490
- )
491
- end
492
-
493
- if constant_mode == :auto && callable.size == 1
494
- return callable.first.constant
495
- end
496
-
497
- details = build_ambiguous_constant_details(callable, path)
498
- raise Error, build_missing_constant_message(
499
- base_const,
500
- candidates.map(&:name),
501
- path,
502
- details: details
503
- )
504
- end
505
-
506
- def build_ambiguous_constant_details(candidates, path)
507
- command_target = File.basename(path)
508
- if candidates.size == 1
509
- candidate = candidates.first
510
- lines = []
511
- lines << "This file defines #{candidate.name}, but its name does not match #{command_target}."
512
- lines << 'Re-run by specifying the constant explicitly:'
513
- lines << " rubycli #{command_target} #{candidate.name} ..."
514
- lines << 'Alternatively pass --auto-target (or RUBYCLI_AUTO_TARGET=auto) to auto-select it.'
515
- return lines.join("\n")
516
- end
517
-
518
- lines = ['Multiple CLI-capable constants were found in this file:']
519
- candidates.each do |candidate|
520
- hint = candidate.instance_only? ? ' (instance methods only; use --new)' : ''
521
- lines << " - #{candidate.name}: #{candidate.summary}#{hint}"
522
- end
523
- lines << "Specify one explicitly, e.g. rubycli #{command_target} MyRunner"
524
- lines << 'Or pass --auto-target to allow Rubycli to auto-select a single candidate.'
525
- lines.join("\n")
526
- end
527
-
528
- def normalize_path(path)
529
- File.expand_path(path.to_s)
530
- end
531
-
532
- def build_missing_constant_message(name, defined_constants, full_path, details: nil)
533
- lines = ["Could not find definition: #{name}"]
534
- lines << ''
535
- lines << "Loaded file: #{File.expand_path(full_path)}" if full_path
536
-
537
- if defined_constants && !defined_constants.empty?
538
- sample = defined_constants.first(5)
539
- suffix = defined_constants.size > sample.size ? " … (#{defined_constants.size} total)" : ''
540
- lines << "Constants found in this file: #{sample.join(', ')}#{suffix}"
541
- else
542
- lines << 'Rubycli could not detect any publicly exposable constants in this file.'
543
- end
544
-
545
- if details
546
- lines << ''
547
- lines << details
548
- end
549
-
550
- lines << ''
551
- lines << 'Hint: Ensure the CLASS_OR_MODULE argument is correct when invoking the CLI.'
552
- lines.join("\n")
553
- end
554
- end
555
159
  end
160
+
161
+ # Defined after the Rubycli module so that Runner::Error can subclass Rubycli::Error.
162
+ require_relative 'rubycli/runner'
metadata CHANGED
@@ -1,14 +1,56 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubycli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - inakaegg
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-11-11 00:00:00.000000000 Z
11
- dependencies: []
10
+ date: 2026-07-26 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.25'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.25'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: bigdecimal
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.1'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.1'
12
54
  description: Rubycli turns plain Ruby classes and modules into command-line interfaces
13
55
  by reading their documentation comments, inspired by Python Fire but tailored for
14
56
  Ruby tooling.
@@ -23,6 +65,17 @@ files:
23
65
  - LICENSE
24
66
  - README.ja.md
25
67
  - README.md
68
+ - examples/documentation_style_showcase.rb
69
+ - examples/fallback_example.rb
70
+ - examples/fallback_example_with_extra_docs.rb
71
+ - examples/hello_app.rb
72
+ - examples/hello_app_with_docs.rb
73
+ - examples/hello_app_with_require.rb
74
+ - examples/mismatched_constant_runner.rb
75
+ - examples/multi_constant_runner.rb
76
+ - examples/new_mode_runner.rb
77
+ - examples/strict_choices_demo.rb
78
+ - examples/typed_arguments_demo.rb
26
79
  - exe/rubycli
27
80
  - lib/rubycli.rb
28
81
  - lib/rubycli/argument_mode_controller.rb
@@ -40,6 +93,7 @@ files:
40
93
  - lib/rubycli/help_renderer.rb
41
94
  - lib/rubycli/json_coercer.rb
42
95
  - lib/rubycli/result_emitter.rb
96
+ - lib/rubycli/runner.rb
43
97
  - lib/rubycli/type_utils.rb
44
98
  - lib/rubycli/types.rb
45
99
  - lib/rubycli/version.rb
@@ -49,8 +103,9 @@ licenses:
49
103
  metadata:
50
104
  homepage_uri: https://github.com/inakaegg/rubycli
51
105
  documentation_uri: https://github.com/inakaegg/rubycli#readme
52
- changelog_uri: https://github.com/inakaegg/rubycli/releases
106
+ changelog_uri: https://github.com/inakaegg/rubycli/blob/main/CHANGELOG.md
53
107
  bug_tracker_uri: https://github.com/inakaegg/rubycli/issues
108
+ rubygems_mfa_required: 'true'
54
109
  rdoc_options: []
55
110
  require_paths:
56
111
  - lib