rubycli 0.1.7 → 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,453 +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
- new_args: nil,
213
- json: false,
214
- eval_args: false,
215
- eval_lax: false,
216
- pre_scripts: [],
217
- constant_mode: nil
218
- )
219
- raise ArgumentError, 'target_path must be specified' if target_path.nil? || target_path.empty?
220
- if json && eval_args
221
- raise Error, '--json-args cannot be combined with --eval-args or --eval-lax'
222
- end
223
-
224
- runner_target, full_path = prepare_runner_target(
225
- target_path,
226
- class_name,
227
- new: new,
228
- new_args: new_args,
229
- json_mode: json,
230
- eval_mode: eval_args,
231
- eval_lax: eval_lax,
232
- pre_scripts: pre_scripts,
233
- constant_mode: constant_mode
234
- )
235
-
236
- original_program_name = $PROGRAM_NAME
237
- original_argv = nil
238
- $PROGRAM_NAME = File.basename(full_path)
239
- original_argv = ARGV.dup
240
- ARGV.replace(Array(cli_args).dup)
241
- run_with_modes(runner_target, json: json, eval_args: eval_args, eval_lax: eval_lax)
242
- ensure
243
- $PROGRAM_NAME = original_program_name if original_program_name
244
- ARGV.replace(original_argv) if original_argv
245
- end
246
-
247
- def check(
248
- target_path,
249
- class_name = nil,
250
- new: false,
251
- new_args: nil,
252
- pre_scripts: [],
253
- constant_mode: nil,
254
- json_mode: false,
255
- eval_mode: false,
256
- eval_lax: false
257
- )
258
- raise ArgumentError, 'target_path must be specified' if target_path.nil? || target_path.empty?
259
- previous_doc_check = Rubycli.environment.doc_check_mode?
260
- Rubycli.environment.clear_documentation_issues!
261
- Rubycli.environment.enable_doc_check!
262
-
263
- runner_target, full_path = prepare_runner_target(
264
- target_path,
265
- class_name,
266
- new: new,
267
- new_args: new_args,
268
- json_mode: json_mode,
269
- eval_mode: eval_mode,
270
- eval_lax: eval_lax,
271
- pre_scripts: pre_scripts,
272
- constant_mode: constant_mode
273
- )
274
-
275
- original_program_name = $PROGRAM_NAME
276
- $PROGRAM_NAME = File.basename(full_path)
277
-
278
- catalog = Rubycli.cli.command_catalog_for(runner_target)
279
- Array(catalog&.entries).each do |entry|
280
- method_obj = entry&.method
281
- Rubycli.documentation_registry.metadata_for(method_obj) if method_obj
282
- end
283
-
284
- if catalog&.entries&.empty? && runner_target.respond_to?(:call)
285
- method_obj = runner_target.method(:call) rescue nil
286
- Rubycli.documentation_registry.metadata_for(method_obj) if method_obj
287
- end
288
-
289
- issues = Rubycli.environment.documentation_issues
290
- if issues.empty?
291
- puts 'rubycli documentation OK'
292
- 0
293
- else
294
- warn "[ERROR] rubycli documentation check failed (#{issues.size} issue#{issues.size == 1 ? '' : 's'})"
295
- 1
296
- end
297
- ensure
298
- Rubycli.environment.disable_doc_check! unless previous_doc_check
299
- $PROGRAM_NAME = original_program_name if original_program_name
300
- end
301
-
302
- def apply_pre_scripts(sources, base_target, initial_target)
303
- Array(sources).reduce(initial_target) do |current_target, source|
304
- result = evaluate_pre_script(source, base_target, current_target)
305
- result.nil? ? current_target : result
306
- end
307
- end
308
-
309
- def evaluate_pre_script(source, base_target, current_target)
310
- code, context = read_pre_script_code(source)
311
- pre_binding = Object.new.instance_eval { binding }
312
- pre_binding.local_variable_set(:target, base_target)
313
- pre_binding.local_variable_set(:current, current_target)
314
- pre_binding.local_variable_set(:instance, current_target)
315
-
316
- Rubycli.with_eval_mode(true) do
317
- pre_binding.eval(code, context)
318
- end
319
- rescue Errno::ENOENT
320
- raise PreScriptError, "Pre-script file not found: #{context}"
321
- rescue StandardError => e
322
- raise PreScriptError, "Failed to evaluate pre-script (#{context}): #{e.message}"
323
- end
324
-
325
- def read_pre_script_code(source)
326
- value = source[:value]
327
- inline_context = source[:context] || '(inline pre-script)'
328
-
329
- if !value.nil? && File.file?(value)
330
- [File.read(value), File.expand_path(value)]
331
- else
332
- [String(value), inline_context]
333
- end
334
- end
335
-
336
- def find_target_path(path)
337
- if File.file?(path)
338
- File.expand_path(path)
339
- elsif File.file?("#{path}.rb")
340
- File.expand_path("#{path}.rb")
341
- else
342
- raise Error, "File not found: #{path}"
343
- end
344
- end
345
-
346
- def camelize(name)
347
- name.split(/[^a-zA-Z0-9]+/).reject(&:empty?).map { |part|
348
- part[0].upcase + part[1..].downcase
349
- }.join
350
- end
351
-
352
- def constantize(name, defined_constants: nil, full_path: nil)
353
- parts = name.to_s.split('::').reject(&:empty?)
354
- raise Error, "Unable to resolve class/module name: #{name.inspect}" if parts.empty?
355
-
356
- parts.reduce(Object) do |context, const_name|
357
- context.const_get(const_name)
358
- end
359
- rescue NameError
360
- message = build_missing_constant_message(name, defined_constants, full_path)
361
- raise Error.new(message), cause: nil
362
- end
363
-
364
- def instantiate_target(target, initializer_args = nil)
365
- positional_args, keyword_args = Array(initializer_args || [[], {}])
366
- positional_args ||= []
367
- keyword_args ||= {}
368
-
369
- case target
370
- when Class
371
- if keyword_args.empty?
372
- target.new(*positional_args)
373
- else
374
- target.new(*positional_args, **keyword_args)
375
- end
376
- when Module
377
- Object.new.extend(target)
378
- else
379
- target
380
- end
381
- rescue ArgumentError => e
382
- raise Error, "Failed to instantiate target: #{e.message}"
383
- end
384
-
385
- def run_with_modes(target, json:, eval_args:, eval_lax:)
386
- runner = proc { Rubycli.run(target) }
387
-
388
- if json
389
- Rubycli.with_json_mode(true, &runner)
390
- elsif eval_args
391
- Rubycli.with_eval_mode(true, lax: eval_lax, &runner)
392
- else
393
- runner.call
394
- end
395
- end
396
-
397
- def parse_initializer_arguments(raw_value, target, json_mode:, eval_mode:, eval_lax:)
398
- return [[], {}] if raw_value.nil?
399
- raise Rubycli::ArgumentError, '--json-args cannot be combined with --eval-args or --eval-lax' if json_mode && eval_mode
400
-
401
- initializer_method = initializer_method_for(target)
402
- tokens = Array(raw_value)
403
-
404
- positional_args = []
405
- keyword_args = {}
406
-
407
- Rubycli.argument_mode_controller.with_json_mode(json_mode) do
408
- Rubycli.argument_mode_controller.with_eval_mode(eval_mode, lax: eval_lax) do
409
- positional_args, keyword_args = Rubycli.argument_parser.parse(tokens.dup, initializer_method)
410
- Rubycli.apply_argument_coercions(positional_args, keyword_args)
411
- Rubycli.argument_parser.validate_inputs(initializer_method, positional_args, keyword_args)
412
- end
413
- end
414
-
415
- [positional_args || [], keyword_args || {}]
416
- rescue Rubycli::ArgumentError => e
417
- raise Runner::Error, "Failed to parse --new arguments: #{e.message}"
418
- end
419
-
420
- def initializer_method_for(target)
421
- return nil unless target.is_a?(Class)
422
-
423
- target.instance_method(:initialize) rescue nil
424
- end
425
-
426
- def prepare_runner_target(
427
- target_path,
428
- class_name,
429
- new: false,
430
- new_args: nil,
431
- json_mode: false,
432
- eval_mode: false,
433
- eval_lax: false,
434
- pre_scripts: [],
435
- constant_mode: nil
436
- )
437
- full_path = find_target_path(target_path)
438
- capture = Rubycli.constant_capture
439
- capture.capture(full_path) { load full_path }
440
- constant_mode ||= Rubycli.environment.constant_resolution_mode
441
- candidates = build_constant_candidates(full_path, capture.constants_for(full_path))
442
- defined_constants = candidates.map(&:name)
443
-
444
- target = if class_name
445
- constantize(
446
- class_name,
447
- defined_constants: defined_constants,
448
- full_path: full_path
449
- )
450
- else
451
- select_constant_candidate(
452
- full_path,
453
- camelize(File.basename(full_path, '.rb')),
454
- candidates,
455
- constant_mode,
456
- instantiate: new
457
- )
458
- end
459
-
460
- initializer_args = new ? parse_initializer_arguments(new_args, target, json_mode: json_mode, eval_mode: eval_mode, eval_lax: eval_lax) : nil
461
-
462
- runner_target = new ? instantiate_target(target, initializer_args) : target
463
- runner_target = apply_pre_scripts(pre_scripts, target, runner_target)
464
- [runner_target, full_path]
465
- end
466
-
467
- def build_constant_candidates(path, constant_names)
468
- normalized = normalize_path(path)
469
- Array(constant_names).each_with_object([]) do |const_name, memo|
470
- constant = safe_constant_lookup(const_name)
471
- next unless constant.is_a?(Module)
472
-
473
- class_methods = collect_defined_methods(constant.singleton_class, normalized)
474
- instance_methods = collect_defined_methods(constant, normalized)
475
-
476
- memo << ConstantCandidate.new(
477
- name: const_name,
478
- constant: constant,
479
- class_methods: class_methods,
480
- instance_methods: instance_methods
481
- )
482
- end
483
- end
484
-
485
- def collect_defined_methods(owner, normalized_path)
486
- owner.public_instance_methods(false).each_with_object([]) do |method_name, memo|
487
- method_object = owner.instance_method(method_name)
488
- location = method_object.source_location
489
- next unless location && normalize_path(location[0]) == normalized_path
490
-
491
- memo << method_name
492
- end
493
- rescue TypeError
494
- []
495
- end
496
-
497
- def safe_constant_lookup(name)
498
- parts = name.split('::').reject(&:empty?)
499
- context = Object
500
-
501
- parts.each do |const_name|
502
- return nil unless context.const_defined?(const_name, false)
503
-
504
- context = context.const_get(const_name)
505
- end
506
-
507
- context
508
- rescue NameError
509
- nil
510
- end
511
-
512
- def select_constant_candidate(path, base_const, candidates, constant_mode, instantiate: false)
513
- if candidates.empty?
514
- raise Error, build_missing_constant_message(
515
- base_const,
516
- [],
517
- path,
518
- details: 'Rubycli could not detect any constants in this file.'
519
- )
520
- end
521
-
522
- matching = candidates.find { |candidate| candidate.matches?(base_const) }
523
- if matching
524
- return matching.constant if matching.callable?(instantiate: instantiate)
525
-
526
- detail = if matching.instance_only?
527
- "#{matching.name} only defines instance methods in this file. Run with --new to instantiate before invoking CLI commands."
528
- else
529
- "#{matching.name} does not define any CLI-callable methods in this file. Add a public class or instance method defined in this file."
530
- end
531
- raise Error, build_missing_constant_message(
532
- base_const,
533
- candidates.map(&:name),
534
- path,
535
- details: detail
536
- )
537
- end
538
-
539
- callable = candidates.select { |candidate| candidate.callable?(instantiate: instantiate) }
540
- if callable.empty?
541
- raise Error, build_missing_constant_message(
542
- base_const,
543
- candidates.map(&:name),
544
- path,
545
- details: 'Rubycli detected constants in this file, but none define CLI-callable methods. Add a public class or instance method defined in this file.'
546
- )
547
- end
548
-
549
- if constant_mode == :auto && callable.size == 1
550
- return callable.first.constant
551
- end
552
-
553
- details = build_ambiguous_constant_details(callable, path)
554
- raise Error, build_missing_constant_message(
555
- base_const,
556
- candidates.map(&:name),
557
- path,
558
- details: details
559
- )
560
- end
561
-
562
- def build_ambiguous_constant_details(candidates, path)
563
- command_target = File.basename(path)
564
- if candidates.size == 1
565
- candidate = candidates.first
566
- lines = []
567
- lines << "This file defines #{candidate.name}, but its name does not match #{command_target}."
568
- lines << 'Re-run by specifying the constant explicitly:'
569
- lines << " rubycli #{command_target} #{candidate.name} ..."
570
- lines << 'Alternatively pass --auto-target (or RUBYCLI_AUTO_TARGET=auto) to auto-select it.'
571
- return lines.join("\n")
572
- end
573
-
574
- lines = ['Multiple CLI-capable constants were found in this file:']
575
- candidates.each do |candidate|
576
- hint = candidate.instance_only? ? ' (instance methods only; use --new)' : ''
577
- lines << " - #{candidate.name}: #{candidate.summary}#{hint}"
578
- end
579
- lines << "Specify one explicitly, e.g. rubycli #{command_target} MyRunner"
580
- lines << 'Or pass --auto-target to allow Rubycli to auto-select a single candidate.'
581
- lines.join("\n")
582
- end
583
-
584
- def normalize_path(path)
585
- File.expand_path(path.to_s)
586
- end
587
-
588
- def build_missing_constant_message(name, defined_constants, full_path, details: nil)
589
- lines = ["Could not find definition: #{name}"]
590
- lines << ''
591
- lines << "Loaded file: #{File.expand_path(full_path)}" if full_path
592
-
593
- if defined_constants && !defined_constants.empty?
594
- sample = defined_constants.first(5)
595
- suffix = defined_constants.size > sample.size ? " ... (#{defined_constants.size} total)" : ''
596
- lines << "Constants found in this file: #{sample.join(', ')}#{suffix}"
597
- else
598
- lines << 'Rubycli could not detect any publicly exposable constants in this file.'
599
- end
600
-
601
- if details
602
- lines << ''
603
- lines << details
604
- end
605
-
606
- lines << ''
607
- lines << 'Hint: Ensure the CLASS_OR_MODULE argument is correct when invoking the CLI.'
608
- lines.join("\n")
609
- end
610
- end
611
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.7
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-16 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