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,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ukiryu
4
+ # Platform detection module
5
+ #
6
+ # Provides explicit platform detection with clear error messages.
7
+ # No automatic fallbacks - if platform cannot be determined, raises an error.
8
+ module Platform
9
+ class << self
10
+ # Detect the current platform
11
+ #
12
+ # @return [Symbol] :windows, :macos, or :linux
13
+ # @raise [UnsupportedPlatformError] if platform cannot be determined
14
+ def detect
15
+ if windows?
16
+ :windows
17
+ elsif macos?
18
+ :macos
19
+ elsif linux?
20
+ :linux
21
+ else
22
+ # Try to determine from RbConfig
23
+ host_os = RbConfig::CONFIG["host_os"]
24
+ case host_os
25
+ when /mswin|mingw|windows/i
26
+ :windows
27
+ when /darwin|mac os/i
28
+ :macos
29
+ when /linux/i
30
+ :linux
31
+ else
32
+ raise UnsupportedPlatformError, <<~ERROR
33
+ Unable to detect platform. Host OS: #{host_os}
34
+
35
+ Supported platforms: Windows, macOS, Linux
36
+
37
+ Please configure platform explicitly:
38
+ Ukiryu.configure do |config|
39
+ config.platform = :linux # or :macos, :windows
40
+ end
41
+ ERROR
42
+ end
43
+ end
44
+ end
45
+
46
+ # Check if running on Windows
47
+ #
48
+ # @return [Boolean]
49
+ def windows?
50
+ Gem.win_platform? || RbConfig::CONFIG["host_os"] =~ /mswin|mingw|windows/i
51
+ end
52
+
53
+ # Check if running on macOS
54
+ #
55
+ # @return [Boolean]
56
+ def macos?
57
+ RbConfig::CONFIG["host_os"] =~ /darwin|mac os/i
58
+ end
59
+
60
+ # Check if running on Linux
61
+ #
62
+ # @return [Boolean]
63
+ def linux?
64
+ RbConfig::CONFIG["host_os"] =~ /linux/i
65
+ end
66
+
67
+ # Check if running on a Unix-like system (macOS or Linux)
68
+ #
69
+ # @return [Boolean]
70
+ def unix?
71
+ macos? || linux?
72
+ end
73
+
74
+ # Get the PATH environment variable as an array
75
+ # Handles different PATH separators on Windows (;) vs Unix (:)
76
+ #
77
+ # @return [Array<String>] array of directory paths
78
+ def executable_search_paths
79
+ @executable_search_paths ||= begin
80
+ path_sep = windows? ? ";" : ":"
81
+ (ENV["PATH"] || "").split(path_sep)
82
+ end
83
+ end
84
+
85
+ # Reset cached paths (primarily for testing)
86
+ #
87
+ # @api private
88
+ def reset_cache
89
+ @executable_search_paths = nil
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "find"
5
+
6
+ module Ukiryu
7
+ # YAML profile registry loader
8
+ #
9
+ # Loads tool definitions from YAML profiles in a registry directory.
10
+ class Registry
11
+ class << self
12
+ # Load all tool profiles from a registry directory
13
+ #
14
+ # @param path [String] the registry directory path
15
+ # @param options [Hash] loading options
16
+ # @option options [Boolean] :recursive search recursively (default: true)
17
+ # @option options [Boolean] :validate validate against schema (default: false)
18
+ # @return [Hash] loaded tools keyed by name
19
+ def load_from(path, options = {})
20
+ raise ProfileLoadError, "Registry path not found: #{path}" unless Dir.exist?(path)
21
+
22
+ tools = {}
23
+ recursive = options.fetch(:recursive, true)
24
+
25
+ pattern = recursive ? "**/*.yaml" : "*.yaml"
26
+ files = Dir.glob(File.join(path, "tools", pattern))
27
+
28
+ files.each do |file|
29
+ begin
30
+ profile = load_profile(file)
31
+ name = profile[:name]
32
+ tools[name] ||= []
33
+ tools[name] << profile
34
+ rescue => e
35
+ warn "Warning: Failed to load profile #{file}: #{e.message}"
36
+ end
37
+ end
38
+
39
+ tools
40
+ end
41
+
42
+ # Load a single profile file
43
+ #
44
+ # @param file [String] the path to the YAML file
45
+ # @return [Hash] the loaded profile
46
+ def load_profile(file)
47
+ content = File.read(file)
48
+ profile = YAML.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: true)
49
+
50
+ # Convert string keys to symbols for consistency
51
+ profile = symbolize_keys(profile)
52
+
53
+ # Resolve inheritance if present
54
+ resolve_inheritance(profile, file)
55
+
56
+ profile
57
+ end
58
+
59
+ # Load a specific tool by name
60
+ #
61
+ # @param name [String] the tool name
62
+ # @param options [Hash] loading options
63
+ # @option options [String] :version specific version to load
64
+ # @option options [String] :registry_path path to registry
65
+ # @return [Hash, nil] the tool profile or nil if not found
66
+ def load_tool(name, options = {})
67
+ registry_path = options[:registry_path] || @default_registry_path
68
+
69
+ raise ProfileLoadError, "Registry path not configured" unless registry_path
70
+
71
+ # Try version-specific directory first
72
+ version = options[:version]
73
+ if version
74
+ file = File.join(registry_path, "tools", name, "#{version}.yaml")
75
+ return load_profile(file) if File.exist?(file)
76
+ end
77
+
78
+ # Search in all matching files
79
+ pattern = File.join(registry_path, "tools", name, "*.yaml")
80
+ files = Dir.glob(pattern).sort
81
+
82
+ if files.empty?
83
+ # Try the old format (single file per tool)
84
+ file = File.join(registry_path, "tools", "#{name}.yaml")
85
+ return load_profile(file) if File.exist?(file)
86
+ return nil
87
+ end
88
+
89
+ # Return the latest version if version not specified
90
+ if version.nil?
91
+ # Sort by version and return the newest
92
+ sorted_files = files.sort_by { |f| Gem::Version.new(File.basename(f, ".yaml")) }
93
+ load_profile(sorted_files.last)
94
+ else
95
+ # Find specific version
96
+ version_file = files.find { |f| File.basename(f, ".yaml") == version }
97
+ version_file ? load_profile(version_file) : nil
98
+ end
99
+ end
100
+
101
+ # Set the default registry path
102
+ #
103
+ # @param path [String] the default registry path
104
+ def default_registry_path=(path)
105
+ @default_registry_path = path
106
+ end
107
+
108
+ # Get the default registry path
109
+ #
110
+ # @return [String, nil] the default registry path
111
+ def default_registry_path
112
+ @default_registry_path
113
+ end
114
+
115
+ # Get all available tool names
116
+ #
117
+ # @return [Array<String>] list of tool names
118
+ def tools
119
+ registry_path = @default_registry_path
120
+ return [] unless registry_path
121
+
122
+ tools_dir = File.join(registry_path, "tools")
123
+ return [] unless Dir.exist?(tools_dir)
124
+
125
+ # List all directories in tools/
126
+ Dir.glob(File.join(tools_dir, "*")).select do |path|
127
+ File.directory?(path)
128
+ end.map do |path|
129
+ File.basename(path)
130
+ end.sort
131
+ end
132
+
133
+ # Find the newest compatible version of a tool
134
+ #
135
+ # @param tool_name [String] the tool name
136
+ # @param options [Hash] search options
137
+ # @option options [String] :platform platform (default: auto-detect)
138
+ # @option options [String] :shell shell (default: auto-detect)
139
+ # @option options [String] :version_constraint version constraint
140
+ # @return [Hash, nil] the best matching profile or nil
141
+ def find_compatible_profile(tool_name, options = {})
142
+ profiles = load_tool_profiles(tool_name)
143
+ return nil if profiles.nil? || profiles.empty?
144
+
145
+ platform = options[:platform] || Platform.detect
146
+ shell = options[:shell] || Shell.detect
147
+ version = options[:version]
148
+
149
+ # Filter by platform and shell
150
+ candidates = profiles.select do |profile|
151
+ profile_platforms = profile[:platforms] || profile[:platform]
152
+ profile_shells = profile[:shells] || profile[:shell]
153
+
154
+ platform_match = profile_platforms.include?(platform) if profile_platforms
155
+ shell_match = profile_shells.include?(shell) if profile_shells
156
+
157
+ (platform_match || profile_platforms.nil?) && (shell_match || profile_shells.nil?)
158
+ end
159
+
160
+ # Further filter by version if specified
161
+ if version && !candidates.empty?
162
+ constraint = Gem::Requirement.new(version)
163
+ candidates.select! do |profile|
164
+ profile_version = profile[:version]
165
+ next true unless profile_version
166
+
167
+ constraint.satisfied_by?(Gem::Version.new(profile_version))
168
+ end
169
+ end
170
+
171
+ # Return the first matching profile (prefer newer versions)
172
+ candidates.first
173
+ end
174
+
175
+ private
176
+
177
+ # Load all profiles for a specific tool
178
+ def load_tool_profiles(name)
179
+ registry_path = @default_registry_path
180
+ return nil unless registry_path
181
+
182
+ pattern = File.join(registry_path, "tools", name, "*.yaml")
183
+ files = Dir.glob(pattern)
184
+
185
+ return nil if files.empty?
186
+
187
+ files.flat_map do |file|
188
+ begin
189
+ profile = load_profile(file)
190
+ profile[:_file_path] = file
191
+ profile
192
+ rescue => e
193
+ warn "Warning: Failed to load profile #{file}: #{e.message}"
194
+ []
195
+ end
196
+ end
197
+ end
198
+
199
+ # Resolve profile inheritance
200
+ #
201
+ # @param profile [Hash] the profile to resolve
202
+ # @param file_path [String] the path to the profile file
203
+ def resolve_inheritance(profile, file_path)
204
+ return unless profile[:profiles]
205
+
206
+ base_dir = File.dirname(file_path)
207
+
208
+ profile[:profiles].each do |p|
209
+ next unless p[:inherits]
210
+
211
+ parent_profile = find_parent_profile(p[:inherits], profile[:profiles], base_dir)
212
+ if parent_profile
213
+ # Merge parent into child (child takes precedence)
214
+ p.merge!(parent_profile) { |_, child_val, _| child_val }
215
+ end
216
+ end
217
+ end
218
+
219
+ # Find a parent profile within the same file
220
+ def find_parent_profile(name, profiles, _base_dir)
221
+ profiles.find { |p| p[:name] == name }
222
+ end
223
+
224
+ # Recursively convert string keys to symbols
225
+ #
226
+ # @param hash [Hash] the hash to convert
227
+ # @return [Hash] the hash with symbolized keys
228
+ def symbolize_keys(hash)
229
+ return hash unless hash.is_a?(Hash)
230
+
231
+ hash.transform_keys do |key|
232
+ key.is_a?(String) ? key.to_sym : key
233
+ end.transform_values do |value|
234
+ case value
235
+ when Hash
236
+ symbolize_keys(value)
237
+ when Array
238
+ value.map { |v| v.is_a?(Hash) ? symbolize_keys(v) : v }
239
+ else
240
+ value
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ begin
5
+ require "json-schema"
6
+ rescue LoadError
7
+ # json-schema is optional - only needed for schema validation
8
+ end
9
+ require "yaml"
10
+
11
+ module Ukiryu
12
+ # Schema validator for YAML tool profiles
13
+ #
14
+ # Validates tool profile YAML files against JSON Schema definitions.
15
+ class SchemaValidator
16
+ class << self
17
+ # Validate a tool profile against the schema
18
+ #
19
+ # @param profile [Hash] the loaded profile hash
20
+ # @param options [Hash] validation options
21
+ # @option options [String] :schema_path path to schema file
22
+ # @option options [Boolean] :strict whether to use strict validation
23
+ # @return [Array<String>] list of validation errors (empty if valid)
24
+ def validate_profile(profile, options = {})
25
+ # Check if json-schema gem is available
26
+ unless defined?(JSON::Validator)
27
+ return ["json-schema gem not installed. Add 'json-schema' to Gemfile for schema validation."]
28
+ end
29
+
30
+ errors = []
31
+
32
+ # Load the schema
33
+ schema = load_schema(options[:schema_path])
34
+ return ["Failed to load schema"] unless schema
35
+
36
+ # Validate against JSON schema
37
+ begin
38
+ # JSON Schema library expects the data to be a hash
39
+ validation_errors = JSON::Validator.fully_validate(schema, profile, strict: options[:strict] || false)
40
+
41
+ # Convert errors to readable format
42
+ validation_errors.each do |error|
43
+ errors << format_schema_error(error)
44
+ end
45
+ rescue JSON::Schema::ValidationError => e
46
+ errors << "Schema validation error: #{e.message}"
47
+ end
48
+
49
+ errors
50
+ end
51
+
52
+ # Load and parse the JSON schema
53
+ #
54
+ # @param path [String, nil] path to schema file (optional)
55
+ # @return [Hash] the parsed schema
56
+ def load_schema(path = nil)
57
+ schema_path = path || default_schema_path
58
+ return nil unless schema_path && File.exist?(schema_path)
59
+
60
+ schema_content = File.read(schema_path)
61
+ parsed = YAML.safe_load(schema_content)
62
+
63
+ # Convert YAML schema to JSON schema format
64
+ # YAML schema uses $schema, definitions, etc.
65
+ convert_yaml_schema_to_json(parsed)
66
+ end
67
+
68
+ # Get the default schema path
69
+ #
70
+ # @return [String, nil] the default schema path
71
+ def default_schema_path
72
+ # Schema is in the sibling 'schema' directory at the same level as the gem
73
+ # From lib/ukiryu/, we go up to gem root, then to sibling schema/
74
+ gem_root = File.expand_path("../..", __dir__) # ukiryu gem root
75
+ schema_dir = File.expand_path("../schema", gem_root) # src/ukiryu/schema/
76
+ schema_file = File.join(schema_dir, "tool-profile.schema.yaml")
77
+ return schema_file if File.exist?(schema_file)
78
+
79
+ nil
80
+ end
81
+
82
+ private
83
+
84
+ # Convert YAML schema format to JSON schema format
85
+ #
86
+ # @param yaml_schema [Hash] the parsed YAML schema
87
+ # @return [Hash] the converted JSON schema
88
+ def convert_yaml_schema_to_json(yaml_schema)
89
+ # The YAML schema format is very similar to JSON schema
90
+ # Just need to ensure it has the right structure
91
+ yaml_schema
92
+ end
93
+
94
+ # Format a schema error for readability
95
+ #
96
+ # @param error [String] the raw error message
97
+ # @return [String] the formatted error
98
+ def format_schema_error(error)
99
+ error
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ukiryu
4
+ module Shell
5
+ # Base class for shell implementations
6
+ #
7
+ # Each shell implementation must provide:
8
+ # - name: Symbol identifying the shell
9
+ # - escape(string): Escape a string for this shell
10
+ # - quote(string): Quote an argument for this shell
11
+ # - format_path(path): Format a file path for this shell
12
+ # - env_var(name): Format an environment variable reference
13
+ # - join(executable, *args): Join executable and arguments into a command line
14
+ class Base
15
+ # Identify the shell
16
+ #
17
+ # @return [Symbol] the shell name
18
+ def name
19
+ raise NotImplementedError, "#{self.class} must implement #name"
20
+ end
21
+
22
+ # Escape a string for this shell
23
+ #
24
+ # @param string [String] the string to escape
25
+ # @return [String] the escaped string
26
+ def escape(string)
27
+ raise NotImplementedError, "#{self.class} must implement #escape"
28
+ end
29
+
30
+ # Quote an argument for this shell
31
+ #
32
+ # @param string [String] the string to quote
33
+ # @return [String] the quoted string
34
+ def quote(string)
35
+ raise NotImplementedError, "#{self.class} must implement #quote"
36
+ end
37
+
38
+ # Format a file path for this shell
39
+ #
40
+ # @param path [String] the file path
41
+ # @return [String] the formatted path
42
+ def format_path(path)
43
+ path
44
+ end
45
+
46
+ # Format an environment variable reference
47
+ #
48
+ # @param name [String] the variable name
49
+ # @return [String] the formatted reference
50
+ def env_var(name)
51
+ raise NotImplementedError, "#{self.class} must implement #env_var"
52
+ end
53
+
54
+ # Join executable and arguments into a command line
55
+ #
56
+ # @param executable [String] the executable path
57
+ # @param args [Array<String>] the arguments
58
+ # @return [String] the complete command line
59
+ def join(executable, *args)
60
+ raise NotImplementedError, "#{self.class} must implement #join"
61
+ end
62
+
63
+ # Format environment variables for command execution
64
+ #
65
+ # @param env_vars [Hash] environment variables to set
66
+ # @return [Hash] formatted environment variables
67
+ def format_environment(env_vars)
68
+ env_vars
69
+ end
70
+
71
+ # Get headless environment variables (e.g., DISPLAY="" for Unix)
72
+ #
73
+ # @return [Hash] environment variables for headless operation
74
+ def headless_environment
75
+ {}
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Ukiryu
6
+ module Shell
7
+ # Bash shell implementation
8
+ #
9
+ # Bash uses single quotes for literal strings and backslash for escaping.
10
+ # Environment variables are referenced with $VAR syntax.
11
+ class Bash < Base
12
+ def name
13
+ :bash
14
+ end
15
+
16
+ # Escape a string for Bash
17
+ # Single quotes are literal (no escaping inside), so we end the quote,
18
+ # add an escaped quote, and restart the quote.
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 Bash
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 ($VAR)
39
+ def env_var(name)
40
+ "$#{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
+ # Get headless environment (disable DISPLAY on Unix)
53
+ #
54
+ # @return [Hash] environment variables for headless operation
55
+ def headless_environment
56
+ { "DISPLAY" => "" }
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Ukiryu
6
+ module Shell
7
+ # Windows cmd.exe shell implementation
8
+ #
9
+ # cmd.exe uses caret (^) as the escape character and double quotes
10
+ # for strings containing spaces. Environment variables use %VAR% syntax.
11
+ class Cmd < Base
12
+ def name
13
+ :cmd
14
+ end
15
+
16
+ # Escape a string for cmd.exe
17
+ # Caret is the escape character for special characters: % ^ < > & |
18
+ #
19
+ # @param string [String] the string to escape
20
+ # @return [String] the escaped string
21
+ def escape(string)
22
+ string.to_s.gsub(/[%^<>&|]/) { "^$&" }
23
+ end
24
+
25
+ # Quote an argument for cmd.exe
26
+ # Uses double quotes for strings with spaces
27
+ #
28
+ # @param string [String] the string to quote
29
+ # @return [String] the quoted string
30
+ def quote(string)
31
+ if string.to_s =~ /[ \t]/
32
+ # Contains whitespace, use double quotes
33
+ # Note: cmd.exe doesn't escape quotes inside double quotes the same way
34
+ "\"#{string}\""
35
+ else
36
+ # No whitespace, escape special characters
37
+ escape(string)
38
+ end
39
+ end
40
+
41
+ # Format a file path for cmd.exe
42
+ # Convert forward slashes to backslashes
43
+ #
44
+ # @param path [String] the file path
45
+ # @return [String] the formatted path
46
+ def format_path(path)
47
+ path.to_s.gsub("/", "\\")
48
+ end
49
+
50
+ # Format an environment variable reference
51
+ #
52
+ # @param name [String] the variable name
53
+ # @return [String] the formatted reference (%VAR%)
54
+ def env_var(name)
55
+ "%#{name}%"
56
+ end
57
+
58
+ # Join executable and arguments into a command line
59
+ #
60
+ # @param executable [String] the executable path
61
+ # @param args [Array<String>] the arguments
62
+ # @return [String] the complete command line
63
+ def join(executable, *args)
64
+ [executable, *args.map { |a| quote(a) }].join(" ")
65
+ end
66
+
67
+ # cmd.exe doesn't need DISPLAY variable
68
+ #
69
+ # @return [Hash] empty hash (no headless environment needed)
70
+ def headless_environment
71
+ {}
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bash"
4
+
5
+ module Ukiryu
6
+ module Shell
7
+ # Fish shell implementation
8
+ #
9
+ # Fish uses similar quoting to Bash for most cases.
10
+ class Fish < Bash
11
+ def name
12
+ :fish
13
+ end
14
+ end
15
+ end
16
+ end