rubycode 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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +38 -0
  3. data/.rubocop.yml +4 -0
  4. data/CHANGELOG.md +52 -0
  5. data/README.md +101 -23
  6. data/USAGE.md +1 -23
  7. data/config/locales/en.yml +208 -1
  8. data/config/system_prompt.md +6 -1
  9. data/config/tools/bash.json +1 -1
  10. data/config/tools/fetch.json +22 -0
  11. data/config/tools/websearch.json +22 -0
  12. data/docs/images/demo.png +0 -0
  13. data/lib/rubycode/adapters/base.rb +92 -2
  14. data/lib/rubycode/adapters/concerns/debugging.rb +32 -0
  15. data/lib/rubycode/adapters/concerns/error_handling.rb +89 -0
  16. data/lib/rubycode/adapters/concerns/http_client.rb +67 -0
  17. data/lib/rubycode/adapters/deepseek.rb +97 -0
  18. data/lib/rubycode/adapters/gemini.rb +133 -0
  19. data/lib/rubycode/adapters/ollama.rb +114 -82
  20. data/lib/rubycode/adapters/openai.rb +97 -0
  21. data/lib/rubycode/adapters/openrouter.rb +102 -0
  22. data/lib/rubycode/agent_loop.rb +110 -18
  23. data/lib/rubycode/client/approval_handler.rb +14 -0
  24. data/lib/rubycode/client/display_formatter.rb +18 -10
  25. data/lib/rubycode/client/response_handler.rb +4 -23
  26. data/lib/rubycode/client.rb +9 -0
  27. data/lib/rubycode/config_manager.rb +81 -0
  28. data/lib/rubycode/configuration.rb +21 -10
  29. data/lib/rubycode/database.rb +19 -0
  30. data/lib/rubycode/errors.rb +12 -0
  31. data/lib/rubycode/models/api_key.rb +118 -0
  32. data/lib/rubycode/models/memory.rb +84 -10
  33. data/lib/rubycode/models.rb +1 -0
  34. data/lib/rubycode/pricing.rb +59 -0
  35. data/lib/rubycode/search_providers/base.rb +66 -0
  36. data/lib/rubycode/search_providers/brave_search.rb +60 -0
  37. data/lib/rubycode/search_providers/concerns/debugging.rb +37 -0
  38. data/lib/rubycode/search_providers/concerns/error_handling.rb +64 -0
  39. data/lib/rubycode/search_providers/concerns/http_client.rb +67 -0
  40. data/lib/rubycode/search_providers/duckduckgo_instant.rb +98 -0
  41. data/lib/rubycode/search_providers/exa_ai.rb +171 -0
  42. data/lib/rubycode/search_providers/multi_provider.rb +47 -0
  43. data/lib/rubycode/token_counter.rb +41 -0
  44. data/lib/rubycode/tools/bash.rb +38 -8
  45. data/lib/rubycode/tools/fetch.rb +120 -0
  46. data/lib/rubycode/tools/web_search.rb +122 -0
  47. data/lib/rubycode/tools.rb +5 -1
  48. data/lib/rubycode/value_objects.rb +8 -4
  49. data/lib/rubycode/version.rb +1 -1
  50. data/lib/rubycode/views/adapter/debug_delay.rb +20 -0
  51. data/lib/rubycode/views/adapter/debug_request.rb +33 -0
  52. data/lib/rubycode/views/adapter/debug_response.rb +31 -0
  53. data/lib/rubycode/views/agent_loop/token_summary.rb +54 -0
  54. data/lib/rubycode/views/agent_loop.rb +2 -0
  55. data/lib/rubycode/views/cli/api_key_missing.rb +37 -0
  56. data/lib/rubycode/views/cli/config_saved.rb +17 -0
  57. data/lib/rubycode/views/cli/configuration_table.rb +3 -3
  58. data/lib/rubycode/views/cli/first_time_setup.rb +17 -0
  59. data/lib/rubycode/views/cli/restart_message.rb +17 -0
  60. data/lib/rubycode/views/cli/setup_title.rb +17 -0
  61. data/lib/rubycode/views/cli.rb +5 -0
  62. data/lib/rubycode/views/formatter/fetch_summary.rb +54 -0
  63. data/lib/rubycode/views/formatter/web_search_summary.rb +53 -0
  64. data/lib/rubycode/views/formatter.rb +2 -0
  65. data/lib/rubycode/views/search_provider/debug_request.rb +30 -0
  66. data/lib/rubycode/views/search_provider/debug_response.rb +31 -0
  67. data/lib/rubycode/views/web_search_approval.rb +29 -0
  68. data/lib/rubycode/views.rb +5 -0
  69. data/lib/rubycode.rb +10 -0
  70. data/rubycode_cli.rb +228 -32
  71. metadata +81 -1
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require_relative "base"
7
+
8
+ module RubyCode
9
+ module SearchProviders
10
+ # Brave Search API provider
11
+ # https://brave.com/search/api/
12
+ class BraveSearch < Base
13
+ API_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
14
+
15
+ def initialize(api_key: nil, config: nil)
16
+ super(config: config, api_key: api_key)
17
+ end
18
+
19
+ private
20
+
21
+ def provider_name
22
+ "BraveSearch"
23
+ end
24
+
25
+ def requires_api_key?
26
+ true
27
+ end
28
+
29
+ def build_request(query, max_results)
30
+ uri = URI(API_ENDPOINT).tap do |u|
31
+ u.query = URI.encode_www_form(
32
+ q: query,
33
+ count: max_results
34
+ )
35
+ end
36
+
37
+ request = Net::HTTP::Get.new(uri)
38
+ request["Accept"] = "application/json"
39
+ request["X-Subscription-Token"] = api_key
40
+
41
+ { uri: uri, request: request }
42
+ end
43
+
44
+ def parse_results(json_body, _max_results)
45
+ data = JSON.parse(json_body)
46
+ results = data.dig("web", "results") || []
47
+
48
+ results.map do |result|
49
+ {
50
+ title: result["title"],
51
+ url: result["url"],
52
+ snippet: result["description"] || ""
53
+ }
54
+ end
55
+ rescue JSON::ParserError => e
56
+ raise SearchProviderError, "Failed to parse Brave Search response: #{e.message}"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCode
4
+ module SearchProviders
5
+ module Concerns
6
+ # Debug output for search providers
7
+ module Debugging
8
+ private
9
+
10
+ def debug_search_request(query, max_results, uri_or_request)
11
+ url = if uri_or_request.is_a?(URI)
12
+ uri_or_request.to_s
13
+ elsif uri_or_request.is_a?(Hash)
14
+ uri_or_request[:uri].to_s
15
+ else
16
+ "Unknown"
17
+ end
18
+
19
+ puts Views::SearchProvider::DebugRequest.build(
20
+ provider_name: provider_name,
21
+ query: query,
22
+ max_results: max_results,
23
+ url: url
24
+ )
25
+ end
26
+
27
+ def debug_search_response(response)
28
+ puts Views::SearchProvider::DebugResponse.build(
29
+ provider_name: provider_name,
30
+ status_code: response.code,
31
+ body_preview: response.body[0...500] # First 500 chars
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCode
4
+ module SearchProviders
5
+ module Concerns
6
+ # Error handling for search providers
7
+ module ErrorHandling
8
+ private
9
+
10
+ def handle_network_error(error, uri)
11
+ error_key = network_error_key(error)
12
+ error_params = build_network_error_params(error, uri)
13
+ raise SearchProviderError, I18n.t("rubycode.errors.search_provider.#{error_key}", error_params)
14
+ end
15
+
16
+ def network_error_key(error)
17
+ case error
18
+ when Net::ReadTimeout then :read_timeout
19
+ when Net::OpenTimeout then :open_timeout
20
+ when Errno::ECONNREFUSED then :connection_refused
21
+ when Errno::ETIMEDOUT then :connection_timeout
22
+ when SocketError then :host_unreachable
23
+ end
24
+ end
25
+
26
+ def build_network_error_params(error, uri)
27
+ base_params = { provider: provider_name, error: error.message }
28
+ case error
29
+ when Net::ReadTimeout
30
+ base_params.merge(timeout: http_read_timeout)
31
+ when Net::OpenTimeout
32
+ base_params.merge(timeout: http_open_timeout)
33
+ when Errno::ECONNREFUSED, Errno::ETIMEDOUT
34
+ base_params.merge(uri: uri)
35
+ when SocketError
36
+ base_params.merge(hostname: uri.hostname)
37
+ else
38
+ base_params
39
+ end
40
+ end
41
+
42
+ def handle_http_error(response)
43
+ code = response.code.to_i
44
+ return response if code.between?(200, 299)
45
+
46
+ error_key = http_error_key(code)
47
+ raise SearchProviderError,
48
+ I18n.t("rubycode.errors.search_provider.#{error_key}",
49
+ code: response.code,
50
+ provider: provider_name)
51
+ end
52
+
53
+ def http_error_key(code)
54
+ case code
55
+ when 401, 403 then :auth_failed
56
+ when 429 then :rate_limited
57
+ when 500..599 then :server_error
58
+ else :http_error
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module RubyCode
6
+ module SearchProviders
7
+ module Concerns
8
+ # HTTP client for search providers
9
+ module HttpClient
10
+ private
11
+
12
+ def make_http_request(uri_or_request)
13
+ if uri_or_request.is_a?(URI)
14
+ make_http_request_from_uri(uri_or_request)
15
+ elsif uri_or_request.is_a?(Hash) && uri_or_request[:uri] && uri_or_request[:request]
16
+ make_http_request_with_custom_request(uri_or_request[:uri], uri_or_request[:request])
17
+ else
18
+ raise ArgumentError, "Expected URI or {uri:, request:} hash"
19
+ end
20
+ end
21
+
22
+ def make_http_request_from_uri(uri)
23
+ Net::HTTP.start(
24
+ uri.host,
25
+ uri.port,
26
+ use_ssl: uri.scheme == "https",
27
+ read_timeout: http_read_timeout,
28
+ open_timeout: http_open_timeout
29
+ ) do |http|
30
+ request = Net::HTTP::Get.new(uri)
31
+ request["Accept"] = "application/json"
32
+ request["User-Agent"] = "RubyCode/#{RubyCode::VERSION}"
33
+ http.request(request)
34
+ end
35
+ rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError => e
36
+ handle_network_error(e, uri)
37
+ rescue StandardError => e
38
+ raise SearchProviderError, "HTTP request failed: #{e.message}"
39
+ end
40
+
41
+ def make_http_request_with_custom_request(uri, request)
42
+ Net::HTTP.start(
43
+ uri.host,
44
+ uri.port,
45
+ use_ssl: uri.scheme == "https",
46
+ read_timeout: http_read_timeout,
47
+ open_timeout: http_open_timeout
48
+ ) do |http|
49
+ http.request(request)
50
+ end
51
+ rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError => e
52
+ handle_network_error(e, uri)
53
+ rescue StandardError => e
54
+ raise SearchProviderError, "HTTP request failed: #{e.message}"
55
+ end
56
+
57
+ def http_read_timeout
58
+ @config&.http_read_timeout || 10
59
+ end
60
+
61
+ def http_open_timeout
62
+ @config&.http_open_timeout || 10
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require_relative "base"
7
+
8
+ module RubyCode
9
+ module SearchProviders
10
+ # DuckDuckGo Instant Answer API provider (FREE)
11
+ # https://duckduckgo.com/api
12
+ # Note: Returns instant answers/summaries, not full web search results
13
+ class DuckduckgoInstant < Base
14
+ API_ENDPOINT = "https://api.duckduckgo.com/"
15
+
16
+ def initialize(config: nil)
17
+ super(config: config, api_key: nil)
18
+ end
19
+
20
+ private
21
+
22
+ def provider_name
23
+ "DuckDuckGo"
24
+ end
25
+
26
+ def build_request(query, _max_results)
27
+ URI(API_ENDPOINT).tap do |uri|
28
+ uri.query = URI.encode_www_form(
29
+ q: query,
30
+ format: "json",
31
+ no_html: 1,
32
+ skip_disambig: 1
33
+ )
34
+ end
35
+ end
36
+
37
+ def parse_results(json_body, max_results)
38
+ data = JSON.parse(json_body)
39
+ results = []
40
+
41
+ add_abstract_result(results, data)
42
+ add_related_topics(results, data, max_results)
43
+ add_other_results(results, data, max_results) if results.empty?
44
+
45
+ results
46
+ rescue JSON::ParserError => e
47
+ raise SearchProviderError, "Failed to parse DuckDuckGo response: #{e.message}"
48
+ end
49
+
50
+ def add_abstract_result(results, data)
51
+ return unless data["Abstract"] && !data["Abstract"].empty?
52
+
53
+ results << {
54
+ title: data["Heading"] || "Summary",
55
+ url: data["AbstractURL"] || "",
56
+ snippet: data["Abstract"]
57
+ }
58
+ end
59
+
60
+ def add_related_topics(results, data, max_results)
61
+ related = data["RelatedTopics"] || []
62
+ related.first(max_results - results.length).each do |topic|
63
+ next if skip_topic?(topic)
64
+
65
+ results << build_topic_result(topic)
66
+ end
67
+ end
68
+
69
+ def skip_topic?(topic)
70
+ topic["Topics"] # Skip categories
71
+ end
72
+
73
+ def build_topic_result(topic)
74
+ {
75
+ title: extract_topic_title(topic),
76
+ url: topic["FirstURL"] || "",
77
+ snippet: topic["Text"] || ""
78
+ }
79
+ end
80
+
81
+ def extract_topic_title(topic)
82
+ topic["Text"]&.split(" - ")&.first || "Related"
83
+ end
84
+
85
+ def add_other_results(results, data, max_results)
86
+ return unless data["Results"]
87
+
88
+ data["Results"].first(max_results).each do |result|
89
+ results << {
90
+ title: result["Text"] || "Result",
91
+ url: result["FirstURL"] || "",
92
+ snippet: result["Text"] || ""
93
+ }
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require_relative "base"
7
+
8
+ module RubyCode
9
+ module SearchProviders
10
+ # Exa.ai search provider - AI-native search engine optimized for LLMs
11
+ # https://exa.ai
12
+ # Uses MCP (Model Context Protocol) endpoint
13
+ class ExaAi < Base
14
+ MCP_ENDPOINT = "https://mcp.exa.ai/mcp"
15
+ TIMEOUT = 25 # seconds
16
+
17
+ def initialize(api_key: nil, config: nil)
18
+ super(config: config, api_key: api_key || ENV.fetch("EXA_API_KEY", nil))
19
+ end
20
+
21
+ private
22
+
23
+ def provider_name
24
+ "Exa.ai"
25
+ end
26
+
27
+ def requires_api_key?
28
+ true
29
+ end
30
+
31
+ def build_request(query, max_results)
32
+ uri = URI(MCP_ENDPOINT)
33
+ request = Net::HTTP::Post.new(uri)
34
+ request["Content-Type"] = "application/json"
35
+ request["Accept"] = "application/json, text/event-stream"
36
+ request["User-Agent"] = "RubyCode/#{RubyCode::VERSION}"
37
+
38
+ request.body = build_mcp_payload(query, max_results).to_json
39
+
40
+ { uri: uri, request: request }
41
+ end
42
+
43
+ def build_mcp_payload(query, max_results)
44
+ {
45
+ jsonrpc: "2.0",
46
+ id: 1,
47
+ method: "tools/call",
48
+ params: {
49
+ name: "web_search_exa",
50
+ arguments: {
51
+ query: query,
52
+ type: "auto",
53
+ numResults: max_results,
54
+ livecrawl: "fallback",
55
+ contextMaxCharacters: 10_000
56
+ }
57
+ }
58
+ }
59
+ end
60
+
61
+ def parse_results(body, _max_results)
62
+ results = []
63
+
64
+ # Parse SSE (Server-Sent Events) response
65
+ body.split("\n").each do |line|
66
+ next unless line.start_with?("data: ")
67
+
68
+ process_sse_line(line, results)
69
+ end
70
+
71
+ results
72
+ rescue JSON::ParserError => e
73
+ raise SearchProviderError, "Failed to parse Exa.ai response: #{e.message}"
74
+ end
75
+
76
+ def process_sse_line(line, results)
77
+ data = JSON.parse(line[6..]) # Skip "data: " prefix
78
+ return unless data["result"]&.dig("content")
79
+
80
+ data["result"]["content"].each do |item|
81
+ next unless item["type"] == "text"
82
+
83
+ results.concat(parse_markdown_results(item["text"]))
84
+ end
85
+ end
86
+
87
+ def parse_markdown_results(text)
88
+ results = []
89
+ current_result = {}
90
+ collecting_text = false
91
+
92
+ text.split("\n").each do |line|
93
+ line = line.strip
94
+ next if line.empty?
95
+
96
+ result = process_markdown_line(line, current_result, results, collecting_text)
97
+ collecting_text = result[:collecting]
98
+ end
99
+
100
+ finalize_result(current_result, results)
101
+ cleanup_snippets(results)
102
+
103
+ results
104
+ end
105
+
106
+ def process_markdown_line(line, current_result, results, collecting_text)
107
+ return handle_title_line(line, current_result, results) if line.start_with?("Title:")
108
+ return handle_url_line(line, current_result, collecting_text) if line.start_with?("URL:")
109
+ return handle_text_line(line, current_result) if line.start_with?("Text:")
110
+
111
+ handle_content_line(line, current_result, collecting_text)
112
+ end
113
+
114
+ def handle_title_line(line, current_result, results)
115
+ save_current_result(current_result, results)
116
+ start_new_result(line, current_result)
117
+ { collecting: false }
118
+ end
119
+
120
+ def handle_url_line(line, current_result, collecting_text)
121
+ current_result[:url] = line.sub("URL:", "").strip if current_result[:title]
122
+ { collecting: collecting_text }
123
+ end
124
+
125
+ def handle_text_line(line, current_result)
126
+ current_result[:snippet] = line.sub("Text:", "").strip if current_result[:title]
127
+ { collecting: true }
128
+ end
129
+
130
+ def handle_content_line(line, current_result, collecting_text)
131
+ return { collecting: collecting_text } unless collecting_text && current_result[:title]
132
+
133
+ append_text_if_valid(line, current_result)
134
+ { collecting: collecting_text }
135
+ end
136
+
137
+ def save_current_result(current_result, results)
138
+ return unless current_result[:title] && current_result[:url] && !current_result[:url].empty?
139
+
140
+ results << current_result
141
+ end
142
+
143
+ def start_new_result(line, current_result)
144
+ current_result.replace(
145
+ title: line.sub("Title:", "").strip,
146
+ url: "",
147
+ snippet: ""
148
+ )
149
+ end
150
+
151
+ def append_text_if_valid(line, current_result)
152
+ return if current_result[:snippet].length >= 500
153
+
154
+ break_keywords = ["Title:", "Author:", "Published Date:", "URL:"]
155
+ return if break_keywords.any? { |kw| line.start_with?(kw) }
156
+
157
+ current_result[:snippet] = "#{current_result[:snippet]} #{line}"
158
+ end
159
+
160
+ def finalize_result(current_result, results)
161
+ save_current_result(current_result, results)
162
+ end
163
+
164
+ def cleanup_snippets(results)
165
+ results.each do |result|
166
+ result[:snippet] = result[:snippet].strip[0..300] # Limit to 300 chars
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCode
4
+ module SearchProviders
5
+ # Multi-provider search with automatic fallback
6
+ class MultiProvider
7
+ attr_reader :last_used_provider
8
+
9
+ def initialize(providers: [])
10
+ @providers = providers
11
+ @last_used_provider = nil
12
+ end
13
+
14
+ def search(query, max_results: 5)
15
+ last_error = nil
16
+
17
+ @providers.each do |provider|
18
+ results = provider.search(query, max_results: max_results)
19
+ if results && !results.empty?
20
+ @last_used_provider = provider.class.name.split("::").last
21
+ return results
22
+ end
23
+ rescue StandardError => e
24
+ last_error = e
25
+ # Continue to next provider
26
+ end
27
+
28
+ # If all providers failed, raise the last error
29
+ raise last_error if last_error
30
+
31
+ # If no error but no results, return empty array
32
+ @last_used_provider = nil
33
+ []
34
+ end
35
+
36
+ # Add a provider to the list
37
+ def add_provider(provider)
38
+ @providers << provider
39
+ end
40
+
41
+ # Get list of available providers
42
+ def provider_names
43
+ @providers.map { |p| p.class.name.split("::").last }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCode
4
+ # Tracks token usage across LLM requests
5
+ class TokenCounter
6
+ attr_reader :input, :output, :cached, :cache_creation, :thinking
7
+
8
+ def initialize(input: 0, output: 0, cached: 0, cache_creation: 0, thinking: 0)
9
+ @input = input || 0
10
+ @output = output || 0
11
+ @cached = cached || 0
12
+ @cache_creation = cache_creation || 0
13
+ @thinking = thinking || 0
14
+ end
15
+
16
+ def total
17
+ input + output + thinking
18
+ end
19
+
20
+ def to_h
21
+ {
22
+ input_tokens: input,
23
+ output_tokens: output,
24
+ cached_tokens: cached,
25
+ cache_creation_tokens: cache_creation,
26
+ thinking_tokens: thinking,
27
+ total_tokens: total
28
+ }.compact
29
+ end
30
+
31
+ def +(other)
32
+ TokenCounter.new(
33
+ input: input + other.input,
34
+ output: output + other.output,
35
+ cached: cached + other.cached,
36
+ cache_creation: cache_creation + other.cache_creation,
37
+ thinking: thinking + other.thinking
38
+ )
39
+ end
40
+ end
41
+ end
@@ -44,19 +44,49 @@ module RubyCode
44
44
 
45
45
  def execute_command(command)
46
46
  Dir.chdir(root_path) do
47
- output = `#{command} 2>&1`
48
- exit_code = $CHILD_STATUS.exitstatus
47
+ require "open3"
49
48
 
50
- raise CommandExecutionError, "Command failed with exit code #{exit_code}:\n#{output}" unless exit_code.zero?
49
+ output, exit_code = run_command_with_streaming(command)
50
+ raise CommandExecutionError, build_error_message(exit_code, output) unless exit_code.zero?
51
51
 
52
- CommandResult.new(
53
- stdout: truncate_output(output),
54
- stderr: "",
55
- exit_code: exit_code
56
- )
52
+ build_command_result(output, exit_code)
57
53
  end
58
54
  end
59
55
 
56
+ def run_command_with_streaming(command)
57
+ output_lines = []
58
+ exit_code = 0
59
+
60
+ Open3.popen2e(command) do |stdin, stdout_err, wait_thr|
61
+ stdin.close # Close stdin to prevent interactive prompts
62
+ output_lines = stream_output(stdout_err)
63
+ exit_code = wait_thr.value.exitstatus
64
+ end
65
+
66
+ [output_lines.join, exit_code]
67
+ end
68
+
69
+ def stream_output(stdout_err)
70
+ output_lines = []
71
+ stdout_err.each_line do |line|
72
+ print line # Show output in real-time
73
+ output_lines << line
74
+ end
75
+ output_lines
76
+ end
77
+
78
+ def build_error_message(exit_code, output)
79
+ "Command failed with exit code #{exit_code}:\n#{output}"
80
+ end
81
+
82
+ def build_command_result(output, exit_code)
83
+ CommandResult.new(
84
+ stdout: truncate_output(output),
85
+ stderr: "",
86
+ exit_code: exit_code
87
+ )
88
+ end
89
+
60
90
  def truncate_output(output)
61
91
  lines = output.split("\n")
62
92
  return output if lines.length <= 200