ukiryu 0.1.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.
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Ukiryu
6
+ module Shell
7
+ # PowerShell shell implementation
8
+ #
9
+ # PowerShell uses single quotes for literal strings and backtick
10
+ # for escaping special characters inside double quotes.
11
+ # Environment variables are referenced with $ENV:NAME syntax.
12
+ class PowerShell < Base
13
+ def name
14
+ :powershell
15
+ end
16
+
17
+ # Escape a string for PowerShell
18
+ # Backtick is the escape character for: ` $ "
19
+ #
20
+ # @param string [String] the string to escape
21
+ # @return [String] the escaped string
22
+ def escape(string)
23
+ string.to_s.gsub(/[`"$]/) { "`$&" }
24
+ end
25
+
26
+ # Quote an argument for PowerShell
27
+ # Uses single quotes for literal strings
28
+ #
29
+ # @param string [String] the string to quote
30
+ # @return [String] the quoted string
31
+ def quote(string)
32
+ "'#{escape(string)}'"
33
+ end
34
+
35
+ # Format an environment variable reference
36
+ #
37
+ # @param name [String] the variable name
38
+ # @return [String] the formatted reference ($ENV:NAME)
39
+ def env_var(name)
40
+ "$ENV:#{name}"
41
+ end
42
+
43
+ # Join executable and arguments into a command line
44
+ #
45
+ # @param executable [String] the executable path
46
+ # @param args [Array<String>] the arguments
47
+ # @return [String] the complete command line
48
+ def join(executable, *args)
49
+ [executable, *args.map { |a| quote(a) }].join(" ")
50
+ end
51
+
52
+ # PowerShell doesn't need DISPLAY variable
53
+ #
54
+ # @return [Hash] empty hash (no headless environment needed)
55
+ def headless_environment
56
+ {}
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bash"
4
+
5
+ module Ukiryu
6
+ module Shell
7
+ # POSIX sh shell implementation
8
+ #
9
+ # sh uses the same quoting and escaping rules as Bash.
10
+ class Sh < Bash
11
+ def name
12
+ :sh
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bash"
4
+
5
+ module Ukiryu
6
+ module Shell
7
+ # Zsh shell implementation
8
+ #
9
+ # Zsh uses the same quoting and escaping rules as Bash.
10
+ class Zsh < Bash
11
+ def name
12
+ :zsh
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shell/base"
4
+
5
+ module Ukiryu
6
+ # Shell detection and management
7
+ #
8
+ # Provides EXPLICIT shell detection with no fallbacks.
9
+ # If shell cannot be determined, raises a clear error.
10
+ module Shell
11
+ class << self
12
+ # Get or set the current shell (for explicit configuration)
13
+ attr_writer :current_shell
14
+
15
+ # Detect the current shell
16
+ #
17
+ # @return [Symbol] :bash, :zsh, :fish, :sh, :powershell, or :cmd
18
+ # @raise [UnknownShellError] if shell cannot be determined
19
+ def detect
20
+ # Return explicitly configured shell if set
21
+ return @current_shell if @current_shell
22
+
23
+ # Detect based on platform and environment
24
+ if Platform.windows?
25
+ detect_windows_shell
26
+ else
27
+ detect_unix_shell
28
+ end
29
+ end
30
+
31
+ # Get the shell class for the detected/configured shell
32
+ #
33
+ # @return [Shell::Base] the shell implementation
34
+ def shell_class
35
+ @shell_class ||= begin
36
+ shell_name = detect
37
+ class_for(shell_name)
38
+ end
39
+ end
40
+
41
+ # Reset cached shell detection (mainly for testing)
42
+ #
43
+ # @api private
44
+ def reset
45
+ @current_shell = nil
46
+ @shell_class = nil
47
+ end
48
+
49
+ # Get shell class by name
50
+ #
51
+ # @param name [Symbol] the shell name
52
+ # @return [Class] the shell class
53
+ # @raise [UnknownShellError] if shell class not found
54
+ def class_for(name)
55
+ case name
56
+ when :bash
57
+ require_relative "shell/bash"
58
+ Bash
59
+ when :zsh
60
+ require_relative "shell/zsh"
61
+ Zsh
62
+ when :fish
63
+ require_relative "shell/fish"
64
+ Fish
65
+ when :sh
66
+ require_relative "shell/sh"
67
+ Sh
68
+ when :powershell
69
+ require_relative "shell/powershell"
70
+ PowerShell
71
+ when :cmd
72
+ require_relative "shell/cmd"
73
+ Cmd
74
+ else
75
+ raise UnknownShellError, "Unknown shell: #{name}"
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ # Detect shell on Windows
82
+ #
83
+ # @return [Symbol] detected shell
84
+ def detect_windows_shell
85
+ # PowerShell check
86
+ return :powershell if ENV["PSModulePath"]
87
+
88
+ # Git Bash / MSYS check
89
+ return :bash if ENV["MSYSTEM"] || ENV["MINGW_PREFIX"]
90
+
91
+ # WSL check
92
+ return :bash if ENV["WSL_DISTRO"]
93
+
94
+ # Default to cmd on Windows
95
+ :cmd
96
+ end
97
+
98
+ # Detect shell on Unix-like systems
99
+ #
100
+ # @return [Symbol] detected shell
101
+ def detect_unix_shell
102
+ shell_env = ENV["SHELL"]
103
+
104
+ # Try to determine from SHELL environment variable
105
+ if shell_env
106
+ return :bash if shell_env.end_with?("bash")
107
+ return :zsh if shell_env.end_with?("zsh")
108
+ return :fish if shell_env.end_with?("fish")
109
+ return :sh if shell_env.end_with?("sh")
110
+
111
+ # Try to determine from executable name
112
+ shell_name = File.basename(shell_env)
113
+ case shell_name
114
+ when "bash"
115
+ :bash
116
+ when "zsh"
117
+ :zsh
118
+ when "fish"
119
+ :fish
120
+ when "sh"
121
+ :sh
122
+ else
123
+ # Unknown shell in ENV - check if executable
124
+ if File.executable?(shell_env)
125
+ # Return as symbol for custom shell
126
+ shell_name.to_sym
127
+ else
128
+ raise UnknownShellError, unknown_shell_error_msg("Unknown shell in SHELL: #{shell_env}")
129
+ end
130
+ end
131
+ else
132
+ raise UnknownShellError, unknown_shell_error_msg("SHELL environment variable not set")
133
+ end
134
+ end
135
+
136
+ # Generate error message for unknown shell
137
+ #
138
+ # @param reason [String] the reason for failure
139
+ # @return [String] formatted error message
140
+ def unknown_shell_error_msg(reason)
141
+ <<~ERROR
142
+ #{reason}
143
+
144
+ Unable to detect shell automatically.
145
+
146
+ Supported shells:
147
+ Unix/macOS/Linux: bash, zsh, fish, sh
148
+ Windows: powershell, cmd, bash (Git Bash/MSYS)
149
+
150
+ Please configure explicitly:
151
+
152
+ Ukiryu.configure do |config|
153
+ config.default_shell = :bash # or :zsh, :powershell, :cmd
154
+ end
155
+
156
+ Current environment:
157
+ Platform: #{RbConfig::CONFIG['host_os']}
158
+ SHELL: #{ENV['SHELL']}
159
+ PSModulePath: #{ENV['PSModulePath']}
160
+ ERROR
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,439 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "registry"
4
+ require_relative "executor"
5
+ require_relative "shell"
6
+
7
+ module Ukiryu
8
+ # Tool wrapper class for external command-line tools
9
+ #
10
+ # Provides a Ruby interface to external CLI tools defined in YAML profiles.
11
+ class Tool
12
+ class << self
13
+ # Registered tools cache
14
+ attr_reader :tools
15
+
16
+ # Get a tool by name
17
+ #
18
+ # @param name [String] the tool name
19
+ # @param options [Hash] initialization options
20
+ # @option options [String] :registry_path path to tool profiles
21
+ # @option options [Symbol] :platform platform to use
22
+ # @option options [Symbol] :shell shell to use
23
+ # @option options [String] :version specific version to use
24
+ # @return [Tool] the tool instance
25
+ def get(name, options = {})
26
+ # Check cache first
27
+ cache_key = cache_key_for(name, options)
28
+ return @tools[cache_key] if @tools && @tools[cache_key]
29
+
30
+ # Load profile from registry
31
+ profile = load_profile(name, options)
32
+ raise ToolNotFoundError, "Tool not found: #{name}" unless profile
33
+
34
+ # Create tool instance
35
+ tool = new(profile, options)
36
+ @tools ||= {}
37
+ @tools[cache_key] = tool
38
+ tool
39
+ end
40
+
41
+ # Clear the tool cache
42
+ #
43
+ # @api private
44
+ def clear_cache
45
+ @tools = nil
46
+ end
47
+
48
+ # Configure default options
49
+ #
50
+ # @param options [Hash] default options
51
+ def configure(options = {})
52
+ @default_options ||= {}
53
+ @default_options.merge!(options)
54
+ end
55
+
56
+ private
57
+
58
+ # Generate a cache key for a tool
59
+ def cache_key_for(name, options)
60
+ platform = options[:platform] || Platform.detect
61
+ shell = options[:shell] || Shell.detect
62
+ version = options[:version] || "latest"
63
+ "#{name}-#{platform}-#{shell}-#{version}"
64
+ end
65
+
66
+ # Load a profile for a tool
67
+ def load_profile(name, options)
68
+ registry_path = options[:registry_path] || Registry.default_registry_path
69
+
70
+ if registry_path && Dir.exist?(registry_path)
71
+ Registry.load_tool(name, options)
72
+ else
73
+ # Fall back to built-in profiles if available
74
+ load_builtin_profile(name, options)
75
+ end
76
+ end
77
+
78
+ # Load a built-in profile
79
+ def load_builtin_profile(name, options)
80
+ # This will be extended with bundled profiles
81
+ nil
82
+ end
83
+ end
84
+
85
+ # Create a new Tool instance
86
+ #
87
+ # @param profile [Hash] the tool profile
88
+ # @param options [Hash] initialization options
89
+ def initialize(profile, options = {})
90
+ @profile = profile
91
+ @options = options
92
+ @platform = options[:platform] || Platform.detect
93
+ @shell = options[:shell] || Shell.detect
94
+ @version = options[:version]
95
+
96
+ # Find compatible profile
97
+ @command_profile = find_command_profile
98
+ raise ProfileNotFoundError, "No compatible profile for #{name}" unless @command_profile
99
+
100
+ # Find executable
101
+ @executable = find_executable
102
+ end
103
+
104
+ # Get the raw profile data
105
+ #
106
+ # @return [Hash] the tool profile
107
+ attr_reader :profile
108
+
109
+ # Get the tool name
110
+ #
111
+ # @return [String] the tool name
112
+ def name
113
+ @profile[:name]
114
+ end
115
+
116
+ # Get the tool version
117
+ #
118
+ # @return [String, nil] the tool version
119
+ def version
120
+ @version || detect_version
121
+ end
122
+
123
+ # Get the executable path
124
+ #
125
+ # @return [String] the executable path
126
+ def executable
127
+ @executable
128
+ end
129
+
130
+ # Check if the tool is available
131
+ #
132
+ # @return [Boolean]
133
+ def available?
134
+ !@executable.nil?
135
+ end
136
+
137
+ # Get the commands defined in the active profile
138
+ #
139
+ # @return [Hash, nil] the commands hash
140
+ def commands
141
+ @command_profile[:commands]
142
+ end
143
+
144
+ # Execute a command defined in the profile
145
+ #
146
+ # @param command_name [Symbol] the command to execute
147
+ # @param params [Hash] command parameters
148
+ # @return [Executor::Result] the execution result
149
+ def execute(command_name, params = {})
150
+ command = @command_profile[:commands][command_name.to_s] ||
151
+ @command_profile[:commands][command_name.to_sym]
152
+
153
+ raise ArgumentError, "Unknown command: #{command_name}" unless command
154
+
155
+ # Build command arguments
156
+ args = build_args(command, params)
157
+
158
+ # Execute with environment
159
+ Executor.execute(
160
+ @executable,
161
+ args,
162
+ env: build_env_vars(command, params),
163
+ timeout: @profile[:timeout] || 90,
164
+ shell: @shell
165
+ )
166
+ end
167
+
168
+ # Check if a command is available
169
+ #
170
+ # @param command_name [Symbol] the command name
171
+ # @return [Boolean]
172
+ def command?(command_name)
173
+ cmd = @command_profile[:commands][command_name.to_s] ||
174
+ @command_profile[:commands][command_name.to_sym]
175
+ !cmd.nil?
176
+ end
177
+
178
+ private
179
+
180
+ # Find the best matching command profile
181
+ def find_command_profile
182
+ return @profile[:profiles].first if @profile[:profiles].one?
183
+
184
+ @profile[:profiles].find do |p|
185
+ platforms = p[:platforms] || p[:platform]
186
+ shells = p[:shells] || p[:shell]
187
+
188
+ # Convert array elements to symbols for comparison
189
+ # (YAML arrays contain strings, but platform/shell are symbols)
190
+ platform_match = platforms.nil? || platforms.map(&:to_sym).include?(@platform)
191
+ shell_match = shells.nil? || shells.map(&:to_sym).include?(@shell)
192
+
193
+ platform_match && shell_match
194
+ end
195
+ end
196
+
197
+ # Find the executable path
198
+ def find_executable
199
+ # Try primary name first
200
+ exe = try_find_executable(@profile[:name])
201
+ return exe if exe
202
+
203
+ # Try aliases
204
+ aliases = @profile[:aliases] || []
205
+ aliases.each do |alias_name|
206
+ exe = try_find_executable(alias_name)
207
+ return exe if exe
208
+ end
209
+
210
+ nil
211
+ end
212
+
213
+ # Try to find an executable by name
214
+ def try_find_executable(command)
215
+ # Check custom search paths first
216
+ search_paths = custom_search_paths
217
+ unless search_paths.empty?
218
+ search_paths.each do |path_pattern|
219
+ paths = Dir.glob(path_pattern)
220
+ paths.each do |path|
221
+ return path if File.executable?(path) && !File.directory?(path)
222
+ end
223
+ end
224
+ end
225
+
226
+ # Fall back to PATH
227
+ Executor.find_executable(command)
228
+ end
229
+
230
+ # Get custom search paths from profile
231
+ def custom_search_paths
232
+ return [] unless @profile[:search_paths]
233
+
234
+ case @platform
235
+ when :windows
236
+ @profile[:search_paths][:windows] || []
237
+ when :macos
238
+ @profile[:search_paths][:macos] || []
239
+ else
240
+ [] # Unix: rely on PATH only
241
+ end
242
+ end
243
+
244
+ # Detect tool version
245
+ def detect_version
246
+ return nil unless @profile[:version_detection]
247
+
248
+ vd = @profile[:version_detection]
249
+ cmd = vd[:command] || "--version"
250
+
251
+ result = Executor.execute(@executable, [cmd], shell: @shell)
252
+
253
+ if result.success?
254
+ pattern = vd[:pattern] || /(\d+\.\d+)/
255
+ match = result.stdout.match(pattern) || result.stderr.match(pattern)
256
+ match[1] if match
257
+ end
258
+ end
259
+
260
+ # Build command arguments from parameters
261
+ def build_args(command, params)
262
+ args = []
263
+
264
+ # Add subcommand prefix if present (e.g., for ImageMagick "magick convert")
265
+ if command[:subcommand]
266
+ args << command[:subcommand]
267
+ end
268
+
269
+ # Add options first (before arguments)
270
+ (command[:options] || []).each do |opt_def|
271
+ # Convert name to symbol for params lookup
272
+ param_key = opt_def[:name].is_a?(String) ? opt_def[:name].to_sym : opt_def[:name]
273
+ next unless params.key?(param_key)
274
+ next if params[param_key].nil?
275
+
276
+ formatted_opt = format_option(opt_def, params[param_key])
277
+ Array(formatted_opt).each { |opt| args << opt unless opt.nil? || opt.empty? }
278
+ end
279
+
280
+ # Add flags
281
+ (command[:flags] || []).each do |flag_def|
282
+ # Convert name to symbol for params lookup
283
+ param_key = flag_def[:name].is_a?(String) ? flag_def[:name].to_sym : flag_def[:name]
284
+ value = params[param_key]
285
+ value = flag_def[:default] if value.nil?
286
+
287
+ formatted_flag = format_flag(flag_def, value)
288
+ Array(formatted_flag).each { |flag| args << flag unless flag.nil? || flag.empty? }
289
+ end
290
+
291
+ # Separate "last" positioned argument from other arguments
292
+ arguments = command[:arguments] || []
293
+ last_arg = arguments.find { |a| a[:position] == "last" || a[:position] == :last }
294
+ regular_args = arguments.reject { |a| a[:position] == "last" || a[:position] == :last }
295
+
296
+ # Add regular positional arguments (in order, excluding "last")
297
+ regular_args.sort_by do |a|
298
+ pos = a[:position]
299
+ pos.is_a?(Integer) ? pos : (pos || 99)
300
+ end.each do |arg_def|
301
+ # Convert name to symbol for params lookup (YAML uses strings, Ruby uses symbols)
302
+ param_key = arg_def[:name].is_a?(String) ? arg_def[:name].to_sym : arg_def[:name]
303
+ next unless params.key?(param_key)
304
+
305
+ value = params[param_key]
306
+ next if value.nil?
307
+
308
+ if arg_def[:variadic]
309
+ # Variadic argument - expand array
310
+ array = Type.validate(value, :array, arg_def)
311
+ array.each { |v| args << format_arg(v, arg_def) }
312
+ else
313
+ args << format_arg(value, arg_def)
314
+ end
315
+ end
316
+
317
+ # Add post_options (options that come before the "last" argument)
318
+ (command[:post_options] || []).each do |opt_def|
319
+ # Convert name to symbol for params lookup
320
+ param_key = opt_def[:name].is_a?(String) ? opt_def[:name].to_sym : opt_def[:name]
321
+ next unless params.key?(param_key)
322
+ next if params[param_key].nil?
323
+
324
+ formatted_opt = format_option(opt_def, params[param_key])
325
+ Array(formatted_opt).each { |opt| args << opt unless opt.nil? || opt.empty? }
326
+ end
327
+
328
+ # Add the "last" positioned argument (typically output file)
329
+ if last_arg
330
+ param_key = last_arg[:name].is_a?(String) ? last_arg[:name].to_sym : last_arg[:name]
331
+ if params.key?(param_key) && !params[param_key].nil?
332
+ if last_arg[:variadic]
333
+ array = Type.validate(params[param_key], :array, last_arg)
334
+ array.each { |v| args << format_arg(v, last_arg) }
335
+ else
336
+ args << format_arg(params[param_key], last_arg)
337
+ end
338
+ end
339
+ end
340
+
341
+ args
342
+ end
343
+
344
+ # Format a positional argument
345
+ def format_arg(value, arg_def)
346
+ # Validate type
347
+ Type.validate(value, arg_def[:type] || :string, arg_def)
348
+
349
+ # Apply platform-specific path formatting
350
+ if arg_def[:type] == :file
351
+ shell_class = Shell.class_for(@shell)
352
+ shell_class.new.format_path(value.to_s)
353
+ else
354
+ value.to_s
355
+ end
356
+ end
357
+
358
+ # Format an option
359
+ def format_option(opt_def, value)
360
+ # Validate type
361
+ Type.validate(value, opt_def[:type] || :string, opt_def)
362
+
363
+ # Handle boolean types - just return the CLI flag (no value)
364
+ type_val = opt_def[:type]
365
+ if type_val == :boolean || type_val == TrueClass || type_val == "boolean"
366
+ return nil if value.nil? || value == false
367
+ return opt_def[:cli] || ""
368
+ end
369
+
370
+ cli = opt_def[:cli] || ""
371
+ format = opt_def[:format] || "double_dash_equals"
372
+ format_sym = format.is_a?(String) ? format.to_sym : format
373
+ separator = opt_def[:separator] || "="
374
+
375
+ # Convert value to string (handle symbols)
376
+ value_str = value.is_a?(Symbol) ? value.to_s : value.to_s
377
+
378
+ # Handle array values with separator
379
+ if value.is_a?(Array) && opt_def[:separator]
380
+ joined = value.join(opt_def[:separator])
381
+ case format_sym
382
+ when :double_dash_equals
383
+ "#{cli}#{joined}"
384
+ when :double_dash_space, :single_dash_space
385
+ [cli, joined] # Return array for space-separated
386
+ when :single_dash_equals
387
+ "#{cli}#{joined}"
388
+ else
389
+ "#{cli}#{joined}"
390
+ end
391
+ else
392
+ case format_sym
393
+ when :double_dash_equals
394
+ "#{cli}#{value_str}"
395
+ when :double_dash_space, :single_dash_space
396
+ [cli, value_str] # Return array for space-separated
397
+ when :single_dash_equals
398
+ "#{cli}#{value_str}"
399
+ when :slash_colon
400
+ "#{cli}:#{value_str}"
401
+ when :slash_space
402
+ "#{cli} #{value_str}"
403
+ else
404
+ "#{cli}#{value_str}"
405
+ end
406
+ end
407
+ end
408
+
409
+ # Format a flag
410
+ def format_flag(flag_def, value)
411
+ return nil if value.nil? || value == false
412
+
413
+ flag_def[:cli] || ""
414
+ end
415
+
416
+ # Build environment variables for command
417
+ def build_env_vars(command, params)
418
+ env_vars = {}
419
+
420
+ (command[:env_vars] || []).each do |ev|
421
+ # Check platform restriction
422
+ platforms = ev[:platforms] || ev[:platform]
423
+ next if platforms && !platforms.include?(@platform)
424
+
425
+ # Get value - use ev[:value] if provided, or extract from params
426
+ value = if ev.key?(:value)
427
+ ev[:value]
428
+ elsif ev[:from]
429
+ params[ev[:from].to_sym]
430
+ end
431
+
432
+ # Set the environment variable if value is defined (including empty string)
433
+ env_vars[ev[:name]] = value.to_s unless value.nil?
434
+ end
435
+
436
+ env_vars
437
+ end
438
+ end
439
+ end