ukiryu 0.1.0 → 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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/docs.yml +63 -0
  3. data/.github/workflows/links.yml +99 -0
  4. data/.github/workflows/rake.yml +19 -0
  5. data/.github/workflows/release.yml +27 -0
  6. data/.gitignore +18 -4
  7. data/.rubocop.yml +1 -0
  8. data/.rubocop_todo.yml +213 -0
  9. data/Gemfile +12 -8
  10. data/README.adoc +613 -0
  11. data/Rakefile +2 -2
  12. data/docs/assets/logo.svg +1 -0
  13. data/exe/ukiryu +11 -0
  14. data/lib/ukiryu/action/base.rb +77 -0
  15. data/lib/ukiryu/cache.rb +199 -0
  16. data/lib/ukiryu/cli.rb +133 -307
  17. data/lib/ukiryu/cli_commands/base_command.rb +155 -0
  18. data/lib/ukiryu/cli_commands/commands_command.rb +120 -0
  19. data/lib/ukiryu/cli_commands/commands_command.rb.fixed +40 -0
  20. data/lib/ukiryu/cli_commands/config_command.rb +249 -0
  21. data/lib/ukiryu/cli_commands/describe_command.rb +326 -0
  22. data/lib/ukiryu/cli_commands/describe_command.rb.fixed +254 -0
  23. data/lib/ukiryu/cli_commands/exec_inline_command.rb.fixed +180 -0
  24. data/lib/ukiryu/cli_commands/extract_command.rb +84 -0
  25. data/lib/ukiryu/cli_commands/info_command.rb +156 -0
  26. data/lib/ukiryu/cli_commands/list_command.rb +70 -0
  27. data/lib/ukiryu/cli_commands/opts_command.rb +106 -0
  28. data/lib/ukiryu/cli_commands/opts_command.rb.fixed +105 -0
  29. data/lib/ukiryu/cli_commands/response_formatter.rb +240 -0
  30. data/lib/ukiryu/cli_commands/run_command.rb +375 -0
  31. data/lib/ukiryu/cli_commands/run_file_command.rb +215 -0
  32. data/lib/ukiryu/cli_commands/system_command.rb +90 -0
  33. data/lib/ukiryu/cli_commands/validate_command.rb +87 -0
  34. data/lib/ukiryu/cli_commands/version_command.rb +16 -0
  35. data/lib/ukiryu/cli_commands/which_command.rb +166 -0
  36. data/lib/ukiryu/command_builder.rb +205 -0
  37. data/lib/ukiryu/config/env_provider.rb +64 -0
  38. data/lib/ukiryu/config/env_schema.rb +63 -0
  39. data/lib/ukiryu/config/override_resolver.rb +68 -0
  40. data/lib/ukiryu/config/type_converter.rb +59 -0
  41. data/lib/ukiryu/config.rb +249 -0
  42. data/lib/ukiryu/errors.rb +3 -0
  43. data/lib/ukiryu/executable_locator.rb +114 -0
  44. data/lib/ukiryu/execution/command_info.rb +64 -0
  45. data/lib/ukiryu/execution/metadata.rb +97 -0
  46. data/lib/ukiryu/execution/output.rb +144 -0
  47. data/lib/ukiryu/execution/result.rb +194 -0
  48. data/lib/ukiryu/execution.rb +15 -0
  49. data/lib/ukiryu/execution_context.rb +251 -0
  50. data/lib/ukiryu/executor.rb +76 -493
  51. data/lib/ukiryu/extractors/base_extractor.rb +63 -0
  52. data/lib/ukiryu/extractors/extractor.rb +150 -0
  53. data/lib/ukiryu/extractors/help_parser.rb +188 -0
  54. data/lib/ukiryu/extractors/native_extractor.rb +47 -0
  55. data/lib/ukiryu/io.rb +196 -0
  56. data/lib/ukiryu/logger.rb +544 -0
  57. data/lib/ukiryu/models/argument.rb +28 -0
  58. data/lib/ukiryu/models/argument_definition.rb +119 -0
  59. data/lib/ukiryu/models/arguments.rb +113 -0
  60. data/lib/ukiryu/models/command_definition.rb +176 -0
  61. data/lib/ukiryu/models/command_info.rb +37 -0
  62. data/lib/ukiryu/models/components.rb +107 -0
  63. data/lib/ukiryu/models/env_var_definition.rb +30 -0
  64. data/lib/ukiryu/models/error_response.rb +41 -0
  65. data/lib/ukiryu/models/execution_metadata.rb +31 -0
  66. data/lib/ukiryu/models/execution_report.rb +236 -0
  67. data/lib/ukiryu/models/exit_codes.rb +74 -0
  68. data/lib/ukiryu/models/flag_definition.rb +67 -0
  69. data/lib/ukiryu/models/option_definition.rb +102 -0
  70. data/lib/ukiryu/models/output_info.rb +25 -0
  71. data/lib/ukiryu/models/platform_profile.rb +153 -0
  72. data/lib/ukiryu/models/routing.rb +211 -0
  73. data/lib/ukiryu/models/search_paths.rb +39 -0
  74. data/lib/ukiryu/models/success_response.rb +85 -0
  75. data/lib/ukiryu/models/tool_definition.rb +145 -0
  76. data/lib/ukiryu/models/tool_metadata.rb +82 -0
  77. data/lib/ukiryu/models/validation_result.rb +80 -0
  78. data/lib/ukiryu/models/version_compatibility.rb +152 -0
  79. data/lib/ukiryu/models/version_detection.rb +39 -0
  80. data/lib/ukiryu/models.rb +23 -0
  81. data/lib/ukiryu/options/base.rb +95 -0
  82. data/lib/ukiryu/options_builder/formatter.rb +87 -0
  83. data/lib/ukiryu/options_builder/validator.rb +43 -0
  84. data/lib/ukiryu/options_builder.rb +311 -0
  85. data/lib/ukiryu/platform.rb +6 -6
  86. data/lib/ukiryu/registry.rb +143 -183
  87. data/lib/ukiryu/response/base.rb +217 -0
  88. data/lib/ukiryu/runtime.rb +179 -0
  89. data/lib/ukiryu/schema_validator.rb +8 -10
  90. data/lib/ukiryu/shell/bash.rb +3 -3
  91. data/lib/ukiryu/shell/cmd.rb +4 -4
  92. data/lib/ukiryu/shell/fish.rb +1 -1
  93. data/lib/ukiryu/shell/powershell.rb +3 -3
  94. data/lib/ukiryu/shell/sh.rb +1 -1
  95. data/lib/ukiryu/shell/zsh.rb +1 -1
  96. data/lib/ukiryu/shell.rb +146 -39
  97. data/lib/ukiryu/thor_ext.rb +208 -0
  98. data/lib/ukiryu/tool.rb +649 -258
  99. data/lib/ukiryu/tool_index.rb +224 -0
  100. data/lib/ukiryu/tools/base.rb +381 -0
  101. data/lib/ukiryu/tools/class_generator.rb +132 -0
  102. data/lib/ukiryu/tools/executable_finder.rb +29 -0
  103. data/lib/ukiryu/tools/generator.rb +154 -0
  104. data/lib/ukiryu/tools.rb +109 -0
  105. data/lib/ukiryu/type.rb +28 -43
  106. data/lib/ukiryu/validation/constraints.rb +281 -0
  107. data/lib/ukiryu/validation/validator.rb +188 -0
  108. data/lib/ukiryu/validation.rb +21 -0
  109. data/lib/ukiryu/version.rb +1 -1
  110. data/lib/ukiryu/version_detector.rb +51 -0
  111. data/lib/ukiryu.rb +31 -15
  112. data/ukiryu-proposal.md +2952 -0
  113. data/ukiryu.gemspec +18 -14
  114. metadata +137 -5
  115. data/.github/workflows/test.yml +0 -143
data/lib/ukiryu/tool.rb CHANGED
@@ -1,19 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "registry"
4
- require_relative "executor"
5
- require_relative "shell"
3
+ require_relative 'registry'
4
+ require_relative 'executor'
5
+ require_relative 'shell'
6
+ require_relative 'runtime'
7
+ require_relative 'command_builder'
8
+ require_relative 'tools/base'
9
+ require_relative 'tools/generator'
10
+ require_relative 'cache'
11
+ require_relative 'executable_locator'
12
+ require_relative 'version_detector'
13
+ require_relative 'logger'
14
+ require_relative 'tool_index'
15
+ require_relative 'models/routing'
6
16
 
7
17
  module Ukiryu
8
18
  # Tool wrapper class for external command-line tools
9
19
  #
10
20
  # Provides a Ruby interface to external CLI tools defined in YAML profiles.
21
+ #
22
+ # ## Usage
23
+ #
24
+ # ### Traditional API (backward compatible)
25
+ # tool = Ukiryu::Tool.get(:imagemagick)
26
+ # tool.execute(:convert, inputs: ["image.png"], resize: "50%")
27
+ #
28
+ # ### New OOP API (recommended)
29
+ # # Lazy autoload - creates Ukiryu::Tools::Imagemagick class on first access
30
+ # Ukiryu::Tools::Imagemagick.new.tap do |tool|
31
+ # convert_options = tool.options_for(:convert)
32
+ # convert_options.set(inputs: ["image.png"], resize: "50%")
33
+ # convert_options.output = "output.jpg"
34
+ # convert_options.run
35
+ # end
11
36
  class Tool
37
+ include CommandBuilder
38
+
12
39
  class << self
13
- # Registered tools cache
14
- attr_reader :tools
40
+ # Get the tools cache (bounded LRU cache)
41
+ #
42
+ # @return [Cache] the tools cache
43
+ def tools_cache
44
+ @tools_cache ||= Cache.new(max_size: 50, ttl: 3600)
45
+ end
15
46
 
16
- # Get a tool by name
47
+ # Get a tool by name (traditional API)
17
48
  #
18
49
  # @param name [String] the tool name
19
50
  # @param options [Hash] initialization options
@@ -25,7 +56,8 @@ module Ukiryu
25
56
  def get(name, options = {})
26
57
  # Check cache first
27
58
  cache_key = cache_key_for(name, options)
28
- return @tools[cache_key] if @tools && @tools[cache_key]
59
+ cached = tools_cache[cache_key]
60
+ return cached if cached
29
61
 
30
62
  # Load profile from registry
31
63
  profile = load_profile(name, options)
@@ -33,16 +65,154 @@ module Ukiryu
33
65
 
34
66
  # Create tool instance
35
67
  tool = new(profile, options)
36
- @tools ||= {}
37
- @tools[cache_key] = tool
68
+ tools_cache[cache_key] = tool
38
69
  tool
39
70
  end
40
71
 
72
+ # Find a tool by name, alias, or interface
73
+ #
74
+ # Searches for a tool that matches the given identifier by:
75
+ # 1. Exact name match (fastest)
76
+ # 2. Interface match via ToolIndex (O(1) lookup)
77
+ # 3. Alias match via ToolIndex (O(1) lookup)
78
+ # 4. Returns the first tool that is available on the current platform
79
+ #
80
+ # Debug mode: Set UKIRYU_DEBUG=1 or UKIRYU_DEBUG=true to enable structured debug output
81
+ #
82
+ # @param identifier [String, Symbol] the tool name, interface, or alias
83
+ # @param options [Hash] initialization options
84
+ # @return [Tool, nil] the tool instance or nil if not found
85
+ def find_by(identifier, options = {})
86
+ identifier = identifier.to_s
87
+ runtime = Runtime.instance
88
+ platform = options[:platform] || runtime.platform
89
+ shell = options[:shell] || runtime.shell
90
+
91
+ # Create logger instance
92
+ logger = Logger.new
93
+
94
+ # 1. Try exact name match first (fastest path)
95
+ begin
96
+ tool = get(identifier, options)
97
+ if logger.debug_enabled?
98
+ require_relative 'registry'
99
+ all_tools = Registry.tools
100
+ logger.debug_section_tool_resolution(
101
+ identifier: identifier,
102
+ platform: platform,
103
+ shell: shell,
104
+ all_tools: all_tools,
105
+ selected_tool: identifier,
106
+ executable: tool.executable
107
+ )
108
+ end
109
+ return tool
110
+ rescue ToolNotFoundError
111
+ # Continue to search by interface/alias
112
+ end
113
+
114
+ # 2. Use ToolIndex for O(1) interface lookup
115
+ index = ToolIndex.instance
116
+ interface_metadata = index.find_by_interface(identifier.to_sym)
117
+ if interface_metadata
118
+ tool_name = interface_metadata.name
119
+ begin
120
+ return get(tool_name, options)
121
+ rescue ToolNotFoundError
122
+ # Tool indexed but not available, continue
123
+ end
124
+ end
125
+
126
+ # 3. Use ToolIndex for O(1) alias lookup
127
+ alias_tool_name = index.find_by_alias(identifier)
128
+ if alias_tool_name
129
+ begin
130
+ return get(alias_tool_name.to_s, options)
131
+ rescue ToolNotFoundError
132
+ # Alias indexed but tool not available, continue
133
+ end
134
+ end
135
+
136
+ # 4. Fallback to exhaustive search (should rarely reach here)
137
+ require_relative 'registry'
138
+ all_tools = Registry.tools
139
+
140
+ all_tools.each do |tool_name|
141
+ tool_def = Tools::Generator.load_tool_definition(tool_name)
142
+ next unless tool_def
143
+
144
+ # Check if tool matches by interface
145
+ interface_match = tool_def.implements == identifier.to_sym
146
+
147
+ # Check if tool matches by alias
148
+ alias_match = tool_def.aliases&.include?(identifier)
149
+
150
+ next unless alias_match || interface_match
151
+
152
+ # Check if tool is compatible with current platform/shell
153
+ profile = tool_def.compatible_profile(platform: platform, shell: shell)
154
+ next unless profile
155
+
156
+ # Create tool instance
157
+ cache_key = cache_key_for(tool_name, options)
158
+ cached = tools_cache[cache_key]
159
+
160
+ if cached
161
+ if logger.debug_enabled?
162
+ logger.debug_section_tool_resolution(
163
+ identifier: identifier,
164
+ platform: platform,
165
+ shell: shell,
166
+ all_tools: all_tools,
167
+ selected_tool: tool_name,
168
+ executable: cached.executable
169
+ )
170
+ end
171
+ return cached
172
+ end
173
+
174
+ tool = new(tool_def, options.merge(platform: platform, shell: shell))
175
+ tools_cache[cache_key] = tool
176
+
177
+ if logger.debug_enabled?
178
+ logger.debug_section_tool_resolution(
179
+ identifier: identifier,
180
+ platform: platform,
181
+ shell: shell,
182
+ all_tools: all_tools,
183
+ selected_tool: tool_name,
184
+ executable: tool.executable
185
+ )
186
+ end
187
+
188
+ return tool
189
+ end
190
+
191
+ if logger.debug_enabled?
192
+ logger.debug_section_tool_not_found(
193
+ identifier: identifier,
194
+ platform: platform,
195
+ shell: shell,
196
+ all_tools: all_tools
197
+ )
198
+ end
199
+ nil
200
+ end
201
+
202
+ # Get the tool-specific class (new OOP API)
203
+ #
204
+ # @param tool_name [Symbol, String] the tool name
205
+ # @return [Class] the tool class (e.g., Ukiryu::Tools::Imagemagick)
206
+ def get_class(tool_name)
207
+ Tools::Generator.generate_and_const_set(tool_name)
208
+ end
209
+
41
210
  # Clear the tool cache
42
211
  #
43
- # @api private
212
+ # @api public
44
213
  def clear_cache
45
- @tools = nil
214
+ tools_cache.clear
215
+ Tools::Generator.clear_cache
46
216
  end
47
217
 
48
218
  # Configure default options
@@ -53,30 +223,208 @@ module Ukiryu
53
223
  @default_options.merge!(options)
54
224
  end
55
225
 
226
+ # Load a tool definition from a file path
227
+ #
228
+ # @param file_path [String] path to the YAML file
229
+ # @param options [Hash] initialization options
230
+ # @option options [Symbol] :validation validation mode (:strict, :lenient, :none)
231
+ # @option options [Symbol] :version_check version check mode (:strict, :lenient, :probe)
232
+ # @return [Tool] the tool instance
233
+ # @raise [LoadError] if file cannot be loaded or validation fails
234
+ def load(file_path, options = {})
235
+ require 'yaml'
236
+ require_relative 'models/tool_definition'
237
+
238
+ raise LoadError, "File not found: #{file_path}" unless File.exist?(file_path)
239
+
240
+ content = File.read(file_path)
241
+ load_from_string(content, options.merge(file_path: file_path))
242
+ end
243
+
244
+ # Load a tool definition from a YAML string
245
+ #
246
+ # @param yaml_string [String] YAML content
247
+ # @param options [Hash] initialization options
248
+ # @option options [String] :file_path optional file path for error messages
249
+ # @option options [Symbol] :validation validation mode (:strict, :lenient, :none)
250
+ # @option options [Symbol] :version_check version check mode (:strict, :lenient, :probe)
251
+ # @return [Tool] the tool instance
252
+ # @raise [LoadError] if YAML cannot be parsed or validation fails
253
+ def load_from_string(yaml_string, options = {})
254
+ require_relative 'models/tool_definition'
255
+
256
+ begin
257
+ # Use lutaml-model's from_yaml to parse
258
+ profile = Models::ToolDefinition.from_yaml(yaml_string)
259
+ rescue Psych::SyntaxError => e
260
+ raise LoadError, "Invalid YAML: #{e.message}"
261
+ rescue StandardError => e
262
+ raise LoadError, "Invalid YAML: #{e.message}"
263
+ end
264
+
265
+ # Validate profile if validation mode is set
266
+ validation_mode = options[:validation] || :strict
267
+ validate_profile(profile, validation_mode) if validation_mode != :none
268
+
269
+ # Create tool instance
270
+ new(profile, options)
271
+ end
272
+
273
+ # Load a tool from bundled system locations
274
+ #
275
+ # Searches standard system locations for tool definitions:
276
+ # - /usr/share/ukiryu/
277
+ # - /usr/local/share/ukiryu/
278
+ # - /opt/homebrew/share/ukiryu/
279
+ # - C:\\Program Files\\Ukiryu\\
280
+ #
281
+ # @param tool_name [String, Symbol] the tool name
282
+ # @param options [Hash] initialization options
283
+ # @return [Tool, nil] the tool instance or nil if not found
284
+ def from_bundled(tool_name, options = {})
285
+ search_paths = bundled_definition_search_paths
286
+
287
+ search_paths.each do |base_path|
288
+ Dir.glob(File.join(base_path, tool_name.to_s, '*.yaml')).each do |file|
289
+ begin
290
+ return load(file, options)
291
+ rescue LoadError, NameError
292
+ # Try next file
293
+ next
294
+ end
295
+ end
296
+ end
297
+
298
+ nil
299
+ end
300
+
301
+ # Get bundled definition search paths
302
+ #
303
+ # @return [Array<String>] list of search paths
304
+ def bundled_definition_search_paths
305
+ require_relative 'platform'
306
+
307
+ platform = Platform.detect
308
+
309
+ paths = case platform
310
+ when :macos, :linux
311
+ [
312
+ '/usr/share/ukiryu',
313
+ '/usr/local/share/ukiryu',
314
+ '/opt/homebrew/share/ukiryu'
315
+ ]
316
+ when :windows
317
+ [
318
+ File.expand_path('C:/Program Files/Ukiryu'),
319
+ File.expand_path('C:/Program Files (x86)/Ukiryu')
320
+ ]
321
+ else
322
+ []
323
+ end
324
+
325
+ # Add user-local path
326
+ paths << File.expand_path('~/.local/share/ukiryu')
327
+
328
+ paths
329
+ end
330
+
331
+ # Extract tool definition from an installed CLI tool
332
+ #
333
+ # Attempts to extract a tool definition by:
334
+ # 1. Trying the tool's native `--ukiryu-definition` flag
335
+ # 2. Parsing the tool's `--help` output as a fallback
336
+ #
337
+ # @param tool_name [String, Symbol] the tool name to extract
338
+ # @param options [Hash] extraction options
339
+ # @option options [String] :output optional output file path
340
+ # @option options [Symbol] :method specific method (:native, :help, :auto)
341
+ # @option options [Boolean] :verbose enable verbose output
342
+ # @return [Hash] result with :success, :yaml, :method, :error keys
343
+ #
344
+ # @example Extract definition from git
345
+ # result = Tool.extract_definition(:git)
346
+ # if result[:success]
347
+ # puts result[:yaml]
348
+ # end
349
+ #
350
+ # @example Extract and write to file
351
+ # result = Tool.extract_definition(:git, output: './git.yaml')
352
+ def extract_definition(tool_name, options = {})
353
+ require_relative 'extractors/extractor'
354
+
355
+ result = Extractors::Extractor.extract(tool_name, options)
356
+
357
+ # Write to output file if specified
358
+ output = options.delete(:output)
359
+ if output && result[:success]
360
+ require 'fileutils'
361
+ FileUtils.mkdir_p(File.dirname(output))
362
+ File.write(output, result[:yaml])
363
+ end
364
+
365
+ result
366
+ end
367
+
56
368
  private
57
369
 
370
+ # Validate a tool profile
371
+ #
372
+ # @param profile [Models::ToolDefinition] the profile to validate
373
+ # @param mode [Symbol] validation mode (:strict, :lenient)
374
+ # @raise [LoadError] if validation fails in strict mode
375
+ def validate_profile(profile, mode)
376
+ errors = []
377
+
378
+ # Check required fields
379
+ errors << "Missing 'name' field" unless profile.name
380
+ errors << "Missing 'version' field" unless profile.version
381
+ errors << "Missing 'profiles' field or profiles is empty" unless profile.profiles&.any?
382
+
383
+ # Check ukiryu_schema format if present
384
+ if profile.ukiryu_schema && !profile.ukiryu_schema.match?(/^\d+\.\d+$/)
385
+ errors << "Invalid ukiryu_schema format: #{profile.ukiryu_schema}"
386
+ end
387
+
388
+ # Check $self URI format if present
389
+ if profile.self_uri && !valid_uri?(profile.self_uri)
390
+ errors << "Invalid $self URI format: #{profile.self_uri}" if mode == :strict
391
+ end
392
+
393
+ if errors.any?
394
+ message = "Profile validation failed:\n - #{errors.join("\n - ")}"
395
+ if mode == :strict
396
+ raise LoadError, message
397
+ else
398
+ warn "[Ukiryu] #{message}" if mode == :lenient
399
+ end
400
+ end
401
+ end
402
+
403
+ # Check if a string is a valid URI
404
+ #
405
+ # @param uri_string [String] the URI to check
406
+ # @return [Boolean] true if valid URI
407
+ def valid_uri?(uri_string)
408
+ (uri_string =~ %r{^https?://} || uri_string =~ %r{^file://}) ? true : false
409
+ end
410
+
58
411
  # Generate a cache key for a tool
59
412
  def cache_key_for(name, options)
60
- platform = options[:platform] || Platform.detect
61
- shell = options[:shell] || Shell.detect
62
- version = options[:version] || "latest"
413
+ runtime = Runtime.instance
414
+ platform = options[:platform] || runtime.platform
415
+ shell = options[:shell] || runtime.shell
416
+ version = options[:version] || 'latest'
63
417
  "#{name}-#{platform}-#{shell}-#{version}"
64
418
  end
65
419
 
66
420
  # Load a profile for a tool
67
- def load_profile(name, options)
68
- registry_path = options[:registry_path] || Registry.default_registry_path
69
-
70
- if registry_path && Dir.exist?(registry_path)
71
- Registry.load_tool(name, options)
72
- else
73
- # Fall back to built-in profiles if available
74
- load_builtin_profile(name, options)
75
- end
421
+ def load_profile(name, _options)
422
+ require_relative 'tools/generator'
423
+ Tools::Generator.load_tool_definition(name.to_s)
76
424
  end
77
425
 
78
426
  # Load a built-in profile
79
- def load_builtin_profile(name, options)
427
+ def load_builtin_profile(_name, _options)
80
428
  # This will be extended with bundled profiles
81
429
  nil
82
430
  end
@@ -84,13 +432,16 @@ module Ukiryu
84
432
 
85
433
  # Create a new Tool instance
86
434
  #
87
- # @param profile [Hash] the tool profile
435
+ # @param profile [Models::ToolDefinition] the tool definition model
88
436
  # @param options [Hash] initialization options
89
437
  def initialize(profile, options = {})
90
438
  @profile = profile
91
439
  @options = options
92
- @platform = options[:platform] || Platform.detect
93
- @shell = options[:shell] || Shell.detect
440
+ runtime = Runtime.instance
441
+
442
+ # Allow override via options for testing
443
+ @platform = options[:platform]&.to_sym || runtime.platform
444
+ @shell = options[:shell]&.to_sym || runtime.shell
94
445
  @version = options[:version]
95
446
 
96
447
  # Find compatible profile
@@ -110,7 +461,7 @@ module Ukiryu
110
461
  #
111
462
  # @return [String] the tool name
112
463
  def name
113
- @profile[:name]
464
+ @profile.name
114
465
  end
115
466
 
116
467
  # Get the tool version
@@ -123,9 +474,7 @@ module Ukiryu
123
474
  # Get the executable path
124
475
  #
125
476
  # @return [String] the executable path
126
- def executable
127
- @executable
128
- end
477
+ attr_reader :executable
129
478
 
130
479
  # Check if the tool is available
131
480
  #
@@ -138,30 +487,53 @@ module Ukiryu
138
487
  #
139
488
  # @return [Hash, nil] the commands hash
140
489
  def commands
141
- @command_profile[:commands]
490
+ @command_profile.commands
491
+ end
492
+
493
+ # Get a command definition by name
494
+ #
495
+ # @param command_name [String, Symbol] the command name
496
+ # @return [CommandDefinition, nil] the command definition or nil if not found
497
+ def command_definition(command_name)
498
+ @command_profile.command(command_name.to_s)
142
499
  end
143
500
 
144
501
  # Execute a command defined in the profile
145
502
  #
146
503
  # @param command_name [Symbol] the command to execute
147
- # @param params [Hash] command parameters
504
+ # @param params [Hash, Object] command parameters (hash or options object)
148
505
  # @return [Executor::Result] the execution result
149
- def execute(command_name, params = {})
150
- command = @command_profile[:commands][command_name.to_s] ||
151
- @command_profile[:commands][command_name.to_sym]
506
+ def execute_simple(command_name, params = {})
507
+ command = @command_profile.command(command_name.to_s)
152
508
 
153
509
  raise ArgumentError, "Unknown command: #{command_name}" unless command
154
510
 
511
+ # Convert options object to hash if needed
512
+ if params.is_a?(Hash) && params.keys.none? { |k| k.is_a?(Symbol) }
513
+ # Likely has string keys from CLI, convert to symbols
514
+ params = params.transform_keys(&:to_sym)
515
+ elsif !params.is_a?(Hash)
516
+ # It's an options object, convert to hash
517
+ require_relative 'options_builder'
518
+ params = Ukiryu::OptionsBuilder.to_hash(params)
519
+ end
520
+
521
+ # Extract stdin parameter if present (special parameter, not passed to command)
522
+ stdin = params.delete(:stdin)
523
+
155
524
  # Build command arguments
156
525
  args = build_args(command, params)
157
526
 
158
- # Execute with environment
527
+ # Execute with environment and stdin, passing tool_name and command_name for exit code lookups
159
528
  Executor.execute(
160
529
  @executable,
161
530
  args,
162
531
  env: build_env_vars(command, params),
163
- timeout: @profile[:timeout] || 90,
164
- shell: @shell
532
+ timeout: @profile.timeout || 90,
533
+ shell: @shell,
534
+ stdin: stdin,
535
+ tool_name: @profile.name,
536
+ command_name: command.name
165
537
  )
166
538
  end
167
539
 
@@ -170,270 +542,289 @@ module Ukiryu
170
542
  # @param command_name [Symbol] the command name
171
543
  # @return [Boolean]
172
544
  def command?(command_name)
173
- cmd = @command_profile[:commands][command_name.to_s] ||
174
- @command_profile[:commands][command_name.to_sym]
175
- !cmd.nil?
545
+ !@command_profile.command(command_name.to_s).nil?
176
546
  end
177
547
 
178
- private
179
-
180
- # Find the best matching command profile
181
- def find_command_profile
182
- return @profile[:profiles].first if @profile[:profiles].one?
183
-
184
- @profile[:profiles].find do |p|
185
- platforms = p[:platforms] || p[:platform]
186
- shells = p[:shells] || p[:shell]
187
-
188
- # Convert array elements to symbols for comparison
189
- # (YAML arrays contain strings, but platform/shell are symbols)
190
- platform_match = platforms.nil? || platforms.map(&:to_sym).include?(@platform)
191
- shell_match = shells.nil? || shells.map(&:to_sym).include?(@shell)
192
-
193
- platform_match && shell_match
194
- end
548
+ # Get the options class for a command
549
+ #
550
+ # @param command_name [Symbol] the command name
551
+ # @return [Class] the options class for this command
552
+ def options_for(command_name)
553
+ require_relative 'options_builder'
554
+ Ukiryu::OptionsBuilder.for(@profile.name, command_name)
195
555
  end
196
556
 
197
- # Find the executable path
198
- def find_executable
199
- # Try primary name first
200
- exe = try_find_executable(@profile[:name])
201
- return exe if exe
202
-
203
- # Try aliases
204
- aliases = @profile[:aliases] || []
205
- aliases.each do |alias_name|
206
- exe = try_find_executable(alias_name)
207
- return exe if exe
208
- end
557
+ # Get the routing table from the active profile
558
+ #
559
+ # @return [Models::Routing, nil] the routing table or nil if not defined
560
+ def routing
561
+ return nil unless @command_profile.routing?
209
562
 
210
- nil
563
+ @command_profile.routing
211
564
  end
212
565
 
213
- # Try to find an executable by name
214
- def try_find_executable(command)
215
- # Check custom search paths first
216
- search_paths = custom_search_paths
217
- unless search_paths.empty?
218
- search_paths.each do |path_pattern|
219
- paths = Dir.glob(path_pattern)
220
- paths.each do |path|
221
- return path if File.executable?(path) && !File.directory?(path)
222
- end
223
- end
224
- end
225
-
226
- # Fall back to PATH
227
- Executor.find_executable(command)
566
+ # Check if this tool has routing defined
567
+ #
568
+ # @return [Boolean] true if routing table is defined and non-empty
569
+ def routing?
570
+ !routing.nil? && !routing.empty?
228
571
  end
229
572
 
230
- # Get custom search paths from profile
231
- def custom_search_paths
232
- return [] unless @profile[:search_paths]
233
-
234
- case @platform
235
- when :windows
236
- @profile[:search_paths][:windows] || []
237
- when :macos
238
- @profile[:search_paths][:macos] || []
239
- else
240
- [] # Unix: rely on PATH only
241
- end
573
+ # Resolve a hierarchical action path
574
+ #
575
+ # For tools with routing (like git), resolves paths like ['remote', 'add']
576
+ # to their executable targets and action definitions.
577
+ #
578
+ # @param path [Array<String, Symbol>] the action path to resolve
579
+ # @return [Hash, nil] resolution info with :executable, :action, :path keys
580
+ #
581
+ # @example
582
+ # tool.resolve_action_path(['remote', 'add'])
583
+ # # => { executable: 'git-remote', action: <CommandDefinition>, path: ['remote', 'add'] }
584
+ #
585
+ def resolve_action_path(path)
586
+ return nil unless routing?
587
+ return nil if path.empty?
588
+
589
+ # Convert to strings
590
+ path = path.map(&:to_s)
591
+
592
+ # Resolve first level through routing
593
+ first_target = routing.resolve(path.first)
594
+ return nil unless first_target
595
+
596
+ # Find action definition
597
+ action = if path.size > 1
598
+ # Multi-level: find action with belongs_to
599
+ find_action_with_parent(path[0], path[1])
600
+ else
601
+ # Single level: find direct command
602
+ command_definition(path[0])
603
+ end
604
+
605
+ {
606
+ executable: first_target,
607
+ action: action,
608
+ path: path
609
+ }
242
610
  end
243
611
 
244
- # Detect tool version
245
- def detect_version
246
- return nil unless @profile[:version_detection]
247
-
248
- vd = @profile[:version_detection]
249
- cmd = vd[:command] || "--version"
250
-
251
- result = Executor.execute(@executable, [cmd], shell: @shell)
612
+ # Find an action that belongs to a parent command
613
+ #
614
+ # @param parent_name [String, Symbol] the parent command name
615
+ # @param action_name [String, Symbol] the action name
616
+ # @return [Models::CommandDefinition, nil] the action or nil
617
+ #
618
+ def find_action_with_parent(parent_name, action_name)
619
+ parent = parent_name.to_s
620
+ action = action_name.to_s
252
621
 
253
- if result.success?
254
- pattern = vd[:pattern] || /(\d+\.\d+)/
255
- match = result.stdout.match(pattern) || result.stderr.match(pattern)
256
- match[1] if match
622
+ # Search for command with matching belongs_to
623
+ commands&.find do |cmd|
624
+ cmd.belongs_to == parent && cmd.name == action
257
625
  end
258
626
  end
259
627
 
260
- # Build command arguments from parameters
261
- def build_args(command, params)
262
- args = []
263
-
264
- # Add subcommand prefix if present (e.g., for ImageMagick "magick convert")
265
- if command[:subcommand]
266
- args << command[:subcommand]
628
+ # Execute a routed action (for tools with routing)
629
+ #
630
+ # @param path [Array<String, Symbol>] the action path (e.g., ['remote', 'add'])
631
+ # @param params [Hash] action parameters
632
+ # @return [Executor::Result] the execution result
633
+ #
634
+ # @example
635
+ # tool.execute_action(['remote', 'add'], name: 'origin', url: 'https://...')
636
+ #
637
+ def execute_action(path, params = {})
638
+ resolution = resolve_action_path(path)
639
+ raise ArgumentError, "Cannot resolve action path: #{path.inspect}" unless resolution
640
+
641
+ action = resolution[:action]
642
+ raise ArgumentError, "Action not found: #{path.inspect}" unless action
643
+
644
+ # Convert params to hash if needed
645
+ params = params.transform_keys(&:to_sym) if params.is_a?(Hash)
646
+ unless params.is_a?(Hash)
647
+ require_relative 'options_builder'
648
+ params = Ukiryu::OptionsBuilder.to_hash(params)
267
649
  end
268
650
 
269
- # Add options first (before arguments)
270
- (command[:options] || []).each do |opt_def|
271
- # Convert name to symbol for params lookup
272
- param_key = opt_def[:name].is_a?(String) ? opt_def[:name].to_sym : opt_def[:name]
273
- next unless params.key?(param_key)
274
- next if params[param_key].nil?
651
+ # Extract stdin parameter
652
+ stdin = params.delete(:stdin)
275
653
 
276
- formatted_opt = format_option(opt_def, params[param_key])
277
- Array(formatted_opt).each { |opt| args << opt unless opt.nil? || opt.empty? }
278
- end
654
+ # Build command arguments
655
+ args = build_args(action, params)
279
656
 
280
- # Add flags
281
- (command[:flags] || []).each do |flag_def|
282
- # Convert name to symbol for params lookup
283
- param_key = flag_def[:name].is_a?(String) ? flag_def[:name].to_sym : flag_def[:name]
284
- value = params[param_key]
285
- value = flag_def[:default] if value.nil?
657
+ # Execute with the routed executable, passing tool_name and command_name for exit code lookups
658
+ Executor.execute(
659
+ resolution[:executable],
660
+ args,
661
+ env: build_env_vars(action, params),
662
+ timeout: @profile.timeout || 90,
663
+ shell: @shell,
664
+ stdin: stdin,
665
+ tool_name: @profile.name,
666
+ command_name: action.name
667
+ )
668
+ end
286
669
 
287
- formatted_flag = format_flag(flag_def, value)
288
- Array(formatted_flag).each { |flag| args << flag unless flag.nil? || flag.empty? }
670
+ # Execute a command with root-path notation (for hierarchical tools)
671
+ #
672
+ # Root-path uses ':' to separate levels, e.g., 'remote:add' -> ['remote', 'add']
673
+ # This provides a cleaner API for executing routed actions.
674
+ #
675
+ # @param root_path [String, Symbol] the action path with ':' separator (e.g., 'remote:add')
676
+ # @param params [Hash] action parameters
677
+ # @return [Executor::Result] the execution result
678
+ #
679
+ # @example Root-path notation
680
+ # tool.execute('remote:add', name: 'origin', url: 'https://...')
681
+ # tool.execute('branch:delete', branch_name: 'feature')
682
+ # tool.execute('stash:save', message: 'WIP')
683
+ #
684
+ # @example Simple command (backward compatible)
685
+ # tool.execute(:convert, inputs: ['image.png'], output: 'output.jpg')
686
+ #
687
+ def execute(root_path, params = {})
688
+ # Check if this is a root-path (contains ':')
689
+ if root_path.is_a?(String) && root_path.include?(':')
690
+ path = root_path.split(':').map(&:strip)
691
+ execute_action(path, params)
692
+ else
693
+ # Use simple execute for regular commands
694
+ execute_simple(root_path, params)
289
695
  end
696
+ end
290
697
 
291
- # Separate "last" positioned argument from other arguments
292
- arguments = command[:arguments] || []
293
- last_arg = arguments.find { |a| a[:position] == "last" || a[:position] == :last }
294
- regular_args = arguments.reject { |a| a[:position] == "last" || a[:position] == :last }
295
-
296
- # Add regular positional arguments (in order, excluding "last")
297
- regular_args.sort_by do |a|
298
- pos = a[:position]
299
- pos.is_a?(Integer) ? pos : (pos || 99)
300
- end.each do |arg_def|
301
- # Convert name to symbol for params lookup (YAML uses strings, Ruby uses symbols)
302
- param_key = arg_def[:name].is_a?(String) ? arg_def[:name].to_sym : arg_def[:name]
303
- next unless params.key?(param_key)
304
-
305
- value = params[param_key]
306
- next if value.nil?
307
-
308
- if arg_def[:variadic]
309
- # Variadic argument - expand array
310
- array = Type.validate(value, :array, arg_def)
311
- array.each { |v| args << format_arg(v, arg_def) }
312
- else
313
- args << format_arg(value, arg_def)
314
- end
315
- end
698
+ private
316
699
 
317
- # Add post_options (options that come before the "last" argument)
318
- (command[:post_options] || []).each do |opt_def|
319
- # Convert name to symbol for params lookup
320
- param_key = opt_def[:name].is_a?(String) ? opt_def[:name].to_sym : opt_def[:name]
321
- next unless params.key?(param_key)
322
- next if params[param_key].nil?
700
+ # Find the best matching command profile
701
+ def find_command_profile
702
+ return nil unless @profile.profiles
703
+ return @profile.profiles.first if @profile.profiles.one?
323
704
 
324
- formatted_opt = format_option(opt_def, params[param_key])
325
- Array(formatted_opt).each { |opt| args << opt unless opt.nil? || opt.empty? }
326
- end
705
+ @profile.profiles.find do |p|
706
+ platforms = p.platforms&.map(&:to_sym) || []
707
+ shells = p.shells&.map(&:to_sym) || []
327
708
 
328
- # Add the "last" positioned argument (typically output file)
329
- if last_arg
330
- param_key = last_arg[:name].is_a?(String) ? last_arg[:name].to_sym : last_arg[:name]
331
- if params.key?(param_key) && !params[param_key].nil?
332
- if last_arg[:variadic]
333
- array = Type.validate(params[param_key], :array, last_arg)
334
- array.each { |v| args << format_arg(v, last_arg) }
335
- else
336
- args << format_arg(params[param_key], last_arg)
337
- end
338
- end
709
+ # Convert array elements to symbols for comparison
710
+ # (YAML arrays contain strings, but platform/shell are symbols)
711
+ platform_match = platforms.empty? || platforms.include?(@platform)
712
+ shell_match = shells.empty? || shells.include?(@shell)
713
+
714
+ platform_match && shell_match
339
715
  end
716
+ end
340
717
 
341
- args
718
+ # Find the executable path using ExecutableLocator
719
+ def find_executable
720
+ ExecutableLocator.find(
721
+ tool_name: @profile.name,
722
+ aliases: @profile.aliases || [],
723
+ search_paths: @profile.search_paths,
724
+ platform: @platform
725
+ )
342
726
  end
343
727
 
344
- # Format a positional argument
345
- def format_arg(value, arg_def)
346
- # Validate type
347
- Type.validate(value, arg_def[:type] || :string, arg_def)
728
+ # Detect tool version using VersionDetector
729
+ def detect_version
730
+ vd = @profile.version_detection
731
+ return nil unless vd
348
732
 
349
- # Apply platform-specific path formatting
350
- if arg_def[:type] == :file
351
- shell_class = Shell.class_for(@shell)
352
- shell_class.new.format_path(value.to_s)
353
- else
354
- value.to_s
355
- end
733
+ VersionDetector.detect(
734
+ executable: @executable,
735
+ command: vd.command || '--version',
736
+ pattern: vd.pattern || /(\d+\.\d+)/,
737
+ shell: @shell
738
+ )
356
739
  end
357
740
 
358
- # Format an option
359
- def format_option(opt_def, value)
360
- # Validate type
361
- Type.validate(value, opt_def[:type] || :string, opt_def)
362
-
363
- # Handle boolean types - just return the CLI flag (no value)
364
- type_val = opt_def[:type]
365
- if type_val == :boolean || type_val == TrueClass || type_val == "boolean"
366
- return nil if value.nil? || value == false
367
- return opt_def[:cli] || ""
741
+ # Check version compatibility with profile requirements
742
+ #
743
+ # @param mode [Symbol] check mode (:strict, :lenient, :probe)
744
+ # @return [VersionCompatibility] the compatibility result
745
+ def check_version_compatibility(mode = :strict)
746
+ require_relative 'models/version_compatibility'
747
+
748
+ installed = version
749
+ requirement = profile_version_requirement
750
+
751
+ # If no requirement, always compatible
752
+ return VersionCompatibility.new(
753
+ installed_version: installed || 'unknown',
754
+ required_version: 'none',
755
+ compatible: true,
756
+ reason: nil
757
+ ) if !requirement || requirement.empty?
758
+
759
+ # If installed version unknown, probe for it
760
+ if !installed && mode == :probe
761
+ installed = detect_version
368
762
  end
369
763
 
370
- cli = opt_def[:cli] || ""
371
- format = opt_def[:format] || "double_dash_equals"
372
- format_sym = format.is_a?(String) ? format.to_sym : format
373
- separator = opt_def[:separator] || "="
374
-
375
- # Convert value to string (handle symbols)
376
- value_str = value.is_a?(Symbol) ? value.to_s : value.to_s
377
-
378
- # Handle array values with separator
379
- if value.is_a?(Array) && opt_def[:separator]
380
- joined = value.join(opt_def[:separator])
381
- case format_sym
382
- when :double_dash_equals
383
- "#{cli}#{joined}"
384
- when :double_dash_space, :single_dash_space
385
- [cli, joined] # Return array for space-separated
386
- when :single_dash_equals
387
- "#{cli}#{joined}"
388
- else
389
- "#{cli}#{joined}"
390
- end
391
- else
392
- case format_sym
393
- when :double_dash_equals
394
- "#{cli}#{value_str}"
395
- when :double_dash_space, :single_dash_space
396
- [cli, value_str] # Return array for space-separated
397
- when :single_dash_equals
398
- "#{cli}#{value_str}"
399
- when :slash_colon
400
- "#{cli}:#{value_str}"
401
- when :slash_space
402
- "#{cli} #{value_str}"
764
+ # If still unknown, handle based on mode
765
+ if !installed
766
+ if mode == :strict
767
+ return VersionCompatibility.new(
768
+ installed_version: 'unknown',
769
+ required_version: requirement,
770
+ compatible: false,
771
+ reason: 'Cannot determine installed tool version'
772
+ )
403
773
  else
404
- "#{cli}#{value_str}"
774
+ return VersionCompatibility.new(
775
+ installed_version: 'unknown',
776
+ required_version: requirement,
777
+ compatible: true,
778
+ reason: 'Warning: Could not verify version compatibility'
779
+ )
405
780
  end
406
781
  end
407
- end
408
782
 
409
- # Format a flag
410
- def format_flag(flag_def, value)
411
- return nil if value.nil? || value == false
783
+ # Check compatibility
784
+ result = VersionCompatibility.check(installed, requirement)
785
+
786
+ if !result.compatible? && mode == :lenient
787
+ # In lenient mode, return compatible but with warning
788
+ return VersionCompatibility.new(
789
+ installed_version: installed,
790
+ required_version: requirement,
791
+ compatible: true,
792
+ reason: "Warning: #{result.reason}"
793
+ )
794
+ end
412
795
 
413
- flag_def[:cli] || ""
796
+ result
414
797
  end
415
798
 
416
- # Build environment variables for command
417
- def build_env_vars(command, params)
418
- env_vars = {}
799
+ # Probe for a feature flag
800
+ #
801
+ # Tests if the tool supports a specific feature by checking
802
+ # for a command-line flag.
803
+ #
804
+ # @param flag [String] the feature flag to probe (e.g., '--worktree')
805
+ # @return [Boolean] true if the feature is supported
806
+ def probe_flag(flag)
807
+ return false unless @executable
419
808
 
420
- (command[:env_vars] || []).each do |ev|
421
- # Check platform restriction
422
- platforms = ev[:platforms] || ev[:platform]
423
- next if platforms && !platforms.include?(@platform)
809
+ result = Executor.execute(
810
+ @executable,
811
+ [flag, '--help'],
812
+ shell: @shell,
813
+ timeout: 5
814
+ )
424
815
 
425
- # Get value - use ev[:value] if provided, or extract from params
426
- value = if ev.key?(:value)
427
- ev[:value]
428
- elsif ev[:from]
429
- params[ev[:from].to_sym]
430
- end
816
+ # Some tools exit 0 even for unknown flags, check stderr
817
+ # If the flag is valid, --help should show info about it
818
+ result.success? && !result.stderr.include?('unknown')
819
+ end
431
820
 
432
- # Set the environment variable if value is defined (including empty string)
433
- env_vars[ev[:name]] = value.to_s unless value.nil?
434
- end
821
+ private
435
822
 
436
- env_vars
823
+ # Get version requirement from compatible profile
824
+ #
825
+ # @return [String, nil] the version requirement
826
+ def profile_version_requirement
827
+ @command_profile&.version_requirement
437
828
  end
438
829
  end
439
830
  end