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.
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: []