ukiryu 0.1.6 → 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 +209 -89
  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 +286 -51
  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,30 +43,66 @@ 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
 
68
+ # Check if a string needs quoting for PowerShell
69
+ # Overrides base class to add PowerShell-specific handling
70
+ #
71
+ # In PowerShell, arguments starting with - are interpreted as PowerShell
72
+ # parameters when passed to the call operator (&). This causes the prefix
73
+ # to be stripped (e.g., -sDEVICE=pdfwrite becomes =pdfwrite).
74
+ # To prevent this, we must quote all arguments starting with -.
75
+ #
76
+ # Also, arguments containing $ must be quoted to prevent variable expansion.
77
+ #
78
+ # @param string [String] the string to check
79
+ # @return [Boolean] true if quoting is needed
80
+ def needs_quoting?(string)
81
+ str = string.to_s
82
+ # Call super for base checks (empty, whitespace, special chars)
83
+ return true if super(string)
84
+ # PowerShell-specific: arguments starting with - must be quoted
85
+ # to prevent PowerShell's parameter binder from stripping the prefix
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?('$')
90
+
91
+ false
92
+ end
93
+
53
94
  # Quote an argument for PowerShell
54
- # Uses single quotes for literal strings
55
- # 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.
56
98
  #
57
99
  # @param string [String] the string to quote
58
- # @param for_exe [Boolean] true if quoting for executable path
100
+ # @param for_exe [Boolean] true if quoting for executable path (same behavior)
59
101
  # @return [String] the quoted string
60
102
  def quote(string, for_exe: false)
61
- if for_exe
62
- # For executable paths, use double quotes which work in both cmd.exe and PowerShell
63
- # This is needed because Ruby's Open3 uses cmd.exe on Windows, not PowerShell
64
- "\"#{string}\""
65
- else
66
- # For arguments, use single quotes for literal strings
67
- "'#{escape(string)}'"
68
- 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)}\""
69
106
  end
70
107
 
71
108
  # Format an environment variable reference
@@ -76,6 +113,18 @@ module Ukiryu
76
113
  "$ENV:#{name}"
77
114
  end
78
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
+
79
128
  # Join executable and arguments into a command line
80
129
  # Uses smart quoting: only quote arguments that need it
81
130
  #
@@ -86,12 +135,20 @@ module Ukiryu
86
135
  # @param args [Array<String>] the arguments
87
136
  # @return [String] the complete command line
88
137
  def join(executable, *args)
89
- # Debug logging for Ruby 4.0 CI
138
+ # Debug logging for CI - helps identify where prefix stripping might occur
90
139
  if ENV['UKIRYU_DEBUG_EXECUTABLE']
91
140
  warn "[UKIRYU DEBUG PowerShell#join] executable: #{executable.inspect}"
92
141
  warn "[UKIRYU DEBUG PowerShell#join] args: #{args.inspect}"
93
142
  warn "[UKIRYU DEBUG PowerShell#join] args.size: #{args.size}"
94
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
95
152
  end
96
153
 
97
154
  # Quote executable if it needs quoting (e.g., contains spaces)
@@ -140,9 +197,12 @@ module Ukiryu
140
197
 
141
198
  # Execute a command using PowerShell
142
199
  #
143
- # Uses PowerShell's -Command flag to execute the command string.
144
- # The executable and arguments are quoted individually and passed to
145
- # 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.
146
206
  #
147
207
  # @param executable [String] the executable path
148
208
  # @param args [Array<String>] command arguments
@@ -152,28 +212,72 @@ module Ukiryu
152
212
  # @return [Hash] execution result with :status, :stdout, :stderr keys
153
213
  # @raise [Timeout::Error] if command times out
154
214
  def execute_command(executable, args, env, timeout, cwd = nil)
155
- # Build the command with proper quoting for each element
156
- # Use double quotes for PowerShell -Command (works better than single quotes)
157
- exe_quoted = %("#{escape(executable)}")
158
-
159
- # Quote each argument - only quote if it contains special chars or spaces
160
- args_quoted = args.map do |a|
161
- if needs_quoting?(a)
162
- %("#{escape(a)}")
163
- else
164
- 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)
165
224
  end
166
225
  end
167
226
 
168
- # Build PowerShell command: & "executable" "arg1" "arg2" ...
169
- # Append "; exit $LASTEXITCODE" to properly propagate exit codes from
170
- # the invoked command/program back through the PowerShell wrapper
171
- ps_command_base = if args_quoted.empty?
172
- "& #{exe_quoted}"
173
- else
174
- "& #{exe_quoted} #{args_quoted.join(' ')}"
175
- end
176
- ps_command = "#{ps_command_base}; exit $LASTEXITCODE"
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
272
+ end
273
+
274
+ # Propagate exit code from external command using $LASTEXITCODE
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"
279
+
280
+ warn "[UKIRYU DEBUG PowerShell#execute_command] ps_command:\n#{ps_command}" if ENV['UKIRYU_DEBUG_EXECUTABLE']
177
281
 
178
282
  # Convert Environment to Hash ONLY at Open3 call site
179
283
  env_hash = environment_to_h(env)
@@ -202,9 +306,7 @@ module Ukiryu
202
306
 
203
307
  # Execute a command with stdin input using PowerShell
204
308
  #
205
- # Uses PowerShell's -Command flag to execute the command string.
206
- # The executable and arguments are quoted individually and passed to
207
- # PowerShell's call operator &.
309
+ # Uses platform-specific execution with stdin redirection.
208
310
  #
209
311
  # @param executable [String] the executable path
210
312
  # @param args [Array<String>] command arguments
@@ -215,73 +317,91 @@ module Ukiryu
215
317
  # @return [Hash] execution result with :status, :stdout, :stderr keys
216
318
  # @raise [Timeout::Error] if command times out
217
319
  def execute_command_with_stdin(executable, args, env, timeout, cwd, stdin_data)
218
- # Build the command with proper quoting for each element
219
- # Use double quotes for PowerShell -Command (works better than single quotes)
220
- exe_quoted = %("#{escape(executable)}")
221
-
222
- # Quote each argument - only quote if it contains special chars or spaces
223
- args_quoted = args.map do |a|
224
- if needs_quoting?(a)
225
- %("#{escape(a)}")
226
- else
227
- 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)
228
327
  end
229
- end
328
+ stdin_file.close
230
329
 
231
- # Build PowerShell command: & "executable" "arg1" "arg2" ...
232
- # Append "; exit $LASTEXITCODE" to properly propagate exit codes from
233
- # the invoked command/program back through the PowerShell wrapper
234
- ps_command_base = if args_quoted.empty?
235
- "& #{exe_quoted}"
236
- else
237
- "& #{exe_quoted} #{args_quoted.join(' ')}"
238
- end
239
- ps_command = "#{ps_command_base}; exit $LASTEXITCODE"
330
+ stdin_path = stdin_file.path
240
331
 
241
- # Convert Environment to Hash ONLY at Open3 call site
242
- 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("'", "''")
243
338
 
244
- # Execute using PowerShell's -Command flag
245
- Timeout.timeout(timeout) do
246
- execution = lambda do
247
- Open3.popen3(env_hash, powershell_command, '-NoLogo', '-Command',
248
- ps_command) do |stdin, stdout, stderr, wait_thr|
249
- # Write stdin data
250
- begin
251
- if stdin_data.is_a?(IO)
252
- IO.copy_stream(stdin_data, stdin)
253
- elsif stdin_data.is_a?(String)
254
- stdin.write(stdin_data)
255
- end
256
- rescue Errno::EPIPE
257
- # Process closed stdin early
258
- ensure
259
- 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
260
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("'", "''")
261
364
 
262
- # Read output
263
- out = stdout.read
264
- err = stderr.read
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
374
+ end
375
+ end
376
+
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"
265
382
 
266
- # Wait for process to complete
267
- status = wait_thr.value
383
+ env_hash = environment_to_h(env)
268
384
 
385
+ Timeout.timeout(timeout) do
386
+ execution = lambda do
387
+ stdout, stderr, status = Open3.capture3(env_hash, powershell_command, '-NoLogo', '-Command', ps_command)
269
388
  {
270
389
  status: Ukiryu::Executor.extract_status(status),
271
- stdout: out,
272
- stderr: err
390
+ stdout: stdout,
391
+ stderr: stderr
273
392
  }
274
393
  end
275
- end
276
394
 
277
- if cwd
278
- Dir.chdir(cwd) { execution.call }
279
- else
280
- execution.call
395
+ if cwd
396
+ Dir.chdir(cwd) { execution.call }
397
+ else
398
+ execution.call
399
+ end
281
400
  end
401
+ ensure
402
+ stdin_file.unlink
282
403
  end
283
404
  rescue Timeout::Error, Timeout::ExitException
284
- # Re-raise with context
285
405
  raise Timeout::Error, "Command timed out after #{timeout}s: #{executable}"
286
406
  end
287
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