ruby_raider 2.0.0 → 3.0.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 (224) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/e2e_tests.yml +58 -0
  3. data/.github/workflows/steep.yml +21 -0
  4. data/.gitignore +1 -1
  5. data/.reek.yml +46 -4
  6. data/.ruby-version +1 -1
  7. data/README.md +138 -77
  8. data/Steepfile +22 -0
  9. data/assets/ruby_raider_logo.svg +51 -0
  10. data/lib/adopter/adopt_menu.rb +11 -15
  11. data/lib/adopter/converters/base_converter.rb +1 -2
  12. data/lib/adopter/converters/identity_converter.rb +3 -6
  13. data/lib/adopter/migration_plan.rb +0 -1
  14. data/lib/adopter/plan_builder.rb +2 -5
  15. data/lib/adopter/project_analyzer.rb +1 -5
  16. data/lib/adopter/project_detector.rb +3 -5
  17. data/lib/commands/adopt_commands.rb +0 -1
  18. data/lib/commands/plugin_commands.rb +0 -2
  19. data/lib/commands/scaffolding_commands.rb +220 -37
  20. data/lib/commands/utility_commands.rb +82 -2
  21. data/lib/generators/automation/automation_generator.rb +0 -7
  22. data/lib/generators/automation/templates/partials/element.tt +1 -1
  23. data/lib/generators/automation/templates/partials/initialize_selector.tt +0 -7
  24. data/lib/generators/automation/templates/partials/url_methods.tt +0 -1
  25. data/lib/generators/common_generator.rb +12 -0
  26. data/lib/generators/cucumber/cucumber_generator.rb +36 -0
  27. data/lib/generators/cucumber/templates/accessibility_feature.tt +5 -0
  28. data/lib/generators/cucumber/templates/accessibility_steps.tt +21 -0
  29. data/lib/generators/cucumber/templates/cucumber.tt +8 -1
  30. data/lib/generators/cucumber/templates/feature.tt +0 -4
  31. data/lib/generators/cucumber/templates/partials/appium_env.tt +5 -0
  32. data/lib/generators/cucumber/templates/partials/capybara_env.tt +19 -1
  33. data/lib/generators/cucumber/templates/partials/driver_world.tt +1 -4
  34. data/lib/generators/cucumber/templates/partials/selenium_env.tt +22 -35
  35. data/lib/generators/cucumber/templates/partials/watir_env.tt +20 -1
  36. data/lib/generators/cucumber/templates/partials/web_steps.tt +6 -12
  37. data/lib/generators/cucumber/templates/performance_feature.tt +5 -0
  38. data/lib/generators/cucumber/templates/performance_steps.tt +17 -0
  39. data/lib/generators/cucumber/templates/visual_feature.tt +5 -0
  40. data/lib/generators/cucumber/templates/visual_steps.tt +19 -0
  41. data/lib/generators/generator.rb +38 -7
  42. data/lib/generators/helper_generator.rb +24 -7
  43. data/lib/generators/infrastructure/templates/github.tt +1 -1
  44. data/lib/generators/infrastructure/templates/github_appium.tt +2 -2
  45. data/lib/generators/infrastructure/templates/gitlab.tt +1 -1
  46. data/lib/generators/invoke_generators.rb +42 -9
  47. data/lib/generators/menu_generator.rb +120 -11
  48. data/lib/generators/minitest/minitest_generator.rb +16 -4
  49. data/lib/generators/minitest/templates/accessibility_test.tt +26 -0
  50. data/lib/generators/minitest/templates/performance_test.tt +18 -0
  51. data/lib/generators/minitest/templates/test.tt +5 -34
  52. data/lib/generators/minitest/templates/visual_test.tt +23 -0
  53. data/lib/generators/rspec/rspec_generator.rb +16 -4
  54. data/lib/generators/rspec/templates/accessibility_spec.tt +25 -0
  55. data/lib/generators/rspec/templates/performance_spec.tt +18 -0
  56. data/lib/generators/rspec/templates/spec.tt +5 -35
  57. data/lib/generators/rspec/templates/visual_spec.tt +20 -0
  58. data/lib/generators/template_renderer/partial_cache.rb +11 -1
  59. data/lib/generators/template_renderer/partial_resolver.rb +17 -10
  60. data/lib/generators/template_renderer.rb +17 -1
  61. data/lib/generators/templates/common/gemfile.tt +21 -6
  62. data/lib/generators/templates/common/git_ignore.tt +6 -1
  63. data/lib/generators/templates/common/partials/mobile_config.tt +5 -1
  64. data/lib/generators/templates/common/partials/web_config.tt +16 -7
  65. data/lib/generators/templates/common/rakefile.tt +36 -0
  66. data/lib/generators/templates/common/read_me.tt +41 -91
  67. data/lib/generators/templates/common/rspec.tt +3 -0
  68. data/lib/generators/templates/common/ruby_version.tt +1 -0
  69. data/lib/generators/templates/helpers/allure_helper.tt +11 -0
  70. data/lib/generators/templates/helpers/browser_helper.tt +12 -2
  71. data/lib/generators/templates/helpers/capybara_helper.tt +5 -1
  72. data/lib/generators/templates/helpers/debug_helper.tt +190 -0
  73. data/lib/generators/templates/helpers/driver_helper.tt +2 -10
  74. data/lib/generators/templates/helpers/partials/appium_driver.tt +0 -2
  75. data/lib/generators/templates/helpers/partials/debug_diagnostics.tt +7 -0
  76. data/lib/generators/templates/helpers/partials/debug_start.tt +7 -0
  77. data/lib/generators/templates/helpers/partials/driver_and_options.tt +1 -3
  78. data/lib/generators/templates/helpers/partials/selenium_driver.tt +8 -7
  79. data/lib/generators/templates/helpers/partials/video_start.tt +9 -0
  80. data/lib/generators/templates/helpers/partials/video_stop.tt +4 -0
  81. data/lib/generators/templates/helpers/performance_helper.tt +57 -0
  82. data/lib/generators/templates/helpers/spec_helper.tt +57 -8
  83. data/lib/generators/templates/helpers/test_helper.tt +69 -1
  84. data/lib/generators/templates/helpers/video_helper.tt +270 -0
  85. data/lib/generators/templates/helpers/visual_helper.tt +39 -46
  86. data/lib/llm/client.rb +79 -0
  87. data/lib/llm/config.rb +57 -0
  88. data/lib/llm/prompts.rb +84 -0
  89. data/lib/llm/provider.rb +27 -0
  90. data/lib/llm/providers/anthropic_provider.rb +43 -0
  91. data/lib/llm/providers/ollama_provider.rb +56 -0
  92. data/lib/llm/providers/openai_provider.rb +42 -0
  93. data/lib/llm/response_parser.rb +67 -0
  94. data/lib/plugin/plugin.rb +22 -20
  95. data/lib/plugin/plugin_exposer.rb +16 -38
  96. data/lib/ruby_raider.rb +47 -12
  97. data/lib/scaffolding/crud_generator.rb +94 -0
  98. data/lib/scaffolding/dry_run_presenter.rb +16 -0
  99. data/lib/scaffolding/name_normalizer.rb +63 -0
  100. data/lib/scaffolding/page_introspector.rb +45 -0
  101. data/lib/scaffolding/project_detector.rb +72 -0
  102. data/lib/scaffolding/scaffold_menu.rb +103 -0
  103. data/lib/scaffolding/scaffolding.rb +158 -11
  104. data/lib/scaffolding/templates/component.tt +30 -0
  105. data/lib/scaffolding/templates/feature.tt +4 -4
  106. data/lib/scaffolding/templates/helper.tt +15 -1
  107. data/lib/scaffolding/templates/page_from_url.tt +75 -0
  108. data/lib/scaffolding/templates/page_object.tt +50 -1
  109. data/lib/scaffolding/templates/spec.tt +33 -2
  110. data/lib/scaffolding/templates/spec_from_page.tt +31 -0
  111. data/lib/scaffolding/templates/spec_from_url.tt +46 -0
  112. data/lib/scaffolding/templates/steps.tt +17 -5
  113. data/lib/scaffolding/url_analyzer.rb +179 -0
  114. data/lib/utilities/desktop_downloader.rb +177 -0
  115. data/lib/utilities/logo.rb +83 -0
  116. data/lib/utilities/utilities.rb +53 -20
  117. data/lib/version +1 -1
  118. data/ruby_raider.gemspec +1 -0
  119. data/sig/adopter/adopt_menu.rbs +25 -0
  120. data/sig/adopter/converters/base_converter.rbs +23 -0
  121. data/sig/adopter/converters/identity_converter.rbs +16 -0
  122. data/sig/adopter/migration_plan.rbs +34 -0
  123. data/sig/adopter/migrator.rbs +21 -0
  124. data/sig/adopter/plan_builder.rbs +38 -0
  125. data/sig/adopter/project_analyzer.rbs +39 -0
  126. data/sig/adopter/project_detector.rbs +26 -0
  127. data/sig/commands/adopt_commands.rbs +8 -0
  128. data/sig/commands/loaded_commands.rbs +5 -0
  129. data/sig/commands/plugin_commands.rbs +9 -0
  130. data/sig/commands/scaffolding_commands.rbs +28 -0
  131. data/sig/commands/utility_commands.rbs +21 -0
  132. data/sig/generators/automation/automation_generator.rbs +20 -0
  133. data/sig/generators/common_generator.rbs +12 -0
  134. data/sig/generators/cucumber/cucumber_generator.rbs +16 -0
  135. data/sig/generators/generator.rbs +40 -0
  136. data/sig/generators/helper_generator.rbs +18 -0
  137. data/sig/generators/infrastructure/github_generator.rbs +5 -0
  138. data/sig/generators/infrastructure/gitlab_generator.rbs +4 -0
  139. data/sig/generators/invoke_generators.rbs +10 -0
  140. data/sig/generators/menu_generator.rbs +29 -0
  141. data/sig/generators/minitest/minitest_generator.rbs +8 -0
  142. data/sig/generators/rspec/rspec_generator.rbs +8 -0
  143. data/sig/generators/template_renderer/partial_cache.rbs +20 -0
  144. data/sig/generators/template_renderer/partial_resolver.rbs +20 -0
  145. data/sig/generators/template_renderer/template_error.rbs +19 -0
  146. data/sig/generators/template_renderer.rbs +10 -0
  147. data/sig/llm/client.rbs +15 -0
  148. data/sig/llm/config.rbs +20 -0
  149. data/sig/llm/prompts.rbs +8 -0
  150. data/sig/llm/provider.rbs +12 -0
  151. data/sig/llm/providers/anthropic_provider.rbs +16 -0
  152. data/sig/llm/providers/ollama_provider.rbs +18 -0
  153. data/sig/llm/providers/openai_provider.rbs +16 -0
  154. data/sig/llm/response_parser.rbs +13 -0
  155. data/sig/plugin/plugin.rbs +24 -0
  156. data/sig/plugin/plugin_exposer.rbs +20 -0
  157. data/sig/ruby_raider.rbs +15 -0
  158. data/sig/scaffolding/crud_generator.rbs +16 -0
  159. data/sig/scaffolding/dry_run_presenter.rbs +4 -0
  160. data/sig/scaffolding/name_normalizer.rbs +17 -0
  161. data/sig/scaffolding/page_introspector.rbs +14 -0
  162. data/sig/scaffolding/project_detector.rbs +14 -0
  163. data/sig/scaffolding/scaffold_menu.rbs +18 -0
  164. data/sig/scaffolding/scaffolding.rbs +55 -0
  165. data/sig/scaffolding/url_analyzer.rbs +28 -0
  166. data/sig/utilities/desktop_downloader.rbs +23 -0
  167. data/sig/utilities/logger.rbs +13 -0
  168. data/sig/utilities/logo.rbs +16 -0
  169. data/sig/utilities/utilities.rbs +30 -0
  170. data/sig/vendor/thor.rbs +34 -0
  171. data/sig/vendor/tty_prompt.rbs +15 -0
  172. data/spec/adopter/adopt_menu_spec.rb +12 -12
  173. data/spec/adopter/migration_plan_spec.rb +1 -1
  174. data/spec/adopter/migrator_spec.rb +2 -2
  175. data/spec/adopter/project_detector_spec.rb +1 -1
  176. data/spec/commands/raider_commands_spec.rb +129 -0
  177. data/spec/generators/generator_spec.rb +23 -0
  178. data/spec/integration/commands/scaffolding_commands_spec.rb +1 -1
  179. data/spec/integration/commands/utility_commands_spec.rb +23 -3
  180. data/spec/integration/content/ci_content_spec.rb +119 -0
  181. data/spec/integration/content/common_content_spec.rb +288 -0
  182. data/spec/integration/content/config_content_spec.rb +175 -0
  183. data/spec/integration/content/content_helper.rb +32 -0
  184. data/spec/integration/content/gemfile_content_spec.rb +209 -0
  185. data/spec/integration/content/helper_content_spec.rb +485 -0
  186. data/spec/integration/content/page_content_spec.rb +259 -0
  187. data/spec/integration/content/reporter_content_spec.rb +236 -0
  188. data/spec/integration/content/skip_flags_content_spec.rb +206 -0
  189. data/spec/integration/content/syntax_validation_spec.rb +30 -0
  190. data/spec/integration/content/test_content_spec.rb +266 -0
  191. data/spec/integration/end_to_end_features_spec.rb +690 -0
  192. data/spec/integration/end_to_end_spec.rb +52 -16
  193. data/spec/integration/generators/automation_generator_spec.rb +0 -12
  194. data/spec/integration/generators/axe_addon_spec.rb +150 -0
  195. data/spec/integration/generators/common_generator_spec.rb +12 -13
  196. data/spec/integration/generators/config_features_spec.rb +155 -0
  197. data/spec/integration/generators/debug_helper_spec.rb +68 -0
  198. data/spec/integration/generators/helpers_generator_spec.rb +0 -12
  199. data/spec/integration/generators/lighthouse_addon_spec.rb +132 -0
  200. data/spec/integration/generators/minitest_generator_spec.rb +0 -6
  201. data/spec/integration/generators/reporter_spec.rb +159 -0
  202. data/spec/integration/generators/skip_flags_spec.rb +134 -0
  203. data/spec/integration/generators/visual_addon_spec.rb +148 -0
  204. data/spec/integration/settings_helper.rb +0 -3
  205. data/spec/integration/spec_helper.rb +30 -13
  206. data/spec/llm/client_spec.rb +79 -0
  207. data/spec/llm/config_spec.rb +92 -0
  208. data/spec/llm/prompts_spec.rb +49 -0
  209. data/spec/llm/response_parser_spec.rb +92 -0
  210. data/spec/menus/adopter_adopt_menu_spec.rb +97 -0
  211. data/spec/menus/menu_generator_spec.rb +263 -0
  212. data/spec/scaffolding/name_normalizer_spec.rb +113 -0
  213. data/spec/scaffolding/page_introspector_spec.rb +82 -0
  214. data/spec/scaffolding/scaffold_project_detector_spec.rb +104 -0
  215. data/spec/scaffolding/scaffolding_features_spec.rb +311 -0
  216. data/spec/scaffolding/url_analyzer_spec.rb +110 -0
  217. data/spec/system/adopt_matrix_spec.rb +537 -0
  218. data/spec/system/adopt_spec.rb +225 -0
  219. data/spec/system/support/system_test_helper.rb +0 -2
  220. data/spec/utilities/desktop_downloader_spec.rb +92 -0
  221. metadata +150 -5
  222. data/lib/generators/automation/templates/visual_options.tt +0 -16
  223. data/lib/generators/templates/helpers/partials/axe_driver.tt +0 -10
  224. data/lib/generators/templates/helpers/visual_spec_helper.tt +0 -35
data/lib/llm/client.rb ADDED
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require_relative 'config'
5
+
6
+ module Llm
7
+ # Facade for LLM completion with retry logic and graceful fallback.
8
+ # Returns nil on any failure — callers should always have a non-AI fallback.
9
+ module Client
10
+ MAX_RETRIES = 3
11
+ BASE_DELAY = 1
12
+
13
+ # Only retry on transient network errors, not configuration or API errors
14
+ RETRYABLE_ERRORS = [
15
+ Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED,
16
+ Errno::ECONNRESET, Errno::ETIMEDOUT, SocketError
17
+ ].freeze
18
+
19
+ class << self
20
+ def complete(prompt, system_prompt: nil)
21
+ provider = build_provider
22
+ return nil unless provider
23
+
24
+ attempt = 0
25
+ begin
26
+ attempt += 1
27
+ provider.complete(prompt, system_prompt:)
28
+ rescue *RETRYABLE_ERRORS => e
29
+ if attempt < MAX_RETRIES
30
+ sleep(BASE_DELAY * (2**(attempt - 1)))
31
+ retry
32
+ end
33
+ warn "[Ruby Raider] LLM failed after #{MAX_RETRIES} attempts: #{e.message}"
34
+ nil
35
+ rescue StandardError => e
36
+ warn "[Ruby Raider] LLM error: #{e.message}"
37
+ nil
38
+ end
39
+ end
40
+
41
+ def available?
42
+ config = Config.new
43
+ return false unless config.configured?
44
+
45
+ provider = config.build_provider
46
+ provider&.available? || false
47
+ end
48
+
49
+ def status
50
+ config = Config.new
51
+ return { configured: false, provider: nil } unless config.configured?
52
+
53
+ provider = config.build_provider
54
+ {
55
+ configured: true,
56
+ provider: config.provider_name,
57
+ model: config.model,
58
+ available: provider&.available? || false
59
+ }
60
+ end
61
+
62
+ private
63
+
64
+ def build_provider
65
+ config = Config.new
66
+ unless config.configured?
67
+ warn '[Ruby Raider] No LLM configured. Use: raider u llm ollama'
68
+ return nil
69
+ end
70
+ provider = config.build_provider
71
+ unless provider&.available?
72
+ warn "[Ruby Raider] LLM provider '#{config.provider_name}' is not available"
73
+ return nil
74
+ end
75
+ provider
76
+ end
77
+ end
78
+ end
79
+ end
data/lib/llm/config.rb ADDED
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Llm
6
+ # Reads LLM configuration from env vars and config/config.yml
7
+ # Env vars take precedence over config file values.
8
+ class Config
9
+ PROVIDERS = %w[openai anthropic ollama].freeze
10
+
11
+ attr_reader :provider_name, :api_key, :model, :url
12
+
13
+ def initialize
14
+ @provider_name = env('RUBY_RAIDER_LLM_PROVIDER') || config_value('llm_provider')
15
+ @api_key = env('RUBY_RAIDER_LLM_API_KEY') || config_value('llm_api_key')
16
+ @model = env('RUBY_RAIDER_LLM_MODEL') || config_value('llm_model')
17
+ @url = env('RUBY_RAIDER_LLM_URL') || config_value('llm_url')
18
+ end
19
+
20
+ def configured?
21
+ return false unless @provider_name && PROVIDERS.include?(@provider_name)
22
+ return true if @provider_name == 'ollama'
23
+
24
+ !@api_key.nil? && !@api_key.empty?
25
+ end
26
+
27
+ def build_provider
28
+ return nil unless configured?
29
+
30
+ case @provider_name
31
+ when 'openai'
32
+ require_relative 'providers/openai_provider'
33
+ Providers::OpenaiProvider.new(api_key: @api_key, model: @model)
34
+ when 'anthropic'
35
+ require_relative 'providers/anthropic_provider'
36
+ Providers::AnthropicProvider.new(api_key: @api_key, model: @model)
37
+ when 'ollama'
38
+ require_relative 'providers/ollama_provider'
39
+ Providers::OllamaProvider.new(model: @model, url: @url)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def env(key)
46
+ value = ENV.fetch(key, nil)
47
+ value&.strip&.empty? ? nil : value&.strip
48
+ end
49
+
50
+ def config_value(key)
51
+ return nil unless File.exist?('config/config.yml')
52
+
53
+ data = YAML.load_file('config/config.yml')
54
+ data&.dig(key)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llm
4
+ # Centralized prompt templates for LLM-enhanced code generation.
5
+ # All prompts instruct the model to return JSON matching specific schemas.
6
+ module Prompts
7
+ module_function
8
+
9
+ def analyze_page(html, url)
10
+ <<~PROMPT
11
+ Analyze this HTML page and extract all interactive elements that would be useful for a page object model in UI test automation.
12
+
13
+ URL: #{url}
14
+
15
+ HTML:
16
+ #{html[0..8000]}
17
+
18
+ Return a JSON object with this exact structure:
19
+ {
20
+ "elements": [
21
+ {
22
+ "name": "descriptive_snake_case_name",
23
+ "type": "input|select|textarea|button|submit|link",
24
+ "locator": {"type": "id|name|css|xpath", "value": "the_locator"},
25
+ "purpose": "brief description of what this element does",
26
+ "input_type": "text|email|password|etc (only for inputs)",
27
+ "text": "visible text (only for buttons/links)"
28
+ }
29
+ ]
30
+ }
31
+
32
+ Guidelines:
33
+ - Use semantic, descriptive names (e.g., "email_field" not "input_1", "submit_login" not "button_1")
34
+ - Prefer ID locators, then name, then CSS, then XPath
35
+ - Skip hidden inputs and purely decorative elements
36
+ - Include up to 5 important links
37
+ - The "purpose" field should be a brief, clear description
38
+
39
+ Return ONLY the JSON object, no other text.
40
+ PROMPT
41
+ end
42
+
43
+ def generate_test_scenarios(class_name, methods, automation, framework)
44
+ methods_desc = methods.map do |m|
45
+ params = m[:params].empty? ? '' : "(#{m[:params].join(', ')})"
46
+ " - #{m[:name]}#{params}"
47
+ end.join("\n")
48
+
49
+ <<~PROMPT
50
+ Generate meaningful test scenarios for this page object class used in UI test automation.
51
+
52
+ Class: #{class_name}
53
+ Automation: #{automation}
54
+ Framework: #{framework}
55
+ Public methods:
56
+ #{methods_desc}
57
+
58
+ Return a JSON object with this exact structure:
59
+ {
60
+ "scenarios": [
61
+ {
62
+ "method": "method_name",
63
+ "description": "human-readable test description",
64
+ "assertion_hint": "what to assert after calling this method"
65
+ }
66
+ ]
67
+ }
68
+
69
+ Guidelines:
70
+ - Generate one scenario per method
71
+ - Descriptions should read naturally (e.g., "fills in the login form with valid credentials")
72
+ - Assertion hints should be specific (e.g., "expect page to redirect to dashboard")
73
+ - Consider the class name for context about what page this is
74
+
75
+ Return ONLY the JSON object, no other text.
76
+ PROMPT
77
+ end
78
+
79
+ def system_prompt
80
+ 'You are a UI test automation expert. You generate clean, idiomatic Ruby code ' \
81
+ 'for page object models and test specs. Always return valid JSON when asked for JSON.'
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llm
4
+ # Abstract base class for LLM providers
5
+ class Provider
6
+ def complete(prompt, system_prompt: nil)
7
+ raise NotImplementedError, "#{self.class}#complete must be implemented"
8
+ end
9
+
10
+ def available?
11
+ raise NotImplementedError, "#{self.class}#available? must be implemented"
12
+ end
13
+
14
+ def name
15
+ self.class.name.split('::').last.sub('Provider', '').downcase
16
+ end
17
+
18
+ private
19
+
20
+ def build_messages(prompt, system_prompt)
21
+ messages = []
22
+ messages << { role: 'system', content: system_prompt } if system_prompt
23
+ messages << { role: 'user', content: prompt }
24
+ messages
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../provider'
4
+
5
+ module Llm
6
+ module Providers
7
+ class AnthropicProvider < Provider
8
+ DEFAULT_MODEL = 'claude-sonnet-4-20250514'
9
+
10
+ def initialize(api_key:, model: nil)
11
+ super()
12
+ @api_key = api_key
13
+ @model = model || DEFAULT_MODEL
14
+ end
15
+
16
+ def complete(prompt, system_prompt: nil)
17
+ client = build_client
18
+ params = { model: @model, max_tokens: 4096, messages: [{ role: 'user', content: prompt }] }
19
+ params[:system] = system_prompt if system_prompt
20
+ response = client.messages(parameters: params)
21
+ response.dig('content', 0, 'text')
22
+ rescue StandardError => e
23
+ warn "[Ruby Raider] Anthropic error: #{e.message}"
24
+ nil
25
+ end
26
+
27
+ def available?
28
+ require 'anthropic'
29
+ !@api_key.nil? && !@api_key.empty?
30
+ rescue LoadError
31
+ warn '[Ruby Raider] Install anthropic gem: gem install anthropic-rb'
32
+ false
33
+ end
34
+
35
+ private
36
+
37
+ def build_client
38
+ require 'anthropic'
39
+ Anthropic::Client.new(access_token: @api_key)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+ require_relative '../provider'
7
+
8
+ module Llm
9
+ module Providers
10
+ class OllamaProvider < Provider
11
+ DEFAULT_MODEL = 'llama3.2'
12
+ DEFAULT_URL = 'http://localhost:11434'
13
+ TIMEOUT = 60
14
+
15
+ def initialize(model: nil, url: nil)
16
+ super()
17
+ @model = model || DEFAULT_MODEL
18
+ @base_url = url || DEFAULT_URL
19
+ end
20
+
21
+ def complete(prompt, system_prompt: nil)
22
+ uri = URI("#{@base_url}/api/generate")
23
+ body = { model: @model, prompt:, stream: false }
24
+ body[:system] = system_prompt if system_prompt
25
+
26
+ response = post_request(uri, body)
27
+ return nil unless response.is_a?(Net::HTTPSuccess)
28
+
29
+ JSON.parse(response.body)['response']
30
+ rescue StandardError => e
31
+ warn "[Ruby Raider] Ollama error: #{e.message}"
32
+ nil
33
+ end
34
+
35
+ def available?
36
+ uri = URI("#{@base_url}/api/tags")
37
+ response = Net::HTTP.get_response(uri)
38
+ response.is_a?(Net::HTTPSuccess)
39
+ rescue StandardError
40
+ warn '[Ruby Raider] Ollama not reachable. Start it with: ollama serve'
41
+ false
42
+ end
43
+
44
+ private
45
+
46
+ def post_request(uri, body)
47
+ http = Net::HTTP.new(uri.host, uri.port)
48
+ http.read_timeout = TIMEOUT
49
+ http.open_timeout = 10
50
+ request = Net::HTTP::Post.new(uri, 'Content-Type' => 'application/json')
51
+ request.body = JSON.generate(body)
52
+ http.request(request)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../provider'
4
+
5
+ module Llm
6
+ module Providers
7
+ class OpenaiProvider < Provider
8
+ DEFAULT_MODEL = 'gpt-4o-mini'
9
+
10
+ def initialize(api_key:, model: nil)
11
+ super()
12
+ @api_key = api_key
13
+ @model = model || DEFAULT_MODEL
14
+ end
15
+
16
+ def complete(prompt, system_prompt: nil)
17
+ client = build_client
18
+ messages = build_messages(prompt, system_prompt)
19
+ response = client.chat(parameters: { model: @model, messages:, temperature: 0.2 })
20
+ response.dig('choices', 0, 'message', 'content')
21
+ rescue StandardError => e
22
+ warn "[Ruby Raider] OpenAI error: #{e.message}"
23
+ nil
24
+ end
25
+
26
+ def available?
27
+ require 'openai'
28
+ !@api_key.nil? && !@api_key.empty?
29
+ rescue LoadError
30
+ warn '[Ruby Raider] Install ruby-openai gem: gem install ruby-openai'
31
+ false
32
+ end
33
+
34
+ private
35
+
36
+ def build_client
37
+ require 'openai'
38
+ OpenAI::Client.new(access_token: @api_key)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Llm
6
+ # Extracts JSON from LLM responses, handling markdown wrapping and malformed output.
7
+ # Returns nil on parse failure — callers should fall back to non-AI path.
8
+ module ResponseParser
9
+ module_function
10
+
11
+ def parse_json(response)
12
+ return nil if response.nil? || response.strip.empty?
13
+
14
+ json_str = extract_json(response)
15
+ result = JSON.parse(json_str, symbolize_names: true)
16
+ result.is_a?(Hash) ? result : nil
17
+ rescue JSON::ParserError
18
+ nil
19
+ end
20
+
21
+ def extract_elements(response)
22
+ parsed = parse_json(response)
23
+ return nil unless parsed && parsed[:elements].is_a?(Array)
24
+
25
+ parsed[:elements].map { |el| normalize_element(el) }.compact
26
+ end
27
+
28
+ def extract_scenarios(response)
29
+ parsed = parse_json(response)
30
+ return nil unless parsed && parsed[:scenarios].is_a?(Array)
31
+
32
+ parsed[:scenarios]
33
+ end
34
+
35
+ def normalize_element(element)
36
+ return nil unless element[:name] && element[:type] && element[:locator]
37
+
38
+ locator = element[:locator]
39
+ locator = { type: locator[:type]&.to_sym, value: locator[:value] } if locator.is_a?(Hash)
40
+
41
+ {
42
+ name: element[:name].to_s.gsub(/[^a-z0-9_]/i, '_').downcase,
43
+ type: element[:type].to_sym,
44
+ locator:,
45
+ purpose: element[:purpose],
46
+ input_type: element[:input_type],
47
+ text: element[:text]
48
+ }.compact
49
+ end
50
+
51
+ def extract_json(text)
52
+ # Try raw JSON first
53
+ stripped = text.strip
54
+ return stripped if stripped.start_with?('{')
55
+
56
+ # Try markdown code block
57
+ match = text.match(/```(?:json)?\s*\n?(.*?)\n?\s*```/m)
58
+ return match[1].strip if match
59
+
60
+ # Try finding first { ... } block
61
+ brace_match = text.match(/(\{.*\})/m)
62
+ return brace_match[1] if brace_match
63
+
64
+ text
65
+ end
66
+ end
67
+ end
data/lib/plugin/plugin.rb CHANGED
@@ -13,6 +13,7 @@ module RubyRaider
13
13
 
14
14
  pp "Adding #{plugin_name}..."
15
15
  add_plugin_to_gemfile(plugin_name)
16
+ invalidate_gemfile_cache
16
17
  system('bundle install')
17
18
  PluginExposer.expose_commands(plugin_name)
18
19
  pp "The plugin #{plugin_name} is added"
@@ -24,6 +25,7 @@ module RubyRaider
24
25
 
25
26
  pp "Deleting #{plugin_name}..."
26
27
  remove_plugin_from_gemfile(plugin_name)
28
+ invalidate_gemfile_cache
27
29
  PluginExposer.remove_command(plugin_name)
28
30
  system('bundle install')
29
31
  pp "The plugin #{plugin_name} is deleted"
@@ -32,8 +34,10 @@ module RubyRaider
32
34
  def installed_plugins
33
35
  return gemfile_guard unless File.exist?('Gemfile')
34
36
 
35
- parsed_gemfile = File.readlines('Gemfile').map { |line| line.sub('gem ', '').strip.delete("'") }
36
- parsed_gemfile.select { |line| plugins.include?(line) }
37
+ cached_gemfile_lines.filter_map do |line|
38
+ stripped = line.sub('gem ', '').strip.delete("'")
39
+ stripped if plugins.include?(stripped)
40
+ end
37
41
  end
38
42
 
39
43
  def installed?(plugin_name)
@@ -54,12 +58,25 @@ module RubyRaider
54
58
 
55
59
  private
56
60
 
61
+ # Read Gemfile once and cache until explicitly invalidated
62
+ def cached_gemfile_lines
63
+ @cached_gemfile_lines ||= File.readlines('Gemfile')
64
+ end
65
+
66
+ def invalidate_gemfile_cache
67
+ @cached_gemfile_lines = nil
68
+ end
69
+
57
70
  def add_plugin_to_gemfile(plugin_name)
58
71
  return gemfile_guard unless File.exist?('Gemfile')
59
72
 
73
+ lines = cached_gemfile_lines
74
+ has_comment = lines.any? { |l| l.include?('Ruby Raider Plugins') }
75
+ has_plugin = lines.any? { |l| l.include?(plugin_name) }
76
+
60
77
  File.open('Gemfile', 'a') do |file|
61
- file.puts "\n# Ruby Raider Plugins\n" unless comment_present?
62
- file.puts "gem '#{plugin_name}'" unless plugin_present?(plugin_name)
78
+ file.puts "\n# Ruby Raider Plugins\n" unless has_comment
79
+ file.puts "gem '#{plugin_name}'" unless has_plugin
63
80
  end
64
81
  end
65
82
 
@@ -72,27 +89,12 @@ module RubyRaider
72
89
  installed_plugins.count == 1
73
90
  end
74
91
 
75
- def read_gemfile
76
- return gemfile_guard unless File.exist?('Gemfile')
77
-
78
- File.readlines('Gemfile')
79
- end
80
-
81
- def comment_present?
82
- read_gemfile.grep(/Ruby Raider Plugins/).any?
83
- end
84
-
85
- def plugin_present?(plugin_name)
86
- read_gemfile.grep(/#{plugin_name}/).any?
87
- end
88
-
89
92
  def remove_plugins_and_comments(plugin_name)
90
- read_gemfile.reject do |line|
93
+ cached_gemfile_lines.reject do |line|
91
94
  line.include?(plugin_name) || line.include?('Ruby Raider Plugins') && last_plugin?
92
95
  end
93
96
  end
94
97
 
95
- # :reek:NestedIterators { enabled: false }
96
98
  def update_gemfile(output_lines)
97
99
  return gemfile_guard unless File.exist?('Gemfile')
98
100
 
@@ -6,33 +6,38 @@ module RubyRaider
6
6
  module PluginExposer
7
7
  class << self
8
8
  FILE_PATH = File.expand_path('../commands/loaded_commands.rb', __dir__)
9
- # :reek:NestedIterators { enabled: false }
10
9
  def expose_commands(plugin_name)
11
- return pp 'The plugin is already installed' if plugin_present?(plugin_name)
12
-
13
10
  commands = read_loaded_commands
11
+ return pp 'The plugin is already installed' if commands.any? { |l| l.include?(plugin_name) }
12
+
13
+ has_subcommands = commands.any? { |l| l.include?('subcommand') }
14
14
 
15
15
  File.open(FILE_PATH, 'w') do |file|
16
16
  commands.each do |line|
17
17
  file.puts line
18
- file.puts require_plugin(plugin_name, line)
19
- file.puts select_command_formatting(plugin_name, line)
18
+ file.puts "require '#{plugin_name}'" if line.include?("require 'thor'")
19
+ if line.strip == 'class LoadedCommands < Thor' && !has_subcommands
20
+ file.puts formatted_command_without_space(plugin_name)
21
+ elsif line.strip == 'class LoadedCommands < Thor' && has_subcommands
22
+ file.puts formatted_command_with_space(plugin_name)
23
+ end
20
24
  end
21
25
  end
22
26
  end
23
27
 
24
28
  def remove_command(plugin_name)
25
- return pp 'The plugin is not installed' unless plugin_present?(plugin_name)
29
+ commands = read_loaded_commands
30
+ return pp 'The plugin is not installed' unless commands.any? { |l| l.include?(plugin_name) }
26
31
 
27
- delete_plugin_command(plugin_name)
32
+ output_lines = commands.reject { |line| line.include?(plugin_name) }
33
+
34
+ File.open(FILE_PATH, 'w') do |file|
35
+ output_lines.each { |line| file.puts line }
36
+ end
28
37
  end
29
38
 
30
39
  private
31
40
 
32
- def any_commands?
33
- read_loaded_commands.any? { |line| line.include?('subcommand') }
34
- end
35
-
36
41
  def read_loaded_commands
37
42
  File.readlines(FILE_PATH)
38
43
  end
@@ -45,33 +50,6 @@ module RubyRaider
45
50
  def formatted_command_with_space(plugin_name)
46
51
  "\n#{formatted_command_without_space(plugin_name)}"
47
52
  end
48
-
49
- def plugin_present?(plugin_name)
50
- read_loaded_commands.grep(/#{plugin_name}/).any?
51
- end
52
-
53
- def select_command_formatting(plugin_name, line)
54
- if line.strip == 'class LoadedCommands < Thor' && !any_commands?
55
- formatted_command_without_space(plugin_name)
56
- elsif any_commands?
57
- formatted_command_with_space(plugin_name)
58
- end
59
- end
60
-
61
- def require_plugin(plugin_name, line)
62
- "require '#{plugin_name}'" if line.include?("require 'thor'") && !plugin_present?(plugin_name)
63
- end
64
-
65
- # :reek:NestedIterators { enabled: false }
66
- def delete_plugin_command(plugin_name)
67
- output_lines = read_loaded_commands.reject do |line|
68
- line.include?(plugin_name) if plugin_present?(plugin_name)
69
- end
70
-
71
- File.open(FILE_PATH, 'w') do |file|
72
- output_lines.each { |line| file.puts line }
73
- end
74
- end
75
53
  end
76
54
  end
77
55
  end