roast-ai 0.4.0 → 0.4.2

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +2 -2
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +103 -0
  5. data/CLAUDE.md +55 -9
  6. data/Gemfile.lock +19 -10
  7. data/README.md +69 -3
  8. data/bin/console +1 -0
  9. data/docs/AGENT_STEPS.md +33 -9
  10. data/docs/VALIDATION.md +178 -0
  11. data/examples/agent_continue/add_documentation/prompt.md +5 -0
  12. data/examples/agent_continue/add_error_handling/prompt.md +5 -0
  13. data/examples/agent_continue/analyze_codebase/prompt.md +7 -0
  14. data/examples/agent_continue/combined_workflow.yml +24 -0
  15. data/examples/agent_continue/continue_adding_features/prompt.md +4 -0
  16. data/examples/agent_continue/create_integration_tests/prompt.md +3 -0
  17. data/examples/agent_continue/document_with_context/prompt.md +5 -0
  18. data/examples/agent_continue/explore_api/prompt.md +6 -0
  19. data/examples/agent_continue/implement_client/prompt.md +6 -0
  20. data/examples/agent_continue/inline_workflow.yml +20 -0
  21. data/examples/agent_continue/refactor_code/prompt.md +2 -0
  22. data/examples/agent_continue/verify_changes/prompt.md +6 -0
  23. data/examples/agent_continue/workflow.yml +27 -0
  24. data/examples/agent_workflow/workflow.png +0 -0
  25. data/examples/api_workflow/workflow.png +0 -0
  26. data/examples/apply_diff_demo/README.md +58 -0
  27. data/examples/apply_diff_demo/apply_simple_change/prompt.md +13 -0
  28. data/examples/apply_diff_demo/create_sample_file/prompt.md +11 -0
  29. data/examples/apply_diff_demo/workflow.yml +24 -0
  30. data/examples/available_tools_demo/workflow.png +0 -0
  31. data/examples/bash_prototyping/api_testing.png +0 -0
  32. data/examples/bash_prototyping/system_analysis.png +0 -0
  33. data/examples/case_when/workflow.png +0 -0
  34. data/examples/cmd/basic_workflow.png +0 -0
  35. data/examples/cmd/dev_workflow.png +0 -0
  36. data/examples/cmd/explorer_workflow.png +0 -0
  37. data/examples/conditional/simple_workflow.png +0 -0
  38. data/examples/conditional/workflow.png +0 -0
  39. data/examples/context_management_demo/README.md +43 -0
  40. data/examples/context_management_demo/workflow.yml +42 -0
  41. data/examples/direct_coerce_syntax/workflow.png +0 -0
  42. data/examples/dot_notation/workflow.png +0 -0
  43. data/examples/exit_on_error/workflow.png +0 -0
  44. data/examples/grading/rb_test_runner +1 -1
  45. data/examples/grading/workflow.png +0 -0
  46. data/examples/interpolation/workflow.png +0 -0
  47. data/examples/interpolation/workflow.yml +1 -1
  48. data/examples/iteration/workflow.png +0 -0
  49. data/examples/json_handling/workflow.png +0 -0
  50. data/examples/mcp/database_workflow.png +0 -0
  51. data/examples/mcp/env_demo/workflow.png +0 -0
  52. data/examples/mcp/filesystem_demo/workflow.png +0 -0
  53. data/examples/mcp/github_workflow.png +0 -0
  54. data/examples/mcp/multi_mcp_workflow.png +0 -0
  55. data/examples/mcp/workflow.png +0 -0
  56. data/examples/no_model_fallback/README.md +17 -0
  57. data/examples/no_model_fallback/analyze_file/prompt.md +1 -0
  58. data/examples/no_model_fallback/analyze_patterns/prompt.md +27 -0
  59. data/examples/no_model_fallback/generate_report_for_md/prompt.md +10 -0
  60. data/examples/no_model_fallback/generate_report_for_rb/prompt.md +3 -0
  61. data/examples/no_model_fallback/sample.rb +42 -0
  62. data/examples/no_model_fallback/workflow.yml +19 -0
  63. data/examples/openrouter_example/workflow.png +0 -0
  64. data/examples/pre_post_processing/workflow.png +0 -0
  65. data/examples/rspec_to_minitest/workflow.png +0 -0
  66. data/examples/shared_config/example_with_shared_config/workflow.png +0 -0
  67. data/examples/shared_config/shared.png +0 -0
  68. data/examples/single_target_prepost/workflow.png +0 -0
  69. data/examples/smart_coercion_defaults/workflow.png +0 -0
  70. data/examples/step_configuration/workflow.png +0 -0
  71. data/examples/swarm_example.yml +25 -0
  72. data/examples/tool_config_example/workflow.png +0 -0
  73. data/examples/user_input/funny_name/workflow.png +0 -0
  74. data/examples/user_input/simple_input_demo/workflow.png +0 -0
  75. data/examples/user_input/survey_workflow.png +0 -0
  76. data/examples/user_input/workflow.png +0 -0
  77. data/examples/workflow_generator/workflow.png +0 -0
  78. data/lib/roast/errors.rb +3 -0
  79. data/lib/roast/helpers/timeout_handler.rb +91 -0
  80. data/lib/roast/services/context_threshold_checker.rb +42 -0
  81. data/lib/roast/services/token_counting_service.rb +44 -0
  82. data/lib/roast/tools/apply_diff.rb +128 -0
  83. data/lib/roast/tools/bash.rb +15 -9
  84. data/lib/roast/tools/cmd.rb +32 -12
  85. data/lib/roast/tools/coding_agent.rb +65 -10
  86. data/lib/roast/tools/context_summarizer.rb +108 -0
  87. data/lib/roast/tools/swarm.rb +124 -0
  88. data/lib/roast/version.rb +1 -1
  89. data/lib/roast/workflow/agent_step.rb +9 -2
  90. data/lib/roast/workflow/base_iteration_step.rb +3 -2
  91. data/lib/roast/workflow/base_workflow.rb +41 -2
  92. data/lib/roast/workflow/command_executor.rb +3 -1
  93. data/lib/roast/workflow/configuration.rb +2 -1
  94. data/lib/roast/workflow/configuration_loader.rb +63 -1
  95. data/lib/roast/workflow/configuration_parser.rb +2 -0
  96. data/lib/roast/workflow/context_manager.rb +89 -0
  97. data/lib/roast/workflow/each_step.rb +1 -1
  98. data/lib/roast/workflow/input_step.rb +2 -0
  99. data/lib/roast/workflow/interpolator.rb +23 -1
  100. data/lib/roast/workflow/output_handler.rb +1 -1
  101. data/lib/roast/workflow/repeat_step.rb +1 -1
  102. data/lib/roast/workflow/replay_handler.rb +1 -1
  103. data/lib/roast/workflow/sqlite_state_repository.rb +342 -0
  104. data/lib/roast/workflow/state_manager.rb +2 -2
  105. data/lib/roast/workflow/state_repository_factory.rb +36 -0
  106. data/lib/roast/workflow/step_completion_reporter.rb +27 -0
  107. data/lib/roast/workflow/step_executor_coordinator.rb +19 -18
  108. data/lib/roast/workflow/step_executor_with_reporting.rb +68 -0
  109. data/lib/roast/workflow/step_loader.rb +1 -1
  110. data/lib/roast/workflow/step_name_extractor.rb +84 -0
  111. data/lib/roast/workflow/validation_command.rb +197 -0
  112. data/lib/roast/workflow/validators/base_validator.rb +44 -0
  113. data/lib/roast/workflow/validators/dependency_validator.rb +223 -0
  114. data/lib/roast/workflow/validators/linting_validator.rb +113 -0
  115. data/lib/roast/workflow/validators/schema_validator.rb +90 -0
  116. data/lib/roast/workflow/validators/step_collector.rb +57 -0
  117. data/lib/roast/workflow/validators/validation_orchestrator.rb +52 -0
  118. data/lib/roast/workflow/workflow_executor.rb +11 -4
  119. data/lib/roast/workflow/workflow_initializer.rb +80 -0
  120. data/lib/roast/workflow/workflow_runner.rb +6 -0
  121. data/lib/roast/workflow_diagram_generator.rb +298 -0
  122. data/lib/roast.rb +158 -0
  123. data/roast.gemspec +4 -1
  124. data/schema/workflow.json +77 -1
  125. metadata +129 -1
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Sample Ruby file for testing interpolation in workflows
4
+
5
+ class Calculator
6
+ def initialize
7
+ @memory = 0
8
+ end
9
+
10
+ def add(number)
11
+ @memory += number
12
+ end
13
+
14
+ def subtract(number)
15
+ @memory -= number
16
+ end
17
+
18
+ def multiply(number)
19
+ @memory *= number
20
+ end
21
+
22
+ def divide(number)
23
+ raise "Division by zero!" if number.zero?
24
+
25
+ @memory /= number
26
+ end
27
+
28
+ attr_reader :memory
29
+
30
+ def clear
31
+ @memory = 0
32
+ end
33
+ end
34
+
35
+ # Example usage
36
+ if __FILE__ == $PROGRAM_NAME
37
+ calc = Calculator.new
38
+ calc.add(10)
39
+ calc.multiply(2)
40
+ calc.subtract(5)
41
+ puts "Result: #{calc.memory}"
42
+ end
@@ -0,0 +1,19 @@
1
+ name: no_model_fallback_example
2
+
3
+ tools:
4
+ - Roast::Tools::ReadFile
5
+
6
+ steps:
7
+ - analyze_file
8
+ - analyze_patterns
9
+ - generate_report_for_{{File.extname(workflow.file).sub('.', '')}}
10
+ - '$(echo "Processing completed for file: {{File.basename(workflow.file)}}")'
11
+
12
+ analyze_patterns:
13
+ json: true
14
+
15
+ generate_report_for_rb:
16
+ print_response: true
17
+
18
+ generate_report_for_md:
19
+ print_response: true
Binary file
@@ -0,0 +1,25 @@
1
+ name: Swarm Example Workflow
2
+
3
+ # Example workflow demonstrating Roast's integration with Claude Swarm
4
+ # The Swarm tool is available to the LLM, which can choose to use it when appropriate
5
+
6
+ tools:
7
+ - Roast::Tools::Swarm:
8
+ path: ".swarm.yml" # Optional - will use default locations if not specified
9
+
10
+ steps:
11
+ - orchestrate_refactoring: |
12
+ Help me refactor this codebase for better performance. Coordinate multiple
13
+ Claude agents using the swarm configuration to:
14
+ 1. Analyze the current code structure
15
+ 2. Identify performance bottlenecks
16
+ 3. Implement optimizations
17
+ 4. Ensure backward compatibility
18
+
19
+ - specialized_analysis: |
20
+ Now use the specialized swarm configuration at ./specialized-swarm.yml to run
21
+ a comprehensive code analysis that includes:
22
+ - Architecture review
23
+ - Security audit
24
+ - Documentation generation
25
+ - Test coverage analysis
Binary file
data/lib/roast/errors.rb CHANGED
@@ -7,5 +7,8 @@ module Roast
7
7
 
8
8
  # Custom error for when API authentication fails
9
9
  class AuthenticationError < StandardError; end
10
+
11
+ # Exit the app, for instance via Ctrl-C during an InputStep
12
+ class ExitEarly < StandardError; end
10
13
  end
11
14
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require "open3"
5
+
6
+ module Roast
7
+ module Helpers
8
+ # Shared timeout handling logic for command-based tools
9
+ #
10
+ # This class provides centralized timeout functionality for executing shell commands
11
+ # with proper process management and resource cleanup.
12
+ #
13
+ # @example Basic usage
14
+ # output, status = TimeoutHandler.call("echo hello", timeout: 5)
15
+ #
16
+ # @example With custom working directory
17
+ # output, status = TimeoutHandler.call("pwd", timeout: 10, working_directory: "/tmp")
18
+ class TimeoutHandler
19
+ DEFAULT_TIMEOUT = 30
20
+ MAX_TIMEOUT = 300
21
+
22
+ class << self
23
+ # Execute a command with timeout using Open3 with proper process cleanup
24
+ # @param command [String] The command to execute
25
+ # @param timeout [Integer] Timeout in seconds
26
+ # @param working_directory [String] Directory to execute in (default: Dir.pwd)
27
+ # @return [Array<String, Integer>] [output, exit_status]
28
+ # @raise [Timeout::Error] When command exceeds timeout duration
29
+ def call(command, timeout: DEFAULT_TIMEOUT, working_directory: Dir.pwd)
30
+ timeout = validate_timeout(timeout)
31
+ output = ""
32
+ exit_status = nil
33
+ wait_thr = nil
34
+
35
+ begin
36
+ Timeout.timeout(timeout) do
37
+ stdin, stdout, stderr, wait_thr = Open3.popen3(command, chdir: working_directory)
38
+ stdin.close # Prevent hanging on stdin-waiting commands
39
+ output = stdout.read + stderr.read
40
+ wait_thr.join
41
+ exit_status = wait_thr.value.exitstatus
42
+
43
+ [stdout, stderr].each(&:close)
44
+ end
45
+ rescue Timeout::Error
46
+ # Clean up any remaining processes to prevent zombies
47
+ cleanup_process(wait_thr) if wait_thr&.alive?
48
+ raise Timeout::Error, "Command '#{command}' in '#{working_directory}' timed out after #{timeout} seconds"
49
+ end
50
+
51
+ [output, exit_status]
52
+ end
53
+
54
+ # Validate and normalize timeout value
55
+ # @param timeout [Integer, nil] Raw timeout value
56
+ # @return [Integer] Validated timeout between 1 and MAX_TIMEOUT
57
+ def validate_timeout(timeout)
58
+ return DEFAULT_TIMEOUT if timeout.nil? || timeout <= 0
59
+
60
+ [timeout, MAX_TIMEOUT].min
61
+ end
62
+
63
+ private
64
+
65
+ # Clean up process on timeout to prevent zombie processes
66
+ # @param wait_thr [Process::Waiter] The process thread to clean up
67
+ def cleanup_process(wait_thr)
68
+ return unless wait_thr&.alive?
69
+
70
+ pid = wait_thr.pid
71
+ # First try graceful termination
72
+ Process.kill("TERM", pid)
73
+ sleep(0.1)
74
+
75
+ # Force kill if still alive
76
+ if wait_thr.alive?
77
+ Process.kill("KILL", pid)
78
+ end
79
+ rescue Errno::ESRCH
80
+ # Process already terminated, which is fine
81
+ rescue Errno::EPERM
82
+ # Permission denied - process may be owned by different user
83
+ Roast::Helpers::Logger.debug("Could not kill process #{pid}: Permission denied")
84
+ rescue => e
85
+ # Catch any other unexpected errors during cleanup
86
+ Roast::Helpers::Logger.debug("Unexpected error during process cleanup: #{e.message}")
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Services
5
+ class ContextThresholdChecker
6
+ # Default max tokens if not specified (128k for GPT-4)
7
+ DEFAULT_MAX_TOKENS = 128_000
8
+
9
+ # Warning threshold as percentage of compaction threshold
10
+ WARNING_THRESHOLD_RATIO = 0.9
11
+
12
+ # Critical threshold as percentage of max tokens
13
+ CRITICAL_THRESHOLD_RATIO = 0.95
14
+
15
+ def should_compact?(token_count, threshold, max_tokens)
16
+ max_tokens ||= DEFAULT_MAX_TOKENS
17
+ token_count >= (max_tokens * threshold)
18
+ end
19
+
20
+ def check_warning_threshold(token_count, compaction_threshold, max_tokens)
21
+ max_tokens ||= DEFAULT_MAX_TOKENS
22
+ percentage_used = (token_count.to_f / max_tokens * 100).round
23
+
24
+ if token_count >= (max_tokens * CRITICAL_THRESHOLD_RATIO)
25
+ {
26
+ level: :critical,
27
+ percentage_used: percentage_used,
28
+ tokens_used: token_count,
29
+ max_tokens: max_tokens,
30
+ }
31
+ elsif token_count >= (max_tokens * compaction_threshold * WARNING_THRESHOLD_RATIO)
32
+ {
33
+ level: :approaching_limit,
34
+ percentage_used: percentage_used,
35
+ tokens_used: token_count,
36
+ max_tokens: max_tokens,
37
+ }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Services
5
+ class TokenCountingService
6
+ # Approximate character-to-token ratio for English text
7
+ # Based on OpenAI's rule of thumb: ~4 characters per token
8
+ CHARS_PER_TOKEN = 4.0
9
+
10
+ # Base token overhead for message structure
11
+ MESSAGE_OVERHEAD_TOKENS = 3
12
+
13
+ def count_messages(messages)
14
+ return 0 if messages.nil? || messages.empty?
15
+
16
+ messages.sum do |message|
17
+ count_message(message)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def count_message(message)
24
+ return 0 if message.nil?
25
+
26
+ role_tokens = estimate_tokens(message[:role].to_s)
27
+ content_tokens = estimate_tokens(message[:content].to_s)
28
+
29
+ # Don't add overhead for empty messages
30
+ return 0 if role_tokens == 0 && content_tokens == 0
31
+
32
+ # Add overhead for message structure and special tokens
33
+ role_tokens + content_tokens + MESSAGE_OVERHEAD_TOKENS
34
+ end
35
+
36
+ def estimate_tokens(text)
37
+ return 0 if text.nil? || text.empty?
38
+
39
+ # Simple character-based estimation
40
+ (text.length / CHARS_PER_TOKEN).ceil
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cli/ui"
4
+
5
+ module Roast
6
+ module Tools
7
+ module ApplyDiff
8
+ extend self
9
+
10
+ class << self
11
+ def included(base)
12
+ base.class_eval do
13
+ function(
14
+ :apply_diff,
15
+ "Show a diff to the user and apply changes based on their yes/no response",
16
+ file_path: { type: "string", description: "Path to the file to modify" },
17
+ old_content: { type: "string", description: "The current content to be replaced" },
18
+ new_content: { type: "string", description: "The new content to replace with" },
19
+ description: { type: "string", description: "Optional description of the change", required: false },
20
+ ) do |params|
21
+ Roast::Tools::ApplyDiff.call(
22
+ params[:file_path],
23
+ params[:old_content],
24
+ params[:new_content],
25
+ params[:description],
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ def call(file_path, old_content, new_content, description = nil)
33
+ unless File.exist?(file_path)
34
+ error_msg = "File not found: #{file_path}"
35
+ Roast::Helpers::Logger.error(error_msg + "\n")
36
+ return error_msg
37
+ end
38
+
39
+ current_content = File.read(file_path)
40
+ unless current_content.include?(old_content)
41
+ error_msg = "Old content not found in file: #{file_path}"
42
+ Roast::Helpers::Logger.error(error_msg + "\n")
43
+ return error_msg
44
+ end
45
+
46
+ # Show the diff
47
+ show_diff(file_path, old_content, new_content, description)
48
+
49
+ # Ask for confirmation
50
+ prompt_text = "Apply this change? (y/n)"
51
+ response = ::CLI::UI::Prompt.ask(prompt_text)
52
+
53
+ if response.to_s.downcase.start_with?("y")
54
+ # Apply the change
55
+ updated_content = current_content.gsub(old_content, new_content)
56
+ File.write(file_path, updated_content)
57
+
58
+ success_msg = "✅ Changes applied to #{file_path}"
59
+ Roast::Helpers::Logger.info(success_msg + "\n")
60
+ success_msg
61
+ else
62
+ cancel_msg = "❌ Changes cancelled for #{file_path}"
63
+ Roast::Helpers::Logger.info(cancel_msg + "\n")
64
+ cancel_msg
65
+ end
66
+ rescue StandardError => e
67
+ error_message = "Error applying diff: #{e.message}"
68
+ Roast::Helpers::Logger.error(error_message + "\n")
69
+ Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
70
+ error_message
71
+ end
72
+
73
+ private
74
+
75
+ def show_diff(file_path, old_content, new_content, description)
76
+ require "tmpdir"
77
+
78
+ Roast::Helpers::Logger.info("📝 Proposed change for #{file_path}:\n")
79
+
80
+ if description
81
+ Roast::Helpers::Logger.info("Description: #{description}\n\n")
82
+ end
83
+
84
+ # Create temporary files for git diff
85
+ Dir.mktmpdir do |tmpdir|
86
+ # Write current content with old_content replaced by new_content
87
+ current_content = File.read(file_path)
88
+ updated_content = current_content.gsub(old_content, new_content)
89
+
90
+ # Create temp file with the proposed changes
91
+ temp_file = File.join(tmpdir, File.basename(file_path))
92
+ File.write(temp_file, updated_content)
93
+
94
+ # Run git diff
95
+ diff_output = %x(git diff --no-index --no-prefix "#{file_path}" "#{temp_file}" 2>/dev/null)
96
+
97
+ if diff_output.empty?
98
+ Roast::Helpers::Logger.info("No differences found (files are identical)\n")
99
+ else
100
+ # Clean up the diff output - remove temp file paths and use relative paths with colors
101
+ cleaned_diff = diff_output.lines.map do |line|
102
+ case line
103
+ when /^diff --git /
104
+ ::CLI::UI.fmt("{{bold:diff --git a/#{file_path} b/#{file_path}}}")
105
+ when /^--- /
106
+ ::CLI::UI.fmt("{{red:--- a/#{file_path}}}")
107
+ when /^\+\+\+ /
108
+ ::CLI::UI.fmt("{{green:+++ b/#{file_path}}}")
109
+ when /^@@/
110
+ ::CLI::UI.fmt("{{cyan:#{line.chomp}}}")
111
+ when /^-/
112
+ ::CLI::UI.fmt("{{red:#{line.chomp}}}")
113
+ when /^\+/
114
+ ::CLI::UI.fmt("{{green:#{line.chomp}}}")
115
+ else
116
+ line.chomp
117
+ end
118
+ end.join("\n")
119
+
120
+ Roast::Helpers::Logger.info("#{cleaned_diff}\n")
121
+ end
122
+ end
123
+
124
+ Roast::Helpers::Logger.info("\n")
125
+ end
126
+ end
127
+ end
128
+ end
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
4
+ require "roast/helpers/logger"
5
+ require "roast/helpers/timeout_handler"
6
+
3
7
  module Roast
4
8
  module Tools
5
9
  module Bash
@@ -12,14 +16,15 @@ module Roast
12
16
  :bash,
13
17
  "Execute any bash command without restrictions. ⚠️ WARNING: Use only in trusted environments!",
14
18
  command: { type: "string", description: "The bash command to execute" },
19
+ timeout: { type: "integer", description: "Timeout in seconds (optional, default: 30)", required: false },
15
20
  ) do |params|
16
- Roast::Tools::Bash.call(params[:command])
21
+ Roast::Tools::Bash.call(params[:command], timeout: params[:timeout])
17
22
  end
18
23
  end
19
24
  end
20
25
  end
21
26
 
22
- def call(command)
27
+ def call(command, timeout: 30)
23
28
  Roast::Helpers::Logger.info("🚀 Executing bash command: #{command}\n")
24
29
 
25
30
  # Show warning unless explicitly disabled
@@ -27,15 +32,16 @@ module Roast
27
32
  Roast::Helpers::Logger.warn("⚠️ WARNING: Unrestricted bash execution - use with caution!\n")
28
33
  end
29
34
 
30
- # Execute the command without any restrictions
31
- result = ""
32
- IO.popen("#{command} 2>&1", chdir: Dir.pwd) do |io|
33
- result = io.read
34
- end
35
-
36
- exit_status = $CHILD_STATUS.exitstatus
35
+ result, exit_status = Roast::Helpers::TimeoutHandler.call(
36
+ "#{command} 2>&1",
37
+ timeout: timeout,
38
+ working_directory: Dir.pwd,
39
+ )
37
40
 
38
41
  format_output(command, result, exit_status)
42
+ rescue Timeout::Error => e
43
+ Roast::Helpers::Logger.error(e.message + "\n")
44
+ e.message
39
45
  rescue StandardError => e
40
46
  handle_error(e)
41
47
  end
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
4
+ require "roast/helpers/logger"
5
+ require "roast/helpers/timeout_handler"
6
+
3
7
  module Roast
4
8
  module Tools
5
9
  module Cmd
@@ -62,6 +66,11 @@ module Roast
62
66
  description: "Arguments to pass to the #{command} command",
63
67
  required: false,
64
68
  },
69
+ timeout: {
70
+ type: "integer",
71
+ description: "Timeout in seconds (optional, default: 30)",
72
+ required: false,
73
+ },
65
74
  ) do |params|
66
75
  full_command = if params[:args].nil? || params[:args].empty?
67
76
  command
@@ -69,7 +78,7 @@ module Roast
69
78
  "#{command} #{params[:args]}"
70
79
  end
71
80
 
72
- Roast::Tools::Cmd.execute_allowed_command(full_command, command)
81
+ Roast::Tools::Cmd.execute_allowed_command(full_command, command, params[:timeout])
73
82
  end
74
83
  end
75
84
  end
@@ -80,15 +89,16 @@ module Roast
80
89
  end
81
90
  end
82
91
 
83
- def execute_allowed_command(full_command, command_prefix)
92
+ def execute_allowed_command(full_command, command_prefix, timeout = 30)
84
93
  Roast::Helpers::Logger.info("🔧 Running command: #{full_command}\n")
85
- execute_command(full_command, command_prefix)
94
+
95
+ execute_command(full_command, command_prefix, timeout)
86
96
  rescue StandardError => e
87
97
  handle_error(e)
88
98
  end
89
99
 
90
100
  # Legacy method for backward compatibility
91
- def call(command, config = {})
101
+ def call(command, config = {}, timeout: 30)
92
102
  Roast::Helpers::Logger.info("🔧 Running command: #{command}\n")
93
103
 
94
104
  allowed_commands = config[CONFIG_ALLOWED_COMMANDS] || DEFAULT_ALLOWED_COMMANDS
@@ -96,7 +106,8 @@ module Roast
96
106
  return validation_result unless validation_result.nil?
97
107
 
98
108
  command_prefix = command.split(" ").first
99
- execute_command(command, command_prefix)
109
+
110
+ execute_command(command, command_prefix, timeout)
100
111
  rescue StandardError => e
101
112
  handle_error(e)
102
113
  end
@@ -127,16 +138,25 @@ module Roast
127
138
  configuration&.tool_config("Roast::Tools::Cmd") || {}
128
139
  end
129
140
 
130
- def execute_command(command, command_prefix)
131
- result = if command_prefix == "dev"
132
- # Use bash -l -c to ensure we get a login shell with all environment variables
133
- full_command = "bash -l -c '#{command.gsub("'", "\\'")}'"
134
- IO.popen(full_command, chdir: Dir.pwd, &:read)
141
+ def execute_command(command, command_prefix, timeout)
142
+ timeout = Roast::Helpers::TimeoutHandler.validate_timeout(timeout)
143
+
144
+ full_command = if command_prefix == "dev"
145
+ "bash -l -c '#{command.gsub("'", "\\'")}'"
135
146
  else
136
- IO.popen(command, chdir: Dir.pwd, &:read)
147
+ command
137
148
  end
138
149
 
139
- format_output(command, result, $CHILD_STATUS.exitstatus)
150
+ result, exit_status = Roast::Helpers::TimeoutHandler.call(
151
+ full_command,
152
+ timeout: timeout,
153
+ working_directory: Dir.pwd,
154
+ )
155
+
156
+ format_output(command, result, exit_status)
157
+ rescue Timeout::Error => e
158
+ Roast::Helpers::Logger.error(e.message + "\n")
159
+ e.message
140
160
  end
141
161
 
142
162
  def format_output(command, result, exit_status)