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.
- checksums.yaml +4 -4
- data/.github/workflows/docs.yml +63 -0
- data/.github/workflows/links.yml +99 -0
- data/.github/workflows/rake.yml +19 -0
- data/.github/workflows/release.yml +27 -0
- data/.gitignore +18 -4
- data/.rubocop.yml +1 -0
- data/.rubocop_todo.yml +213 -0
- data/Gemfile +12 -8
- data/README.adoc +613 -0
- data/Rakefile +2 -2
- data/docs/assets/logo.svg +1 -0
- data/exe/ukiryu +11 -0
- data/lib/ukiryu/action/base.rb +77 -0
- data/lib/ukiryu/cache.rb +199 -0
- data/lib/ukiryu/cli.rb +133 -307
- data/lib/ukiryu/cli_commands/base_command.rb +155 -0
- data/lib/ukiryu/cli_commands/commands_command.rb +120 -0
- data/lib/ukiryu/cli_commands/commands_command.rb.fixed +40 -0
- data/lib/ukiryu/cli_commands/config_command.rb +249 -0
- data/lib/ukiryu/cli_commands/describe_command.rb +326 -0
- data/lib/ukiryu/cli_commands/describe_command.rb.fixed +254 -0
- data/lib/ukiryu/cli_commands/exec_inline_command.rb.fixed +180 -0
- data/lib/ukiryu/cli_commands/extract_command.rb +84 -0
- data/lib/ukiryu/cli_commands/info_command.rb +156 -0
- data/lib/ukiryu/cli_commands/list_command.rb +70 -0
- data/lib/ukiryu/cli_commands/opts_command.rb +106 -0
- data/lib/ukiryu/cli_commands/opts_command.rb.fixed +105 -0
- data/lib/ukiryu/cli_commands/response_formatter.rb +240 -0
- data/lib/ukiryu/cli_commands/run_command.rb +375 -0
- data/lib/ukiryu/cli_commands/run_file_command.rb +215 -0
- data/lib/ukiryu/cli_commands/system_command.rb +90 -0
- data/lib/ukiryu/cli_commands/validate_command.rb +87 -0
- data/lib/ukiryu/cli_commands/version_command.rb +16 -0
- data/lib/ukiryu/cli_commands/which_command.rb +166 -0
- data/lib/ukiryu/command_builder.rb +205 -0
- data/lib/ukiryu/config/env_provider.rb +64 -0
- data/lib/ukiryu/config/env_schema.rb +63 -0
- data/lib/ukiryu/config/override_resolver.rb +68 -0
- data/lib/ukiryu/config/type_converter.rb +59 -0
- data/lib/ukiryu/config.rb +249 -0
- data/lib/ukiryu/errors.rb +3 -0
- data/lib/ukiryu/executable_locator.rb +114 -0
- data/lib/ukiryu/execution/command_info.rb +64 -0
- data/lib/ukiryu/execution/metadata.rb +97 -0
- data/lib/ukiryu/execution/output.rb +144 -0
- data/lib/ukiryu/execution/result.rb +194 -0
- data/lib/ukiryu/execution.rb +15 -0
- data/lib/ukiryu/execution_context.rb +251 -0
- data/lib/ukiryu/executor.rb +76 -493
- data/lib/ukiryu/extractors/base_extractor.rb +63 -0
- data/lib/ukiryu/extractors/extractor.rb +150 -0
- data/lib/ukiryu/extractors/help_parser.rb +188 -0
- data/lib/ukiryu/extractors/native_extractor.rb +47 -0
- data/lib/ukiryu/io.rb +196 -0
- data/lib/ukiryu/logger.rb +544 -0
- data/lib/ukiryu/models/argument.rb +28 -0
- data/lib/ukiryu/models/argument_definition.rb +119 -0
- data/lib/ukiryu/models/arguments.rb +113 -0
- data/lib/ukiryu/models/command_definition.rb +176 -0
- data/lib/ukiryu/models/command_info.rb +37 -0
- data/lib/ukiryu/models/components.rb +107 -0
- data/lib/ukiryu/models/env_var_definition.rb +30 -0
- data/lib/ukiryu/models/error_response.rb +41 -0
- data/lib/ukiryu/models/execution_metadata.rb +31 -0
- data/lib/ukiryu/models/execution_report.rb +236 -0
- data/lib/ukiryu/models/exit_codes.rb +74 -0
- data/lib/ukiryu/models/flag_definition.rb +67 -0
- data/lib/ukiryu/models/option_definition.rb +102 -0
- data/lib/ukiryu/models/output_info.rb +25 -0
- data/lib/ukiryu/models/platform_profile.rb +153 -0
- data/lib/ukiryu/models/routing.rb +211 -0
- data/lib/ukiryu/models/search_paths.rb +39 -0
- data/lib/ukiryu/models/success_response.rb +85 -0
- data/lib/ukiryu/models/tool_definition.rb +145 -0
- data/lib/ukiryu/models/tool_metadata.rb +82 -0
- data/lib/ukiryu/models/validation_result.rb +80 -0
- data/lib/ukiryu/models/version_compatibility.rb +152 -0
- data/lib/ukiryu/models/version_detection.rb +39 -0
- data/lib/ukiryu/models.rb +23 -0
- data/lib/ukiryu/options/base.rb +95 -0
- data/lib/ukiryu/options_builder/formatter.rb +87 -0
- data/lib/ukiryu/options_builder/validator.rb +43 -0
- data/lib/ukiryu/options_builder.rb +311 -0
- data/lib/ukiryu/platform.rb +6 -6
- data/lib/ukiryu/registry.rb +143 -183
- data/lib/ukiryu/response/base.rb +217 -0
- data/lib/ukiryu/runtime.rb +179 -0
- data/lib/ukiryu/schema_validator.rb +8 -10
- data/lib/ukiryu/shell/bash.rb +3 -3
- data/lib/ukiryu/shell/cmd.rb +4 -4
- data/lib/ukiryu/shell/fish.rb +1 -1
- data/lib/ukiryu/shell/powershell.rb +3 -3
- data/lib/ukiryu/shell/sh.rb +1 -1
- data/lib/ukiryu/shell/zsh.rb +1 -1
- data/lib/ukiryu/shell.rb +146 -39
- data/lib/ukiryu/thor_ext.rb +208 -0
- data/lib/ukiryu/tool.rb +649 -258
- data/lib/ukiryu/tool_index.rb +224 -0
- data/lib/ukiryu/tools/base.rb +381 -0
- data/lib/ukiryu/tools/class_generator.rb +132 -0
- data/lib/ukiryu/tools/executable_finder.rb +29 -0
- data/lib/ukiryu/tools/generator.rb +154 -0
- data/lib/ukiryu/tools.rb +109 -0
- data/lib/ukiryu/type.rb +28 -43
- data/lib/ukiryu/validation/constraints.rb +281 -0
- data/lib/ukiryu/validation/validator.rb +188 -0
- data/lib/ukiryu/validation.rb +21 -0
- data/lib/ukiryu/version.rb +1 -1
- data/lib/ukiryu/version_detector.rb +51 -0
- data/lib/ukiryu.rb +31 -15
- data/ukiryu-proposal.md +2952 -0
- data/ukiryu.gemspec +18 -14
- metadata +137 -5
- 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
|
|
4
|
-
require_relative
|
|
5
|
-
require_relative
|
|
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
|
-
#
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
212
|
+
# @api public
|
|
44
213
|
def clear_cache
|
|
45
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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,
|
|
68
|
-
|
|
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(
|
|
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 [
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
150
|
-
command = @command_profile
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
#
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
@profile
|
|
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
|
-
#
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return
|
|
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
|
-
|
|
563
|
+
@command_profile.routing
|
|
211
564
|
end
|
|
212
565
|
|
|
213
|
-
#
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
#
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
#
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
#
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
#
|
|
270
|
-
(
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
end
|
|
654
|
+
# Build command arguments
|
|
655
|
+
args = build_args(action, params)
|
|
279
656
|
|
|
280
|
-
#
|
|
281
|
-
(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
705
|
+
@profile.profiles.find do |p|
|
|
706
|
+
platforms = p.platforms&.map(&:to_sym) || []
|
|
707
|
+
shells = p.shells&.map(&:to_sym) || []
|
|
327
708
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
345
|
-
def
|
|
346
|
-
|
|
347
|
-
|
|
728
|
+
# Detect tool version using VersionDetector
|
|
729
|
+
def detect_version
|
|
730
|
+
vd = @profile.version_detection
|
|
731
|
+
return nil unless vd
|
|
348
732
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
#
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
796
|
+
result
|
|
414
797
|
end
|
|
415
798
|
|
|
416
|
-
#
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
809
|
+
result = Executor.execute(
|
|
810
|
+
@executable,
|
|
811
|
+
[flag, '--help'],
|
|
812
|
+
shell: @shell,
|
|
813
|
+
timeout: 5
|
|
814
|
+
)
|
|
424
815
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
433
|
-
env_vars[ev[:name]] = value.to_s unless value.nil?
|
|
434
|
-
end
|
|
821
|
+
private
|
|
435
822
|
|
|
436
|
-
|
|
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
|