ukiryu 0.1.3 → 0.1.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '092e272dd1abe46958fdb31f6b4b0d12cd72bbf3b88d4c6c4119165faf210fe3'
4
- data.tar.gz: 22b4c54e212f7c84bbf6dc69dd352c3d55137fa711f712241f2c5a5769366953
3
+ metadata.gz: 72da5a25186dfa4f3b3b53d0503c46f76086aaa94e219523126f331f10ca88d0
4
+ data.tar.gz: 6fcd4fa7a7f1af0830a1d2ea75632f63ff5b8b1333bfc4ce6b565444cc263898
5
5
  SHA512:
6
- metadata.gz: ac196302eda5c010de7849e693bba66a1a844bcbad125d21d4b95b6c97c7110f9c175b087b2820ae61f4d1a6cd951e90663ccbaaf90a189168ad77d900986552
7
- data.tar.gz: 64a86f0d697b12742fbe4a9a9368c55cbbe743d37bbee6ae08f39d199d455f90efa1a43f801e61e303c516268268198a76ebf34cb3d9c5b95e1fd83a172bbe8f
6
+ metadata.gz: 264926ef984787a01fd48941daea277027fa14ae405c63e15b765464338e474ef78c7f3d98a81f41762a281b140139bf9c8d515e468bf3350b584476e5118db0
7
+ data.tar.gz: aa91036800ea42466283e46e05fffc47179cffe08d9340f246f5abb44d3bf9276742804ba61c8ad3e00acb384f9d5ef56b55a3e87cc2990e675f50811a1ab5ec
data/README.adoc CHANGED
@@ -83,7 +83,7 @@ Without Ukiryu, CLI tools suffer from:
83
83
  === The Ukiryu Solution
84
84
 
85
85
  [cols="1,1,4"]
86
- |===|===
86
+ |===
87
87
  |Problem |Traditional CLI |Ukiryu Solution
88
88
 
89
89
  |Argument fragility
@@ -107,18 +107,19 @@ Without Ukiryu, CLI tools suffer from:
107
107
 
108
108
  |Discovery friction
109
109
  |`tool --help` |`ukiryu describe tool command`
110
+
110
111
  |===
111
112
 
112
113
  === Why "OpenAPI for CLIs"?
113
114
 
114
- OpenAPI revolutionized REST APIs by:
115
+ OpenAPI provides the following benefits for REST APIs by:
115
116
 
116
117
  1. **Standardizing** - Machine-readable schema definitions
117
118
  2. **Documenting** - Auto-generated interactive documentation
118
119
  3. **Type-Safe** - Request/response validation
119
120
  4. **Versioning** - Multiple API versions coexisting
120
121
 
121
- Ukiryu brings the same revolution to CLI tools:
122
+ Ukiryu provides the same benefits for CLI tools by:
122
123
 
123
124
  * **Schema**: YAML profiles replace grepping --help
124
125
  * **Docs**: `ukiryu describe` replaces man pages
@@ -136,7 +137,8 @@ Ukiryu brings the same revolution to CLI tools:
136
137
  // What is Ukiryu?
137
138
  == What is Ukiryu?
138
139
 
139
- Ukiryu is a framework that turns CLI tools into well-defined, versioned APIs through declarative YAML profiles.
140
+ Ukiryu is a framework that turns CLI tools into well-defined, versioned APIs
141
+ through declarative YAML profiles.
140
142
 
141
143
  === Key Concepts
142
144
 
@@ -150,32 +152,32 @@ Ukiryu is a framework that turns CLI tools into well-defined, versioned APIs thr
150
152
 
151
153
  [source]
152
154
  ----
153
- ┌─────────────────────────────────────────────────────────────────────┐
154
-
155
+ ┌───────────────────────────────────────────────────────────────────┐
156
+
155
157
  │ User Code (Ruby / CLI) │
156
158
  │ ├─ tool.execute(:inkscape, { inputs: [...] }) │
157
159
  │ └─ ukiryu exec inkscape export inputs=... │
158
-
160
+
159
161
  └─────────────────────────────┬─────────────────────────────────────┘
160
162
 
161
163
 
162
164
  ┌─────────────────────────────▼─────────────────────────────────────┐
163
- │ Ukiryu Framework
164
- │ ├─ Register (loads YAML profiles)
165
- │ ├─ Tool (selects profile, detects version)
166
- │ ├─ CommandBuilder (formats arguments for shell)
167
- │ ├─ Executor (runs commands, captures output)
168
- │ └─ Shell Layer (platform-specific quoting/escaping)
169
-
165
+ │ Ukiryu Framework
166
+ │ ├─ Register (loads YAML profiles)
167
+ │ ├─ Tool (selects profile, detects version)
168
+ │ ├─ CommandBuilder (formats arguments for shell)
169
+ │ ├─ Executor (runs commands, captures output)
170
+ │ └─ Shell Layer (platform-specific quoting/escaping)
171
+
170
172
  └─────────────────────────────┬─────────────────────────────────────┘
171
173
 
172
174
 
173
175
  ┌─────────────────────────────▼─────────────────────────────────────┐
174
- │ YAML Tool Profiles (Declarative API Definition)
175
- │ ├─ tools/inkscape/1.0.yaml (Modern Inkscape)
176
- │ ├─ tools/inkscape/0.92.yaml (Legacy Inkscape)
177
- │ └─ tools/imagemagick/7.1.yaml (ImageMagick 7.1)
178
- └──────────────────────────────────────────────────────────────────────┘
176
+ │ YAML Tool Profiles (Declarative API Definition)
177
+ │ ├─ tools/inkscape/1.0.yaml (Modern Inkscape)
178
+ │ ├─ tools/inkscape/0.92.yaml (Legacy Inkscape)
179
+ │ └─ tools/imagemagick/7.1.yaml (ImageMagick 7.1)
180
+ └───────────────────────────────────────────────────────────────────┘
179
181
  ----
180
182
 
181
183
  === Tool Profile Schema
@@ -73,12 +73,6 @@ module Ukiryu
73
73
  errors = []
74
74
  warnings = []
75
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
76
  # Basic structural validation (always available)
83
77
  structural_result = validate_structure(definition)
84
78
  errors.concat(structural_result[:errors])
@@ -88,9 +82,6 @@ module Ukiryu
88
82
  if schema_validation_available?
89
83
  schema = schema_path ? load_schema(schema_path) : find_and_load_schema
90
84
  if schema
91
- if ENV['DEBUG_SCHEMA_VALIDATION']
92
- puts "DEBUG: Schema loaded, proceeding with JSON Schema validation"
93
- end
94
85
  schema_result = validate_against_schema(definition, schema)
95
86
  errors.concat(schema_result[:errors])
96
87
  warnings.concat(schema_result[:warnings])
@@ -221,65 +212,9 @@ module Ukiryu
221
212
  warnings = []
222
213
 
223
214
  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
215
  # Convert symbol keys to strings for JSON Schema validation
243
216
  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
217
+
283
218
  validation = JSON::Validator.fully_validate(schema, stringified, errors_as_objects: true)
284
219
 
285
220
  validation.each do |error|
@@ -217,7 +217,7 @@ module Ukiryu
217
217
  def self.metadata_from_file(yaml_file, source_type)
218
218
  # Try to load just the name and version from YAML
219
219
  yaml_content = File.read(yaml_file)
220
- data = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
220
+ data = YAML.safe_load(yaml_content, permitted_classes: [Symbol], aliases: true)
221
221
 
222
222
  return nil unless data.is_a?(Hash)
223
223
  return nil unless data['name']
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'executor'
4
4
  require_relative 'platform'
5
+ require_relative 'models/search_paths'
5
6
 
6
7
  module Ukiryu
7
8
  # Executable locator for finding tool executables
@@ -74,7 +75,7 @@ module Ukiryu
74
75
 
75
76
  # Normalize search paths to array format
76
77
  #
77
- # @param search_paths [Array<String>, Models::SearchPaths] the search paths
78
+ # @param search_paths [Array<String>, Models::SearchPaths, Hash] the search paths
78
79
  # @param platform [Symbol] the platform
79
80
  # @return [Array<String>] normalized array of paths
80
81
  def normalize_search_paths(search_paths, platform)
@@ -83,6 +84,12 @@ module Ukiryu
83
84
  # If it's a SearchPaths model, get platform-specific paths
84
85
  return search_paths.for_platform(platform) || [] if search_paths.is_a?(Models::SearchPaths)
85
86
 
87
+ # If it's a Hash, extract platform-specific paths
88
+ if search_paths.is_a?(Hash)
89
+ platform_paths = search_paths[platform] || search_paths[platform.to_s]
90
+ return platform_paths || [] if platform_paths
91
+ end
92
+
86
93
  # Already an array
87
94
  search_paths
88
95
  end
@@ -45,6 +45,11 @@ module Ukiryu
45
45
  cwd = options[:cwd]
46
46
  stdin = options[:stdin]
47
47
 
48
+ # Suppress thread warnings from Open3 (cosmetic IOError from stream closure)
49
+ # Open3's internal threads may raise IOError when streams close early
50
+ original_setting = Thread.report_on_exception
51
+ Thread.report_on_exception = false
52
+
48
53
  started_at = Time.now
49
54
  begin
50
55
  result = if stdin
@@ -55,6 +60,8 @@ module Ukiryu
55
60
  rescue Timeout::Error
56
61
  Time.now
57
62
  raise TimeoutError, "Command timed out after #{timeout} seconds: #{executable}"
63
+ ensure
64
+ Thread.report_on_exception = original_setting
58
65
  end
59
66
  finished_at = Time.now
60
67
 
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'platform'
4
+ require_relative 'executor'
5
+ require 'time'
6
+ require 'date'
7
+
8
+ module Ukiryu
9
+ # Man page date parser for extracting version information
10
+ #
11
+ # Parses man page `.Dd` macros to extract dates as version information.
12
+ # Used as fallback for system tools that don't support --version flags.
13
+ #
14
+ # @example Parse man page date
15
+ # date = ManPageParser.parse_date('/usr/share/man/man1/xargs.1')
16
+ # # => "2020-09-21"
17
+ module ManPageParser
18
+ class << self
19
+ # Parse date from man page
20
+ #
21
+ # Extracts the `.Dd` (date) macro from a man page and converts it to
22
+ # ISO 8601 format (YYYY-MM-DD).
23
+ #
24
+ # @param man_page_path [String] path to the man page file
25
+ # @return [String, nil] ISO 8601 date string or nil if not found
26
+ def parse_date(man_page_path)
27
+ return nil unless man_page_path
28
+ return nil unless File.exist?(man_page_path)
29
+
30
+ content = File.read(man_page_path, encoding: 'UTF-8')
31
+
32
+ # Find .Dd line
33
+ # Format: .Dd Month DD, YYYY or .Dd DD Month YYYY
34
+ dd_line = content.lines.find { |line| line =~ /^\.Dd\s+/ }
35
+
36
+ return nil unless dd_line
37
+
38
+ # Extract and parse date
39
+ extract_date_from_dd_line(dd_line)
40
+ rescue Errno::ENOENT, Errno::EACCES => e
41
+ # File not found or inaccessible - silent failure
42
+ nil
43
+ end
44
+
45
+ # Get man page path for a tool on current platform
46
+ #
47
+ # @param tool_name [String] the tool name
48
+ # @param paths [Hash] platform-specific path templates
49
+ # @return [String, nil] resolved man page path or nil
50
+ def resolve_man_page_path(tool_name, paths = {})
51
+ platform = Platform.current
52
+
53
+ # Get platform-specific path pattern
54
+ path_pattern = paths[platform]
55
+
56
+ return nil unless path_pattern
57
+
58
+ # Expand tool name placeholder if present
59
+ path_pattern.gsub('{tool}', tool_name)
60
+ end
61
+
62
+ # Parse date from multiple possible man page locations
63
+ #
64
+ # Tries multiple paths in order and returns the first successfully
65
+ # parsed date.
66
+ #
67
+ # @param possible_paths [Array<String>] list of possible man page paths
68
+ # @return [String, nil] ISO 8601 date string or nil if none found
69
+ def parse_from_fallback(possible_paths)
70
+ possible_paths.each do |path|
71
+ date = parse_date(path)
72
+ return date if date
73
+ end
74
+
75
+ nil
76
+ end
77
+
78
+ private
79
+
80
+ # Extract date from .Dd line
81
+ #
82
+ # Handles various man page date formats:
83
+ # - .Dd September 21, 2020
84
+ # - .Dd 21 September 2020
85
+ # - .Dd 2020-09-21
86
+ #
87
+ # @param dd_line [String] the .Dd line
88
+ # @return [String, nil] ISO 8601 date or nil
89
+ def extract_date_from_dd_line(dd_line)
90
+ # Remove .Dd prefix
91
+ date_str = dd_line.sub(/^\.Dd\s+/, '').strip
92
+
93
+ # Try parsing different formats
94
+ parsed = try_parse_date_formats(date_str)
95
+
96
+ parsed&.strftime('%Y-%m-%d')
97
+ end
98
+
99
+ # Try parsing various date formats
100
+ #
101
+ # @param date_str [String] the date string
102
+ # @return [Date, nil] parsed date or nil
103
+ def try_parse_date_formats(date_str)
104
+ # Format: "September 21, 2020" or "21 September 2020"
105
+ # Parse with Date.parse which handles many formats
106
+ Date.parse(date_str)
107
+ rescue ArgumentError, Date::Error
108
+ # Try regex-based extraction
109
+ extract_date_with_regex(date_str)
110
+ end
111
+
112
+ # Extract date using regex patterns
113
+ #
114
+ # @param date_str [String] the date string
115
+ # @return [Date, nil] parsed date or nil
116
+ def extract_date_with_regex(date_str)
117
+ # Match "Month DD, YYYY" or "DD Month YYYY" or "YYYY-MM-DD"
118
+ patterns = [
119
+ /(\w+)\s+(\d+),?\s+(\d{4})/, # Month DD, YYYY
120
+ /(\d+)\s+(\w+)\s+(\d{4})/, # DD Month YYYY
121
+ /(\d{4})-(\d{2})-(\d{2})/ # YYYY-MM-DD
122
+ ]
123
+
124
+ patterns.each do |pattern|
125
+ match = date_str.match(pattern)
126
+ next unless match
127
+
128
+ captures = match.captures
129
+
130
+ # Determine order based on capture count
131
+ if captures[0]&.length == 4 # First capture is year (YYYY-MM-DD)
132
+ year, month, day = captures
133
+ return Date.new(year.to_i, month.to_i, day.to_i)
134
+ elsif captures[2]&.length == 4 # Last capture is year
135
+ month_or_day, day_or_month, year = captures
136
+
137
+ # Check if first is month name or number
138
+ if month_or_day =~ /^\d+$/
139
+ # DD Month YYYY format
140
+ day, month_name, year = captures
141
+ month = month_name_to_number(month_name)
142
+ return Date.new(year.to_i, month, day.to_i) if month
143
+ else
144
+ # Month DD, YYYY format
145
+ month_name, day, year = captures
146
+ month = month_name_to_number(month_name)
147
+ return Date.new(year.to_i, month, day.to_i) if month
148
+ end
149
+ end
150
+ end
151
+
152
+ nil
153
+ end
154
+
155
+ # Convert month name to number
156
+ #
157
+ # @param month_name [String] the month name
158
+ # @return [Integer, nil] month number (1-12) or nil
159
+ def month_name_to_number(month_name)
160
+ months = {
161
+ 'january' => 1, 'jan' => 1,
162
+ 'february' => 2, 'feb' => 2,
163
+ 'march' => 3, 'mar' => 3,
164
+ 'april' => 4, 'apr' => 4,
165
+ 'may' => 5,
166
+ 'june' => 6, 'jun' => 6,
167
+ 'july' => 7, 'jul' => 7,
168
+ 'august' => 8, 'aug' => 8,
169
+ 'september' => 9, 'sep' => 9, 'sept' => 9,
170
+ 'october' => 10, 'oct' => 10,
171
+ 'november' => 11, 'nov' => 11,
172
+ 'december' => 12, 'dec' => 12
173
+ }
174
+
175
+ months[month_name.downcase]
176
+ end
177
+ end
178
+ end
179
+ end
@@ -4,6 +4,23 @@ require 'lutaml/model'
4
4
 
5
5
  module Ukiryu
6
6
  module Models
7
+ # Version detection method
8
+ #
9
+ # Single detection method in the fallback hierarchy
10
+ class VersionDetectionMethod < Lutaml::Model::Serializable
11
+ attribute :type, :string # 'command' or 'man_page'
12
+ attribute :command, :string
13
+ attribute :pattern, :string
14
+ attribute :paths, :hash # Platform-specific paths for man_page
15
+
16
+ yaml do
17
+ map_element 'type', to: :type
18
+ map_element 'command', to: :command
19
+ map_element 'pattern', to: :pattern
20
+ map_element 'paths', to: :paths
21
+ end
22
+ end
23
+
7
24
  # Version detection configuration
8
25
  #
9
26
  # @example Command-based version detection (GNU tools)
@@ -19,17 +36,27 @@ module Ukiryu
19
36
  # pattern: 'macOS ([\d.]+)',
20
37
  # source: 'man'
21
38
  # )
39
+ #
40
+ # @example Fallback hierarchy with detection_methods array
41
+ # vd = VersionDetection.new(
42
+ # detection_methods: [
43
+ # VersionDetectionMethod.new(type: 'command', command: '--version', pattern: '(\d+\.\d+)'),
44
+ # VersionDetectionMethod.new(type: 'man_page', paths: { macos: '/usr/share/man/man1/xargs.1' })
45
+ # ]
46
+ # )
22
47
  class VersionDetection < Lutaml::Model::Serializable
23
48
  attribute :command, :string, collection: true, default: []
24
49
  attribute :pattern, :string
25
50
  attribute :modern_threshold, :string
26
51
  attribute :source, :string, default: 'command' # 'command' or 'man'
52
+ attribute :detection_methods, VersionDetectionMethod, collection: true, default: []
27
53
 
28
54
  yaml do
29
55
  map_element 'command', to: :command
30
56
  map_element 'pattern', to: :pattern
31
57
  map_element 'modern_threshold', to: :modern_threshold
32
58
  map_element 'source', to: :source
59
+ map_element 'detection_methods', to: :detection_methods
33
60
  end
34
61
 
35
62
  # Hash-like access for Base.detect_version compatibility
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ukiryu
4
+ module Models
5
+ # Version information with detection method tracking
6
+ #
7
+ # Represents a version value with metadata about how it was detected.
8
+ # Supports multiple detection methods (command, man_page, etc.) as a
9
+ # fallback hierarchy, NOT mutually exclusive types.
10
+ #
11
+ # @example Command-based version
12
+ # VersionInfo.new(
13
+ # value: '3.11',
14
+ # method_used: :command,
15
+ # available_methods: [:command]
16
+ # )
17
+ #
18
+ # @example Man page fallback version
19
+ # VersionInfo.new(
20
+ # value: '2020-09-21',
21
+ # method_used: :man_page,
22
+ # available_methods: [:command, :man_page]
23
+ # )
24
+ class VersionInfo
25
+ attr_reader :value, :method_used, :available_methods
26
+
27
+ # Initialize version info
28
+ #
29
+ # @param value [String] the version value (e.g., "3.11" or "2020-09-21")
30
+ # @param method_used [Symbol] the method that succeeded (:command, :man_page, etc.)
31
+ # @param available_methods [Array<Symbol>] all methods that were available
32
+ def initialize(value:, method_used:, available_methods: [])
33
+ @value = value
34
+ @method_used = method_used
35
+ @available_methods = available_methods
36
+ end
37
+
38
+ # Check if version was detected via command
39
+ #
40
+ # @return [Boolean] true if from command execution
41
+ def from_command?
42
+ method_used == :command
43
+ end
44
+
45
+ # Check if version was detected via man page
46
+ #
47
+ # @return [Boolean] true if from man page date
48
+ def from_man_page?
49
+ method_used == :man_page
50
+ end
51
+
52
+ # Display format with context
53
+ # Adds "(man page)" suffix only for display, not stored in data
54
+ #
55
+ # @return [String] formatted version string
56
+ def to_s
57
+ case method_used
58
+ when :command
59
+ value
60
+ when :man_page
61
+ "#{value} (man page)"
62
+ else
63
+ value
64
+ end
65
+ end
66
+
67
+ # Hash representation
68
+ #
69
+ # @return [Hash] hash with value, method, and available_methods
70
+ def to_h
71
+ {
72
+ value: value,
73
+ method: method_used,
74
+ available_methods: available_methods
75
+ }
76
+ end
77
+
78
+ # Equality comparison
79
+ #
80
+ # @param other [Object] the object to compare
81
+ # @return [Boolean] true if equal
82
+ def ==(other)
83
+ return false unless other.is_a?(VersionInfo)
84
+
85
+ value == other.value &&
86
+ method_used == other.method_used &&
87
+ available_methods == other.available_methods
88
+ end
89
+
90
+ # Inspect representation
91
+ #
92
+ # @return [String] inspect string
93
+ def inspect
94
+ "#<VersionInfo value=\"#{value}\" method=#{method_used}>"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -98,7 +98,7 @@ module Ukiryu
98
98
  # First try exact name match
99
99
  yaml_content = load_tool_yaml(name, options.merge(register_path: register_path))
100
100
  if yaml_content
101
- hash = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
101
+ hash = YAML.safe_load(yaml_content, permitted_classes: [Symbol], aliases: true)
102
102
  return ToolMetadata.from_hash(hash, tool_name: name.to_s, register_path: register_path) if hash
103
103
  end
104
104
 
@@ -162,7 +162,7 @@ module Ukiryu
162
162
  yaml_content = load_tool_yaml(name, options)
163
163
  return Models::ValidationResult.not_found(name.to_s) unless yaml_content
164
164
 
165
- profile = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
165
+ profile = YAML.safe_load(yaml_content, permitted_classes: [Symbol], aliases: true)
166
166
  return Models::ValidationResult.invalid(name.to_s, ['Failed to parse YAML']) unless profile
167
167
 
168
168
  errors = SchemaValidator.validate_profile(profile, options)
@@ -183,7 +183,7 @@ module Ukiryu
183
183
  # @param error [Exception] the error to format
184
184
  # @return [String] the formatted error message
185
185
  def format_error_message(error)
186
- message = error.message.to_s
186
+ message = error.message.to_s.dup
187
187
  return message if error.is_a?(Thor::Error) && !message.empty?
188
188
 
189
189
  # Add class prefix for non-Thor errors or empty messages
data/lib/ukiryu/tool.rb CHANGED
@@ -13,6 +13,7 @@ require_relative 'version_detector'
13
13
  require_relative 'logger'
14
14
  require_relative 'tool_index'
15
15
  require_relative 'models/routing'
16
+ require_relative 'errors'
16
17
 
17
18
  module Ukiryu
18
19
  # Tool wrapper class for external command-line tools
@@ -441,7 +442,17 @@ module Ukiryu
441
442
  #
442
443
  # @return [String, nil] the tool version
443
444
  def version
444
- @version || detect_version
445
+ return @version if @version
446
+
447
+ info = detect_version
448
+ info&.to_s
449
+ end
450
+
451
+ # Get the tool version info (full metadata)
452
+ #
453
+ # @return [Models::VersionInfo, nil] the version info or nil
454
+ def version_info
455
+ @version_info ||= detect_version
445
456
  end
446
457
 
447
458
  # Get the definition source if loaded from non-register source
@@ -719,14 +730,23 @@ module Ukiryu
719
730
 
720
731
  # Detect tool version using VersionDetector
721
732
  #
722
- # @return [String, nil] the detected version or nil if not detected
733
+ # Supports both legacy format (command/pattern) and new methods array.
734
+ # The methods array allows fallback hierarchy: try command first,
735
+ # then man page, etc.
736
+ #
737
+ # @return [Models::VersionInfo, nil] the version info or nil if not detected
723
738
  public
724
739
 
725
740
  def detect_version
726
741
  vd = @profile.version_detection
727
742
  return nil unless vd
728
743
 
729
- # Only attempt version detection if command is configured
744
+ # Check for new detection_methods array format
745
+ if vd.respond_to?(:detection_methods) && vd.detection_methods && !vd.detection_methods.empty?
746
+ return detect_version_with_detection_methods(vd.detection_methods)
747
+ end
748
+
749
+ # Legacy format: command-based detection
730
750
  return nil if vd.command.nil? || vd.command.empty?
731
751
 
732
752
  # For man page detection, the executable is 'man' and command is the tool name
@@ -743,7 +763,7 @@ module Ukiryu
743
763
  command_args = vd.command
744
764
  end
745
765
 
746
- VersionDetector.detect(
766
+ VersionDetector.detect_info(
747
767
  executable: executable,
748
768
  command: command_args,
749
769
  pattern: vd.pattern || /(\d+\.\d+)/,
@@ -752,6 +772,61 @@ module Ukiryu
752
772
  )
753
773
  end
754
774
 
775
+ # Detect version using detection_methods array with fallback hierarchy
776
+ #
777
+ # @param detection_methods [Array] array of method definitions from YAML
778
+ # @return [Models::VersionInfo, nil] version info or nil
779
+ def detect_version_with_detection_methods(detection_methods)
780
+ # Convert YAML detection_methods to format expected by VersionDetector
781
+ detector_methods = detection_methods.map do |m|
782
+ # Handle both Hash and Lutaml::Model objects
783
+ type = if m.respond_to?(:type)
784
+ m.type
785
+ elsif m.is_a?(Hash)
786
+ m[:type] || m['type']
787
+ end
788
+
789
+ if type == :man_page || type == 'man_page'
790
+ paths = if m.respond_to?(:paths)
791
+ m.paths
792
+ elsif m.is_a?(Hash)
793
+ m[:paths] || m['paths']
794
+ else
795
+ {}
796
+ end
797
+
798
+ {
799
+ type: :man_page,
800
+ paths: paths
801
+ }
802
+ else
803
+ command = if m.respond_to?(:command)
804
+ m.command
805
+ elsif m.is_a?(Hash)
806
+ m[:command] || m['command']
807
+ end
808
+
809
+ pattern = if m.respond_to?(:pattern)
810
+ m.pattern
811
+ elsif m.is_a?(Hash)
812
+ m[:pattern] || m['pattern']
813
+ end
814
+
815
+ {
816
+ type: :command,
817
+ command: command || '--version',
818
+ pattern: pattern || /(\d+\.\d+)/
819
+ }
820
+ end
821
+ end
822
+
823
+ VersionDetector.detect_with_methods(
824
+ executable: @executable,
825
+ methods: detector_methods,
826
+ shell: @shell
827
+ )
828
+ end
829
+
755
830
  # Check version compatibility with profile requirements
756
831
  #
757
832
  # @param mode [Symbol] check mode (:strict, :lenient, :probe)
@@ -773,7 +848,7 @@ module Ukiryu
773
848
  end
774
849
 
775
850
  # If installed version unknown, probe for it
776
- installed = detect_version if !installed && mode == :probe
851
+ installed = detect_version&.to_s if !installed && mode == :probe
777
852
 
778
853
  # If still unknown, handle based on mode
779
854
  unless installed
@@ -160,7 +160,7 @@ module Ukiryu
160
160
  # Scan all tool directories for metadata
161
161
  Dir.glob(File.join(tools_dir, '*', '*.yaml')).each do |file|
162
162
  # Load only the top-level keys (metadata) without full parsing
163
- hash = YAML.safe_load(File.read(file), permitted_classes: [Symbol])
163
+ hash = YAML.safe_load(File.read(file), permitted_classes: [Symbol], aliases: true)
164
164
  next unless hash
165
165
 
166
166
  tool_name = File.basename(File.dirname(file))
@@ -197,7 +197,7 @@ module Ukiryu
197
197
  yaml_content = load_yaml_for_tool(tool_name)
198
198
  return nil unless yaml_content
199
199
 
200
- hash = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
200
+ hash = YAML.safe_load(yaml_content, permitted_classes: [Symbol], aliases: true)
201
201
  return nil unless hash
202
202
 
203
203
  ToolMetadata.from_hash(hash, tool_name: tool_name.to_s, register_path: register_path)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ukiryu
4
- VERSION = '0.1.3'
4
+ VERSION = '0.1.4'
5
5
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'executor'
4
+ require_relative 'models/version_info'
5
+ require_relative 'man_page_parser'
4
6
 
5
7
  module Ukiryu
6
8
  # Version detector for external CLI tools
@@ -10,25 +12,28 @@ module Ukiryu
10
12
  # - Regex pattern matching for version strings
11
13
  # - Proper shell handling for command execution
12
14
  # - Support for man-page based version detection (BSD/system tools)
15
+ # - Fallback hierarchy: try multiple methods, use first success
13
16
  #
14
17
  # @example Detecting version from command output (GNU tools)
15
- # version = VersionDetector.detect(
18
+ # info = VersionDetector.detect(
16
19
  # executable: '/usr/bin/ffmpeg',
17
20
  # command: '-version',
18
21
  # pattern: /version (\d+\.\d+)/,
19
22
  # shell: :bash
20
23
  # )
21
24
  #
22
- # @example Detecting version from man page (BSD/system tools)
23
- # version = VersionDetector.detect(
24
- # executable: '/usr/bin/man',
25
- # command: ['man', 'find'],
26
- # pattern: /macOS ([\d.]+)/,
27
- # source: 'man'
25
+ # @example Detecting version with fallback hierarchy
26
+ # info = VersionDetector.detect_with_methods(
27
+ # executable: '/usr/bin/xargs',
28
+ # methods: [
29
+ # { type: :command, command: '--version', pattern: /xargs \(GNU findutils\) ([\d.]+)/ },
30
+ # { type: :man_page, paths: { macos: '/usr/share/man/man1/xargs.1' } }
31
+ # ],
32
+ # shell: :bash
28
33
  # )
29
34
  module VersionDetector
30
35
  class << self
31
- # Detect the version of an external tool
36
+ # Detect the version of an external tool (legacy API)
32
37
  #
33
38
  # @param executable [String] the executable path
34
39
  # @param command [String, Array<String>] the version command (default: '--version')
@@ -37,6 +42,26 @@ module Ukiryu
37
42
  # @param source [String] the version source: 'command' (default) or 'man'
38
43
  # @return [String, nil] the detected version or nil if not found
39
44
  def detect(executable:, command: '--version', pattern: /(\d+\.\d+)/, shell: nil, source: 'command')
45
+ result = detect_info(
46
+ executable: executable,
47
+ command: command,
48
+ pattern: pattern,
49
+ shell: shell,
50
+ source: source
51
+ )
52
+
53
+ result&.value
54
+ end
55
+
56
+ # Detect version with full info (VersionInfo)
57
+ #
58
+ # @param executable [String] the executable path
59
+ # @param command [String, Array<String>] the version command (default: '--version')
60
+ # @param pattern [Regexp] the regex pattern to extract version
61
+ # @param shell [Symbol] the shell to use for execution
62
+ # @param source [String] the version source: 'command' (default) or 'man'
63
+ # @return [VersionInfo, nil] the version info or nil if not found
64
+ def detect_info(executable:, command: '--version', pattern: /(\d+\.\d+)/, shell: nil, source: 'command')
40
65
  # Return nil if executable is not found
41
66
  return nil if executable.nil? || executable.empty?
42
67
 
@@ -45,7 +70,7 @@ module Ukiryu
45
70
  # Normalize command to array
46
71
  command_args = command.is_a?(Array) ? command : [command]
47
72
 
48
- result = Executor.execute(executable, command_args, shell: shell)
73
+ result = Executor.execute(executable, command_args, shell: shell, allow_failure: true)
49
74
 
50
75
  return nil unless result.success?
51
76
 
@@ -59,11 +84,83 @@ module Ukiryu
59
84
  # Get last 500 characters to catch the OS version at bottom
60
85
  tail = output[-500..] || output
61
86
  match = tail.match(pattern)
62
- return match[1] if match
87
+ return Models::VersionInfo.new(
88
+ value: match[1],
89
+ method_used: :man_page,
90
+ available_methods: [:man_page]
91
+ ) if match
63
92
  end
64
93
 
65
94
  match = stdout.match(pattern) || stderr.match(pattern)
66
- match[1] if match
95
+
96
+ return nil unless match
97
+
98
+ Models::VersionInfo.new(
99
+ value: match[1],
100
+ method_used: :command,
101
+ available_methods: [:command]
102
+ )
103
+ rescue StandardError
104
+ # Return nil on any error (command not found, execution error, etc.)
105
+ nil
106
+ end
107
+
108
+ # Detect version using multiple methods with fallback hierarchy
109
+ #
110
+ # Tries each method in order and returns the first successful result.
111
+ # Methods are NOT mutually exclusive - they work together as fallbacks.
112
+ #
113
+ # @param executable [String] the tool executable path
114
+ # @param methods [Array<Hash>] array of method definitions
115
+ # @param shell [Symbol] the shell to use
116
+ # @return [VersionInfo, nil] version info or nil if all methods fail
117
+ def detect_with_methods(executable:, methods:, shell: nil)
118
+ shell ||= Shell.detect
119
+
120
+ # Track available methods for VersionInfo
121
+ available_methods = methods.map { |m| m[:type] }.uniq
122
+
123
+ # Try each method in order
124
+ methods.each do |method|
125
+ case method[:type]
126
+ when :command
127
+ # Try command-based detection
128
+ info = detect_info(
129
+ executable: executable,
130
+ command: method[:command] || '--version',
131
+ pattern: method[:pattern] || /(\d+\.\d+)/,
132
+ shell: shell,
133
+ source: 'command'
134
+ )
135
+
136
+ return info if info
137
+
138
+ when :man_page
139
+ # Try man page date extraction
140
+ paths = method[:paths] || {}
141
+
142
+ # Resolve man page path for current platform
143
+ require_relative 'platform'
144
+ platform = Platform.detect
145
+ man_path = paths[platform] || paths[platform.to_s]
146
+
147
+ next unless man_path
148
+
149
+ # Parse date from man page
150
+ date_str = ManPageParser.parse_date(man_path)
151
+
152
+ next unless date_str
153
+
154
+ return Models::VersionInfo.new(
155
+ value: date_str,
156
+ method_used: :man_page,
157
+ available_methods: available_methods
158
+ )
159
+ end
160
+ end
161
+
162
+ # All methods failed
163
+ nil
67
164
  end
68
165
  end
69
166
  end
data/ukiryu.gemspec CHANGED
@@ -29,7 +29,7 @@ Gem::Specification.new do |spec|
29
29
  spec.required_ruby_version = '>= 2.7.0'
30
30
 
31
31
  # Core dependencies
32
- spec.add_dependency 'git', '~> 4.0'
32
+ spec.add_dependency 'git', '~> 3.0'
33
33
  spec.add_dependency 'lutaml-model', '~> 0.7'
34
34
  spec.add_dependency 'thor', '~> 1.0'
35
35
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ukiryu
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '4.0'
18
+ version: '3.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '4.0'
25
+ version: '3.0'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: lutaml-model
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -178,6 +178,7 @@ files:
178
178
  - lib/ukiryu/extractors/native_extractor.rb
179
179
  - lib/ukiryu/io.rb
180
180
  - lib/ukiryu/logger.rb
181
+ - lib/ukiryu/man_page_parser.rb
181
182
  - lib/ukiryu/models.rb
182
183
  - lib/ukiryu/models/argument.rb
183
184
  - lib/ukiryu/models/argument_definition.rb
@@ -202,6 +203,7 @@ files:
202
203
  - lib/ukiryu/models/validation_result.rb
203
204
  - lib/ukiryu/models/version_compatibility.rb
204
205
  - lib/ukiryu/models/version_detection.rb
206
+ - lib/ukiryu/models/version_info.rb
205
207
  - lib/ukiryu/options/base.rb
206
208
  - lib/ukiryu/options_builder.rb
207
209
  - lib/ukiryu/options_builder/formatter.rb