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.
- checksums.yaml +7 -0
- data/.github/workflows/test.yml +143 -0
- data/.gitignore +8 -0
- data/.gitmodules +3 -0
- data/Gemfile +17 -0
- data/README.adoc +295 -0
- data/Rakefile +8 -0
- data/lib/ukiryu/cli.rb +348 -0
- data/lib/ukiryu/errors.rb +30 -0
- data/lib/ukiryu/executor.rb +716 -0
- data/lib/ukiryu/platform.rb +93 -0
- data/lib/ukiryu/registry.rb +246 -0
- data/lib/ukiryu/schema_validator.rb +103 -0
- data/lib/ukiryu/shell/base.rb +79 -0
- data/lib/ukiryu/shell/bash.rb +60 -0
- data/lib/ukiryu/shell/cmd.rb +75 -0
- data/lib/ukiryu/shell/fish.rb +16 -0
- data/lib/ukiryu/shell/powershell.rb +60 -0
- data/lib/ukiryu/shell/sh.rb +16 -0
- data/lib/ukiryu/shell/zsh.rb +16 -0
- data/lib/ukiryu/shell.rb +164 -0
- data/lib/ukiryu/tool.rb +439 -0
- data/lib/ukiryu/type.rb +254 -0
- data/lib/ukiryu/version.rb +5 -0
- data/lib/ukiryu.rb +54 -0
- data/ukiryu.gemspec +33 -0
- metadata +72 -0
|
@@ -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
|
data/lib/ukiryu/shell.rb
ADDED
|
@@ -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
|
data/lib/ukiryu/tool.rb
ADDED
|
@@ -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
|