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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +1 -1
  3. data/CHANGELOG.md +48 -0
  4. data/CLAUDE.md +20 -0
  5. data/Gemfile +1 -0
  6. data/Gemfile.lock +11 -6
  7. data/README.md +225 -13
  8. data/bin/roast +27 -0
  9. data/docs/INSTRUMENTATION.md +42 -1
  10. data/docs/ITERATION_SYNTAX.md +119 -0
  11. data/examples/conditional/README.md +161 -0
  12. data/examples/conditional/check_condition/prompt.md +1 -0
  13. data/examples/conditional/simple_workflow.yml +15 -0
  14. data/examples/conditional/workflow.yml +23 -0
  15. data/examples/dot_notation/README.md +37 -0
  16. data/examples/dot_notation/workflow.yml +44 -0
  17. data/examples/exit_on_error/README.md +50 -0
  18. data/examples/exit_on_error/analyze_lint_output/prompt.md +9 -0
  19. data/examples/exit_on_error/apply_fixes/prompt.md +2 -0
  20. data/examples/exit_on_error/workflow.yml +19 -0
  21. data/examples/grading/workflow.yml +5 -1
  22. data/examples/iteration/IMPLEMENTATION.md +88 -0
  23. data/examples/iteration/README.md +68 -0
  24. data/examples/iteration/analyze_complexity/prompt.md +22 -0
  25. data/examples/iteration/generate_recommendations/prompt.md +21 -0
  26. data/examples/iteration/generate_report/prompt.md +129 -0
  27. data/examples/iteration/implement_fix/prompt.md +25 -0
  28. data/examples/iteration/prioritize_issues/prompt.md +24 -0
  29. data/examples/iteration/prompts/analyze_file.md +28 -0
  30. data/examples/iteration/prompts/generate_summary.md +24 -0
  31. data/examples/iteration/prompts/update_report.md +29 -0
  32. data/examples/iteration/prompts/write_report.md +22 -0
  33. data/examples/iteration/read_file/prompt.md +9 -0
  34. data/examples/iteration/select_next_issue/prompt.md +25 -0
  35. data/examples/iteration/simple_workflow.md +39 -0
  36. data/examples/iteration/simple_workflow.yml +58 -0
  37. data/examples/iteration/update_fix_count/prompt.md +26 -0
  38. data/examples/iteration/verify_fix/prompt.md +29 -0
  39. data/examples/iteration/workflow.yml +42 -0
  40. data/examples/openrouter_example/workflow.yml +2 -2
  41. data/examples/workflow_generator/README.md +27 -0
  42. data/examples/workflow_generator/analyze_user_request/prompt.md +34 -0
  43. data/examples/workflow_generator/create_workflow_files/prompt.md +32 -0
  44. data/examples/workflow_generator/get_user_input/prompt.md +14 -0
  45. data/examples/workflow_generator/info_from_roast.rb +22 -0
  46. data/examples/workflow_generator/workflow.yml +35 -0
  47. data/lib/roast/errors.rb +9 -0
  48. data/lib/roast/factories/api_provider_factory.rb +61 -0
  49. data/lib/roast/helpers/function_caching_interceptor.rb +1 -1
  50. data/lib/roast/helpers/minitest_coverage_runner.rb +1 -1
  51. data/lib/roast/helpers/prompt_loader.rb +50 -1
  52. data/lib/roast/resources/base_resource.rb +7 -0
  53. data/lib/roast/resources.rb +6 -6
  54. data/lib/roast/tools/ask_user.rb +40 -0
  55. data/lib/roast/tools/cmd.rb +1 -1
  56. data/lib/roast/tools/search_file.rb +1 -1
  57. data/lib/roast/tools/update_files.rb +413 -0
  58. data/lib/roast/tools.rb +12 -1
  59. data/lib/roast/value_objects/api_token.rb +49 -0
  60. data/lib/roast/value_objects/step_name.rb +39 -0
  61. data/lib/roast/value_objects/workflow_path.rb +77 -0
  62. data/lib/roast/value_objects.rb +5 -0
  63. data/lib/roast/version.rb +1 -1
  64. data/lib/roast/workflow/api_configuration.rb +61 -0
  65. data/lib/roast/workflow/base_iteration_step.rb +165 -0
  66. data/lib/roast/workflow/base_step.rb +4 -24
  67. data/lib/roast/workflow/base_workflow.rb +76 -73
  68. data/lib/roast/workflow/command_executor.rb +88 -0
  69. data/lib/roast/workflow/conditional_executor.rb +50 -0
  70. data/lib/roast/workflow/conditional_step.rb +96 -0
  71. data/lib/roast/workflow/configuration.rb +35 -158
  72. data/lib/roast/workflow/configuration_loader.rb +78 -0
  73. data/lib/roast/workflow/configuration_parser.rb +13 -248
  74. data/lib/roast/workflow/context_path_resolver.rb +43 -0
  75. data/lib/roast/workflow/dot_access_hash.rb +198 -0
  76. data/lib/roast/workflow/each_step.rb +86 -0
  77. data/lib/roast/workflow/error_handler.rb +97 -0
  78. data/lib/roast/workflow/expression_utils.rb +36 -0
  79. data/lib/roast/workflow/file_state_repository.rb +3 -2
  80. data/lib/roast/workflow/interpolator.rb +34 -0
  81. data/lib/roast/workflow/iteration_executor.rb +85 -0
  82. data/lib/roast/workflow/llm_boolean_coercer.rb +55 -0
  83. data/lib/roast/workflow/output_handler.rb +35 -0
  84. data/lib/roast/workflow/output_manager.rb +77 -0
  85. data/lib/roast/workflow/parallel_executor.rb +49 -0
  86. data/lib/roast/workflow/repeat_step.rb +75 -0
  87. data/lib/roast/workflow/replay_handler.rb +123 -0
  88. data/lib/roast/workflow/resource_resolver.rb +77 -0
  89. data/lib/roast/workflow/session_manager.rb +6 -2
  90. data/lib/roast/workflow/state_manager.rb +97 -0
  91. data/lib/roast/workflow/step_executor_coordinator.rb +205 -0
  92. data/lib/roast/workflow/step_executor_factory.rb +47 -0
  93. data/lib/roast/workflow/step_executor_registry.rb +79 -0
  94. data/lib/roast/workflow/step_executors/base_step_executor.rb +23 -0
  95. data/lib/roast/workflow/step_executors/hash_step_executor.rb +43 -0
  96. data/lib/roast/workflow/step_executors/parallel_step_executor.rb +54 -0
  97. data/lib/roast/workflow/step_executors/string_step_executor.rb +29 -0
  98. data/lib/roast/workflow/step_finder.rb +97 -0
  99. data/lib/roast/workflow/step_loader.rb +154 -0
  100. data/lib/roast/workflow/step_orchestrator.rb +45 -0
  101. data/lib/roast/workflow/step_runner.rb +23 -0
  102. data/lib/roast/workflow/step_type_resolver.rb +117 -0
  103. data/lib/roast/workflow/workflow_context.rb +60 -0
  104. data/lib/roast/workflow/workflow_executor.rb +90 -209
  105. data/lib/roast/workflow/workflow_initializer.rb +112 -0
  106. data/lib/roast/workflow/workflow_runner.rb +87 -0
  107. data/lib/roast/workflow.rb +3 -0
  108. data/lib/roast.rb +96 -3
  109. data/roast.gemspec +3 -1
  110. data/schema/workflow.json +85 -0
  111. 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
- CACHE = ActiveSupport::Cache::FileStore.new(File.join(Dir.pwd, ".roast", "cache"))
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/value_objects/api_token"
4
+ require "roast/value_objects/step_name"
5
+ require "roast/value_objects/workflow_path"
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.1.6"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -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