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
@@ -0,0 +1,375 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_command'
4
+ require_relative 'response_formatter'
5
+ require_relative '../tool'
6
+ require_relative '../executor'
7
+ require_relative '../logger'
8
+ require_relative '../models/success_response'
9
+ require_relative '../models/error_response'
10
+ require_relative '../models/execution_report'
11
+ require 'yaml'
12
+
13
+ module Ukiryu
14
+ module CliCommands
15
+ # Execute a tool command inline (shorthand for run)
16
+ class RunCommand < BaseCommand
17
+ include ResponseFormatter
18
+
19
+ # Execute the command
20
+ #
21
+ # @param tool_name [String] the tool name
22
+ # @param command_name [String, nil] the command name (optional, uses default if nil)
23
+ # @param params [Array<String>] key=value parameter pairs
24
+ def run(tool_name, command_name = nil, *params)
25
+ setup_registry
26
+
27
+ # Handle the case where command_name is omitted and first param is a key=value pair
28
+ # When user types: ukiryu exec-inline ping host=127.0.0.1
29
+ # Thor interprets it as: tool_name="ping", command_name="host=127.0.0.1", params=["count=1"]
30
+ # We need to detect if command_name looks like a parameter
31
+ if command_name&.include?('=')
32
+ # command_name is actually a parameter, shift it back to params
33
+ params.unshift(command_name)
34
+ command_name = nil
35
+ end
36
+
37
+ # Special handling for "help" command
38
+ if command_name&.to_s == 'help'
39
+ show_tool_help(tool_name, params)
40
+ return
41
+ end
42
+
43
+ # Resolve command name if not provided
44
+ command_name ||= resolve_default_command(tool_name)
45
+
46
+ # Parse key=value pairs into arguments hash
47
+ arguments = parse_inline_params(params)
48
+
49
+ # Handle stdin from CLI flag (--stdin) or special parameter (stdin=-)
50
+ if options[:stdin] || arguments[:stdin] == '-'
51
+ # Read from actual stdin
52
+ stdin_data = $stdin.read
53
+ arguments[:stdin] = stdin_data
54
+ elsif arguments[:stdin]
55
+ # stdin parameter contains data (string or file path)
56
+ # If it starts with @, treat as file path
57
+ if arguments[:stdin].is_a?(String) && arguments[:stdin].start_with?('@')
58
+ file_path = arguments[:stdin][1..]
59
+ begin
60
+ arguments[:stdin] = File.read(file_path)
61
+ rescue Errno::ENOENT
62
+ error! "File not found: #{file_path}"
63
+ end
64
+ end
65
+ # Otherwise, use the value as-is (already a string or IO object)
66
+ end
67
+
68
+ # Build execution request
69
+ request = {
70
+ 'tool' => tool_name,
71
+ 'command' => command_name,
72
+ 'arguments' => arguments
73
+ }
74
+
75
+ # Output debug: Ukiryu CLI Options
76
+ if config.debug
77
+ logger = Ukiryu::Logger.new
78
+ ukiryu_options = {
79
+ format: config.format,
80
+ debug: config.debug,
81
+ dry_run: config.dry_run,
82
+ output: config.output,
83
+ registry: config.registry,
84
+ stdin: !arguments[:stdin].nil?
85
+ }
86
+ logger.debug_section_ukiryu_options(ukiryu_options)
87
+ end
88
+
89
+ # Get format from Config (priority: CLI > ENV > programmatic > default)
90
+ # --raw flag overrides the format setting
91
+ format = if options[:raw]
92
+ :raw
93
+ else
94
+ config.format.to_sym
95
+ end
96
+ error! "Invalid format: #{format}. Must be one of: #{OUTPUT_FORMATS.join(', ')}" unless OUTPUT_FORMATS.include?(format)
97
+
98
+ if config.dry_run
99
+ # Show dry run output
100
+ say_dry_run(request)
101
+ return
102
+ end
103
+
104
+ # Execute the request
105
+ response = execute_request(request, tool_name, command_name)
106
+
107
+ # Output response
108
+ output_file = config.output
109
+ output_response(response, format, output_file, config)
110
+
111
+ # Don't exit here - let Thor handle the result
112
+ end
113
+
114
+ private
115
+
116
+ # Resolve the default command for a tool
117
+ # Uses the tool's default_command from profile, or falls back to the interface it implements
118
+ #
119
+ # @param tool_name [String] the tool name
120
+ # @return [String] the resolved command name
121
+ def resolve_default_command(tool_name)
122
+ # Use Registry to load tool metadata without full resolution
123
+ # This avoids triggering debug output for "Tool Resolution" twice
124
+ require_relative '../registry'
125
+ require_relative '../models/tool_metadata'
126
+
127
+ metadata = Registry.load_tool_metadata(tool_name.to_sym, registry_path: config.registry)
128
+ error! "Tool not found: #{tool_name}" unless metadata
129
+
130
+ # Get the default command (checks YAML default_command, then implements, then tool name)
131
+ command = metadata.default_command
132
+ return command.to_s if command
133
+
134
+ # Fallback
135
+ 'default'
136
+ end
137
+
138
+ # Parse inline key=value params into a hash
139
+ def parse_inline_params(params_array)
140
+ arguments = {}
141
+
142
+ params_array.each do |param|
143
+ if param.include?('=')
144
+ key, value = param.split('=', 2)
145
+
146
+ # Special case: stdin parameter with special values should be treated as string literals
147
+ # - stdin=- : marker for reading from actual stdin
148
+ # - stdin=@filename : marker for reading from file
149
+ # These syntaxes use characters that are invalid YAML, so skip YAML parsing
150
+ skip_yaml_parse = key == 'stdin' && (value == '-' || value.start_with?('@'))
151
+
152
+ unless skip_yaml_parse
153
+ # Try to parse value as YAML to handle types properly
154
+ begin
155
+ parsed_value = YAML.safe_load(value, permitted_classes: [Symbol])
156
+ value = parsed_value
157
+ rescue StandardError
158
+ # Keep as string if YAML parsing fails
159
+ end
160
+
161
+ # Convert key to symbol for consistency with API
162
+ end
163
+ arguments[key.to_sym] = value
164
+ else
165
+ error! "Invalid parameter format: #{param}. Use key=value"
166
+ end
167
+ end
168
+
169
+ arguments
170
+ end
171
+
172
+ # Execute the request and build response
173
+ def execute_request(request, tool_name = nil, command_name = nil)
174
+ tool_name ||= request['tool']
175
+ command_name ||= request['command']
176
+ arguments = stringify_keys(request['arguments'])
177
+
178
+ logger = Ukiryu::Logger.new if config.debug
179
+ collect_metrics = config.metrics
180
+
181
+ # Initialize execution report if metrics are enabled
182
+ execution_report = if collect_metrics
183
+ Models::ExecutionReport.new(
184
+ tool_resolution: Models::StageMetrics.new(name: 'tool_resolution'),
185
+ command_building: Models::StageMetrics.new(name: 'command_building'),
186
+ execution: Models::StageMetrics.new(name: 'execution'),
187
+ response_building: Models::StageMetrics.new(name: 'response_building'),
188
+ run_environment: Models::RunEnvironment.collect,
189
+ timestamp: Time.now.iso8601
190
+ )
191
+ end
192
+
193
+ begin
194
+ # Stage: Tool Resolution
195
+ execution_report.tool_resolution.start! if collect_metrics
196
+
197
+ # Get tool - try find_by first for interface-based discovery, fallback to get
198
+ tool = Tool.find_by(tool_name.to_sym) || Tool.get(tool_name.to_sym)
199
+ return Models::ErrorResponse.from_message("Tool not available: #{tool_name}") unless tool
200
+
201
+ return Models::ErrorResponse.from_message("Tool found but not executable: #{tool_name}") unless tool.available?
202
+
203
+ execution_report.tool_resolution.finish! if collect_metrics
204
+
205
+ # Stage: Command Building
206
+ execution_report.command_building.start! if collect_metrics
207
+
208
+ # Get command definition for context
209
+ command_definition = tool.command_definition(command_name.to_sym)
210
+
211
+ # Build options object (OOP approach)
212
+ # Note: stdin is a special parameter, not passed to options
213
+ options_arguments = arguments.reject { |k, _| k == :stdin }
214
+ options_class = tool.options_for(command_name.to_sym)
215
+ options = options_class.new
216
+ options_arguments.each { |key, value| options.send("#{key}=", value) }
217
+
218
+ execution_report.command_building.finish! if collect_metrics
219
+
220
+ # Output debug: Structured Options (the tool's options object)
221
+ logger.debug_section_structured_options(tool_name, command_name, options) if config.debug && logger
222
+
223
+ # Stage: Execution
224
+ execution_report.execution.start! if collect_metrics
225
+
226
+ # Execute command (pass arguments hash with stdin, not just options object)
227
+ result = tool.execute(command_name.to_sym, arguments)
228
+
229
+ execution_report.execution.finish! if collect_metrics
230
+
231
+ # Output debug: Shell Command
232
+ if config.debug && logger
233
+ # CommandInfo doesn't have env_vars, so we pass empty hash
234
+ logger.debug_section_shell_command(
235
+ executable: result.command_info.executable,
236
+ full_command: result.command_info.full_command
237
+ )
238
+ end
239
+
240
+ # Output debug: Raw Response
241
+ if config.debug && logger
242
+ logger.debug_section_raw_response(
243
+ stdout: result.stdout,
244
+ stderr: result.stderr,
245
+ exit_code: result.status
246
+ )
247
+ end
248
+
249
+ # Stage: Response Building
250
+ execution_report.response_building.start! if collect_metrics
251
+
252
+ # Build successful response with original arguments and command definition
253
+ response = Models::SuccessResponse.from_result(
254
+ result,
255
+ arguments,
256
+ command_definition,
257
+ execution_report: collect_metrics ? execution_report : nil
258
+ )
259
+
260
+ execution_report.response_building.finish! if collect_metrics
261
+
262
+ # Calculate total duration
263
+ execution_report.calculate_total if collect_metrics
264
+
265
+ # Output debug: Execution Report
266
+ logger.debug_section_execution_report(execution_report) if config.debug && logger && collect_metrics
267
+
268
+ # Output debug: Structured Response
269
+ logger.debug_section_structured_response(response) if config.debug && logger
270
+
271
+ response
272
+ rescue Ukiryu::ToolNotFoundError => e
273
+ Models::ErrorResponse.from_message("Tool not found: #{e.message}")
274
+ rescue Ukiryu::ProfileNotFoundError => e
275
+ Models::ErrorResponse.from_message("Profile not found: #{e.message}")
276
+ rescue Ukiryu::ExecutionError => e
277
+ Models::ErrorResponse.from_message(e.message)
278
+ rescue Ukiryu::TimeoutError => e
279
+ Models::ErrorResponse.from_message("Command timed out: #{e.message}")
280
+ rescue ArgumentError => e
281
+ # Output full backtrace for debugging
282
+ warn 'ArgumentError backtrace:' if config.debug
283
+ e.backtrace.each { |line| warn " #{line}" } if config.debug
284
+ Models::ErrorResponse.from_message("Invalid arguments: #{e.message}")
285
+ rescue StandardError => e
286
+ # Output full backtrace for debugging
287
+ warn 'StandardError backtrace:' if config.debug
288
+ e.backtrace.each { |line| warn " #{line}" } if config.debug
289
+ Models::ErrorResponse.from_message("Unexpected error: #{e.class}: #{e.message}")
290
+ ensure
291
+ # Ensure metrics are finished even on error
292
+ if collect_metrics && execution_report
293
+ execution_report.tool_resolution.finish!(success: false) unless execution_report.tool_resolution.duration
294
+ execution_report.command_building.finish!(success: false) unless execution_report.command_building.duration
295
+ execution_report.execution.finish!(success: false) unless execution_report.execution.duration
296
+ execution_report.response_building.finish!(success: false) unless execution_report.response_building.duration
297
+ end
298
+ end
299
+ end
300
+
301
+ # Show dry run output
302
+ #
303
+ # @param request [Hash] the execution request
304
+ def say_dry_run(request)
305
+ say 'DRY RUN - Ukiryu Structured Execution Request:', :yellow
306
+ say '', :clear
307
+ say "Tool: #{request['tool']}", :cyan
308
+ say "Command: #{request['command']}", :cyan
309
+ say 'Arguments:', :cyan
310
+ request['arguments'].each do |key, value|
311
+ if key == :stdin
312
+ # Show stdin preview (first 100 chars)
313
+ preview = value.is_a?(String) ? value[0..100] : '[IO Stream]'
314
+ preview += '...' if value.is_a?(String) && value.length > 100
315
+ say " #{key}: #{preview.inspect}", :white
316
+ else
317
+ say " #{key}: #{value.inspect}", :white
318
+ end
319
+ end
320
+ end
321
+
322
+ # Show help information for a tool
323
+ #
324
+ # @param tool_name [String] the tool name
325
+ # @param params [Array<String>] additional parameters
326
+ def show_tool_help(tool_name, _params = [])
327
+ setup_registry
328
+
329
+ # Use find_by for interface-based discovery
330
+ tool = Tool.find_by(tool_name.to_sym)
331
+ error! "Tool not found: #{tool_name}\nAvailable tools: #{Registry.tools.sort.join(', ')}" unless tool
332
+
333
+ say '', :clear
334
+ say "Tool: #{tool.name}", :cyan
335
+ say "Display Name: #{tool.profile.display_name || 'N/A'}", :white
336
+ say "Version: #{tool.profile.version || 'N/A'}", :white
337
+ say "Homepage: #{tool.profile.homepage || 'N/A'}", :white
338
+ say '', :clear
339
+
340
+ # Show available commands
341
+ tool_commands = tool.commands
342
+ if tool_commands && !tool_commands.empty?
343
+ say 'Available commands:', :cyan
344
+
345
+ tool_commands.each do |cmd|
346
+ cmd_name = cmd.name || 'unnamed'
347
+ description = cmd.description || ''
348
+ say " #{cmd_name.to_s.ljust(20)} #{description}", :white
349
+
350
+ # Show usage if available
351
+ say " Usage: #{cmd.usage}", :dim if cmd.usage
352
+
353
+ # Show subcommand if exists
354
+ if cmd.subcommand
355
+ subcommand_info = cmd.subcommand.nil? ? '(none)' : cmd.subcommand
356
+ say " Subcommand: #{subcommand_info}", :dim
357
+ end
358
+ end
359
+
360
+ say '', :clear
361
+ say "Usage: ukiryu exec #{tool.name} <command> [KEY=VALUE ...]", :dim
362
+ say " or: ukiryu exec #{tool.name} help", :dim
363
+ say " or: ukiryu describe #{tool.name} <command>", :dim
364
+ say '', :clear
365
+ say 'For more information on a specific command:', :dim
366
+ say " ukiryu opts #{tool.name} <command>", :dim
367
+ say " ukiryu describe #{tool.name} <command>", :dim
368
+ else
369
+ say 'This tool has no defined commands (it may be a simple wrapper)', :dim
370
+ say "Usage: ukiryu exec #{tool.name} [KEY=VALUE ...]", :dim
371
+ end
372
+ end
373
+ end
374
+ end
375
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_command'
4
+ require_relative 'response_formatter'
5
+ require_relative '../tool'
6
+ require_relative '../executor'
7
+ require_relative '../logger'
8
+ require_relative '../models/success_response'
9
+ require_relative '../models/error_response'
10
+ require_relative '../models/execution_report'
11
+ require 'yaml'
12
+
13
+ module Ukiryu
14
+ module CliCommands
15
+ # Execute a Ukiryu Structured Execution Request from a YAML file
16
+ class RunFileCommand < BaseCommand
17
+ include ResponseFormatter
18
+
19
+ # Execute the command
20
+ #
21
+ # @param request_file [String] path to the request YAML file
22
+ def run(request_file)
23
+ setup_registry
24
+
25
+ # Output debug: Ukiryu CLI Options
26
+ if config.debug
27
+ logger = Ukiryu::Logger.new
28
+ ukiryu_options = {
29
+ format: config.format,
30
+ debug: config.debug,
31
+ dry_run: config.dry_run,
32
+ output: config.output,
33
+ registry: config.registry,
34
+ request_file: request_file
35
+ }
36
+ logger.debug_section_ukiryu_options(ukiryu_options)
37
+ end
38
+
39
+ # Get format from Config (priority: CLI > ENV > programmatic > default)
40
+ format = config.format.to_sym
41
+ error! "Invalid format: #{format}. Must be one of: #{OUTPUT_FORMATS.join(', ')}" unless OUTPUT_FORMATS.include?(format)
42
+
43
+ # Load execution request
44
+ request = load_execution_request(request_file)
45
+
46
+ if config.dry_run
47
+ # Show dry run output
48
+ say_dry_run(request)
49
+ return
50
+ end
51
+
52
+ # Execute the request
53
+ response = execute_request(request)
54
+
55
+ # Output response
56
+ output_file = config.output
57
+ output_response(response, format, output_file, config)
58
+
59
+ # Don't exit here - let Thor handle the result
60
+ end
61
+
62
+ private
63
+
64
+ # Load execution request from YAML file
65
+ def load_execution_request(file_path)
66
+ error! "Request file not found: #{file_path}" unless File.exist?(file_path)
67
+
68
+ begin
69
+ request = YAML.safe_load(File.read(file_path), permitted_classes: [Symbol])
70
+ validate_request!(request)
71
+ request
72
+ rescue Psych::SyntaxError => e
73
+ error! "Invalid YAML in request file: #{e.message}"
74
+ rescue StandardError => e
75
+ error! "Error loading request file: #{e.message}"
76
+ end
77
+ end
78
+
79
+ # Validate execution request structure
80
+ def validate_request!(request)
81
+ raise 'Request must be a YAML object (hash)' unless request.is_a?(Hash)
82
+ raise "Request must include 'tool' field" unless request['tool']
83
+ raise "Request must include 'command' field" unless request['command']
84
+ raise "Request must include 'arguments' field" unless request['arguments']
85
+ raise "'arguments' must be a YAML object (hash)" unless request['arguments'].is_a?(Hash)
86
+ end
87
+
88
+ # Execute the request and build response
89
+ def execute_request(request)
90
+ tool_name = request['tool']
91
+ command_name = request['command']
92
+ arguments = stringify_keys(request['arguments'])
93
+
94
+ logger = Ukiryu::Logger.new if config.debug
95
+ collect_metrics = config.metrics
96
+
97
+ # Initialize execution report if metrics are enabled
98
+ execution_report = if collect_metrics
99
+ Models::ExecutionReport.new(
100
+ tool_resolution: Models::StageMetrics.new(name: 'tool_resolution'),
101
+ command_building: Models::StageMetrics.new(name: 'command_building'),
102
+ execution: Models::StageMetrics.new(name: 'execution'),
103
+ response_building: Models::StageMetrics.new(name: 'response_building'),
104
+ run_environment: Models::RunEnvironment.collect,
105
+ timestamp: Time.now.iso8601
106
+ )
107
+ end
108
+
109
+ begin
110
+ # Stage: Tool Resolution
111
+ execution_report.tool_resolution.start! if collect_metrics
112
+
113
+ # Get tool - try find_by first for interface-based discovery, fallback to get
114
+ tool = Tool.find_by(tool_name.to_sym) || Tool.get(tool_name.to_sym)
115
+ return Models::ErrorResponse.from_message("Tool not available: #{tool_name}") unless tool
116
+
117
+ return Models::ErrorResponse.from_message("Tool found but not executable: #{tool_name}") unless tool.available?
118
+
119
+ execution_report.tool_resolution.finish! if collect_metrics
120
+
121
+ # Stage: Command Building
122
+ execution_report.command_building.start! if collect_metrics
123
+
124
+ # Get command definition for context
125
+ command_definition = tool.command_definition(command_name.to_sym)
126
+
127
+ # Build options object (OOP approach)
128
+ options_class = tool.options_for(command_name.to_sym)
129
+ options = options_class.new
130
+ arguments.each { |key, value| options.send("#{key}=", value) }
131
+
132
+ execution_report.command_building.finish! if collect_metrics
133
+
134
+ # Output debug: Structured Options (the tool's options object)
135
+ logger.debug_section_structured_options(tool_name, command_name, options) if config.debug && logger
136
+
137
+ # Stage: Execution
138
+ execution_report.execution.start! if collect_metrics
139
+
140
+ # Execute command
141
+ result = tool.execute(command_name.to_sym, options)
142
+
143
+ execution_report.execution.finish! if collect_metrics
144
+
145
+ # Output debug: Shell Command
146
+ if config.debug && logger
147
+ logger.debug_section_shell_command(
148
+ executable: result.command_info.executable,
149
+ full_command: result.command_info.full_command
150
+ )
151
+ end
152
+
153
+ # Output debug: Raw Response
154
+ if config.debug && logger
155
+ logger.debug_section_raw_response(
156
+ stdout: result.stdout,
157
+ stderr: result.stderr,
158
+ exit_code: result.status
159
+ )
160
+ end
161
+
162
+ # Stage: Response Building
163
+ execution_report.response_building.start! if collect_metrics
164
+
165
+ # Build successful response with original arguments and command definition
166
+ response = Models::SuccessResponse.from_result(
167
+ result,
168
+ arguments,
169
+ command_definition,
170
+ execution_report: collect_metrics ? execution_report : nil
171
+ )
172
+
173
+ execution_report.response_building.finish! if collect_metrics
174
+
175
+ # Calculate total duration
176
+ execution_report.calculate_total if collect_metrics
177
+
178
+ # Output debug: Execution Report
179
+ logger.debug_section_execution_report(execution_report) if config.debug && logger && collect_metrics
180
+
181
+ # Output debug: Structured Response
182
+ logger.debug_section_structured_response(response) if config.debug && logger
183
+
184
+ response
185
+ rescue Ukiryu::ToolNotFoundError => e
186
+ Models::ErrorResponse.from_message("Tool not found: #{e.message}")
187
+ rescue Ukiryu::ProfileNotFoundError => e
188
+ Models::ErrorResponse.from_message("Profile not found: #{e.message}")
189
+ rescue Ukiryu::ExecutionError => e
190
+ Models::ErrorResponse.from_message(e.message)
191
+ rescue Ukiryu::TimeoutError => e
192
+ Models::ErrorResponse.from_message("Command timed out: #{e.message}")
193
+ rescue ArgumentError => e
194
+ # Output full backtrace for debugging
195
+ warn 'ArgumentError backtrace:' if config.debug
196
+ e.backtrace.each { |line| warn " #{line}" } if config.debug
197
+ Models::ErrorResponse.from_message("Invalid arguments: #{e.message}")
198
+ rescue StandardError => e
199
+ # Output full backtrace for debugging
200
+ warn 'StandardError backtrace:' if config.debug
201
+ e.backtrace.each { |line| warn " #{line}" } if config.debug
202
+ Models::ErrorResponse.from_message("Unexpected error: #{e.class}: #{e.message}")
203
+ ensure
204
+ # Ensure metrics are finished even on error
205
+ if collect_metrics && execution_report
206
+ execution_report.tool_resolution.finish!(success: false) unless execution_report.tool_resolution.duration
207
+ execution_report.command_building.finish!(success: false) unless execution_report.command_building.duration
208
+ execution_report.execution.finish!(success: false) unless execution_report.execution.duration
209
+ execution_report.response_building.finish!(success: false) unless execution_report.response_building.duration
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../config'
4
+ require_relative '../shell'
5
+ require_relative '../platform'
6
+ require_relative '../runtime'
7
+
8
+ module Ukiryu
9
+ module CliCommands
10
+ # Command to list system information
11
+ class SystemCommand < BaseCommand
12
+ # Run the system command
13
+ #
14
+ # @param subcommand [String, nil] the subcommand (shells, etc.)
15
+ def run(subcommand = nil)
16
+ case subcommand
17
+ when 'shells', nil
18
+ list_shells
19
+ else
20
+ error!("Unknown subcommand: #{subcommand}. Valid subcommands: shells")
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ # List all available shells on the system
27
+ def list_shells
28
+ require_relative '../shell'
29
+
30
+ all_shells = Shell.all_valid
31
+ available_shells = Shell.available_shells
32
+ platform_shells = Shell.valid_for_platform
33
+
34
+ say 'Available Shells on This System:', :cyan
35
+ say ''
36
+
37
+ if available_shells.empty?
38
+ say ' No shells detected', :dim
39
+ else
40
+ available_shells.each do |shell|
41
+ status = '✓'
42
+ say " #{status} #{shell}", :green
43
+ end
44
+ end
45
+
46
+ say ''
47
+ say 'All Supported Shells:', :cyan
48
+ say ''
49
+
50
+ all_shells.each do |shell|
51
+ is_available = available_shells.include?(shell)
52
+ is_platform = platform_shells.include?(shell)
53
+
54
+ status = if is_available
55
+ '✓'
56
+ elsif is_platform
57
+ '✗'
58
+ else
59
+ '-'
60
+ end
61
+
62
+ color = if is_available
63
+ :green
64
+ else
65
+ (is_platform ? :red : :dim)
66
+ end
67
+ note = if !is_platform
68
+ ' (not supported on this platform)'
69
+ elsif !is_available
70
+ ' (supported but not found)'
71
+ else
72
+ ''
73
+ end
74
+
75
+ say " #{status} #{shell}#{note}", color
76
+ end
77
+
78
+ say ''
79
+ say "Platform: #{Platform.detect}", :dim
80
+ say "Current shell: #{Runtime.instance.shell}", :dim
81
+
82
+ # Show shell override status
83
+ config_shell = Config.shell
84
+ return unless config_shell
85
+
86
+ say "Shell override: #{config_shell} (set via --shell, UKIRYU_SHELL, or config)", :yellow
87
+ end
88
+ end
89
+ end
90
+ end