ukiryu 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ukiryu/cache.rb +6 -0
  3. data/lib/ukiryu/cache_registry.rb +64 -0
  4. data/lib/ukiryu/cli_commands/base_command.rb +6 -5
  5. data/lib/ukiryu/cli_commands/config_command.rb +7 -10
  6. data/lib/ukiryu/cli_commands/register_command.rb +27 -18
  7. data/lib/ukiryu/cli_commands/validate_command.rb +2 -2
  8. data/lib/ukiryu/command_builder.rb +83 -50
  9. data/lib/ukiryu/config.rb +13 -2
  10. data/lib/ukiryu/debug.rb +20 -9
  11. data/lib/ukiryu/definition/loader.rb +3 -3
  12. data/lib/ukiryu/errors.rb +37 -37
  13. data/lib/ukiryu/executable_locator.rb +40 -16
  14. data/lib/ukiryu/extractors/base_extractor.rb +2 -1
  15. data/lib/ukiryu/extractors/help_parser.rb +3 -0
  16. data/lib/ukiryu/logger.rb +51 -0
  17. data/lib/ukiryu/models/implementation_index.rb +2 -1
  18. data/lib/ukiryu/models/implementation_version.rb +18 -1
  19. data/lib/ukiryu/models/interface.rb +2 -1
  20. data/lib/ukiryu/models/run_environment.rb +0 -2
  21. data/lib/ukiryu/models/semantic_version.rb +174 -0
  22. data/lib/ukiryu/models/stage_metrics.rb +0 -1
  23. data/lib/ukiryu/register.rb +473 -232
  24. data/lib/ukiryu/shell/powershell.rb +188 -99
  25. data/lib/ukiryu/shell/sh.rb +4 -1
  26. data/lib/ukiryu/shell.rb +60 -2
  27. data/lib/ukiryu/tool/command_resolution.rb +2 -1
  28. data/lib/ukiryu/tool/executable_discovery.rb +14 -15
  29. data/lib/ukiryu/tool/loader.rb +543 -0
  30. data/lib/ukiryu/tool/version_detection.rb +1 -3
  31. data/lib/ukiryu/tool.rb +79 -87
  32. data/lib/ukiryu/tool_index.rb +127 -62
  33. data/lib/ukiryu/tools/base.rb +4 -2
  34. data/lib/ukiryu/type.rb +26 -15
  35. data/lib/ukiryu/version.rb +1 -1
  36. data/lib/ukiryu.rb +1 -1
  37. data/spec/fixtures/profiles/ghostscript_10.0.yaml +50 -0
  38. data/spec/fixtures/register/tools/ghostscript/default/10.0.yaml +6 -0
  39. data/spec/spec_helper.rb +10 -6
  40. data/spec/support/tool_helper.rb +2 -0
  41. data/spec/ukiryu/definition/loader_spec.rb +2 -2
  42. data/spec/ukiryu/executor_spec.rb +6 -3
  43. data/spec/ukiryu/models/execution_report_spec.rb +3 -2
  44. data/spec/ukiryu/models/semantic_version_spec.rb +284 -0
  45. data/spec/ukiryu/shell/powershell_integration_spec.rb +165 -0
  46. data/spec/ukiryu/shell/powershell_real_command_spec.rb +143 -0
  47. data/spec/ukiryu/shell/powershell_spec.rb +228 -60
  48. data/spec/ukiryu/tool/loader_spec.rb +148 -0
  49. data/spec/ukiryu/tool_index_spec.rb +110 -18
  50. data/spec/ukiryu/tools/ghostscript_spec.rb +242 -0
  51. data/spec/ukiryu/tools/imagemagick_spec.rb +2 -1
  52. data/spec/ukiryu/tools/inkscape_spec.rb +4 -2
  53. metadata +14 -2
  54. data/lib/ukiryu/register_auto_manager.rb +0 -342
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'open3'
4
4
  require 'timeout'
5
+ require 'tempfile'
5
6
 
6
7
  module Ukiryu
7
8
  module Shell
@@ -42,11 +43,25 @@ module Ukiryu
42
43
  end
43
44
 
44
45
  # Escape a string for PowerShell
45
- # Backtick is the escape character for: ` $ "
46
+ # - Single quotes are escaped by doubling them (for single-quoted strings)
47
+ # - Backtick, dollar, and double quotes are escaped with backtick (for double-quoted strings)
48
+ #
49
+ # Note: This method escapes for single-quoted strings by default since we use
50
+ # single quotes for arguments to prevent parameter binding issues.
46
51
  #
47
52
  # @param string [String] the string to escape
48
53
  # @return [String] the escaped string
49
54
  def escape(string)
55
+ # For single-quoted strings, escape single quotes by doubling them
56
+ string.to_s.gsub("'", "''")
57
+ end
58
+
59
+ # Escape a string for double-quoted PowerShell strings
60
+ # Used for executable paths which need double quotes
61
+ #
62
+ # @param string [String] the string to escape
63
+ # @return [String] the escaped string
64
+ def escape_for_double_quotes(string)
50
65
  string.to_s.gsub(/[`"$]/) { "`#{::Regexp.last_match(0)}" }
51
66
  end
52
67
 
@@ -58,6 +73,8 @@ module Ukiryu
58
73
  # to be stripped (e.g., -sDEVICE=pdfwrite becomes =pdfwrite).
59
74
  # To prevent this, we must quote all arguments starting with -.
60
75
  #
76
+ # Also, arguments containing $ must be quoted to prevent variable expansion.
77
+ #
61
78
  # @param string [String] the string to check
62
79
  # @return [Boolean] true if quoting is needed
63
80
  def needs_quoting?(string)
@@ -67,26 +84,25 @@ module Ukiryu
67
84
  # PowerShell-specific: arguments starting with - must be quoted
68
85
  # to prevent PowerShell's parameter binder from stripping the prefix
69
86
  return true if str.start_with?('-')
87
+ # PowerShell-specific: arguments containing $ must be quoted
88
+ # to prevent variable expansion
89
+ return true if str.include?('$')
70
90
 
71
91
  false
72
92
  end
73
93
 
74
94
  # Quote an argument for PowerShell
75
- # Uses single quotes for literal strings
76
- # Uses double quotes for executable paths (works in both cmd.exe and PowerShell)
95
+ # Uses double quotes for all arguments to prevent PowerShell's parameter
96
+ # binder from stripping dash prefixes. Double quotes work consistently
97
+ # across both the call operator (&) and Start-Process.
77
98
  #
78
99
  # @param string [String] the string to quote
79
- # @param for_exe [Boolean] true if quoting for executable path
100
+ # @param for_exe [Boolean] true if quoting for executable path (same behavior)
80
101
  # @return [String] the quoted string
81
102
  def quote(string, for_exe: false)
82
- if for_exe
83
- # For executable paths, use double quotes which work in both cmd.exe and PowerShell
84
- # This is needed because Ruby's Open3 uses cmd.exe on Windows, not PowerShell
85
- "\"#{string}\""
86
- else
87
- # For arguments, use single quotes for literal strings
88
- "'#{escape(string)}'"
89
- end
103
+ # Always use double quotes - this prevents PowerShell's parameter binder
104
+ # from stripping dash prefixes in all contexts (call operator, Start-Process)
105
+ "\"#{escape_for_double_quotes(string)}\""
90
106
  end
91
107
 
92
108
  # Format an environment variable reference
@@ -97,6 +113,18 @@ module Ukiryu
97
113
  "$ENV:#{name}"
98
114
  end
99
115
 
116
+ # Format a file path for PowerShell on Windows
117
+ #
118
+ # Returns the path unchanged. Quoting for paths with spaces is handled
119
+ # by the quote method, which wraps paths in double quotes with proper
120
+ # escaping.
121
+ #
122
+ # @param path [String] the file path
123
+ # @return [String] the formatted path
124
+ def format_path(path)
125
+ path.to_s
126
+ end
127
+
100
128
  # Join executable and arguments into a command line
101
129
  # Uses smart quoting: only quote arguments that need it
102
130
  #
@@ -107,12 +135,20 @@ module Ukiryu
107
135
  # @param args [Array<String>] the arguments
108
136
  # @return [String] the complete command line
109
137
  def join(executable, *args)
110
- # Debug logging for Ruby 4.0 CI
138
+ # Debug logging for CI - helps identify where prefix stripping might occur
111
139
  if ENV['UKIRYU_DEBUG_EXECUTABLE']
112
140
  warn "[UKIRYU DEBUG PowerShell#join] executable: #{executable.inspect}"
113
141
  warn "[UKIRYU DEBUG PowerShell#join] args: #{args.inspect}"
114
142
  warn "[UKIRYU DEBUG PowerShell#join] args.size: #{args.size}"
115
143
  warn "[UKIRYU DEBUG PowerShell#join] args.class: #{args.class}"
144
+ args.each_with_index do |a, i|
145
+ warn "[UKIRYU DEBUG PowerShell#join] args[#{i}]: #{a.inspect} (#{a.class})"
146
+ # Check for nested arrays which would cause stringification issues
147
+ if a.is_a?(Array)
148
+ warn "[UKIRYU DEBUG PowerShell#join] WARNING: args[#{i}] is a NESTED ARRAY!"
149
+ warn "[UKIRYU DEBUG PowerShell#join] This will be converted to string: #{a.to_s.inspect}"
150
+ end
151
+ end
116
152
  end
117
153
 
118
154
  # Quote executable if it needs quoting (e.g., contains spaces)
@@ -161,9 +197,12 @@ module Ukiryu
161
197
 
162
198
  # Execute a command using PowerShell
163
199
  #
164
- # Uses PowerShell's -Command flag to execute the command string.
165
- # The executable and arguments are quoted individually and passed to
166
- # PowerShell's call operator &.
200
+ # On Windows: Uses cmd /c to execute commands. This provides more predictable
201
+ # quoting behavior than PowerShell's Start-Process for native executables
202
+ # with arguments containing spaces.
203
+ #
204
+ # On Unix: Uses the call operator (&) since PowerShell on Unix doesn't have
205
+ # the parameter binding issues that Windows PowerShell has.
167
206
  #
168
207
  # @param executable [String] the executable path
169
208
  # @param args [Array<String>] command arguments
@@ -173,33 +212,72 @@ module Ukiryu
173
212
  # @return [Hash] execution result with :status, :stdout, :stderr keys
174
213
  # @raise [Timeout::Error] if command times out
175
214
  def execute_command(executable, args, env, timeout, cwd = nil)
176
- # Build the command with proper quoting for each element
177
- # Use double quotes for PowerShell -Command (works better than single quotes)
178
- exe_quoted = %("#{escape(executable)}")
179
-
180
- # Quote each argument based on needs_quoting?
181
- # Note: We do NOT add special handling for -Command/-File here because
182
- # this method builds a PowerShell script string (using the & call operator),
183
- # not a command line for the shell. The -Command/-File handling in join()
184
- # is for when building command lines where PowerShell's parameter binder
185
- # would interpret -prefixed arguments.
186
- args_quoted = args.map do |a|
187
- if needs_quoting?(a)
188
- %("#{escape(a)}")
189
- else
190
- a
215
+ # Debug logging for CI - helps identify where prefix stripping might occur
216
+ if ENV['UKIRYU_DEBUG_EXECUTABLE']
217
+ warn "[UKIRYU DEBUG PowerShell#execute_command] executable: #{executable.inspect}"
218
+ warn "[UKIRYU DEBUG PowerShell#execute_command] args: #{args.inspect}"
219
+ warn "[UKIRYU DEBUG PowerShell#execute_command] args.class: #{args.class}"
220
+ args.each_with_index do |a, i|
221
+ warn "[UKIRYU DEBUG PowerShell#execute_command] args[#{i}]: #{a.inspect} (#{a.class})"
222
+ # Check for nested arrays which would cause stringification issues
223
+ warn "[UKIRYU DEBUG PowerShell#execute_command] WARNING: args[#{i}] is a NESTED ARRAY!" if a.is_a?(Array)
224
+ end
225
+ end
226
+
227
+ # Build the command line with proper quoting
228
+ if Platform.windows?
229
+ # On Windows: Use PowerShell call operator with single quotes for all arguments
230
+ # Single quotes are completely literal in PowerShell - no parameter binding issues
231
+ # This works for both paths with spaces and without spaces
232
+ exe_normalized = executable.to_s.gsub('/', '\\')
233
+ exe_escaped = exe_normalized.gsub("'", "''")
234
+
235
+ args_escaped = args.map do |a|
236
+ arg_str = a.to_s.gsub('/', '\\')
237
+ if arg_str.start_with?('-')
238
+ # Arguments starting with - must be single-quoted to prevent PowerShell's
239
+ # parameter binder from stripping the prefix (e.g., -sDEVICE=pdfwrite -> =pdfwrite)
240
+ escaped = arg_str.gsub("'", "''")
241
+ "'#{escaped}'"
242
+ elsif arg_str.include?('$') || arg_str.include?('`')
243
+ # Use single quotes for arguments with $ or ` to prevent expansion
244
+ escaped = arg_str.gsub("'", "''")
245
+ "'#{escaped}'"
246
+ elsif arg_str.include?(' ') || arg_str.include?('"')
247
+ # Use single quotes for spaces or quotes (completely literal)
248
+ escaped = arg_str.gsub("'", "''")
249
+ "'#{escaped}'"
250
+ else
251
+ arg_str
252
+ end
253
+ end
254
+
255
+ # Propagate exit code from external command using $LASTEXITCODE
256
+ else
257
+ # On Unix: Use the call operator directly with single quotes
258
+ # Single quotes are completely literal in PowerShell (no variable expansion)
259
+ exe_escaped = executable.to_s.gsub("'", "''")
260
+
261
+ args_escaped = args.map do |a|
262
+ arg_str = a.to_s
263
+ # Quote arguments that contain special PowerShell characters or
264
+ # start with dash (to prevent parameter binding)
265
+ if arg_str.include?(' ') || arg_str.start_with?('-') || arg_str.include?('$') || arg_str.include?('`') || arg_str.include?(';')
266
+ # Use single quotes for completely literal strings
267
+ escaped = arg_str.gsub("'", "''")
268
+ "'#{escaped}'"
269
+ else
270
+ arg_str
271
+ end
191
272
  end
273
+
274
+ # Propagate exit code from external command using $LASTEXITCODE
192
275
  end
276
+ full_command = ["'#{exe_escaped}'", *args_escaped].join(' ')
277
+ warn "[UKIRYU DEBUG PowerShell#execute_command] full_command: #{full_command.inspect}" if ENV['UKIRYU_DEBUG_EXECUTABLE']
278
+ ps_command = "& #{full_command}; exit $LASTEXITCODE"
193
279
 
194
- # Build PowerShell command: & "executable" "arg1" "arg2" ...
195
- # Append "; exit $LASTEXITCODE" to properly propagate exit codes from
196
- # the invoked command/program back through the PowerShell wrapper
197
- ps_command_base = if args_quoted.empty?
198
- "& #{exe_quoted}"
199
- else
200
- "& #{exe_quoted} #{args_quoted.join(' ')}"
201
- end
202
- ps_command = "#{ps_command_base}; exit $LASTEXITCODE"
280
+ warn "[UKIRYU DEBUG PowerShell#execute_command] ps_command:\n#{ps_command}" if ENV['UKIRYU_DEBUG_EXECUTABLE']
203
281
 
204
282
  # Convert Environment to Hash ONLY at Open3 call site
205
283
  env_hash = environment_to_h(env)
@@ -228,9 +306,7 @@ module Ukiryu
228
306
 
229
307
  # Execute a command with stdin input using PowerShell
230
308
  #
231
- # Uses PowerShell's -Command flag to execute the command string.
232
- # The executable and arguments are quoted individually and passed to
233
- # PowerShell's call operator &.
309
+ # Uses platform-specific execution with stdin redirection.
234
310
  #
235
311
  # @param executable [String] the executable path
236
312
  # @param args [Array<String>] command arguments
@@ -241,78 +317,91 @@ module Ukiryu
241
317
  # @return [Hash] execution result with :status, :stdout, :stderr keys
242
318
  # @raise [Timeout::Error] if command times out
243
319
  def execute_command_with_stdin(executable, args, env, timeout, cwd, stdin_data)
244
- # Build the command with proper quoting for each element
245
- # Use double quotes for PowerShell -Command (works better than single quotes)
246
- exe_quoted = %("#{escape(executable)}")
247
-
248
- # Quote each argument based on needs_quoting?
249
- # Note: We do NOT add special handling for -Command/-File here because
250
- # this method builds a PowerShell script string (using the & call operator),
251
- # not a command line for the shell. The -Command/-File handling in join()
252
- # is for when building command lines where PowerShell's parameter binder
253
- # would interpret -prefixed arguments.
254
- args_quoted = args.map do |a|
255
- if needs_quoting?(a)
256
- %("#{escape(a)}")
257
- else
258
- a
320
+ # Write stdin to temp file for redirection
321
+ stdin_file = Tempfile.new('ukiryu_stdin')
322
+ begin
323
+ if stdin_data.is_a?(IO)
324
+ IO.copy_stream(stdin_data, stdin_file)
325
+ elsif stdin_data.is_a?(String)
326
+ stdin_file.write(stdin_data)
259
327
  end
260
- end
328
+ stdin_file.close
261
329
 
262
- # Build PowerShell command: & "executable" "arg1" "arg2" ...
263
- # Append "; exit $LASTEXITCODE" to properly propagate exit codes from
264
- # the invoked command/program back through the PowerShell wrapper
265
- ps_command_base = if args_quoted.empty?
266
- "& #{exe_quoted}"
267
- else
268
- "& #{exe_quoted} #{args_quoted.join(' ')}"
269
- end
270
- ps_command = "#{ps_command_base}; exit $LASTEXITCODE"
330
+ stdin_path = stdin_file.path
271
331
 
272
- # Convert Environment to Hash ONLY at Open3 call site
273
- env_hash = environment_to_h(env)
332
+ if Platform.windows?
333
+ # On Windows: Use PowerShell call operator with single quotes for all arguments
334
+ # Single quotes are completely literal in PowerShell - no parameter binding issues
335
+ # This works for both paths with spaces and without spaces
336
+ exe_normalized = executable.to_s.gsub('/', '\\')
337
+ exe_escaped = exe_normalized.gsub("'", "''")
274
338
 
275
- # Execute using PowerShell's -Command flag
276
- Timeout.timeout(timeout) do
277
- execution = lambda do
278
- Open3.popen3(env_hash, powershell_command, '-NoLogo', '-Command',
279
- ps_command) do |stdin, stdout, stderr, wait_thr|
280
- # Write stdin data
281
- begin
282
- if stdin_data.is_a?(IO)
283
- IO.copy_stream(stdin_data, stdin)
284
- elsif stdin_data.is_a?(String)
285
- stdin.write(stdin_data)
286
- end
287
- rescue Errno::EPIPE
288
- # Process closed stdin early
289
- ensure
290
- stdin.close
339
+ args_escaped = args.map do |a|
340
+ arg_str = a.to_s.gsub('/', '\\')
341
+ if arg_str.start_with?('-')
342
+ # Arguments starting with - must be single-quoted to prevent PowerShell's
343
+ # parameter binder from stripping the prefix (e.g., -sDEVICE=pdfwrite -> =pdfwrite)
344
+ escaped = arg_str.gsub("'", "''")
345
+ "'#{escaped}'"
346
+ elsif arg_str.include?('$') || arg_str.include?('`')
347
+ # Use single quotes for arguments with $ or ` to prevent expansion
348
+ escaped = arg_str.gsub("'", "''")
349
+ "'#{escaped}'"
350
+ elsif arg_str.include?(' ') || arg_str.include?('"')
351
+ # Use single quotes for spaces or quotes (completely literal)
352
+ escaped = arg_str.gsub("'", "''")
353
+ "'#{escaped}'"
354
+ else
355
+ arg_str
356
+ end
357
+ end
358
+
359
+ # Propagate exit code from external command using $LASTEXITCODE
360
+ else
361
+ # On Unix: Use the call operator with stdin redirection
362
+ # Single quotes are completely literal in PowerShell (no variable expansion)
363
+ exe_escaped = executable.to_s.gsub("'", "''")
364
+
365
+ args_escaped = args.map do |a|
366
+ arg_str = a.to_s
367
+ # Quote arguments that contain special PowerShell characters or
368
+ # start with dash (to prevent parameter binding)
369
+ if arg_str.include?(' ') || arg_str.start_with?('-') || arg_str.include?('$') || arg_str.include?('`') || arg_str.include?(';')
370
+ escaped = arg_str.gsub("'", "''")
371
+ "'#{escaped}'"
372
+ else
373
+ arg_str
291
374
  end
375
+ end
292
376
 
293
- # Read output
294
- out = stdout.read
295
- err = stderr.read
377
+ # Use Get-Content to read stdin file and pipe to command
378
+ # Propagate exit code from external command using $LASTEXITCODE
379
+ end
380
+ full_command = ["'#{exe_escaped}'", *args_escaped].join(' ')
381
+ ps_command = "Get-Content '#{stdin_path.gsub("'", "''")}' | & #{full_command}; exit $LASTEXITCODE"
296
382
 
297
- # Wait for process to complete
298
- status = wait_thr.value
383
+ env_hash = environment_to_h(env)
299
384
 
385
+ Timeout.timeout(timeout) do
386
+ execution = lambda do
387
+ stdout, stderr, status = Open3.capture3(env_hash, powershell_command, '-NoLogo', '-Command', ps_command)
300
388
  {
301
389
  status: Ukiryu::Executor.extract_status(status),
302
- stdout: out,
303
- stderr: err
390
+ stdout: stdout,
391
+ stderr: stderr
304
392
  }
305
393
  end
306
- end
307
394
 
308
- if cwd
309
- Dir.chdir(cwd) { execution.call }
310
- else
311
- execution.call
395
+ if cwd
396
+ Dir.chdir(cwd) { execution.call }
397
+ else
398
+ execution.call
399
+ end
312
400
  end
401
+ ensure
402
+ stdin_file.unlink
313
403
  end
314
404
  rescue Timeout::Error, Timeout::ExitException
315
- # Re-raise with context
316
405
  raise Timeout::Error, "Command timed out after #{timeout}s: #{executable}"
317
406
  end
318
407
  end
@@ -20,7 +20,10 @@ module Ukiryu
20
20
  result = `type #{command_name} 2>/dev/null`
21
21
  return nil unless result
22
22
 
23
- { definition: result.strip, target: ::Regexp.last_match(1) } if result =~ /^#{command_name} is aliased to `(.*)'`$/
23
+ if result =~ /^#{command_name} is aliased to `(.*)'`$/
24
+ { definition: result.strip,
25
+ target: ::Regexp.last_match(1) }
26
+ end
24
27
  nil
25
28
  end
26
29
 
data/lib/ukiryu/shell.rb CHANGED
@@ -25,6 +25,7 @@ module Ukiryu
25
25
  autoload :Tcsh, 'ukiryu/shell/tcsh'
26
26
  autoload :PowerShell, 'ukiryu/shell/powershell'
27
27
  autoload :Cmd, 'ukiryu/shell/cmd'
28
+ autoload :InstanceCache, 'ukiryu/shell/instance_cache'
28
29
 
29
30
  # Platform-grouped shell types (new schema v1)
30
31
  PLATFORM_GROUPS = %i[unix windows powershell].freeze
@@ -56,6 +57,47 @@ module Ukiryu
56
57
  powershell: %i[powershell pwsh]
57
58
  }.freeze
58
59
 
60
+ # Shell registry for custom shells
61
+ # @api private
62
+ class Registry
63
+ class << self
64
+ def shells
65
+ @shells ||= {}
66
+ end
67
+
68
+ # Register a custom shell class
69
+ #
70
+ # @param name [Symbol] the shell name
71
+ # @param shell_class [Class] the shell class (must inherit from Shell::Base)
72
+ # @raise [ArgumentError] if shell_class is not a Shell::Base subclass
73
+ def register(name, shell_class)
74
+ raise ArgumentError, 'Shell class must inherit from Ukiryu::Shell::Base' unless shell_class.ancestors.include?(Base)
75
+
76
+ shells[name.to_sym] = shell_class
77
+ end
78
+
79
+ # Lookup a shell class by name
80
+ #
81
+ # @param name [Symbol] the shell name
82
+ # @return [Class, nil] the shell class or nil if not registered
83
+ def lookup(name)
84
+ shells[name.to_sym]
85
+ end
86
+
87
+ # Get all registered shell names
88
+ #
89
+ # @return [Array<Symbol>] list of registered shell names
90
+ def registered_shells
91
+ shells.keys
92
+ end
93
+
94
+ # Clear all registered shells (mainly for testing)
95
+ def clear
96
+ @shells = nil
97
+ end
98
+ end
99
+ end
100
+
59
101
  class << self
60
102
  # Get or set the current shell (for explicit configuration)
61
103
  attr_writer :current_shell
@@ -94,7 +136,10 @@ module Ukiryu
94
136
  def platform_group_for(shell_sym)
95
137
  return shell_sym if PLATFORM_GROUPS.include?(shell_sym)
96
138
 
97
- raise ArgumentError, "Unknown shell: #{shell_sym}. Valid shells: #{VALID_SHELLS.join(', ')}" unless SHELL_TO_PLATFORM.key?(shell_sym)
139
+ unless SHELL_TO_PLATFORM.key?(shell_sym)
140
+ raise ArgumentError,
141
+ "Unknown shell: #{shell_sym}. Valid shells: #{VALID_SHELLS.join(', ')}"
142
+ end
98
143
 
99
144
  SHELL_TO_PLATFORM[shell_sym]
100
145
  end
@@ -231,7 +276,11 @@ module Ukiryu
231
276
  # @return [Class] the shell class
232
277
  # @raise [UnknownShellError] if shell class not found
233
278
  def class_for(name)
234
- # Platform groups map to their representative shell classes
279
+ # Check registry first (for custom shells)
280
+ registered = Registry.lookup(name)
281
+ return registered if registered
282
+
283
+ # Built-in shells
235
284
  case name
236
285
  when :unix
237
286
  Bash # Most common Unix shell, all Unix shells share the same quoting rules
@@ -260,6 +309,15 @@ module Ukiryu
260
309
  end
261
310
  end
262
311
 
312
+ # Register a custom shell class
313
+ #
314
+ # @param name [Symbol] the shell name
315
+ # @param shell_class [Class] the shell class (must inherit from Shell::Base)
316
+ # @raise [ArgumentError] if shell_class is not a Shell::Base subclass
317
+ def register(name, shell_class)
318
+ Registry.register(name, shell_class)
319
+ end
320
+
263
321
  private
264
322
 
265
323
  # Detect shell on Windows
@@ -92,7 +92,8 @@ module Ukiryu
92
92
  args = build_args(action, params)
93
93
 
94
94
  # Execute with the routed executable, passing tool_name and command_name for exit code lookups
95
- execute_with_config(resolution[:executable], args, action, params, execution_timeout: execution_timeout, stdin: stdin)
95
+ execute_with_config(resolution[:executable], args, action, params, execution_timeout: execution_timeout,
96
+ stdin: stdin)
96
97
  end
97
98
 
98
99
  # Execute a command with root-path notation (for hierarchical tools)
@@ -22,16 +22,15 @@ module Ukiryu
22
22
  # Use executable_name from command profile, falling back to profile name
23
23
  executable_name = @command_profile.executable_name || @profile.name
24
24
 
25
- # Debug logging for Windows CI
26
- if ENV['UKIRYU_DEBUG_EXECUTABLE'] || (Platform.windows? && ENV['CI'])
27
- warn "[UKIRYU DEBUG] Tool: #{@profile.name}"
28
- warn "[UKIRYU DEBUG] Command profile executable_name: #{@command_profile.executable_name.inspect}"
29
- warn "[UKIRYU DEBUG] Profile name: #{@profile.name.inspect}"
30
- warn "[UKIRYU DEBUG] Resolved executable_name: #{executable_name.inspect}"
31
- warn "[UKIRYU DEBUG] Profile aliases: #{@profile.aliases.inspect}"
32
- warn "[UKIRYU DEBUG] Shell: #{@shell.inspect}"
33
- warn "[UKIRYU DEBUG] Platform: #{@platform.inspect}"
34
- end
25
+ # Debug logging for executable discovery
26
+ Logger.debug("Tool: #{@profile.name}", category: :executable)
27
+ Logger.debug("Command profile executable_name: #{@command_profile.executable_name.inspect}",
28
+ category: :executable)
29
+ Logger.debug("Profile name: #{@profile.name.inspect}", category: :executable)
30
+ Logger.debug("Resolved executable_name: #{executable_name.inspect}", category: :executable)
31
+ Logger.debug("Profile aliases: #{@profile.aliases.inspect}", category: :executable)
32
+ Logger.debug("Shell: #{@shell.inspect}", category: :executable)
33
+ Logger.debug("Platform: #{@platform.inspect}", category: :executable)
35
34
 
36
35
  result = ::Ukiryu::ExecutableLocator.find_with_info(
37
36
  tool_name: executable_name,
@@ -39,11 +38,11 @@ module Ukiryu
39
38
  platform: @platform
40
39
  )
41
40
 
42
- if result && (ENV['UKIRYU_DEBUG_EXECUTABLE'] || (Platform.windows? && ENV['CI']))
43
- warn "[UKIRYU DEBUG] Found executable: #{result[:path]}"
44
- warn "[UKIRYU DEBUG] Discovery source: #{result[:info].source.inspect}"
45
- elsif !result && (ENV['UKIRYU_DEBUG_EXECUTABLE'] || (Platform.windows? && ENV['CI']))
46
- warn '[UKIRYU DEBUG] EXECUTABLE NOT FOUND!'
41
+ if result
42
+ Logger.debug("Found executable: #{result[:path]}", category: :executable)
43
+ Logger.debug("Discovery source: #{result[:info].source.inspect}", category: :executable)
44
+ else
45
+ Logger.debug('EXECUTABLE NOT FOUND!', category: :executable)
47
46
  end
48
47
 
49
48
  return nil unless result