roast-ai 0.4.6 → 0.4.7

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +3 -1
  3. data/.gitignore +7 -0
  4. data/.rubocop.yml +14 -0
  5. data/CHANGELOG.md +5 -0
  6. data/Gemfile +2 -1
  7. data/Gemfile.lock +9 -1
  8. data/Rakefile +14 -4
  9. data/examples/available_tools_demo/workflow.yml +2 -2
  10. data/examples/cmd/basic_workflow.yml +0 -1
  11. data/examples/grading/js_test_runner +1 -1
  12. data/examples/grading/run_coverage.rb +1 -1
  13. data/examples/user_input/funny_name/workflow.yml +3 -4
  14. data/lib/roast/dsl/executor.rb +2 -1
  15. data/lib/roast/helpers/cmd_runner.rb +199 -0
  16. data/lib/roast/initializers.rb +1 -1
  17. data/lib/roast/tools/apply_diff.rb +1 -1
  18. data/lib/roast/tools/bash.rb +4 -4
  19. data/lib/roast/tools/cmd.rb +3 -5
  20. data/lib/roast/tools/coding_agent.rb +1 -1
  21. data/lib/roast/tools/grep.rb +6 -2
  22. data/lib/roast/tools/read_file.rb +2 -1
  23. data/lib/roast/tools/swarm.rb +2 -7
  24. data/lib/roast/tools.rb +10 -1
  25. data/lib/roast/version.rb +1 -1
  26. data/lib/roast/workflow/command_executor.rb +3 -3
  27. data/lib/roast/workflow/resource_resolver.rb +1 -1
  28. data/lib/roast/workflow/shell_script_step.rb +1 -1
  29. data/lib/roast.rb +1 -0
  30. data/rubocop/cop/roast/use_cmd_runner.rb +93 -0
  31. data/rubocop/cop/roast.rb +4 -0
  32. data/sorbet/rbi/gems/docile@1.4.1.rbi +377 -0
  33. data/sorbet/rbi/gems/lint_roller@1.1.0.rbi +233 -2
  34. data/sorbet/rbi/gems/racc@1.8.1.rbi +6 -4
  35. data/sorbet/rbi/gems/rainbow@3.1.1.rbi +396 -2
  36. data/sorbet/rbi/gems/regexp_parser@2.10.0.rbi +3788 -2
  37. data/sorbet/rbi/gems/rubocop-ast@1.45.1.rbi +7747 -2
  38. data/sorbet/rbi/gems/rubocop-sorbet@0.10.5.rbi +2386 -0
  39. data/sorbet/rbi/gems/rubocop@1.77.0.rbi +62813 -2
  40. data/sorbet/rbi/gems/ruby-progressbar@1.13.0.rbi +1311 -2
  41. data/sorbet/rbi/gems/simplecov-html@0.13.2.rbi +225 -0
  42. data/sorbet/rbi/gems/simplecov@0.22.0.rbi +2259 -0
  43. data/sorbet/rbi/gems/simplecov_json_formatter@0.1.4.rbi +9 -0
  44. data/sorbet/rbi/gems/unicode-display_width@3.1.4.rbi +125 -2
  45. data/sorbet/rbi/gems/unicode-emoji@4.0.4.rbi +244 -2
  46. data/sorbet/tapioca/require.rb +2 -1
  47. metadata +9 -2
  48. data/lib/roast/helpers/timeout_handler.rb +0 -89
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1b30690bd6345f217ff752e69865abf269792917e6463bbb70745a812ec15a4b
4
- data.tar.gz: de3732ccd3322fb8ff782d10e52168e0e4292fdb21596ff0eff7201532498c8d
3
+ metadata.gz: f1d2d1b26f4618a6036bc890e0426b5fe6c10dfd530e1c391d68b46680d2c2bc
4
+ data.tar.gz: e6b5670d414a2349c639b45a9b9d7b45b74a6dafc48cd706fedf8b55af88b6d2
5
5
  SHA512:
6
- metadata.gz: ba81d35b6484ffbdb3bb5aaed1baacbbc684033e62df23131c6d0bcb7833bc0902600f84a462fb451213d56c43862e49e8db5bfd150fb4a7520b66cd1e078d85
7
- data.tar.gz: 01b3d585d72528c5d8382a13edd379f6af249d75059564adb8eb33671b2dfb252749a09f8e2d8a551b09fad048b735960fcf8f2cf37d1c0f1d06aa758feee50d
6
+ metadata.gz: 447c20f3a2b170ce7ced6c14c5a1f62117299a984f513a6fb83f9c53934bbb6239c3dd11a791fe0437475af127a315120140b6e1918c124c25d774e3cc1cf2d3
7
+ data.tar.gz: cabe9380d1d1859c49d71d79547a1cc13f0d15f550aa373d82523384a0a8a8dda1f21bf1a6a2bfb2196bf3e4d6f1161ff047cbd498ca790c90e86006629e972b
@@ -29,6 +29,8 @@ jobs:
29
29
  with:
30
30
  ruby-version: ${{ matrix.ruby }}
31
31
  bundler-cache: true
32
- - run: bundle exec rake ci
32
+ - run: bundle exec rake minitest_old
33
+ - run: bundle exec rake minitest_functional
34
+ - run: bundle exec rake rubocop_ci
33
35
  - run: bin/srb tc
34
36
 
data/.gitignore CHANGED
@@ -1,3 +1,5 @@
1
+ .DS_Store
2
+
1
3
  /.bundle/
2
4
  /.yardoc
3
5
  /_yardoc/
@@ -16,3 +18,8 @@
16
18
  gemfiles/*.lock
17
19
  bin/claude-swarm
18
20
  *.gem
21
+ coverage
22
+
23
+ .dev
24
+ dev.yml
25
+ .shadowenv
data/.rubocop.yml CHANGED
@@ -1,5 +1,8 @@
1
1
  inherit_from: .rubocop_todo.yml
2
2
 
3
+ require:
4
+ - ./rubocop/cop/roast
5
+
3
6
  plugins:
4
7
  - rubocop-sorbet
5
8
 
@@ -23,3 +26,14 @@ Sorbet/FalseSigil:
23
26
  Exclude:
24
27
  - "test/**/*"
25
28
  - "examples/**/*"
29
+
30
+ Roast/UseCmdRunner:
31
+ Enabled: true
32
+ Exclude:
33
+ - 'lib/roast/helpers/cmd_runner.rb'
34
+ - 'roast.gemspec'
35
+
36
+ Style/MethodCallWithArgsParentheses:
37
+ Enabled: true
38
+ Exclude:
39
+ - 'test/**/*.rb'
data/CHANGELOG.md CHANGED
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.7]
9
+
10
+ ### Fixed
11
+ - Infinite loop if `.roast` directory can't be found. (#382)
12
+
8
13
  ## [0.4.6]
9
14
 
10
15
  ### Added
data/Gemfile CHANGED
@@ -16,9 +16,10 @@ gem "mocha"
16
16
  gem "rake", require: false
17
17
  gem "rubocop-shopify", require: false
18
18
  gem "rubocop-sorbet", require: false
19
+ gem "simplecov", require: false
20
+ gem "minitest-rg"
19
21
  gem "vcr", require: false
20
22
  gem "webmock", require: false
21
- gem "minitest-rg"
22
23
 
23
24
  gem "sorbet", require: false
24
25
  gem "tapioca", require: false
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- roast-ai (0.4.6)
4
+ roast-ai (0.4.7)
5
5
  activesupport (>= 7.0)
6
6
  cli-kit (~> 5.0)
7
7
  cli-ui (= 2.3.0)
@@ -50,6 +50,7 @@ GEM
50
50
  bigdecimal
51
51
  rexml
52
52
  diff-lcs (1.6.2)
53
+ docile (1.4.1)
53
54
  dotenv (3.1.8)
54
55
  drb (2.2.3)
55
56
  dry-configurable (1.3.0)
@@ -214,6 +215,12 @@ GEM
214
215
  ruby2_keywords (0.0.5)
215
216
  securerandom (0.4.1)
216
217
  shellany (0.0.1)
218
+ simplecov (0.22.0)
219
+ docile (~> 1.1)
220
+ simplecov-html (~> 0.11)
221
+ simplecov_json_formatter (~> 0.1)
222
+ simplecov-html (0.13.2)
223
+ simplecov_json_formatter (0.1.4)
217
224
  sorbet (0.5.12414)
218
225
  sorbet-static (= 0.5.12414)
219
226
  sorbet-runtime (0.5.12414)
@@ -277,6 +284,7 @@ DEPENDENCIES
277
284
  roast-ai!
278
285
  rubocop-shopify
279
286
  rubocop-sorbet
287
+ simplecov
280
288
  sorbet
281
289
  tapioca
282
290
  vcr
data/Rakefile CHANGED
@@ -4,17 +4,27 @@ require "bundler/gem_tasks"
4
4
  require "rubocop/rake_task"
5
5
  require "rake/testtask"
6
6
 
7
- Rake::TestTask.new(:minitest) do |t|
7
+ Rake::TestTask.new(:minitest_all) do |t|
8
8
  t.libs << "test"
9
9
  t.libs << "lib"
10
10
  t.test_files = FileList["test/**/*_test.rb"]
11
11
  end
12
12
 
13
- task test: [:minitest]
13
+ Rake::TestTask.new(:minitest_functional) do |t|
14
+ t.libs << "test"
15
+ t.libs << "lib"
16
+ t.test_files = FileList["test/functional/**/*_test.rb"]
17
+ end
14
18
 
15
- RuboCop::RakeTask.new(:rubocop_ci)
19
+ Rake::TestTask.new(:minitest_old) do |t|
20
+ t.libs << "test"
21
+ t.libs << "lib"
22
+ t.test_files = FileList["test/functional/**/*_test.rb"]
23
+ end
16
24
 
17
- task ci: [:test, :rubocop_ci]
25
+ task test: [:minitest_all]
26
+
27
+ RuboCop::RakeTask.new(:rubocop_ci)
18
28
 
19
29
  RuboCop::RakeTask.new(:rubocop) do |task|
20
30
  task.options = ["--autocorrect"]
@@ -1,4 +1,4 @@
1
- model: anthropic:claude-opus-4
1
+ model: openai:gpt-4o-mini
2
2
 
3
3
  tools:
4
4
  - Roast::Tools::Grep
@@ -29,4 +29,4 @@ analyze_files:
29
29
  write_summary:
30
30
  available_tools:
31
31
  - write_file
32
- - echo
32
+ - echo
@@ -1,5 +1,4 @@
1
1
  name: Basic Command Functions
2
- model: default
3
2
 
4
3
  # Learn the basics of using command functions in Roast
5
4
 
@@ -28,4 +28,4 @@ jest_options = [
28
28
  command = "#{detect_package_manager} run test:coverage -- #{test_file} #{jest_options.join(" ")}"
29
29
 
30
30
  $stderr.puts "Running: #{command}"
31
- puts system(command)
31
+ puts Roast::Helpers::CmdRunner.system(command)
@@ -41,7 +41,7 @@ class RunCoverage < Roast::Workflow::BaseStep
41
41
 
42
42
  # Run the test_runner using shadowenv for environment consistency
43
43
  command = "shadowenv exec --dir . -- #{test_runner_path} #{resolved_test_file} #{resolved_subject_file}"
44
- output, status = Open3.capture2(command)
44
+ output, status = Roast::Helpers::CmdRunner.capture2(command)
45
45
 
46
46
  unless status.success?
47
47
  Roast::Helpers::Logger.error("Test runner exited with non-zero status: #{status.exitstatus}")
@@ -1,6 +1,5 @@
1
1
  name: funny_name_backstory
2
2
  description: Create a humorous backstory based on your name
3
- model: anthropic:claude-3-5-sonnet
4
3
 
5
4
  steps:
6
5
  # Collect user's name
@@ -8,7 +7,7 @@ steps:
8
7
  prompt: "What's your name?"
9
8
  name: user_name
10
9
  required: true
11
-
10
+
12
11
  # Ask for preferences
13
12
  - input:
14
13
  prompt: "Pick a genre for your backstory:"
@@ -21,6 +20,6 @@ steps:
21
20
  - "Victorian-Era Vampire Hunter"
22
21
  - "Professional Cat Whisperer"
23
22
  name: genre
24
-
23
+
25
24
  # Generate the backstory
26
- - create_backstory
25
+ - create_backstory
@@ -19,7 +19,8 @@ module Roast
19
19
  # Define methods to be used in workflows below.
20
20
 
21
21
  def shell(command_string)
22
- puts %x(#{command_string})
22
+ output, _status = Roast::Helpers::CmdRunner.capture2e(command_string)
23
+ puts output
23
24
  end
24
25
  end
25
26
  end
@@ -0,0 +1,199 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ module Helpers
6
+ class CmdRunner
7
+ DEFAULT_TIMEOUT = 30
8
+ MAX_TIMEOUT = 3600 # 1 hour
9
+
10
+ @child_processes = {}
11
+ @child_processes_mutex = Mutex.new
12
+
13
+ class << self
14
+ #: (*untyped, **untyped) -> [String, Process::Status]
15
+ def capture2(*args, **options)
16
+ args = args #: as untyped
17
+ stdout, _stderr, status = capture3(*args, **options)
18
+ [stdout, status]
19
+ end
20
+
21
+ #: (*untyped, **untyped) -> [String, Process::Status]
22
+ def capture2e(*args, **options)
23
+ args = args #: as untyped
24
+ stdout, stderr, status = capture3(*args, **options)
25
+ combined_output = stdout + stderr
26
+ [combined_output, status]
27
+ end
28
+
29
+ #: (*untyped, **untyped) -> [String?, String?, Process::Status?]
30
+ def capture3(*args, **options)
31
+ args = args #: as untyped
32
+ popen3(*args, **options) do |stdin, stdout, stderr, wait_thr|
33
+ stdin.close # Prevent hanging on stdin-waiting commands
34
+
35
+ stdout_thread = threaded_read(stdout)
36
+ stderr_thread = threaded_read(stderr)
37
+
38
+ [stdout_thread.value, stderr_thread.value, wait_thr.value]
39
+ end
40
+ end
41
+
42
+ #: (*untyped, **untyped) -> bool
43
+ def system(*args, **options)
44
+ args = args #: as untyped
45
+ popen3(*args, **options) do |stdin, stdout, stderr, wait_thr|
46
+ stdin.close # Prevent hanging on stdin-waiting commands
47
+
48
+ stdout_thread = threaded_stream(from: stdout, to: $stdout)
49
+ stderr_thread = threaded_stream(from: stderr, to: $stderr)
50
+
51
+ stdout_thread.join
52
+ stderr_thread.join
53
+
54
+ wait_thr.value.success?
55
+ end
56
+ end
57
+
58
+ #: (*untyped, **untyped) ?{ (IO, IO, IO, Thread) -> untyped } -> [IO, IO, IO, Thread] | untyped
59
+ def popen3(*args, **options, &block)
60
+ args = args #: as untyped
61
+
62
+ timeout = options.delete(:timeout)
63
+ validate_timeout(timeout) unless timeout.nil?
64
+
65
+ raise ArgumentError, "Timeout provided but no block given" if !timeout.nil? && !block_given?
66
+
67
+ # Mirror Open3.popen3 behavior - if no block, return the IO objects and thread
68
+ unless block_given?
69
+ stdin, stdout, stderr, wait_thr = Open3.popen3(*args, **options)
70
+ track_child_process(wait_thr.pid, presentable_command(args))
71
+ return [stdin, stdout, stderr, wait_thr]
72
+ end
73
+
74
+ Open3.popen3(*args, **options) do |stdin, stdout, stderr, wait_thr|
75
+ track_child_process(wait_thr.pid, presentable_command(args))
76
+
77
+ runnable = proc { yield(stdin, stdout, stderr, wait_thr) } #: Proc
78
+ timeout.nil? ? runnable.call : Timeout.timeout(timeout, &runnable)
79
+ rescue Timeout::Error => e
80
+ raise e.class, "Command '#{presentable_command(args)}' timed out after #{timeout} seconds: #{e.message}"
81
+ ensure
82
+ cleanup_child_process(wait_thr.pid) unless wait_thr.nil?
83
+ end
84
+ end
85
+
86
+ #: -> void
87
+ def cleanup_all_children
88
+ Thread.new do # Thread to avoid issues with calling a mutex in a signal handler
89
+ child_processes = all_child_processes
90
+ Thread.current.exit if child_processes.empty?
91
+
92
+ child_processes.each do |pid, info|
93
+ Roast::Helpers::Logger.info("Cleaning up PID #{pid}: #{info[:command]}")
94
+ cleanup_child_process(pid)
95
+ end
96
+ end.join
97
+ end
98
+
99
+ #: (Integer?) -> Integer
100
+ def normalize_timeout(timeout)
101
+ return DEFAULT_TIMEOUT if timeout.nil? || timeout <= 0
102
+
103
+ [timeout, MAX_TIMEOUT].min
104
+ end
105
+
106
+ private
107
+
108
+ #: (Integer) -> void
109
+ def validate_timeout(timeout)
110
+ if timeout <= 0 || timeout > MAX_TIMEOUT
111
+ raise ArgumentError, "Invalid timeout value: #{timeout.inspect}"
112
+ end
113
+ end
114
+
115
+ #: (Array) -> String
116
+ def presentable_command(args)
117
+ args.flatten.map(&:to_s).join(" ")
118
+ end
119
+
120
+ #: (IO) -> Thread
121
+ def threaded_read(stream)
122
+ Thread.new do
123
+ buffer = ""
124
+ stream.each_line do |line|
125
+ buffer += line
126
+ end
127
+ buffer
128
+ rescue IOError => e
129
+ Roast::Helpers::Logger.debug("IOError while capturing output: #{e.message}")
130
+ end
131
+ end
132
+
133
+ #: (from: IO, to: IO) -> Thread
134
+ def threaded_stream(from:, to:)
135
+ Thread.new do
136
+ from.each_line do |line|
137
+ to.puts(line)
138
+ end
139
+ rescue IOError => e
140
+ Roast::Helpers::Logger.debug("IOError while streaming output: #{e.message}")
141
+ end
142
+ end
143
+
144
+ #: (Integer, String) -> void
145
+ def track_child_process(pid, command)
146
+ @child_processes_mutex.synchronize do
147
+ @child_processes[pid] = {
148
+ command: command,
149
+ started_at: Time.now,
150
+ }
151
+ end
152
+ end
153
+
154
+ #: (Integer) -> void
155
+ def untrack_child_process(pid)
156
+ @child_processes_mutex.synchronize { @child_processes.delete(pid) }
157
+ end
158
+
159
+ #: -> Hash[Integer, { command: String, started_at: Time }]
160
+ def all_child_processes
161
+ @child_processes_mutex.synchronize { @child_processes.dup }
162
+ end
163
+
164
+ #: (Integer) -> void
165
+ def cleanup_child_process(pid)
166
+ untrack_child_process(pid)
167
+
168
+ return unless process_running?(pid)
169
+
170
+ [0.1, 0.2, 0.5].each do |sleep_time|
171
+ Process.kill("TERM", pid)
172
+ break unless process_running?(pid)
173
+
174
+ sleep(sleep_time) # Grace period to let the process terminate
175
+ end
176
+
177
+ # Force kill if still alive
178
+ Process.kill("KILL", pid) if process_running?(pid)
179
+ rescue Errno::ESRCH
180
+ # Process already terminated, which is fine
181
+ rescue Errno::EPERM
182
+ # Permission denied - process may be owned by different user
183
+ Roast::Helpers::Logger.debug("Could not kill process #{pid}: Permission denied")
184
+ rescue => e
185
+ # Catch any other unexpected errors during cleanup
186
+ Roast::Helpers::Logger.debug("Unexpected error during process cleanup: #{e.message}")
187
+ end
188
+
189
+ #: (Integer) -> bool
190
+ def process_running?(pid)
191
+ Process.getpgid(pid)
192
+ true
193
+ rescue Errno::ESRCH
194
+ false
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -7,7 +7,7 @@ module Roast
7
7
  def config_root(starting_path = Dir.pwd, ending_path = File.dirname(Dir.home))
8
8
  paths = []
9
9
  candidate = starting_path
10
- while candidate != ending_path
10
+ while candidate != ending_path && candidate != "/"
11
11
  paths << File.join(candidate, ".roast")
12
12
  candidate = File.dirname(candidate)
13
13
  end
@@ -93,7 +93,7 @@ module Roast
93
93
  File.write(temp_file, updated_content)
94
94
 
95
95
  # Run git diff
96
- diff_output = %x(git diff --no-index --no-prefix "#{file_path}" "#{temp_file}" 2>/dev/null)
96
+ diff_output, _status = Roast::Helpers::CmdRunner.capture2e("git", "diff", "--no-index", "--no-prefix", file_path, temp_file)
97
97
 
98
98
  if diff_output.empty?
99
99
  Roast::Helpers::Logger.info("No differences found (files are identical)\n")
@@ -3,7 +3,6 @@
3
3
 
4
4
  require "English"
5
5
  require "roast/helpers/logger"
6
- require "roast/helpers/timeout_handler"
7
6
 
8
7
  module Roast
9
8
  module Tools
@@ -33,13 +32,14 @@ module Roast
33
32
  Roast::Helpers::Logger.warn("⚠️ WARNING: Unrestricted bash execution - use with caution!\n")
34
33
  end
35
34
 
36
- result, exit_status = Roast::Helpers::TimeoutHandler.call(
35
+ timeout = Roast::Helpers::CmdRunner.normalize_timeout(timeout)
36
+
37
+ result, status = Roast::Helpers::CmdRunner.capture2e(
37
38
  "#{command} 2>&1",
38
39
  timeout: timeout,
39
- working_directory: Dir.pwd,
40
40
  )
41
41
 
42
- format_output(command, result, exit_status)
42
+ format_output(command, result, status.exitstatus)
43
43
  rescue Timeout::Error => e
44
44
  Roast::Helpers::Logger.error(e.message + "\n")
45
45
  e.message
@@ -3,7 +3,6 @@
3
3
 
4
4
  require "English"
5
5
  require "roast/helpers/logger"
6
- require "roast/helpers/timeout_handler"
7
6
 
8
7
  module Roast
9
8
  module Tools
@@ -140,7 +139,7 @@ module Roast
140
139
  end
141
140
 
142
141
  def execute_command(command, command_prefix, timeout)
143
- timeout = Roast::Helpers::TimeoutHandler.validate_timeout(timeout)
142
+ timeout = Roast::Helpers::CmdRunner.normalize_timeout(timeout)
144
143
 
145
144
  full_command = if command_prefix == "dev"
146
145
  "bash -l -c '#{command.gsub("'", "\\'")}'"
@@ -148,13 +147,12 @@ module Roast
148
147
  command
149
148
  end
150
149
 
151
- result, exit_status = Roast::Helpers::TimeoutHandler.call(
150
+ result, status = Roast::Helpers::CmdRunner.capture2e(
152
151
  full_command,
153
152
  timeout: timeout,
154
- working_directory: Dir.pwd,
155
153
  )
156
154
 
157
- format_output(command, result, exit_status)
155
+ format_output(command, result, status.exitstatus)
158
156
  rescue Timeout::Error => e
159
157
  Roast::Helpers::Logger.error(e.message + "\n")
160
158
  e.message
@@ -105,7 +105,7 @@ module Roast
105
105
  command = "cat #{temp_file.path} | #{command_to_run}"
106
106
  result = ""
107
107
 
108
- Open3.popen3(command) do |stdin, stdout, stderr, wait_thread|
108
+ Roast::Helpers::CmdRunner.popen3(command) do |stdin, stdout, stderr, wait_thread|
109
109
  stdin.close
110
110
  if expect_json_output
111
111
  stdout.each_line do |line|
@@ -28,13 +28,17 @@ module Roast
28
28
  def call(string)
29
29
  Roast::Helpers::Logger.info("🔍 Grepping for string: #{string}\n")
30
30
 
31
- unless system("command -v rg >/dev/null 2>&1")
31
+ # Check if ripgrep is available by trying to run it with --version
32
+ unless Roast::Helpers::CmdRunner.system("rg --version > /dev/null 2>&1")
32
33
  raise "ripgrep is not available. Please install it using your package manager (e.g., brew install rg) and make sure it's on your PATH."
33
34
  end
34
35
 
35
36
  # Use Open3 to safely pass the string as an argument, avoiding shell injection
36
37
  cmd = ["rg", "-C", "4", "--trim", "--color=never", "--heading", "-F", "--", string, "."]
37
- stdout, _stderr, _status = Open3.capture3(*cmd)
38
+ stdout, stderr, status = Roast::Helpers::CmdRunner.capture3(*cmd)
39
+ unless status.success?
40
+ return "Error grepping for string: Command failed: #{stderr}"
41
+ end
38
42
 
39
43
  # Limit output to MAX_RESULT_LINES
40
44
  lines = stdout.lines
@@ -34,7 +34,8 @@ module Roast
34
34
  path = File.expand_path(path)
35
35
  Roast::Helpers::Logger.info("📖 Reading file: #{path}\n")
36
36
  if File.directory?(path)
37
- %x(ls -la #{path})
37
+ output, _status = Roast::Helpers::CmdRunner.capture2e("ls", "-la", path)
38
+ output
38
39
  else
39
40
  File.read(path)
40
41
  end
@@ -85,14 +85,9 @@ module Roast
85
85
  # Build the swarm command with proper escaping
86
86
  command = build_swarm_command(prompt, config_path)
87
87
 
88
- result = ""
89
-
90
88
  # Execute the command directly with the prompt included
91
- IO.popen(command, err: [:child, :out]) do |io|
92
- result = io.read
93
- end
94
-
95
- exit_status = $CHILD_STATUS.exitstatus
89
+ result, status = Roast::Helpers::CmdRunner.capture2e(command)
90
+ exit_status = status.exitstatus
96
91
 
97
92
  format_output(command, result, exit_status)
98
93
  end
data/lib/roast/tools.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # typed: true
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "roast/helpers/cmd_runner"
5
+
4
6
  module Roast
5
7
  # @requires_ancestor: Kernel
6
8
  module Tools
@@ -33,7 +35,14 @@ module Roast
33
35
  Signal.trap("INT") do
34
36
  puts "\n\nCaught CTRL-C! Printing before exiting:\n"
35
37
  puts JSON.pretty_generate(object_to_inspect)
36
- exit(1)
38
+
39
+ begin
40
+ Roast::Helpers::CmdRunner.cleanup_all_children
41
+ rescue => e
42
+ puts "Error interrupting tracked child processes: #{e.message}"
43
+ end
44
+
45
+ exit(130)
37
46
  end
38
47
  end
39
48
 
data/lib/roast/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Roast
5
- VERSION = "0.4.6"
5
+ VERSION = "0.4.7"
6
6
  end
@@ -24,14 +24,14 @@ module Roast
24
24
  def execute(command_string, exit_on_error: true)
25
25
  command = extract_command(command_string)
26
26
 
27
- output = %x(#{command})
28
- exit_status = $CHILD_STATUS.exitstatus
27
+ output, status = Roast::Helpers::CmdRunner.capture2e(command)
28
+ exit_status = status.exitstatus
29
29
 
30
30
  handle_execution_result(
31
31
  command: command,
32
32
  output: output,
33
33
  exit_status: exit_status,
34
- success: $CHILD_STATUS.success?,
34
+ success: status.success?,
35
35
  exit_on_error: exit_on_error,
36
36
  )
37
37
  rescue ArgumentError, CommandExecutionError
@@ -52,7 +52,7 @@ module Roast
52
52
  def process_shell_command(command)
53
53
  # If it's a bash command with the $(command) syntax
54
54
  if command =~ /^\$\((.*)\)$/
55
- return Open3.capture2e({}, ::Regexp.last_match(1).to_s).first.strip
55
+ return Roast::Helpers::CmdRunner.capture2e({}, ::Regexp.last_match(1).to_s).first.strip
56
56
  end
57
57
 
58
58
  # Not a shell command, return as is
@@ -48,7 +48,7 @@ module Roast
48
48
  log_debug("Executing shell script: #{cmd}")
49
49
  log_debug("Environment: #{env.inspect}")
50
50
 
51
- Open3.capture3(env, cmd, chdir: Dir.pwd)
51
+ Roast::Helpers::CmdRunner.capture3(env, cmd, chdir: Dir.pwd)
52
52
  end
53
53
 
54
54
  def build_command
data/lib/roast.rb CHANGED
@@ -36,6 +36,7 @@ require "raix/chat_completion"
36
36
  require "raix/function_dispatch"
37
37
  require "ruby-graphviz"
38
38
  require "thor"
39
+ require "timeout"
39
40
 
40
41
  # Autoloading setup
41
42
  require "zeitwerk"