ukiryu 0.1.0 → 0.1.1

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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/docs.yml +63 -0
  3. data/.github/workflows/links.yml +99 -0
  4. data/.github/workflows/rake.yml +19 -0
  5. data/.github/workflows/release.yml +27 -0
  6. data/.gitignore +18 -4
  7. data/.rubocop.yml +1 -0
  8. data/.rubocop_todo.yml +213 -0
  9. data/Gemfile +12 -8
  10. data/README.adoc +613 -0
  11. data/Rakefile +2 -2
  12. data/docs/assets/logo.svg +1 -0
  13. data/exe/ukiryu +11 -0
  14. data/lib/ukiryu/action/base.rb +77 -0
  15. data/lib/ukiryu/cache.rb +199 -0
  16. data/lib/ukiryu/cli.rb +133 -307
  17. data/lib/ukiryu/cli_commands/base_command.rb +155 -0
  18. data/lib/ukiryu/cli_commands/commands_command.rb +120 -0
  19. data/lib/ukiryu/cli_commands/commands_command.rb.fixed +40 -0
  20. data/lib/ukiryu/cli_commands/config_command.rb +249 -0
  21. data/lib/ukiryu/cli_commands/describe_command.rb +326 -0
  22. data/lib/ukiryu/cli_commands/describe_command.rb.fixed +254 -0
  23. data/lib/ukiryu/cli_commands/exec_inline_command.rb.fixed +180 -0
  24. data/lib/ukiryu/cli_commands/extract_command.rb +84 -0
  25. data/lib/ukiryu/cli_commands/info_command.rb +156 -0
  26. data/lib/ukiryu/cli_commands/list_command.rb +70 -0
  27. data/lib/ukiryu/cli_commands/opts_command.rb +106 -0
  28. data/lib/ukiryu/cli_commands/opts_command.rb.fixed +105 -0
  29. data/lib/ukiryu/cli_commands/response_formatter.rb +240 -0
  30. data/lib/ukiryu/cli_commands/run_command.rb +375 -0
  31. data/lib/ukiryu/cli_commands/run_file_command.rb +215 -0
  32. data/lib/ukiryu/cli_commands/system_command.rb +90 -0
  33. data/lib/ukiryu/cli_commands/validate_command.rb +87 -0
  34. data/lib/ukiryu/cli_commands/version_command.rb +16 -0
  35. data/lib/ukiryu/cli_commands/which_command.rb +166 -0
  36. data/lib/ukiryu/command_builder.rb +205 -0
  37. data/lib/ukiryu/config/env_provider.rb +64 -0
  38. data/lib/ukiryu/config/env_schema.rb +63 -0
  39. data/lib/ukiryu/config/override_resolver.rb +68 -0
  40. data/lib/ukiryu/config/type_converter.rb +59 -0
  41. data/lib/ukiryu/config.rb +249 -0
  42. data/lib/ukiryu/errors.rb +3 -0
  43. data/lib/ukiryu/executable_locator.rb +114 -0
  44. data/lib/ukiryu/execution/command_info.rb +64 -0
  45. data/lib/ukiryu/execution/metadata.rb +97 -0
  46. data/lib/ukiryu/execution/output.rb +144 -0
  47. data/lib/ukiryu/execution/result.rb +194 -0
  48. data/lib/ukiryu/execution.rb +15 -0
  49. data/lib/ukiryu/execution_context.rb +251 -0
  50. data/lib/ukiryu/executor.rb +76 -493
  51. data/lib/ukiryu/extractors/base_extractor.rb +63 -0
  52. data/lib/ukiryu/extractors/extractor.rb +150 -0
  53. data/lib/ukiryu/extractors/help_parser.rb +188 -0
  54. data/lib/ukiryu/extractors/native_extractor.rb +47 -0
  55. data/lib/ukiryu/io.rb +196 -0
  56. data/lib/ukiryu/logger.rb +544 -0
  57. data/lib/ukiryu/models/argument.rb +28 -0
  58. data/lib/ukiryu/models/argument_definition.rb +119 -0
  59. data/lib/ukiryu/models/arguments.rb +113 -0
  60. data/lib/ukiryu/models/command_definition.rb +176 -0
  61. data/lib/ukiryu/models/command_info.rb +37 -0
  62. data/lib/ukiryu/models/components.rb +107 -0
  63. data/lib/ukiryu/models/env_var_definition.rb +30 -0
  64. data/lib/ukiryu/models/error_response.rb +41 -0
  65. data/lib/ukiryu/models/execution_metadata.rb +31 -0
  66. data/lib/ukiryu/models/execution_report.rb +236 -0
  67. data/lib/ukiryu/models/exit_codes.rb +74 -0
  68. data/lib/ukiryu/models/flag_definition.rb +67 -0
  69. data/lib/ukiryu/models/option_definition.rb +102 -0
  70. data/lib/ukiryu/models/output_info.rb +25 -0
  71. data/lib/ukiryu/models/platform_profile.rb +153 -0
  72. data/lib/ukiryu/models/routing.rb +211 -0
  73. data/lib/ukiryu/models/search_paths.rb +39 -0
  74. data/lib/ukiryu/models/success_response.rb +85 -0
  75. data/lib/ukiryu/models/tool_definition.rb +145 -0
  76. data/lib/ukiryu/models/tool_metadata.rb +82 -0
  77. data/lib/ukiryu/models/validation_result.rb +80 -0
  78. data/lib/ukiryu/models/version_compatibility.rb +152 -0
  79. data/lib/ukiryu/models/version_detection.rb +39 -0
  80. data/lib/ukiryu/models.rb +23 -0
  81. data/lib/ukiryu/options/base.rb +95 -0
  82. data/lib/ukiryu/options_builder/formatter.rb +87 -0
  83. data/lib/ukiryu/options_builder/validator.rb +43 -0
  84. data/lib/ukiryu/options_builder.rb +311 -0
  85. data/lib/ukiryu/platform.rb +6 -6
  86. data/lib/ukiryu/registry.rb +143 -183
  87. data/lib/ukiryu/response/base.rb +217 -0
  88. data/lib/ukiryu/runtime.rb +179 -0
  89. data/lib/ukiryu/schema_validator.rb +8 -10
  90. data/lib/ukiryu/shell/bash.rb +3 -3
  91. data/lib/ukiryu/shell/cmd.rb +4 -4
  92. data/lib/ukiryu/shell/fish.rb +1 -1
  93. data/lib/ukiryu/shell/powershell.rb +3 -3
  94. data/lib/ukiryu/shell/sh.rb +1 -1
  95. data/lib/ukiryu/shell/zsh.rb +1 -1
  96. data/lib/ukiryu/shell.rb +146 -39
  97. data/lib/ukiryu/thor_ext.rb +208 -0
  98. data/lib/ukiryu/tool.rb +649 -258
  99. data/lib/ukiryu/tool_index.rb +224 -0
  100. data/lib/ukiryu/tools/base.rb +381 -0
  101. data/lib/ukiryu/tools/class_generator.rb +132 -0
  102. data/lib/ukiryu/tools/executable_finder.rb +29 -0
  103. data/lib/ukiryu/tools/generator.rb +154 -0
  104. data/lib/ukiryu/tools.rb +109 -0
  105. data/lib/ukiryu/type.rb +28 -43
  106. data/lib/ukiryu/validation/constraints.rb +281 -0
  107. data/lib/ukiryu/validation/validator.rb +188 -0
  108. data/lib/ukiryu/validation.rb +21 -0
  109. data/lib/ukiryu/version.rb +1 -1
  110. data/lib/ukiryu/version_detector.rb +51 -0
  111. data/lib/ukiryu.rb +31 -15
  112. data/ukiryu-proposal.md +2952 -0
  113. data/ukiryu.gemspec +18 -14
  114. metadata +137 -5
  115. data/.github/workflows/test.yml +0 -143
@@ -1,115 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
4
- require "find"
3
+ require 'yaml'
4
+ require_relative 'models/tool_metadata'
5
+ require_relative 'models/validation_result'
6
+ require_relative 'tool_index'
7
+ require_relative 'schema_validator'
5
8
 
6
9
  module Ukiryu
7
10
  # YAML profile registry loader
8
11
  #
9
- # Loads tool definitions from YAML profiles in a registry directory.
12
+ # Provides access to tool definitions from YAML profiles in a registry directory.
13
+ # Supports lazy loading: metadata can be loaded without full profile parsing.
14
+ #
15
+ # Features:
16
+ # - Cached version listings to avoid repeated glob operations
17
+ # - Automatic cache invalidation when registry path changes
10
18
  class Registry
11
19
  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
20
  # Set the default registry path
102
21
  #
103
22
  # @param path [String] the default registry path
104
- def default_registry_path=(path)
105
- @default_registry_path = path
106
- end
107
23
 
108
24
  # Get the default registry path
109
25
  #
110
26
  # @return [String, nil] the default registry path
111
- def default_registry_path
112
- @default_registry_path
27
+ attr_accessor :default_registry_path
28
+
29
+ # Reset the version cache (mainly for testing)
30
+ def reset_version_cache
31
+ @version_cache = nil
32
+ @registry_cache_key = nil
113
33
  end
114
34
 
115
35
  # Get all available tool names
@@ -119,128 +39,168 @@ module Ukiryu
119
39
  registry_path = @default_registry_path
120
40
  return [] unless registry_path
121
41
 
122
- tools_dir = File.join(registry_path, "tools")
42
+ tools_dir = File.join(registry_path, 'tools')
123
43
  return [] unless Dir.exist?(tools_dir)
124
44
 
125
45
  # List all directories in tools/
126
- Dir.glob(File.join(tools_dir, "*")).select do |path|
46
+ Dir.glob(File.join(tools_dir, '*')).select do |path|
127
47
  File.directory?(path)
128
48
  end.map do |path|
129
49
  File.basename(path)
130
50
  end.sort
131
51
  end
132
52
 
133
- # Find the newest compatible version of a tool
53
+ # Get available versions for a tool (cached)
134
54
  #
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]
55
+ # @param name [String, Symbol] the tool name
56
+ # @param registry_path [String, nil] the registry path
57
+ # @return [Hash] mapping of version filename to Gem::Version
58
+ def list_versions(name, registry_path: nil)
59
+ registry_path ||= @default_registry_path
60
+ return {} unless registry_path
61
+
62
+ # Initialize cache
63
+ @version_cache ||= {}
64
+ @registry_cache_key ||= registry_path
65
+
66
+ # Clear cache if registry path changed
67
+ if @registry_cache_key != registry_path
68
+ @version_cache.clear
69
+ @registry_cache_key = registry_path
70
+ end
148
71
 
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]
72
+ # Check cache
73
+ cache_key = name.to_sym
74
+ return @version_cache[cache_key].dup if @version_cache[cache_key]
153
75
 
154
- platform_match = profile_platforms.include?(platform) if profile_platforms
155
- shell_match = profile_shells.include?(shell) if profile_shells
76
+ # Build version list
77
+ versions = scan_tool_versions(name, registry_path)
78
+ @version_cache[cache_key] = versions
156
79
 
157
- (platform_match || profile_platforms.nil?) && (shell_match || profile_shells.nil?)
158
- end
80
+ versions.dup
81
+ end
159
82
 
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
83
+ # Load tool metadata only (lightweight, without full parsing)
84
+ # This is much faster than loading the full definition when only metadata is needed
85
+ #
86
+ # Supports both exact name lookup and interface-based discovery
87
+ #
88
+ # @param name [String, Symbol] the tool name or interface name
89
+ # @param options [Hash] loading options
90
+ # @option options [String] :version specific version to load
91
+ # @option options [String] :registry_path path to registry
92
+ # @return [ToolMetadata, nil] the tool metadata or nil if not found
93
+ def load_tool_metadata(name, options = {})
94
+ registry_path = options[:registry_path] || @default_registry_path
166
95
 
167
- constraint.satisfied_by?(Gem::Version.new(profile_version))
168
- end
96
+ # First try exact name match
97
+ yaml_content = load_tool_yaml(name, options.merge(registry_path: registry_path))
98
+ if yaml_content
99
+ hash = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
100
+ return ToolMetadata.from_hash(hash, tool_name: name.to_s, registry_path: registry_path) if hash
169
101
  end
170
102
 
171
- # Return the first matching profile (prefer newer versions)
172
- candidates.first
103
+ # If not found, try interface-based discovery using ToolIndex
104
+ index = ToolIndex.instance
105
+ index.find_by_interface(name.to_sym)
173
106
  end
174
107
 
175
- private
108
+ # Load tool YAML file content (for lutaml-model parsing)
109
+ #
110
+ # @param name [String, Symbol] the tool name
111
+ # @param options [Hash] loading options
112
+ # @option options [String] :version specific version to load
113
+ # @option options [String] :registry_path path to registry
114
+ # @return [String, nil] the YAML content or nil if not found
115
+ def load_tool_yaml(name, options = {})
116
+ registry_path = options[:registry_path] || @default_registry_path
176
117
 
177
- # Load all profiles for a specific tool
178
- def load_tool_profiles(name)
179
- registry_path = @default_registry_path
180
118
  return nil unless registry_path
181
119
 
182
- pattern = File.join(registry_path, "tools", name, "*.yaml")
183
- files = Dir.glob(pattern)
120
+ # Convert to string for path operations
121
+ name_str = name.to_s
184
122
 
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
123
+ # Try version-specific directory first
124
+ version = options[:version]
125
+ if version
126
+ file = File.join(registry_path, 'tools', name_str, "#{version}.yaml")
127
+ return File.read(file) if File.exist?(file)
196
128
  end
197
- end
198
129
 
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]
130
+ # Use cached version list if available
131
+ versions = list_versions(name_str, registry_path: registry_path)
205
132
 
206
- base_dir = File.dirname(file_path)
133
+ if versions.empty?
134
+ # Try the old format (single file per tool)
135
+ file = File.join(registry_path, 'tools', "#{name_str}.yaml")
136
+ return File.read(file) if File.exist?(file)
207
137
 
208
- profile[:profiles].each do |p|
209
- next unless p[:inherits]
138
+ return nil
139
+ end
210
140
 
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
141
+ # Return specific version if requested
142
+ if version
143
+ version_file = versions.keys.find { |f| File.basename(f, '.yaml') == version }
144
+ return version_file ? File.read(version_file) : nil
216
145
  end
146
+
147
+ # Return the latest version (already sorted from scan_tool_versions)
148
+ File.read(versions.keys.last)
217
149
  end
218
150
 
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 }
151
+ # Validate a tool profile against the schema
152
+ #
153
+ # @param name [String, Symbol] the tool name
154
+ # @param options [Hash] validation options
155
+ # @option options [String] :version specific version to validate
156
+ # @option options [String] :registry_path path to registry
157
+ # @option options [String] :schema_path path to schema file
158
+ # @return [ValidationResult] the validation result
159
+ def validate_tool(name, options = {})
160
+ yaml_content = load_tool_yaml(name, options)
161
+ return Models::ValidationResult.not_found(name.to_s) unless yaml_content
162
+
163
+ profile = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
164
+ return Models::ValidationResult.invalid(name.to_s, ['Failed to parse YAML']) unless profile
165
+
166
+ errors = SchemaValidator.validate_profile(profile, options)
167
+ if errors.empty?
168
+ Models::ValidationResult.valid(name.to_s)
169
+ else
170
+ Models::ValidationResult.invalid(name.to_s, errors)
171
+ end
222
172
  end
223
173
 
224
- # Recursively convert string keys to symbols
174
+ # Validate all tool profiles in the registry
225
175
  #
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
176
+ # @param options [Hash] validation options
177
+ # @option options [String] :registry_path path to registry
178
+ # @option options [String] :schema_path path to schema file
179
+ # @return [Array<ValidationResult>] list of validation results
180
+ def validate_all_tools(options = {})
181
+ tools.map do |tool_name|
182
+ validate_tool(tool_name, options)
242
183
  end
243
184
  end
185
+
186
+ private
187
+
188
+ # Scan tool versions and sort by Gem::Version
189
+ #
190
+ # @param name [String] the tool name
191
+ # @param registry_path [String] the registry path
192
+ # @return [Hash] mapping of version filename to Gem::Version
193
+ def scan_tool_versions(name, registry_path)
194
+ pattern = File.join(registry_path, 'tools', name.to_s, '*.yaml')
195
+ files = Dir.glob(pattern)
196
+
197
+ # Sort files by Gem::Version for proper version ordering
198
+ files.sort_by { |f| Gem::Version.new(File.basename(f, '.yaml')) }
199
+ .each_with_object({}) { |file, hash| hash[file] = Gem::Version.new(File.basename(file, '.yaml')) }
200
+ rescue ArgumentError
201
+ # If version parsing fails, return unsorted files
202
+ files.each_with_object({}) { |file, hash| hash[file] = File.basename(file, '.yaml') }
203
+ end
244
204
  end
245
205
  end
246
206
  end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ukiryu
4
+ module Response
5
+ # Abstract base class for all response classes
6
+ #
7
+ # This class wraps the raw Executor::Result object and provides
8
+ # a structured interface for accessing command execution results.
9
+ #
10
+ # @abstract
11
+ class Base
12
+ # Create a new response
13
+ #
14
+ # @param result [Executor::Result] the raw execution result
15
+ def initialize(result)
16
+ @result = result
17
+ end
18
+
19
+ # Check if the command was successful
20
+ #
21
+ # @return [Boolean] true if exit code was 0
22
+ def success?
23
+ @result.status.zero?
24
+ end
25
+
26
+ # Get the exit code
27
+ #
28
+ # @return [Integer] the exit code
29
+ def exit_code
30
+ @result.status
31
+ end
32
+
33
+ # Get the exit code meaning (symbol)
34
+ #
35
+ # @return [String, nil] the exit code meaning (e.g., "merge_conflict") or nil if not defined
36
+ def exit_code_meaning
37
+ tool_name = @result.command_info.tool_name
38
+ command_name = @result.command_info.command_name
39
+ return nil unless tool_name && command_name
40
+
41
+ # Look up the tool by name
42
+ require_relative '../tool'
43
+ tool = Tool.find_by(tool_name.to_sym)
44
+ return nil unless tool
45
+
46
+ # Get exit codes from the tool's profile
47
+ profile = tool.profile
48
+ return nil unless profile
49
+
50
+ command_profile = profile.compatible_profile
51
+ return nil unless command_profile
52
+
53
+ # First, try to get exit codes from the specific command
54
+ command = command_profile.command(command_name.to_s)
55
+ exit_codes = command&.exit_codes
56
+
57
+ # Fall back to profile-level exit codes if command doesn't define its own
58
+ exit_codes ||= command_profile.exit_codes
59
+ return nil unless exit_codes
60
+
61
+ exit_codes.meaning(@result.status)
62
+ end
63
+
64
+ # Get the standard output
65
+ #
66
+ # @return [String] the stdout content
67
+ def stdout
68
+ @result.output
69
+ end
70
+
71
+ # Get the standard error
72
+ #
73
+ # @return [String] the stderr content
74
+ def stderr
75
+ @result.error_output
76
+ end
77
+
78
+ # Get stdout as lines
79
+ #
80
+ # @return [Array<String>] the stdout split into lines
81
+ def stdout_lines
82
+ @result.stdout_lines
83
+ end
84
+
85
+ # Get stderr as lines
86
+ #
87
+ # @return [Array<String>] the stderr split into lines
88
+ def stderr_lines
89
+ @result.stderr_lines
90
+ end
91
+
92
+ # Get the command that was executed
93
+ #
94
+ # @return [String] the full command string
95
+ def command
96
+ @result.command_info.full_command
97
+ end
98
+
99
+ # Get the executable path
100
+ #
101
+ # @return [String] the executable that was run
102
+ def executable
103
+ @result.command_info.executable
104
+ end
105
+
106
+ # Get the command arguments
107
+ #
108
+ # @return [Array<String>] the arguments passed to the command
109
+ def arguments
110
+ @result.command_info.arguments
111
+ end
112
+
113
+ # Get the shell type used
114
+ #
115
+ # @return [Symbol] the shell type
116
+ def shell
117
+ @result.command_info.shell
118
+ end
119
+
120
+ # Get the execution duration
121
+ #
122
+ # @return [Float] duration in seconds
123
+ def duration
124
+ @result.metadata.duration
125
+ end
126
+
127
+ # Get the formatted duration string
128
+ #
129
+ # @return [String] human-readable duration (e.g., "1.2s", "450ms")
130
+ def formatted_duration
131
+ @result.metadata.formatted_duration
132
+ end
133
+
134
+ # Get the start time
135
+ #
136
+ # @return [Time] when the command started
137
+ def started_at
138
+ @result.metadata.started_at
139
+ end
140
+
141
+ # Get the end time
142
+ #
143
+ # @return [Time] when the command finished
144
+ def finished_at
145
+ @result.metadata.finished_at
146
+ end
147
+
148
+ # Check if the command timed out
149
+ #
150
+ # @return [Boolean] true if the command exceeded its timeout
151
+ def timed_out?
152
+ @result.timeout_exceeded?
153
+ end
154
+
155
+ # Get the raw result object
156
+ #
157
+ # @return [Executor::Result] the raw execution result
158
+ def raw_result
159
+ @result
160
+ end
161
+
162
+ # Convert to hash representation
163
+ #
164
+ # @return [Hash] hash representation of the response
165
+ def to_h
166
+ hash = {
167
+ success: success?,
168
+ exit_code: exit_code,
169
+ stdout: stdout,
170
+ stderr: stderr,
171
+ command: command,
172
+ executable: executable,
173
+ arguments: arguments,
174
+ shell: shell,
175
+ duration: duration,
176
+ started_at: started_at.iso8601,
177
+ finished_at: finished_at.iso8601
178
+ }
179
+
180
+ # Add exit code meaning if available
181
+ meaning = exit_code_meaning
182
+ hash[:exit_code_meaning] = meaning if meaning
183
+
184
+ hash
185
+ end
186
+ alias to_hash to_h
187
+
188
+ # String representation
189
+ #
190
+ # @return [String] summary of the response
191
+ def to_s
192
+ if success?
193
+ "Success (exit #{exit_code}#{format_meaning}, #{formatted_duration})"
194
+ else
195
+ "Failed (exit #{exit_code}#{format_meaning}, #{formatted_duration})"
196
+ end
197
+ end
198
+
199
+ # Format exit code meaning
200
+ #
201
+ # @return [String] formatted meaning (e.g., ": merge_conflict")
202
+ def format_meaning
203
+ meaning = exit_code_meaning
204
+ return '' unless meaning
205
+
206
+ ": #{meaning}"
207
+ end
208
+
209
+ # Inspect representation
210
+ #
211
+ # @return [String] detailed inspection string
212
+ def inspect
213
+ "#<#{self.class.name} success=#{success?} exit_code=#{exit_code} duration=#{formatted_duration}>"
214
+ end
215
+ end
216
+ end
217
+ end