roast-ai 0.1.6 → 0.2.0
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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yaml +1 -1
- data/CHANGELOG.md +48 -0
- data/CLAUDE.md +20 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +11 -6
- data/README.md +225 -13
- data/bin/roast +27 -0
- data/docs/INSTRUMENTATION.md +42 -1
- data/docs/ITERATION_SYNTAX.md +119 -0
- data/examples/conditional/README.md +161 -0
- data/examples/conditional/check_condition/prompt.md +1 -0
- data/examples/conditional/simple_workflow.yml +15 -0
- data/examples/conditional/workflow.yml +23 -0
- data/examples/dot_notation/README.md +37 -0
- data/examples/dot_notation/workflow.yml +44 -0
- data/examples/exit_on_error/README.md +50 -0
- data/examples/exit_on_error/analyze_lint_output/prompt.md +9 -0
- data/examples/exit_on_error/apply_fixes/prompt.md +2 -0
- data/examples/exit_on_error/workflow.yml +19 -0
- data/examples/grading/workflow.yml +5 -1
- data/examples/iteration/IMPLEMENTATION.md +88 -0
- data/examples/iteration/README.md +68 -0
- data/examples/iteration/analyze_complexity/prompt.md +22 -0
- data/examples/iteration/generate_recommendations/prompt.md +21 -0
- data/examples/iteration/generate_report/prompt.md +129 -0
- data/examples/iteration/implement_fix/prompt.md +25 -0
- data/examples/iteration/prioritize_issues/prompt.md +24 -0
- data/examples/iteration/prompts/analyze_file.md +28 -0
- data/examples/iteration/prompts/generate_summary.md +24 -0
- data/examples/iteration/prompts/update_report.md +29 -0
- data/examples/iteration/prompts/write_report.md +22 -0
- data/examples/iteration/read_file/prompt.md +9 -0
- data/examples/iteration/select_next_issue/prompt.md +25 -0
- data/examples/iteration/simple_workflow.md +39 -0
- data/examples/iteration/simple_workflow.yml +58 -0
- data/examples/iteration/update_fix_count/prompt.md +26 -0
- data/examples/iteration/verify_fix/prompt.md +29 -0
- data/examples/iteration/workflow.yml +42 -0
- data/examples/openrouter_example/workflow.yml +2 -2
- data/examples/workflow_generator/README.md +27 -0
- data/examples/workflow_generator/analyze_user_request/prompt.md +34 -0
- data/examples/workflow_generator/create_workflow_files/prompt.md +32 -0
- data/examples/workflow_generator/get_user_input/prompt.md +14 -0
- data/examples/workflow_generator/info_from_roast.rb +22 -0
- data/examples/workflow_generator/workflow.yml +35 -0
- data/lib/roast/errors.rb +9 -0
- data/lib/roast/factories/api_provider_factory.rb +61 -0
- data/lib/roast/helpers/function_caching_interceptor.rb +1 -1
- data/lib/roast/helpers/minitest_coverage_runner.rb +1 -1
- data/lib/roast/helpers/prompt_loader.rb +50 -1
- data/lib/roast/resources/base_resource.rb +7 -0
- data/lib/roast/resources.rb +6 -6
- data/lib/roast/tools/ask_user.rb +40 -0
- data/lib/roast/tools/cmd.rb +1 -1
- data/lib/roast/tools/search_file.rb +1 -1
- data/lib/roast/tools/update_files.rb +413 -0
- data/lib/roast/tools.rb +12 -1
- data/lib/roast/value_objects/api_token.rb +49 -0
- data/lib/roast/value_objects/step_name.rb +39 -0
- data/lib/roast/value_objects/workflow_path.rb +77 -0
- data/lib/roast/value_objects.rb +5 -0
- data/lib/roast/version.rb +1 -1
- data/lib/roast/workflow/api_configuration.rb +61 -0
- data/lib/roast/workflow/base_iteration_step.rb +165 -0
- data/lib/roast/workflow/base_step.rb +4 -24
- data/lib/roast/workflow/base_workflow.rb +76 -73
- data/lib/roast/workflow/command_executor.rb +88 -0
- data/lib/roast/workflow/conditional_executor.rb +50 -0
- data/lib/roast/workflow/conditional_step.rb +96 -0
- data/lib/roast/workflow/configuration.rb +35 -158
- data/lib/roast/workflow/configuration_loader.rb +78 -0
- data/lib/roast/workflow/configuration_parser.rb +13 -248
- data/lib/roast/workflow/context_path_resolver.rb +43 -0
- data/lib/roast/workflow/dot_access_hash.rb +198 -0
- data/lib/roast/workflow/each_step.rb +86 -0
- data/lib/roast/workflow/error_handler.rb +97 -0
- data/lib/roast/workflow/expression_utils.rb +36 -0
- data/lib/roast/workflow/file_state_repository.rb +3 -2
- data/lib/roast/workflow/interpolator.rb +34 -0
- data/lib/roast/workflow/iteration_executor.rb +85 -0
- data/lib/roast/workflow/llm_boolean_coercer.rb +55 -0
- data/lib/roast/workflow/output_handler.rb +35 -0
- data/lib/roast/workflow/output_manager.rb +77 -0
- data/lib/roast/workflow/parallel_executor.rb +49 -0
- data/lib/roast/workflow/repeat_step.rb +75 -0
- data/lib/roast/workflow/replay_handler.rb +123 -0
- data/lib/roast/workflow/resource_resolver.rb +77 -0
- data/lib/roast/workflow/session_manager.rb +6 -2
- data/lib/roast/workflow/state_manager.rb +97 -0
- data/lib/roast/workflow/step_executor_coordinator.rb +205 -0
- data/lib/roast/workflow/step_executor_factory.rb +47 -0
- data/lib/roast/workflow/step_executor_registry.rb +79 -0
- data/lib/roast/workflow/step_executors/base_step_executor.rb +23 -0
- data/lib/roast/workflow/step_executors/hash_step_executor.rb +43 -0
- data/lib/roast/workflow/step_executors/parallel_step_executor.rb +54 -0
- data/lib/roast/workflow/step_executors/string_step_executor.rb +29 -0
- data/lib/roast/workflow/step_finder.rb +97 -0
- data/lib/roast/workflow/step_loader.rb +154 -0
- data/lib/roast/workflow/step_orchestrator.rb +45 -0
- data/lib/roast/workflow/step_runner.rb +23 -0
- data/lib/roast/workflow/step_type_resolver.rb +117 -0
- data/lib/roast/workflow/workflow_context.rb +60 -0
- data/lib/roast/workflow/workflow_executor.rb +90 -209
- data/lib/roast/workflow/workflow_initializer.rb +112 -0
- data/lib/roast/workflow/workflow_runner.rb +87 -0
- data/lib/roast/workflow.rb +3 -0
- data/lib/roast.rb +96 -3
- data/roast.gemspec +3 -1
- data/schema/workflow.json +85 -0
- metadata +112 -4
@@ -0,0 +1,413 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fileutils"
|
4
|
+
require "roast/helpers/logger"
|
5
|
+
require "diff/lcs"
|
6
|
+
|
7
|
+
module Roast
|
8
|
+
module Tools
|
9
|
+
module UpdateFiles
|
10
|
+
extend self
|
11
|
+
|
12
|
+
class << self
|
13
|
+
# Add this method to be included in other classes
|
14
|
+
def included(base)
|
15
|
+
base.class_eval do
|
16
|
+
function(
|
17
|
+
:update_files,
|
18
|
+
"Apply a unified diff/patch to files in the workspace. Changes are applied atomically if possible.",
|
19
|
+
diff: {
|
20
|
+
type: "string",
|
21
|
+
description: "The unified diff/patch content to apply",
|
22
|
+
},
|
23
|
+
base_path: {
|
24
|
+
type: "string",
|
25
|
+
description: "Base path for relative file paths in the diff (default: current working directory)",
|
26
|
+
required: false,
|
27
|
+
},
|
28
|
+
restrict_path: {
|
29
|
+
type: "string",
|
30
|
+
description: "Optional path restriction to limit where files can be modified",
|
31
|
+
required: false,
|
32
|
+
},
|
33
|
+
create_files: {
|
34
|
+
type: "boolean",
|
35
|
+
description: "Whether to create new files if they don't exist (default: true)",
|
36
|
+
required: false,
|
37
|
+
},
|
38
|
+
) do |params|
|
39
|
+
base_path = params[:base_path] || Dir.pwd
|
40
|
+
create_files = params.fetch(:create_files, true)
|
41
|
+
restrict_path = params[:restrict_path]
|
42
|
+
|
43
|
+
Roast::Tools::UpdateFiles.call(
|
44
|
+
params[:diff],
|
45
|
+
base_path,
|
46
|
+
restrict_path,
|
47
|
+
create_files,
|
48
|
+
)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Apply a unified diff to files
|
55
|
+
# @param diff [String] unified diff content
|
56
|
+
# @param base_path [String] base path for relative paths in the diff
|
57
|
+
# @param restrict_path [String, nil] optional path restriction
|
58
|
+
# @param create_files [Boolean] whether to create new files if they don't exist
|
59
|
+
# @return [String] result message
|
60
|
+
def call(diff, base_path = Dir.pwd, restrict_path = nil, create_files = true)
|
61
|
+
Roast::Helpers::Logger.info("🔄 Applying patch to files\n")
|
62
|
+
|
63
|
+
# Parse the unified diff to identify files and changes
|
64
|
+
file_changes = parse_unified_diff(diff)
|
65
|
+
|
66
|
+
if file_changes.empty?
|
67
|
+
return "Error: No valid file changes found in the provided diff"
|
68
|
+
end
|
69
|
+
|
70
|
+
# Validate changes
|
71
|
+
validation_result = validate_changes(file_changes, base_path, restrict_path, create_files)
|
72
|
+
return validation_result if validation_result.is_a?(String) && validation_result.start_with?("Error:")
|
73
|
+
|
74
|
+
# Apply changes atomically
|
75
|
+
apply_changes(file_changes, base_path, create_files)
|
76
|
+
rescue StandardError => e
|
77
|
+
"Error applying patch: #{e.message}".tap do |error_message|
|
78
|
+
Roast::Helpers::Logger.error(error_message + "\n")
|
79
|
+
Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
# Parse a unified diff to extract file changes
|
86
|
+
# @param diff [String] unified diff content
|
87
|
+
# @return [Array<Hash>] array of file change objects
|
88
|
+
def parse_unified_diff(diff)
|
89
|
+
lines = diff.split("\n")
|
90
|
+
file_changes = []
|
91
|
+
current_files = { src: nil, dst: nil }
|
92
|
+
current_hunks = []
|
93
|
+
|
94
|
+
i = 0
|
95
|
+
while i < lines.length
|
96
|
+
line = lines[i]
|
97
|
+
|
98
|
+
# New file header (--- line followed by +++ line)
|
99
|
+
if line.start_with?("--- ") && i + 1 < lines.length && lines[i + 1].start_with?("+++ ")
|
100
|
+
# Save previous file if exists
|
101
|
+
if current_files[:src] && current_files[:dst] && !current_hunks.empty?
|
102
|
+
file_changes << {
|
103
|
+
src_path: current_files[:src],
|
104
|
+
dst_path: current_files[:dst],
|
105
|
+
hunks: current_hunks.dup,
|
106
|
+
}
|
107
|
+
end
|
108
|
+
|
109
|
+
# Extract new file paths
|
110
|
+
current_files = {
|
111
|
+
src: extract_file_path(line),
|
112
|
+
dst: extract_file_path(lines[i + 1]),
|
113
|
+
}
|
114
|
+
current_hunks = []
|
115
|
+
i += 2
|
116
|
+
next
|
117
|
+
end
|
118
|
+
|
119
|
+
# Hunk header
|
120
|
+
if line.match(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/)
|
121
|
+
current_hunk = { header: line, changes: [] }
|
122
|
+
|
123
|
+
# Parse the header
|
124
|
+
header_match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
|
125
|
+
if header_match
|
126
|
+
current_hunk[:src_start] = header_match[1].to_i
|
127
|
+
current_hunk[:src_count] = header_match[2] ? header_match[2].to_i : 1 # rubocop:disable Metrics/BlockNesting
|
128
|
+
current_hunk[:dst_start] = header_match[3].to_i
|
129
|
+
current_hunk[:dst_count] = header_match[4] ? header_match[4].to_i : 1 # rubocop:disable Metrics/BlockNesting
|
130
|
+
end
|
131
|
+
|
132
|
+
current_hunks << current_hunk
|
133
|
+
i += 1
|
134
|
+
next
|
135
|
+
end
|
136
|
+
|
137
|
+
# Capture content lines for the current hunk
|
138
|
+
if !current_hunks.empty? && line.start_with?("+", "-", " ")
|
139
|
+
current_hunks.last[:changes] << line
|
140
|
+
end
|
141
|
+
|
142
|
+
i += 1
|
143
|
+
end
|
144
|
+
|
145
|
+
# Add the last file
|
146
|
+
if current_files[:src] && current_files[:dst] && !current_hunks.empty?
|
147
|
+
file_changes << {
|
148
|
+
src_path: current_files[:src],
|
149
|
+
dst_path: current_files[:dst],
|
150
|
+
hunks: current_hunks.dup,
|
151
|
+
}
|
152
|
+
end
|
153
|
+
|
154
|
+
file_changes
|
155
|
+
end
|
156
|
+
|
157
|
+
# Extract file path from a diff header line
|
158
|
+
# @param line [String] diff header line (--- or +++ line)
|
159
|
+
# @return [String] file path
|
160
|
+
def extract_file_path(line)
|
161
|
+
# Handle special cases
|
162
|
+
return "/dev/null" if line.include?("/dev/null")
|
163
|
+
|
164
|
+
# Remove prefixes
|
165
|
+
path = line.sub(%r{^(\+\+\+|\-\-\-) (a|b)/}, "")
|
166
|
+
# Handle files without 'a/' or 'b/' prefix
|
167
|
+
path = line.sub(/^(\+\+\+|\-\-\-) /, "") if path == line
|
168
|
+
# Remove timestamps if present
|
169
|
+
path = path.sub(/\t.*$/, "")
|
170
|
+
path
|
171
|
+
end
|
172
|
+
|
173
|
+
# Validate changes before applying them
|
174
|
+
# @param file_changes [Array<Hash>] array of file change objects
|
175
|
+
# @param base_path [String] base path for relative paths
|
176
|
+
# @param restrict_path [String, nil] optional path restriction
|
177
|
+
# @param create_files [Boolean] whether to create new files if they don't exist
|
178
|
+
# @return [Boolean, String] true if valid, error message if invalid
|
179
|
+
def validate_changes(file_changes, base_path, restrict_path, create_files)
|
180
|
+
# Validate each file in the changes
|
181
|
+
file_changes.each do |file_change|
|
182
|
+
# For destination files (they will be written to)
|
183
|
+
if file_change[:dst_path] && file_change[:dst_path] != "/dev/null"
|
184
|
+
absolute_path = File.expand_path(file_change[:dst_path], base_path)
|
185
|
+
|
186
|
+
# Check path restriction
|
187
|
+
if restrict_path && !absolute_path.start_with?(restrict_path)
|
188
|
+
return "Error: Path #{file_change[:dst_path]} must start with '#{restrict_path}' to use the update_files tool"
|
189
|
+
end
|
190
|
+
|
191
|
+
# Check if file exists
|
192
|
+
if !File.exist?(absolute_path) && !create_files
|
193
|
+
return "Error: File #{file_change[:dst_path]} does not exist and create_files is false"
|
194
|
+
end
|
195
|
+
|
196
|
+
# Check if file is readable and writable if it exists
|
197
|
+
if File.exist?(absolute_path)
|
198
|
+
unless File.readable?(absolute_path)
|
199
|
+
return "Error: File #{file_change[:dst_path]} is not readable"
|
200
|
+
end
|
201
|
+
|
202
|
+
unless File.writable?(absolute_path)
|
203
|
+
return "Error: File #{file_change[:dst_path]} is not writable"
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
# For source files (they will be read from)
|
209
|
+
next unless file_change[:src_path] && file_change[:src_path] != "/dev/null" && file_change[:src_path] != file_change[:dst_path]
|
210
|
+
|
211
|
+
absolute_path = File.expand_path(file_change[:src_path], base_path)
|
212
|
+
|
213
|
+
# Source file must exist unless it's a new file
|
214
|
+
if !File.exist?(absolute_path) && file_change[:src_path] != "/dev/null"
|
215
|
+
# Special case for new files (src: /dev/null)
|
216
|
+
if file_change[:src_path] != "/dev/null"
|
217
|
+
return "Error: Source file #{file_change[:src_path]} does not exist"
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
# Check if file is readable if it exists
|
222
|
+
next unless File.exist?(absolute_path)
|
223
|
+
unless File.readable?(absolute_path)
|
224
|
+
return "Error: Source file #{file_change[:src_path]} is not readable"
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
true
|
229
|
+
end
|
230
|
+
|
231
|
+
# Apply changes to files
|
232
|
+
# @param file_changes [Array<Hash>] array of file change objects
|
233
|
+
# @param base_path [String] base path for relative paths
|
234
|
+
# @param create_files [Boolean] whether to create new files if they don't exist
|
235
|
+
# @return [String] result message
|
236
|
+
def apply_changes(file_changes, base_path, create_files)
|
237
|
+
# Create a temporary backup of all files to be modified
|
238
|
+
backup_files = {}
|
239
|
+
modified_files = []
|
240
|
+
|
241
|
+
# Step 1: Create backups
|
242
|
+
file_changes.each do |file_change|
|
243
|
+
next unless file_change[:dst_path] && file_change[:dst_path] != "/dev/null"
|
244
|
+
|
245
|
+
absolute_path = File.expand_path(file_change[:dst_path], base_path)
|
246
|
+
|
247
|
+
if File.exist?(absolute_path)
|
248
|
+
backup_files[absolute_path] = File.read(absolute_path)
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
# Step 2: Try to apply all changes
|
253
|
+
begin
|
254
|
+
file_changes.each do |file_change|
|
255
|
+
next unless file_change[:dst_path]
|
256
|
+
|
257
|
+
# Special case for file deletion
|
258
|
+
if file_change[:dst_path] == "/dev/null" && file_change[:src_path] != "/dev/null"
|
259
|
+
absolute_src_path = File.expand_path(file_change[:src_path], base_path)
|
260
|
+
if File.exist?(absolute_src_path)
|
261
|
+
File.delete(absolute_src_path)
|
262
|
+
modified_files << file_change[:src_path]
|
263
|
+
end
|
264
|
+
next
|
265
|
+
end
|
266
|
+
|
267
|
+
# Skip if both src and dst are /dev/null (shouldn't happen but just in case)
|
268
|
+
next if file_change[:dst_path] == "/dev/null" && file_change[:src_path] == "/dev/null"
|
269
|
+
|
270
|
+
absolute_dst_path = File.expand_path(file_change[:dst_path], base_path)
|
271
|
+
|
272
|
+
# Special case for new files
|
273
|
+
if file_change[:src_path] == "/dev/null"
|
274
|
+
# For new files, ensure directory exists
|
275
|
+
dir = File.dirname(absolute_dst_path)
|
276
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
277
|
+
|
278
|
+
# Create the file with the added content
|
279
|
+
content = []
|
280
|
+
file_change[:hunks].each do |hunk|
|
281
|
+
hunk[:changes].each do |line|
|
282
|
+
content << line[1..-1] if line.start_with?("+")
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
# Write the content
|
287
|
+
File.write(absolute_dst_path, content.join("\n") + (content.empty? ? "" : "\n"))
|
288
|
+
modified_files << file_change[:dst_path]
|
289
|
+
next
|
290
|
+
end
|
291
|
+
|
292
|
+
# Normal case: Modify existing file
|
293
|
+
content = ""
|
294
|
+
if File.exist?(absolute_dst_path)
|
295
|
+
content = File.read(absolute_dst_path)
|
296
|
+
else
|
297
|
+
# For new files that aren't from /dev/null, ensure directory exists
|
298
|
+
dir = File.dirname(absolute_dst_path)
|
299
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
300
|
+
end
|
301
|
+
content_lines = content.split("\n")
|
302
|
+
|
303
|
+
# Apply each hunk to the file
|
304
|
+
file_change[:hunks].each do |hunk|
|
305
|
+
# Apply the changes to the content
|
306
|
+
new_content_lines = apply_hunk(content_lines, hunk)
|
307
|
+
|
308
|
+
# Check if the hunk was applied successfully
|
309
|
+
if new_content_lines
|
310
|
+
content_lines = new_content_lines
|
311
|
+
else
|
312
|
+
raise "Hunk could not be applied cleanly: #{hunk[:header]}"
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
# Write the updated content
|
317
|
+
File.write(absolute_dst_path, content_lines.join("\n") + (content_lines.empty? ? "" : "\n"))
|
318
|
+
modified_files << file_change[:dst_path]
|
319
|
+
end
|
320
|
+
|
321
|
+
"Successfully applied patch to #{modified_files.size} file(s): #{modified_files.join(", ")}"
|
322
|
+
rescue StandardError => e
|
323
|
+
# Restore backups if any change fails
|
324
|
+
backup_files.each do |path, content|
|
325
|
+
File.write(path, content) if File.exist?(path)
|
326
|
+
end
|
327
|
+
|
328
|
+
"Error applying patch: #{e.message}"
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
# Apply a single hunk to file content
|
333
|
+
# @param content_lines [Array<String>] lines of file content
|
334
|
+
# @param hunk [Hash] hunk information
|
335
|
+
# @return [Array<String>, nil] updated content lines or nil if cannot apply
|
336
|
+
def apply_hunk(content_lines, hunk)
|
337
|
+
# For completely new files with no content
|
338
|
+
if content_lines.empty? && hunk[:src_start] == 1 && hunk[:src_count] == 0
|
339
|
+
# Just extract the added lines
|
340
|
+
return hunk[:changes].select { |line| line.start_with?("+") }.map { |line| line[1..-1] }
|
341
|
+
end
|
342
|
+
|
343
|
+
# For complete file replacement
|
344
|
+
if !content_lines.empty? &&
|
345
|
+
hunk[:src_start] == 1 &&
|
346
|
+
hunk[:changes].count { |line| line.start_with?("-") } >= content_lines.size
|
347
|
+
# Get only the added lines for the new content
|
348
|
+
return hunk[:changes].select { |line| line.start_with?("+") }.map { |line| line[1..-1] }
|
349
|
+
end
|
350
|
+
|
351
|
+
# Standard case with context matching
|
352
|
+
result = content_lines.dup
|
353
|
+
src_line = hunk[:src_start] - 1 # 0-based index
|
354
|
+
dst_line = hunk[:dst_start] - 1 # 0-based index
|
355
|
+
|
356
|
+
# Process each change line
|
357
|
+
hunk[:changes].each do |line|
|
358
|
+
if line.start_with?(" ") # Context line
|
359
|
+
# Verify context matches
|
360
|
+
if src_line >= result.size || result[src_line] != line[1..-1]
|
361
|
+
# Try to find the context nearby (fuzzy matching)
|
362
|
+
found = false
|
363
|
+
(-3..3).each do |offset|
|
364
|
+
check_pos = src_line + offset
|
365
|
+
next if check_pos < 0 || check_pos >= result.size
|
366
|
+
|
367
|
+
next unless result[check_pos] == line[1..-1]
|
368
|
+
|
369
|
+
src_line = check_pos
|
370
|
+
dst_line = check_pos
|
371
|
+
found = true
|
372
|
+
break
|
373
|
+
end
|
374
|
+
|
375
|
+
return nil unless found # Context doesn't match, cannot apply hunk
|
376
|
+
end
|
377
|
+
|
378
|
+
src_line += 1
|
379
|
+
dst_line += 1
|
380
|
+
elsif line.start_with?("-") # Removal
|
381
|
+
# Verify line exists and matches
|
382
|
+
if src_line >= result.size || result[src_line] != line[1..-1]
|
383
|
+
# Try to find the line nearby (fuzzy matching)
|
384
|
+
found = false
|
385
|
+
(-3..3).each do |offset|
|
386
|
+
check_pos = src_line + offset
|
387
|
+
next if check_pos < 0 || check_pos >= result.size
|
388
|
+
|
389
|
+
next unless result[check_pos] == line[1..-1]
|
390
|
+
|
391
|
+
src_line = check_pos
|
392
|
+
dst_line = check_pos
|
393
|
+
found = true
|
394
|
+
break
|
395
|
+
end
|
396
|
+
|
397
|
+
return nil unless found # Line to remove doesn't match, cannot apply hunk
|
398
|
+
end
|
399
|
+
|
400
|
+
# Remove the line
|
401
|
+
result.delete_at(src_line)
|
402
|
+
elsif line.start_with?("+") # Addition
|
403
|
+
# Insert the new line
|
404
|
+
result.insert(dst_line, line[1..-1])
|
405
|
+
dst_line += 1
|
406
|
+
end
|
407
|
+
end
|
408
|
+
|
409
|
+
result
|
410
|
+
end
|
411
|
+
end
|
412
|
+
end
|
413
|
+
end
|
data/lib/roast/tools.rb
CHANGED
@@ -2,19 +2,30 @@
|
|
2
2
|
|
3
3
|
require "active_support/cache"
|
4
4
|
require "English"
|
5
|
+
require "fileutils"
|
5
6
|
|
6
7
|
require "roast/tools/grep"
|
7
8
|
require "roast/tools/read_file"
|
8
9
|
require "roast/tools/search_file"
|
9
10
|
require "roast/tools/write_file"
|
11
|
+
require "roast/tools/update_files"
|
10
12
|
require "roast/tools/cmd"
|
11
13
|
require "roast/tools/coding_agent"
|
14
|
+
require "roast/tools/ask_user"
|
12
15
|
|
13
16
|
module Roast
|
14
17
|
module Tools
|
15
18
|
extend self
|
16
19
|
|
17
|
-
|
20
|
+
# Initialize cache and ensure .gitignore exists
|
21
|
+
cache_dir = File.join(Dir.pwd, ".roast", "cache")
|
22
|
+
FileUtils.mkdir_p(cache_dir) unless File.directory?(cache_dir)
|
23
|
+
|
24
|
+
# Add .gitignore to cache directory
|
25
|
+
gitignore_path = File.join(cache_dir, ".gitignore")
|
26
|
+
File.write(gitignore_path, "*") unless File.exist?(gitignore_path)
|
27
|
+
|
28
|
+
CACHE = ActiveSupport::Cache::FileStore.new(cache_dir)
|
18
29
|
|
19
30
|
def file_to_prompt(file)
|
20
31
|
<<~PROMPT
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module ValueObjects
|
5
|
+
# Value object representing an API token with validation
|
6
|
+
class ApiToken
|
7
|
+
class InvalidTokenError < StandardError; end
|
8
|
+
|
9
|
+
attr_reader :value
|
10
|
+
|
11
|
+
def initialize(value)
|
12
|
+
@value = value&.to_s
|
13
|
+
validate!
|
14
|
+
freeze
|
15
|
+
end
|
16
|
+
|
17
|
+
def present?
|
18
|
+
!blank?
|
19
|
+
end
|
20
|
+
|
21
|
+
def blank?
|
22
|
+
@value.nil? || @value.strip.empty?
|
23
|
+
end
|
24
|
+
|
25
|
+
def to_s
|
26
|
+
@value
|
27
|
+
end
|
28
|
+
|
29
|
+
def ==(other)
|
30
|
+
return false unless other.is_a?(ApiToken)
|
31
|
+
|
32
|
+
value == other.value
|
33
|
+
end
|
34
|
+
alias_method :eql?, :==
|
35
|
+
|
36
|
+
def hash
|
37
|
+
[self.class, @value].hash
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def validate!
|
43
|
+
return if @value.nil? # Allow nil tokens, just not empty strings
|
44
|
+
|
45
|
+
raise InvalidTokenError, "API token cannot be an empty string" if @value.strip.empty?
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module ValueObjects
|
5
|
+
# Value object representing a step name, which can be either a plain text prompt
|
6
|
+
# or a reference to a step file
|
7
|
+
class StepName
|
8
|
+
attr_reader :value
|
9
|
+
|
10
|
+
def initialize(name)
|
11
|
+
@value = name.to_s.strip
|
12
|
+
freeze
|
13
|
+
end
|
14
|
+
|
15
|
+
def plain_text?
|
16
|
+
@value.include?(" ")
|
17
|
+
end
|
18
|
+
|
19
|
+
def file_reference?
|
20
|
+
!plain_text?
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_s
|
24
|
+
@value
|
25
|
+
end
|
26
|
+
|
27
|
+
def ==(other)
|
28
|
+
return false unless other.is_a?(StepName)
|
29
|
+
|
30
|
+
value == other.value
|
31
|
+
end
|
32
|
+
alias_method :eql?, :==
|
33
|
+
|
34
|
+
def hash
|
35
|
+
[self.class, @value].hash
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "pathname"
|
4
|
+
|
5
|
+
module Roast
|
6
|
+
module ValueObjects
|
7
|
+
# Value object representing a workflow file path with validation and resolution
|
8
|
+
class WorkflowPath
|
9
|
+
class InvalidPathError < StandardError; end
|
10
|
+
|
11
|
+
attr_reader :value
|
12
|
+
|
13
|
+
def initialize(path)
|
14
|
+
@value = normalize_path(path)
|
15
|
+
@pathname = Pathname.new(@value)
|
16
|
+
validate!
|
17
|
+
freeze
|
18
|
+
end
|
19
|
+
|
20
|
+
def exist?
|
21
|
+
pathname.exist?
|
22
|
+
end
|
23
|
+
|
24
|
+
def absolute?
|
25
|
+
pathname.absolute?
|
26
|
+
end
|
27
|
+
|
28
|
+
def relative?
|
29
|
+
pathname.relative?
|
30
|
+
end
|
31
|
+
|
32
|
+
def dirname
|
33
|
+
pathname.dirname.to_s
|
34
|
+
end
|
35
|
+
|
36
|
+
def basename
|
37
|
+
pathname.basename.to_s
|
38
|
+
end
|
39
|
+
|
40
|
+
def to_s
|
41
|
+
@value
|
42
|
+
end
|
43
|
+
|
44
|
+
def to_path
|
45
|
+
@value
|
46
|
+
end
|
47
|
+
|
48
|
+
def ==(other)
|
49
|
+
return false unless other.is_a?(WorkflowPath)
|
50
|
+
|
51
|
+
value == other.value
|
52
|
+
end
|
53
|
+
alias_method :eql?, :==
|
54
|
+
|
55
|
+
def hash
|
56
|
+
[self.class, @value].hash
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
attr_reader :pathname
|
62
|
+
|
63
|
+
def normalize_path(path)
|
64
|
+
path.to_s.strip
|
65
|
+
end
|
66
|
+
|
67
|
+
def validate!
|
68
|
+
raise InvalidPathError, "Workflow path cannot be empty" if @value.empty?
|
69
|
+
raise InvalidPathError, "Workflow path must have .yml or .yaml extension" unless valid_extension?
|
70
|
+
end
|
71
|
+
|
72
|
+
def valid_extension?
|
73
|
+
@value.end_with?(".yml") || @value.end_with?(".yaml")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
data/lib/roast/version.rb
CHANGED
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/factories/api_provider_factory"
|
4
|
+
require "roast/workflow/resource_resolver"
|
5
|
+
|
6
|
+
module Roast
|
7
|
+
module Workflow
|
8
|
+
# Handles API-related configuration including tokens and providers
|
9
|
+
class ApiConfiguration
|
10
|
+
attr_reader :api_token, :api_provider
|
11
|
+
|
12
|
+
def initialize(config_hash)
|
13
|
+
@config_hash = config_hash
|
14
|
+
process_api_configuration
|
15
|
+
end
|
16
|
+
|
17
|
+
# Check if using OpenRouter
|
18
|
+
# @return [Boolean] true if using OpenRouter
|
19
|
+
def openrouter?
|
20
|
+
Roast::Factories::ApiProviderFactory.openrouter?(@api_provider)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Check if using OpenAI
|
24
|
+
# @return [Boolean] true if using OpenAI
|
25
|
+
def openai?
|
26
|
+
Roast::Factories::ApiProviderFactory.openai?(@api_provider)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Get the effective API token including environment variables
|
30
|
+
# @return [String, nil] The API token
|
31
|
+
def effective_token
|
32
|
+
@api_token || environment_token
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def process_api_configuration
|
38
|
+
extract_api_token
|
39
|
+
extract_api_provider
|
40
|
+
end
|
41
|
+
|
42
|
+
def extract_api_token
|
43
|
+
if @config_hash["api_token"]
|
44
|
+
@api_token = ResourceResolver.process_shell_command(@config_hash["api_token"])
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def extract_api_provider
|
49
|
+
@api_provider = Roast::Factories::ApiProviderFactory.from_config(@config_hash)
|
50
|
+
end
|
51
|
+
|
52
|
+
def environment_token
|
53
|
+
if openai?
|
54
|
+
ENV["OPENAI_API_KEY"]
|
55
|
+
elsif openrouter?
|
56
|
+
ENV["OPENROUTER_API_KEY"]
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|