ukiryu 0.1.1 → 0.1.3

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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +58 -14
  3. data/.gitignore +3 -0
  4. data/.rubocop_todo.yml +170 -79
  5. data/Gemfile +1 -1
  6. data/README.adoc +1603 -576
  7. data/docs/.gitignore +1 -0
  8. data/docs/Gemfile +10 -0
  9. data/docs/INDEX.adoc +261 -0
  10. data/docs/_config.yml +180 -0
  11. data/docs/advanced/custom-tool-classes.adoc +581 -0
  12. data/docs/advanced/index.adoc +20 -0
  13. data/docs/features/configuration.adoc +657 -0
  14. data/docs/features/index.adoc +31 -0
  15. data/docs/features/platform-support.adoc +488 -0
  16. data/docs/getting-started/core-concepts.adoc +666 -0
  17. data/docs/getting-started/index.adoc +36 -0
  18. data/docs/getting-started/installation.adoc +216 -0
  19. data/docs/getting-started/quick-start.adoc +258 -0
  20. data/docs/guides/env-var-sets.adoc +388 -0
  21. data/docs/guides/index.adoc +20 -0
  22. data/docs/interfaces/cli.adoc +609 -0
  23. data/docs/interfaces/index.adoc +153 -0
  24. data/docs/interfaces/ruby-api.adoc +538 -0
  25. data/docs/lychee.toml +49 -0
  26. data/docs/reference/configuration-options.adoc +720 -0
  27. data/docs/reference/error-codes.adoc +634 -0
  28. data/docs/reference/index.adoc +20 -0
  29. data/docs/reference/ruby-api.adoc +1217 -0
  30. data/docs/understanding/index.adoc +20 -0
  31. data/lib/ukiryu/cli.rb +43 -58
  32. data/lib/ukiryu/cli_commands/base_command.rb +16 -27
  33. data/lib/ukiryu/cli_commands/cache_command.rb +100 -0
  34. data/lib/ukiryu/cli_commands/commands_command.rb +8 -8
  35. data/lib/ukiryu/cli_commands/commands_command.rb.fixed +1 -1
  36. data/lib/ukiryu/cli_commands/config_command.rb +49 -7
  37. data/lib/ukiryu/cli_commands/definitions_command.rb +254 -0
  38. data/lib/ukiryu/cli_commands/describe_command.rb +13 -7
  39. data/lib/ukiryu/cli_commands/describe_command.rb.fixed +1 -1
  40. data/lib/ukiryu/cli_commands/docs_command.rb +148 -0
  41. data/lib/ukiryu/cli_commands/exec_inline_command.rb.fixed +1 -1
  42. data/lib/ukiryu/cli_commands/extract_command.rb +2 -2
  43. data/lib/ukiryu/cli_commands/info_command.rb +7 -7
  44. data/lib/ukiryu/cli_commands/lint_command.rb +167 -0
  45. data/lib/ukiryu/cli_commands/list_command.rb +6 -6
  46. data/lib/ukiryu/cli_commands/opts_command.rb +2 -2
  47. data/lib/ukiryu/cli_commands/opts_command.rb.fixed +1 -1
  48. data/lib/ukiryu/cli_commands/register_command.rb +144 -0
  49. data/lib/ukiryu/cli_commands/resolve_command.rb +124 -0
  50. data/lib/ukiryu/cli_commands/run_command.rb +38 -14
  51. data/lib/ukiryu/cli_commands/run_file_command.rb +2 -2
  52. data/lib/ukiryu/cli_commands/system_command.rb +50 -32
  53. data/lib/ukiryu/cli_commands/validate_command.rb +452 -51
  54. data/lib/ukiryu/cli_commands/which_command.rb +5 -5
  55. data/lib/ukiryu/command_builder.rb +81 -23
  56. data/lib/ukiryu/config/env_provider.rb +3 -3
  57. data/lib/ukiryu/config/env_schema.rb +6 -6
  58. data/lib/ukiryu/config.rb +11 -11
  59. data/lib/ukiryu/definition/definition_cache.rb +238 -0
  60. data/lib/ukiryu/definition/definition_composer.rb +257 -0
  61. data/lib/ukiryu/definition/definition_linter.rb +460 -0
  62. data/lib/ukiryu/definition/definition_validator.rb +320 -0
  63. data/lib/ukiryu/definition/discovery.rb +239 -0
  64. data/lib/ukiryu/definition/documentation_generator.rb +429 -0
  65. data/lib/ukiryu/definition/lint_issue.rb +168 -0
  66. data/lib/ukiryu/definition/loader.rb +139 -0
  67. data/lib/ukiryu/definition/metadata.rb +159 -0
  68. data/lib/ukiryu/definition/source.rb +87 -0
  69. data/lib/ukiryu/definition/sources/file.rb +138 -0
  70. data/lib/ukiryu/definition/sources/string.rb +88 -0
  71. data/lib/ukiryu/definition/validation_result.rb +158 -0
  72. data/lib/ukiryu/definition/version_resolver.rb +194 -0
  73. data/lib/ukiryu/definition.rb +40 -0
  74. data/lib/ukiryu/errors.rb +6 -0
  75. data/lib/ukiryu/execution_context.rb +11 -11
  76. data/lib/ukiryu/executor.rb +6 -0
  77. data/lib/ukiryu/extractors/extractor.rb +6 -5
  78. data/lib/ukiryu/extractors/help_parser.rb +13 -19
  79. data/lib/ukiryu/logger.rb +3 -1
  80. data/lib/ukiryu/models/command_definition.rb +3 -3
  81. data/lib/ukiryu/models/command_info.rb +1 -1
  82. data/lib/ukiryu/models/components.rb +1 -3
  83. data/lib/ukiryu/models/env_var_definition.rb +11 -3
  84. data/lib/ukiryu/models/flag_definition.rb +15 -0
  85. data/lib/ukiryu/models/option_definition.rb +7 -7
  86. data/lib/ukiryu/models/platform_profile.rb +6 -3
  87. data/lib/ukiryu/models/routing.rb +1 -1
  88. data/lib/ukiryu/models/tool_definition.rb +2 -4
  89. data/lib/ukiryu/models/tool_metadata.rb +6 -6
  90. data/lib/ukiryu/models/validation_result.rb +1 -1
  91. data/lib/ukiryu/models/version_compatibility.rb +6 -3
  92. data/lib/ukiryu/models/version_detection.rb +10 -1
  93. data/lib/ukiryu/{registry.rb → register.rb} +54 -38
  94. data/lib/ukiryu/register_auto_manager.rb +268 -0
  95. data/lib/ukiryu/schema_validator.rb +31 -10
  96. data/lib/ukiryu/shell/base.rb +18 -0
  97. data/lib/ukiryu/shell/bash.rb +19 -1
  98. data/lib/ukiryu/shell/cmd.rb +11 -1
  99. data/lib/ukiryu/shell/powershell.rb +11 -1
  100. data/lib/ukiryu/shell.rb +1 -1
  101. data/lib/ukiryu/tool.rb +107 -95
  102. data/lib/ukiryu/tool_index.rb +22 -22
  103. data/lib/ukiryu/tools/base.rb +12 -25
  104. data/lib/ukiryu/tools/generator.rb +7 -7
  105. data/lib/ukiryu/tools.rb +3 -3
  106. data/lib/ukiryu/type.rb +20 -5
  107. data/lib/ukiryu/version.rb +1 -1
  108. data/lib/ukiryu/version_detector.rb +21 -2
  109. data/lib/ukiryu.rb +6 -3
  110. data/ukiryu-proposal.md +41 -41
  111. data/ukiryu.gemspec +1 -0
  112. metadata +64 -8
  113. data/.gitmodules +0 -3
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'validation_result'
4
+ require_relative 'loader'
5
+ require 'yaml'
6
+ require 'open-uri'
7
+ require 'net/http'
8
+
9
+ module Ukiryu
10
+ module Definition
11
+ # Validate tool definitions against JSON Schema
12
+ #
13
+ # This class provides validation functionality for tool definitions
14
+ # using JSON Schema. The json-schema gem is optional; if not available,
15
+ # basic structural validation is performed instead.
16
+ class DefinitionValidator
17
+ # Default schema version
18
+ DEFAULT_SCHEMA_VERSION = '1.0'
19
+
20
+ # Remote schema URL
21
+ REMOTE_SCHEMA_URL = 'https://raw.githubusercontent.com/ukiryu/schemas/refs/heads/main/v1/tool.schema.yaml'
22
+
23
+ class << self
24
+ # Check if JSON Schema validation is available
25
+ #
26
+ # @return [Boolean] true if json-schema gem is available
27
+ def schema_validation_available?
28
+ return @schema_validation_available if defined?(@schema_validation_available)
29
+
30
+ @schema_validation_available = begin
31
+ require 'json-schema'
32
+ true
33
+ rescue LoadError
34
+ false
35
+ end
36
+ end
37
+
38
+ # Find schema file
39
+ #
40
+ # @param version [String] schema version
41
+ # @return [String, nil] path to schema file from UKIRYU_SCHEMA_PATH env var, or nil
42
+ def find_schema(_version = DEFAULT_SCHEMA_VERSION)
43
+ # Only check environment variable for local schema path
44
+ schema_path = ENV['UKIRYU_SCHEMA_PATH']
45
+ return schema_path if schema_path && File.exist?(schema_path)
46
+
47
+ nil
48
+ end
49
+
50
+ # Load schema
51
+ #
52
+ # @param schema_path [String] path to schema file
53
+ # @return [Hash, nil] parsed schema, or nil if not available
54
+ def load_schema(schema_path)
55
+ return nil unless schema_path && File.exist?(schema_path)
56
+
57
+ case File.extname(schema_path)
58
+ when '.json'
59
+ JSON.parse(File.read(schema_path))
60
+ when '.yaml', '.yml'
61
+ YAML.safe_load(File.read(schema_path), permitted_classes: [Symbol])
62
+ end
63
+ rescue JSON::ParserError, Psych::SyntaxError, Errno::ENOENT
64
+ nil
65
+ end
66
+
67
+ # Validate a definition hash
68
+ #
69
+ # @param definition [Hash] the definition to validate
70
+ # @param schema_path [String, nil] optional schema path
71
+ # @return [ValidationResult] validation result
72
+ def validate(definition, schema_path: nil)
73
+ errors = []
74
+ warnings = []
75
+
76
+ # Debug: Check which tool is being validated
77
+ if ENV['DEBUG_SCHEMA_VALIDATION']
78
+ tool_name = definition['name'] || definition[:name] || 'unknown'
79
+ puts "DEBUG: Validating tool: #{tool_name}"
80
+ end
81
+
82
+ # Basic structural validation (always available)
83
+ structural_result = validate_structure(definition)
84
+ errors.concat(structural_result[:errors])
85
+ warnings.concat(structural_result[:warnings])
86
+
87
+ # JSON Schema validation (if available)
88
+ if schema_validation_available?
89
+ schema = schema_path ? load_schema(schema_path) : find_and_load_schema
90
+ if schema
91
+ if ENV['DEBUG_SCHEMA_VALIDATION']
92
+ puts "DEBUG: Schema loaded, proceeding with JSON Schema validation"
93
+ end
94
+ schema_result = validate_against_schema(definition, schema)
95
+ errors.concat(schema_result[:errors])
96
+ warnings.concat(schema_result[:warnings])
97
+ end
98
+ # If schema file not found, silently skip JSON schema validation
99
+ # Structural validation is sufficient
100
+ else
101
+ warnings << 'json-schema gem not available, only structural validation performed'
102
+ end
103
+
104
+ if errors.empty?
105
+ warnings.empty? ? ValidationResult.success : ValidationResult.with_warnings(warnings)
106
+ else
107
+ ValidationResult.failure(errors, warnings)
108
+ end
109
+ end
110
+
111
+ # Validate a definition file
112
+ #
113
+ # @param file_path [String] path to definition file
114
+ # @param schema_path [String, nil] optional schema path
115
+ # @return [ValidationResult] validation result
116
+ def validate_file(file_path, schema_path: nil)
117
+ # Load raw YAML hash for validation
118
+ definition = YAML.safe_load(File.read(file_path), permitted_classes: [Symbol, Date, Time])
119
+ validate(definition, schema_path: schema_path)
120
+ rescue Ukiryu::DefinitionNotFoundError
121
+ ValidationResult.failure(["File not found: #{file_path}"])
122
+ rescue Ukiryu::DefinitionLoadError, Ukiryu::DefinitionValidationError => e
123
+ ValidationResult.failure([e.message])
124
+ rescue Errno::ENOENT
125
+ ValidationResult.failure(["File not found: #{file_path}"])
126
+ rescue Psych::SyntaxError => e
127
+ ValidationResult.failure(["Invalid YAML: #{e.message}"])
128
+ end
129
+
130
+ # Validate a YAML string
131
+ #
132
+ # @param yaml_string [String] YAML content
133
+ # @param schema_path [String, nil] optional schema path
134
+ # @return [ValidationResult] validation result
135
+ def validate_string(yaml_string, schema_path: nil)
136
+ definition = YAML.safe_load(yaml_string, permitted_classes: [Symbol, Date, Time])
137
+ validate(definition, schema_path: schema_path)
138
+ rescue Psych::SyntaxError => e
139
+ ValidationResult.failure(["Invalid YAML: #{e.message}"])
140
+ end
141
+
142
+ private
143
+
144
+ # Find and load schema
145
+ #
146
+ # @return [Hash, nil] schema hash or nil
147
+ def find_and_load_schema
148
+ # Try local schema first
149
+ schema_path = find_schema
150
+ schema = load_schema(schema_path) if schema_path
151
+ return schema if schema
152
+
153
+ # Fallback to remote schema
154
+ download_and_load_schema
155
+ end
156
+
157
+ # Download schema from remote URL
158
+ #
159
+ # @return [Hash, nil] schema hash or nil
160
+ def download_and_load_schema
161
+ YAML.safe_load(URI.open(REMOTE_SCHEMA_URL).read, permitted_classes: [Symbol])
162
+ rescue OpenURI::HTTPError, SocketError, Timeout::Error, Psych::SyntaxError
163
+ nil
164
+ end
165
+
166
+ # Validate basic structure
167
+ #
168
+ # @param definition [Hash] the definition
169
+ # @return [Hash] errors and warnings
170
+ def validate_structure(definition)
171
+ errors = []
172
+ warnings = []
173
+
174
+ # Check if definition is a hash
175
+ return { errors: ['Definition must be a hash/object'], warnings: [] } unless definition.is_a?(Hash)
176
+
177
+ # Check name format (NOT validated by schema)
178
+ name = definition[:name] || definition['name']
179
+ if name
180
+ name_str = name.to_s
181
+ errors << 'Tool name must not contain whitespace' if name_str =~ /\s/
182
+ warnings << 'Tool name starting with number may cause issues' if name_str =~ /^[0-9]/
183
+ warnings << 'Tool name should contain only lowercase letters, numbers, hyphens, and underscores' if name_str !~ /^[a-z0-9_-]+$/
184
+ end
185
+
186
+ # Check schema version format (NOT validated by schema)
187
+ has_schema_version = definition.key?(:ukiryu_schema) || definition.key?('ukiryu_schema')
188
+ if has_schema_version
189
+ schema_version = definition[:ukiryu_schema] || definition['ukiryu_schema']
190
+ schema_version_str = schema_version.to_s
191
+ warnings << "Invalid schema version format: #{schema_version} (expected format: '1.0')" unless schema_version_str =~ /^\d+\.\d+$/
192
+ end
193
+
194
+ { errors: errors, warnings: warnings }
195
+ end
196
+
197
+ # Validate a profile
198
+ #
199
+ # @param profile [Hash] the profile
200
+ # @param index [Integer] profile index
201
+ # @return [Hash] errors and warnings
202
+ def validate_profile(profile, index)
203
+ errors = []
204
+ warnings = []
205
+
206
+ return { errors: ["Profile #{index} must be a hash/object"], warnings: [] } unless profile.is_a?(Hash)
207
+
208
+ # All profile structural validation is already done by JSON Schema
209
+ # (required fields, types, enum values, etc.)
210
+
211
+ { errors: errors, warnings: warnings }
212
+ end
213
+
214
+ # Validate against JSON Schema
215
+ #
216
+ # @param definition [Hash] the definition
217
+ # @param schema [Hash] JSON Schema
218
+ # @return [Hash] errors and warnings
219
+ def validate_against_schema(definition, schema)
220
+ errors = []
221
+ warnings = []
222
+
223
+ begin
224
+ # Debug: Check data BEFORE stringify_keys
225
+ if ENV['DEBUG_SCHEMA_VALIDATION']
226
+ tool_name = definition['name'] || definition[:name] || 'unknown'
227
+ puts "DEBUG: validate_against_schema for tool: #{tool_name}"
228
+
229
+ # Check flags[0] before stringify
230
+ if definition['profiles'] && definition['profiles'][0] &&
231
+ definition['profiles'][0]['commands'] &&
232
+ definition['profiles'][0]['commands'][0] &&
233
+ definition['profiles'][0]['commands'][0]['flags'] &&
234
+ definition['profiles'][0]['commands'][0]['flags'][0]
235
+ flg0_before = definition['profiles'][0]['commands'][0]['flags'][0]
236
+ puts "DEBUG: Flag 0 BEFORE stringify: #{flg0_before.inspect}"
237
+ puts "DEBUG: Flag 0 name BEFORE stringify: #{flg0_before['name'].inspect}"
238
+ puts "DEBUG: Flag 0 name class BEFORE stringify: #{flg0_before['name'].class}"
239
+ end
240
+ end
241
+
242
+ # Convert symbol keys to strings for JSON Schema validation
243
+ stringified = stringify_keys(definition)
244
+ # Debug: Check if keys are strings after stringify_keys
245
+ if ENV['DEBUG_SCHEMA_VALIDATION']
246
+ puts "DEBUG: After stringify_keys, checking keys..."
247
+ puts "DEBUG: Top-level keys: #{stringified.keys.inspect}"
248
+ puts "DEBUG: Top-level key classes: #{stringified.keys.map(&:class).inspect}"
249
+
250
+ # Check options[0] and flags[0] if they exist
251
+ if stringified['profiles'] && stringified['profiles'][0] &&
252
+ stringified['profiles'][0]['commands'] &&
253
+ stringified['profiles'][0]['commands'][0]
254
+ cmd = stringified['profiles'][0]['commands'][0]
255
+
256
+ # Check options[0]
257
+ if cmd['options'] && cmd['options'][0]
258
+ opt0 = cmd['options'][0]
259
+ puts "DEBUG: Option 0 after stringify: #{opt0.inspect}"
260
+ puts "DEBUG: Option 0 keys: #{opt0.keys.inspect}"
261
+ puts "DEBUG: Option 0 name: #{opt0['name'].inspect}"
262
+ puts "DEBUG: Option 0 name class: #{opt0['name'].class}"
263
+ end
264
+
265
+ # Check flags[0]
266
+ if cmd['flags'] && cmd['flags'][0]
267
+ flg0 = cmd['flags'][0]
268
+ puts "DEBUG: Flag 0 after stringify: #{flg0.inspect}"
269
+ puts "DEBUG: Flag 0 keys: #{flg0.keys.inspect}"
270
+ puts "DEBUG: Flag 0 name: #{flg0['name'].inspect}"
271
+ puts "DEBUG: Flag 0 name class: #{flg0['name'].class}"
272
+ end
273
+
274
+ # Also check options[26] for grep
275
+ if cmd['options'] && cmd['options'][26]
276
+ opt26 = cmd['options'][26]
277
+ puts "DEBUG: Option 26 after stringify: #{opt26.inspect}"
278
+ puts "DEBUG: Option 26 name: #{opt26['name'].inspect}"
279
+ end
280
+ end
281
+ puts "DEBUG: JSON::Schema version: #{JSON::Schema::VERSION rescue 'unknown'}"
282
+ end
283
+ validation = JSON::Validator.fully_validate(schema, stringified, errors_as_objects: true)
284
+
285
+ validation.each do |error|
286
+ if error[:type] == 'unknown'
287
+ warnings << error[:message]
288
+ else
289
+ errors << "#{error[:message]} (at #{error[:fragment]})"
290
+ end
291
+ end
292
+ rescue JSON::Schema::ValidationError => e
293
+ errors << "Schema validation error: #{e.message}"
294
+ end
295
+
296
+ { errors: errors, warnings: warnings }
297
+ end
298
+
299
+ # Convert symbol keys to strings
300
+ #
301
+ # @param hash [Hash] the hash
302
+ # @return [Hash] hash with string keys
303
+ def stringify_keys(hash)
304
+ return hash unless hash.is_a?(Hash)
305
+
306
+ hash.transform_keys(&:to_s).transform_values do |v|
307
+ case v
308
+ when Hash
309
+ stringify_keys(v)
310
+ when Array
311
+ v.map { |item| item.is_a?(Hash) ? stringify_keys(item) : item }
312
+ else
313
+ v
314
+ end
315
+ end
316
+ end
317
+ end
318
+ end
319
+ end
320
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'metadata'
4
+ require_relative 'loader'
5
+
6
+ module Ukiryu
7
+ module Definition
8
+ # Discover tool definitions in standard filesystem locations
9
+ #
10
+ # This class searches for tool definitions in XDG-compliant paths
11
+ # and tool-bundled locations.
12
+ class Discovery
13
+ # User data directory (XDG_DATA_HOME or ~/.local/share)
14
+ #
15
+ # @return [String] user data directory
16
+ def self.xdg_data_home
17
+ ENV.fetch('XDG_DATA_HOME', File.expand_path('~/.local/share'))
18
+ end
19
+
20
+ # System data directories (XDG_DATA_DIRS or /usr/local/share:/usr/share)
21
+ #
22
+ # @return [Array<String>] system data directories
23
+ def self.xdg_data_dirs
24
+ if ENV.key?('XDG_DATA_DIRS')
25
+ ENV.fetch('XDG_DATA_DIRS', '').split(':').map(&:strip).reject(&:empty?)
26
+ else
27
+ ['/usr/local/share', '/usr/share']
28
+ end
29
+ end
30
+
31
+ # Get the user definitions directory
32
+ #
33
+ # @return [String] path to user definitions directory
34
+ def self.user_definitions_directory
35
+ File.join(xdg_data_home, 'ukiryu', 'definitions')
36
+ end
37
+
38
+ # Get all search paths for definitions
39
+ #
40
+ # Returns paths in priority order (highest priority first):
41
+ # 1. User definitions
42
+ # 2. Tool-bundled paths (dynamically discovered)
43
+ # 3. Local system definitions
44
+ # 4. System definitions
45
+ #
46
+ # @return [Array<String>] search paths
47
+ def self.search_paths
48
+ paths = []
49
+
50
+ # 1. User definitions (highest priority after explicit flag)
51
+ paths << user_definitions_directory
52
+
53
+ # 2. Tool-bundled paths (dynamically discovered)
54
+ paths.concat(tool_bundled_paths)
55
+
56
+ # 3. Local system definitions
57
+ xdg_data_dirs.each do |dir|
58
+ paths << File.join(dir, 'ukiryu', 'definitions')
59
+ end
60
+
61
+ # 4. System definitions
62
+ paths << File.join(xdg_data_home, 'ukiryu', 'definitions')
63
+
64
+ paths.uniq
65
+ end
66
+
67
+ # Get tool-bundled definition paths
68
+ #
69
+ # Searches for tool installations and checks for bundled definitions
70
+ #
71
+ # @return [Array<String>] tool-bundled paths
72
+ def self.tool_bundled_paths
73
+ paths = []
74
+
75
+ # Check PATH for tool installations
76
+ ENV.fetch('PATH', '').split(':').each do |bin_dir|
77
+ next unless File.directory?(bin_dir)
78
+
79
+ # Check for ukiryu subdirectory alongside bin
80
+ parent_dir = File.dirname(bin_dir)
81
+ ukiryu_dir = File.join(parent_dir, 'ukiryu')
82
+ paths << ukiryu_dir if File.directory?(ukiryu_dir)
83
+
84
+ # Check for share/ukiryu subdirectory
85
+ share_dir = File.join(parent_dir, 'share', 'ukiryu')
86
+ paths << share_dir if File.directory?(share_dir)
87
+ end
88
+
89
+ # Check /opt directory structure
90
+ opt_paths = Dir.glob('/opt/*/ukiryu').select { |d| File.directory?(d) }
91
+ paths.concat(opt_paths)
92
+
93
+ paths.uniq
94
+ end
95
+
96
+ # Discover all available definitions
97
+ #
98
+ # Searches all paths for tool definitions and returns metadata
99
+ #
100
+ # @return [Hash<String, Array<DefinitionMetadata>>] hash of tool names to metadata
101
+ def self.discover
102
+ definitions = Hash.new { |h, k| h[k] = [] }
103
+
104
+ search_paths.each do |search_path|
105
+ next unless File.directory?(search_path)
106
+
107
+ discover_in_path(search_path).each do |metadata|
108
+ definitions[metadata.name] << metadata
109
+ end
110
+ end
111
+
112
+ # Sort each tool's definitions by priority
113
+ definitions.each_value(&:sort!)
114
+
115
+ definitions
116
+ end
117
+
118
+ # Find the best definition for a tool
119
+ #
120
+ # @param tool_name [String] the tool name
121
+ # @param version [String, nil] optional version constraint
122
+ # @return [DefinitionMetadata, nil] the best matching definition, or nil
123
+ def self.find(tool_name, version = nil)
124
+ definitions = discover[tool_name]
125
+ return nil if definitions.nil? || definitions.empty?
126
+
127
+ if version
128
+ # Find exact version match
129
+ definitions.find { |d| d.version == version }
130
+ else
131
+ # Return highest priority definition
132
+ definitions.first
133
+ end
134
+ end
135
+
136
+ # List all discovered tool names
137
+ #
138
+ # @return [Array<String>] tool names
139
+ def self.available_tools
140
+ discover.keys.sort
141
+ end
142
+
143
+ # Get all definitions for a specific tool
144
+ #
145
+ # @param tool_name [String] the tool name
146
+ # @return [Array<DefinitionMetadata>] array of definitions
147
+ def self.definitions_for(tool_name)
148
+ discover[tool_name] || []
149
+ end
150
+
151
+ # Discover definitions in a specific path
152
+ #
153
+ # @param search_path [String] the path to search
154
+ # @return [Array<DefinitionMetadata>] array of discovered metadata
155
+ def self.discover_in_path(search_path)
156
+ definitions = []
157
+
158
+ # Determine source type based on path
159
+ source_type = determine_source_type(search_path)
160
+
161
+ # Look for tool subdirectories (structure: {tool}/{version}.yaml)
162
+ if File.directory?(search_path)
163
+ Dir.foreach(search_path) do |tool_name|
164
+ next if tool_name.start_with?('.')
165
+
166
+ tool_dir = File.join(search_path, tool_name)
167
+ next unless File.directory?(tool_dir)
168
+
169
+ # Find YAML files in tool directory
170
+ Dir.glob(File.join(tool_dir, '*.yaml')).each do |yaml_file|
171
+ metadata = metadata_from_file(yaml_file, source_type)
172
+ definitions << metadata if metadata
173
+ rescue StandardError => e
174
+ # Skip invalid files silently
175
+ warn "[Ukiryu] Skipping invalid definition #{yaml_file}: #{e.message}"
176
+ end
177
+ end
178
+ end
179
+
180
+ # Also check for flat YAML files (structure: {tool}-{version}.yaml or {version}.yaml)
181
+ Dir.glob(File.join(search_path, '*-*.yaml')).each do |yaml_file|
182
+ metadata = metadata_from_file(yaml_file, source_type)
183
+ definitions << metadata if metadata
184
+ rescue StandardError => e
185
+ # Skip invalid files silently
186
+ warn "[Ukiryu] Skipping invalid definition #{yaml_file}: #{e.message}"
187
+ end
188
+
189
+ definitions
190
+ end
191
+
192
+ # Determine the source type for a search path
193
+ #
194
+ # @param path [String] the search path
195
+ # @return [Symbol] source type
196
+ def self.determine_source_type(path)
197
+ expanded = File.expand_path(path)
198
+
199
+ if expanded.include?(xdg_data_home)
200
+ :user
201
+ elsif expanded.include?('/opt/')
202
+ :bundled
203
+ elsif expanded.include?('/usr/local/share')
204
+ :local_system
205
+ elsif expanded.include?('/usr/share')
206
+ :system
207
+ else
208
+ :bundled # Default to bundled for unknown paths
209
+ end
210
+ end
211
+
212
+ # Create metadata from a YAML file
213
+ #
214
+ # @param yaml_file [String] path to the YAML file
215
+ # @param source_type [Symbol] source type
216
+ # @return [DefinitionMetadata, nil] metadata or nil if invalid
217
+ def self.metadata_from_file(yaml_file, source_type)
218
+ # Try to load just the name and version from YAML
219
+ yaml_content = File.read(yaml_file)
220
+ data = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
221
+
222
+ return nil unless data.is_a?(Hash)
223
+ return nil unless data['name']
224
+
225
+ name = data['name']
226
+ version = data['version'] || '1.0' # Default version
227
+
228
+ DefinitionMetadata.new(
229
+ name: name,
230
+ version: version,
231
+ path: yaml_file,
232
+ source_type: source_type
233
+ )
234
+ rescue Psych::SyntaxError, StandardError
235
+ nil
236
+ end
237
+ end
238
+ end
239
+ end