roast-ai 0.4.1 → 0.4.3

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +43 -0
  4. data/Gemfile +0 -1
  5. data/Gemfile.lock +48 -23
  6. data/README.md +228 -29
  7. data/examples/coding_agent_with_model.yml +20 -0
  8. data/examples/coding_agent_with_retries.yml +30 -0
  9. data/examples/grading/rb_test_runner +1 -1
  10. data/lib/roast/errors.rb +3 -0
  11. data/lib/roast/helpers/metadata_access.rb +39 -0
  12. data/lib/roast/helpers/timeout_handler.rb +1 -1
  13. data/lib/roast/tools/coding_agent.rb +99 -27
  14. data/lib/roast/tools/grep.rb +4 -0
  15. data/lib/roast/version.rb +1 -1
  16. data/lib/roast/workflow/agent_step.rb +57 -4
  17. data/lib/roast/workflow/base_workflow.rb +4 -2
  18. data/lib/roast/workflow/command_executor.rb +3 -1
  19. data/lib/roast/workflow/configuration_parser.rb +2 -0
  20. data/lib/roast/workflow/each_step.rb +5 -3
  21. data/lib/roast/workflow/input_step.rb +2 -0
  22. data/lib/roast/workflow/interpolator.rb +23 -1
  23. data/lib/roast/workflow/metadata_manager.rb +47 -0
  24. data/lib/roast/workflow/output_handler.rb +1 -0
  25. data/lib/roast/workflow/replay_handler.rb +8 -0
  26. data/lib/roast/workflow/shell_script_step.rb +115 -0
  27. data/lib/roast/workflow/sqlite_state_repository.rb +17 -17
  28. data/lib/roast/workflow/state_manager.rb +8 -0
  29. data/lib/roast/workflow/step_executor_coordinator.rb +43 -8
  30. data/lib/roast/workflow/step_executor_with_reporting.rb +2 -2
  31. data/lib/roast/workflow/step_loader.rb +55 -9
  32. data/lib/roast/workflow/workflow_executor.rb +3 -4
  33. data/lib/roast/workflow/workflow_initializer.rb +95 -4
  34. data/lib/roast/workflow/workflow_runner.rb +2 -2
  35. data/lib/roast.rb +2 -0
  36. data/roast.gemspec +3 -2
  37. metadata +36 -18
  38. data/lib/roast/workflow/step_orchestrator.rb +0 -48
@@ -4,6 +4,7 @@ module Roast
4
4
  module Tools
5
5
  module CodingAgent
6
6
  extend self
7
+ include Roast::Helpers::MetadataAccess
7
8
 
8
9
  class CodingAgentError < StandardError; end
9
10
 
@@ -11,9 +12,10 @@ module Roast
11
12
  private_constant :CONFIG_CODING_AGENT_COMMAND
12
13
 
13
14
  @configured_command = nil
15
+ @configured_options = {}
14
16
 
15
17
  class << self
16
- attr_accessor :configured_command
18
+ attr_accessor :configured_command, :configured_options
17
19
 
18
20
  def included(base)
19
21
  base.class_eval do
@@ -23,11 +25,13 @@ module Roast
23
25
  prompt: { type: "string", description: "The prompt to send to Claude Code" },
24
26
  include_context_summary: { type: "boolean", description: "Whether to set a summary of the current workflow context as system directive (default: false)", required: false },
25
27
  continue: { type: "boolean", description: "Whether to continue where the previous coding agent left off or start with a fresh context (default: false, start fresh)", required: false },
28
+ retries: { type: "integer", description: "Number of times to retry the coding agent invocation if it terminates with an error (default: 0, no retry)", required: false },
26
29
  ) do |params|
27
30
  Roast::Tools::CodingAgent.call(
28
31
  params[:prompt],
29
32
  include_context_summary: params[:include_context_summary].presence || false,
30
33
  continue: params[:continue].presence || false,
34
+ retries: params[:retries],
31
35
  )
32
36
  end
33
37
  end
@@ -36,14 +40,26 @@ module Roast
36
40
  # Called after configuration is loaded
37
41
  def post_configuration_setup(base, config = {})
38
42
  self.configured_command = config[CONFIG_CODING_AGENT_COMMAND]
43
+ # Store any other configuration options (like model)
44
+ self.configured_options = config.except(CONFIG_CODING_AGENT_COMMAND)
39
45
  end
40
46
  end
41
47
 
42
- def call(prompt, include_context_summary: false, continue: false)
43
- Roast::Helpers::Logger.info("🤖 Running CodingAgent\n")
44
- run_claude_code(prompt, include_context_summary:, continue:)
48
+ def call(prompt, include_context_summary: false, continue: false, retries: nil)
49
+ # Use configured retries as default, fall back to 0 if not configured
50
+ retries ||= CodingAgent.configured_options[:retries] || CodingAgent.configured_options["retries"] || 0
51
+ (retries + 1).times do |iteration|
52
+ Roast::Helpers::Logger.info("🤖 Running CodingAgent#{iteration > 0 ? ", attempt #{iteration + 1} of #{retries + 1}" : ""}\n")
53
+ return run_claude_code(prompt, include_context_summary:, continue:)
54
+ rescue CodingAgentError => e
55
+ raise e if iteration >= retries
56
+
57
+ Roast::Helpers::Logger.warn("🤖 Retrying after error running CodingAgent: #{e.message}")
58
+ Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
59
+ end
60
+ Roast::Helpers::Logger.error("🤖 CodingAgent did not complete successfully after multiple retries")
45
61
  rescue StandardError => e
46
- "Error running CodingAgent: #{e.message}".tap do |error_message|
62
+ "🤖 Error running CodingAgent: #{e.message}".tap do |error_message|
47
63
  Roast::Helpers::Logger.error(error_message + "\n")
48
64
  Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
49
65
  end
@@ -68,9 +84,19 @@ module Roast
68
84
  temp_file.write(final_prompt)
69
85
  temp_file.close
70
86
 
71
- # Build the command with continue option if specified
87
+ # Check for session ID if continue is requested
88
+ # Resuming from a specific session id is more resilient than simply continuing if there are
89
+ # parallel invocations of claude being run in the same working directory.
90
+ session_id = nil
91
+ if continue
92
+ session_id = step_metadata["coding_agent_session_id"]
93
+ end
94
+
95
+ # Build the command with continue option (may become resume if session_id exists)
72
96
  base_command = claude_code_command
73
- command_to_run = build_command(base_command, continue:)
97
+ command_to_run = build_command(base_command, continue:, session_id:)
98
+
99
+ Roast::Helpers::Logger.debug(command_to_run)
74
100
 
75
101
  # Run Claude Code CLI using the temp file as input with streaming output
76
102
  expect_json_output = command_to_run.include?("--output-format stream-json") ||
@@ -85,19 +111,22 @@ module Roast
85
111
  json = parse_json(line)
86
112
  next unless json
87
113
 
114
+ handle_session_info(json)
88
115
  handle_intermediate_message(json)
89
- result += handle_result(json) || ""
116
+ handled_result = handle_result(json)
117
+ result += handled_result if handled_result
90
118
  end
91
119
  else
92
120
  result = stdout.read
121
+ # Clear any stale session ID we might have when not using JSON formatting
122
+ set_current_step_metadata("coding_agent_session_id", nil)
93
123
  end
94
124
 
95
125
  status = wait_thread.value
96
126
  if status.success?
97
127
  return result
98
128
  else
99
- error_output = stderr.read
100
- return "Error running CodingAgent: #{error_output}"
129
+ raise CodingAgentError, stderr.read
101
130
  end
102
131
  end
103
132
  ensure
@@ -127,13 +156,18 @@ module Roast
127
156
  def handle_result(json)
128
157
  if json["type"] == "result"
129
158
  # NOTE: the format of an error response is { "subtype": "success", "is_error": true }
130
- if json["is_error"]
131
- raise CodingAgentError, json["result"]
132
- elsif json["subtype"] == "success"
133
- json["result"]
134
- else
135
- raise CodingAgentError, "CodingAgent did not complete successfully: #{line}"
136
- end
159
+ is_error = json["is_error"] || false
160
+ success = !is_error && json["subtype"] == "success"
161
+ raise CodingAgentError, json.inspect unless success
162
+
163
+ json["result"]
164
+ end
165
+ end
166
+
167
+ def handle_session_info(json)
168
+ session_id = json["session_id"]
169
+ if session_id
170
+ set_current_step_metadata("coding_agent_session_id", session_id)
137
171
  end
138
172
  end
139
173
 
@@ -147,20 +181,58 @@ module Roast
147
181
  end
148
182
 
149
183
  def claude_code_command
150
- CodingAgent.configured_command || ENV["CLAUDE_CODE_COMMAND"] || "claude -p --verbose --output-format stream-json"
184
+ CodingAgent.configured_command || ENV["CLAUDE_CODE_COMMAND"] || "claude -p --verbose --output-format stream-json --dangerously-skip-permissions"
151
185
  end
152
186
 
153
- def build_command(base_command, continue:)
154
- return base_command unless continue
187
+ def build_command(base_command, continue:, session_id: nil)
188
+ command = base_command.dup
155
189
 
156
- # Add --continue flag to the command
157
- # If the command already has flags, insert --continue after 'claude'
158
- if base_command.start_with?("claude ")
159
- base_command.sub("claude ", "claude --continue ")
160
- else
161
- # Fallback for non-standard commands
162
- "#{base_command} --continue"
190
+ # Add configured options (like --model), excluding retries which is handled internally
191
+ options_for_command = CodingAgent.configured_options.except("retries", :retries)
192
+ if options_for_command.any?
193
+ options_str = build_options_string(options_for_command)
194
+ command = if command.start_with?("claude ")
195
+ command.sub("claude ", "claude #{options_str} ")
196
+ else
197
+ # For non-standard commands, append at the end
198
+ "#{command} #{options_str}"
199
+ end
163
200
  end
201
+
202
+ # Add --resume or --continue flag based on continue option and session_id value
203
+ if continue
204
+ command = if session_id
205
+ # Use --resume with session ID if available
206
+ if command.start_with?("claude ")
207
+ command.sub("claude ", "claude --resume #{session_id} ")
208
+ else
209
+ # Fallback for non-standard commands
210
+ "#{command} --resume #{session_id}"
211
+ end
212
+ elsif command.start_with?("claude ")
213
+ # Use --continue if no session ID
214
+ command.sub("claude ", "claude --continue ")
215
+ else
216
+ # Fallback for non-standard commands
217
+ "#{command} --continue"
218
+ end
219
+ end
220
+
221
+ command
222
+ end
223
+
224
+ def build_options_string(options)
225
+ options.map do |key, value|
226
+ # Convert Ruby hash keys to command line format
227
+ flag = "--#{key.to_s.tr("_", "-")}"
228
+ if value == true
229
+ flag
230
+ elsif value == false || value.nil?
231
+ nil
232
+ else
233
+ "#{flag} #{value}"
234
+ end
235
+ end.compact.join(" ")
164
236
  end
165
237
 
166
238
  def prepare_prompt(prompt, include_context_summary)
@@ -27,6 +27,10 @@ module Roast
27
27
  def call(string)
28
28
  Roast::Helpers::Logger.info("🔍 Grepping for string: #{string}\n")
29
29
 
30
+ unless system("command -v rg >/dev/null 2>&1")
31
+ 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."
32
+ end
33
+
30
34
  # Use Open3 to safely pass the string as an argument, avoiding shell injection
31
35
  cmd = ["rg", "-C", "4", "--trim", "--color=never", "--heading", "-F", "--", string, "."]
32
36
  stdout, _stderr, _status = Open3.capture3(*cmd)
data/lib/roast/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Roast
4
- VERSION = "0.4.1"
4
+ VERSION = "0.4.3"
5
5
  end
@@ -3,6 +3,15 @@
3
3
  module Roast
4
4
  module Workflow
5
5
  class AgentStep < BaseStep
6
+ attr_accessor :include_context_summary, :continue, :resume
7
+
8
+ def initialize(workflow, **kwargs)
9
+ super
10
+ # Set default values for agent-specific options
11
+ @include_context_summary = false
12
+ @continue = false
13
+ end
14
+
6
15
  def call
7
16
  # For inline prompts (detected by plain text step names), use the name as the prompt
8
17
  # For file-based steps, load from the prompt file
@@ -12,22 +21,66 @@ module Roast
12
21
  read_sidecar_prompt
13
22
  end
14
23
 
15
- # Extract agent-specific configuration from workflow config
16
- step_config = workflow.config[name.to_s] || {}
24
+ # Handle resume option by copying session ID from referenced step
25
+ if @resume.present?
26
+ if (session_id = workflow.metadata.dig(@resume, "coding_agent_session_id"))
27
+ workflow.metadata[name.to_s] ||= {}
28
+ workflow.metadata[name.to_s]["coding_agent_session_id"] = session_id
29
+ else
30
+ Roast::Helpers::Logger.warn("Cannot resume from step '#{@resume}'. It does not have a coding_agent_session_id in its metadata.")
31
+ end
32
+ end
33
+
34
+ # Use agent-specific configuration that was applied by StepLoader
35
+ # If resume is set and a session_id is available, translate it to continue
17
36
  agent_options = {
18
- include_context_summary: step_config.fetch("include_context_summary", false),
19
- continue: step_config.fetch("continue", false),
37
+ include_context_summary: @include_context_summary,
38
+ continue: @continue || session_id.present?, # Use continue if either continue is set or a session_id to resume from is available
20
39
  }
21
40
 
22
41
  # Call CodingAgent directly with the prompt content and options
23
42
  result = Roast::Tools::CodingAgent.call(prompt_content, **agent_options)
24
43
 
44
+ # Parse as JSON if json: true is configured (since CodingAgent response is not handled by Raix)
45
+ if @json && result.is_a?(String)
46
+ # Don't try to parse error messages as JSON
47
+ if result.start_with?("Error running CodingAgent:")
48
+ raise result
49
+ end
50
+
51
+ # Extract JSON from markdown code blocks anywhere in the response
52
+ cleaned_result = extract_json_from_markdown(result)
53
+
54
+ begin
55
+ result = JSON.parse(cleaned_result)
56
+ rescue JSON::ParserError => e
57
+ raise "Failed to parse CodingAgent result as JSON: #{e.message}"
58
+ end
59
+ end
60
+
25
61
  # Process output if print_response is enabled
26
62
  process_output(result, print_response:)
27
63
 
28
64
  # Apply coercion if configured
29
65
  apply_coercion(result)
30
66
  end
67
+
68
+ private
69
+
70
+ def extract_json_from_markdown(text)
71
+ # Look for JSON code blocks anywhere in the text
72
+ # Matches ```json or ``` followed by content, then closing ```
73
+ json_block_pattern = /```(?:json)?\s*\n(.*?)\n```/m
74
+
75
+ match = text.match(json_block_pattern)
76
+ if match
77
+ # Return the content inside the code block
78
+ match[1].strip
79
+ else
80
+ # No code block found, return original text
81
+ text.strip
82
+ end
83
+ end
31
84
  end
32
85
  end
33
86
  end
@@ -24,6 +24,7 @@ module Roast
24
24
 
25
25
  delegate :api_provider, :openai?, to: :workflow_configuration, allow_nil: true
26
26
  delegate :output, :output=, :append_to_final_output, :final_output, to: :output_manager
27
+ delegate :metadata, :metadata=, to: :metadata_manager
27
28
  delegate_missing_to :output
28
29
 
29
30
  def initialize(file = nil, name: nil, context_path: nil, resource: nil, session_name: nil, workflow_configuration: nil, pre_processing_data: nil)
@@ -38,6 +39,7 @@ module Roast
38
39
 
39
40
  # Initialize managers
40
41
  @output_manager = OutputManager.new
42
+ @metadata_manager = MetadataManager.new
41
43
  @context_manager = ContextManager.new
42
44
  @context_management_config = {}
43
45
 
@@ -125,8 +127,8 @@ module Roast
125
127
  self
126
128
  end
127
129
 
128
- # Expose output manager for state management
129
- attr_reader :output_manager
130
+ # Expose output and metadata managers for state management
131
+ attr_reader :output_manager, :metadata_manager
130
132
 
131
133
  private
132
134
 
@@ -14,6 +14,8 @@ module Roast
14
14
  end
15
15
  end
16
16
 
17
+ BASH_COMMAND_REGEX = /^\$\((.*)\)$/m
18
+
17
19
  def initialize(logger: nil)
18
20
  @logger = logger || NullLogger.new
19
21
  end
@@ -44,7 +46,7 @@ module Roast
44
46
  private
45
47
 
46
48
  def extract_command(command_string)
47
- match = command_string.strip.match(/^\$\((.*)\)$/)
49
+ match = command_string.strip.match(BASH_COMMAND_REGEX)
48
50
  raise ArgumentError, "Invalid command format. Expected $(command), got: #{command_string}" unless match
49
51
 
50
52
  match[1]
@@ -38,6 +38,8 @@ module Roast
38
38
  else
39
39
  @workflow_runner.run_targetless
40
40
  end
41
+ rescue Roast::Errors::ExitEarly
42
+ $stderr.puts "Exiting workflow early."
41
43
  ensure
42
44
  execution_time = Time.now - start_time
43
45
 
@@ -54,10 +54,12 @@ module Roast
54
54
  # Set the variable in the workflow's context
55
55
  workflow.instance_variable_set("@#{@variable_name}", value)
56
56
 
57
- # Define a getter method for the variable
57
+ # Define a getter method for the variable if it doesn't exist
58
58
  var_name = @variable_name.to_sym
59
- workflow.singleton_class.class_eval do
60
- attr_reader(var_name)
59
+ unless workflow.respond_to?(var_name)
60
+ workflow.singleton_class.class_eval do
61
+ attr_reader(var_name)
62
+ end
61
63
  end
62
64
 
63
65
  # Make the variable accessible in the output hash
@@ -29,6 +29,8 @@ module Roast
29
29
  store_in_state(result) if step_name
30
30
 
31
31
  result
32
+ rescue Interrupt
33
+ raise Roast::Errors::ExitEarly
32
34
  rescue Timeout::Error
33
35
  handle_timeout
34
36
  end
@@ -11,12 +11,22 @@ module Roast
11
11
  def interpolate(text)
12
12
  return text unless text.is_a?(String) && text.include?("{{") && text.include?("}}")
13
13
 
14
+ # Check if this is a shell command context
15
+ is_shell_command = text.strip.start_with?("$(") && text.strip.end_with?(")")
16
+
14
17
  # Replace all {{expression}} with their evaluated values
15
18
  text.gsub(/\{\{([^}]+)\}\}/) do |match|
16
19
  expression = Regexp.last_match(1).strip
17
20
  begin
18
21
  # Evaluate the expression in the context
19
- @context.instance_eval(expression).to_s
22
+ result = @context.instance_eval(expression).to_s
23
+
24
+ # Escape shell metacharacters if this is a shell command
25
+ if is_shell_command
26
+ escape_shell_metacharacters(result)
27
+ else
28
+ result
29
+ end
20
30
  rescue => e
21
31
  # Provide a detailed error message but preserve the original expression
22
32
  error_msg = "Error interpolating {{#{expression}}}: #{e.message}. This variable is not defined in the workflow context."
@@ -26,6 +36,18 @@ module Roast
26
36
  end
27
37
  end
28
38
 
39
+ private
40
+
41
+ # Escape shell metacharacters to prevent injection and command substitution
42
+ # Order matters: escape backslashes first to avoid double-escaping
43
+ def escape_shell_metacharacters(text)
44
+ text
45
+ .gsub("\\", "\\\\\\\\") # Escape backslashes first (4 backslashes become 2, then 1)
46
+ .gsub('"', '\\\\"') # Escape double quotes
47
+ .gsub("$", "\\\\$") # Escape dollar signs (variable expansion)
48
+ .gsub("`", "\\\\`") # Escape backticks (command substitution)
49
+ end
50
+
29
51
  class NullLogger
30
52
  def error(_message); end
31
53
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Manages workflow metadata, providing a structure parallel to output
6
+ # but specifically for internal metadata that shouldn't be user-facing
7
+ class MetadataManager
8
+ def initialize
9
+ @metadata = ActiveSupport::HashWithIndifferentAccess.new
10
+ @metadata_wrapper = nil
11
+ end
12
+
13
+ # Get metadata wrapped in DotAccessHash for dot notation access
14
+ def metadata
15
+ @metadata_wrapper ||= DotAccessHash.new(@metadata)
16
+ end
17
+
18
+ # Set metadata, ensuring it's always a HashWithIndifferentAccess
19
+ def metadata=(value)
20
+ @metadata = if value.is_a?(ActiveSupport::HashWithIndifferentAccess)
21
+ value
22
+ else
23
+ ActiveSupport::HashWithIndifferentAccess.new(value)
24
+ end
25
+ # Reset the wrapper when metadata changes
26
+ @metadata_wrapper = nil
27
+ end
28
+
29
+ # Get the raw metadata hash (for internal use)
30
+ def raw_metadata
31
+ @metadata
32
+ end
33
+
34
+ # Get a snapshot of the current state for persistence
35
+ def to_h
36
+ @metadata.to_h
37
+ end
38
+
39
+ # Restore state from a hash
40
+ def from_h(data)
41
+ return unless data
42
+
43
+ self.metadata = data
44
+ end
45
+ end
46
+ end
47
+ end
@@ -25,6 +25,7 @@ module Roast
25
25
  File.write(workflow.output_file, workflow.final_output)
26
26
  $stdout.puts "Results saved to #{workflow.output_file}"
27
27
  else
28
+ $stderr.puts "🔥🔥🔥 Final Output: 🔥🔥🔥"
28
29
  $stdout.puts workflow.final_output
29
30
  end
30
31
  end
@@ -77,6 +77,7 @@ module Roast
77
77
  return unless state_data && @workflow
78
78
 
79
79
  restore_output(state_data)
80
+ restore_metadata(state_data)
80
81
  restore_transcript(state_data)
81
82
  restore_final_output(state_data)
82
83
  end
@@ -88,6 +89,13 @@ module Roast
88
89
  @workflow.output = state_data[:output]
89
90
  end
90
91
 
92
+ def restore_metadata(state_data)
93
+ return unless state_data.key?(:metadata)
94
+ return unless @workflow.respond_to?(:metadata=)
95
+
96
+ @workflow.metadata = state_data[:metadata]
97
+ end
98
+
91
99
  def restore_transcript(state_data)
92
100
  return unless state_data.key?(:transcript)
93
101
  return unless @workflow.respond_to?(:transcript)
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ class ShellScriptStep < BaseStep
6
+ attr_reader :script_path
7
+ attr_accessor :exit_on_error, :env
8
+
9
+ def initialize(workflow, script_path:, **options)
10
+ super(workflow, **options)
11
+ @script_path = script_path
12
+ @exit_on_error = true # default to true
13
+ @env = {} # custom environment variables
14
+ end
15
+
16
+ def call
17
+ validate_script!
18
+
19
+ stdout, stderr, status = execute_script
20
+
21
+ result = if status.success?
22
+ parse_output(stdout)
23
+ else
24
+ handle_script_error(stderr, status.exitstatus)
25
+ end
26
+
27
+ process_output(result, print_response: @print_response)
28
+ result
29
+ end
30
+
31
+ private
32
+
33
+ def validate_script!
34
+ unless File.exist?(script_path)
35
+ raise ::CLI::Kit::Abort, "Shell script not found: #{script_path}"
36
+ end
37
+
38
+ unless File.executable?(script_path)
39
+ raise ::CLI::Kit::Abort, "Shell script is not executable: #{script_path}. Run: chmod +x #{script_path}"
40
+ end
41
+ end
42
+
43
+ def execute_script
44
+ env = setup_environment
45
+ cmd = build_command
46
+
47
+ log_debug("Executing shell script: #{cmd}")
48
+ log_debug("Environment: #{env.inspect}")
49
+
50
+ Open3.capture3(env, cmd, chdir: Dir.pwd)
51
+ end
52
+
53
+ def build_command
54
+ script_path
55
+ end
56
+
57
+ def setup_environment
58
+ env_vars = {}
59
+
60
+ # Add workflow context as environment variables
61
+ env_vars["ROAST_WORKFLOW_RESOURCE"] = workflow.resource.to_s if workflow.resource
62
+ env_vars["ROAST_STEP_NAME"] = name.value
63
+
64
+ # Add workflow outputs as JSON
65
+ if workflow.output && !workflow.output.empty?
66
+ env_vars["ROAST_WORKFLOW_OUTPUT"] = JSON.generate(workflow.output)
67
+ end
68
+
69
+ # Add any custom environment variables from step configuration
70
+ if @env.is_a?(Hash)
71
+ @env.each do |key, value|
72
+ env_vars[key.to_s] = value.to_s
73
+ end
74
+ end
75
+
76
+ env_vars
77
+ end
78
+
79
+ def parse_output(stdout)
80
+ return "" if stdout.strip.empty?
81
+
82
+ if @json
83
+ begin
84
+ JSON.parse(stdout.strip)
85
+ rescue JSON::ParserError => e
86
+ raise "Failed to parse shell script output as JSON: #{e.message}\nOutput was: #{stdout.strip}"
87
+ end
88
+ else
89
+ stdout.strip
90
+ end
91
+ end
92
+
93
+ def handle_script_error(stderr, exit_code)
94
+ error_message = "Shell script failed with exit code #{exit_code}"
95
+ error_message += "\nError output:\n#{stderr}" unless stderr.strip.empty?
96
+
97
+ if @exit_on_error == false
98
+ log_error(error_message)
99
+ # Return stderr as the result when not exiting on error
100
+ stderr.strip.empty? ? "" : stderr.strip
101
+ else
102
+ raise ::CLI::Kit::Abort, error_message
103
+ end
104
+ end
105
+
106
+ def log_debug(message)
107
+ $stderr.puts "[ShellScriptStep] #{message}" if ENV["ROAST_DEBUG"]
108
+ end
109
+
110
+ def log_error(message)
111
+ $stderr.puts "[ShellScriptStep] ERROR: #{message}"
112
+ end
113
+ end
114
+ end
115
+ end