headless_browser_tool 0.1.1

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 (74) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/settings.json +21 -0
  3. data/.rubocop.yml +56 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CLAUDE.md +298 -0
  7. data/LICENSE.md +7 -0
  8. data/README.md +522 -0
  9. data/Rakefile +12 -0
  10. data/config.ru +8 -0
  11. data/exe/hbt +7 -0
  12. data/lib/headless_browser_tool/browser.rb +374 -0
  13. data/lib/headless_browser_tool/browser_adapter.rb +320 -0
  14. data/lib/headless_browser_tool/cli.rb +34 -0
  15. data/lib/headless_browser_tool/directory_setup.rb +25 -0
  16. data/lib/headless_browser_tool/logger.rb +31 -0
  17. data/lib/headless_browser_tool/server.rb +150 -0
  18. data/lib/headless_browser_tool/session_manager.rb +199 -0
  19. data/lib/headless_browser_tool/session_middleware.rb +158 -0
  20. data/lib/headless_browser_tool/session_persistence.rb +146 -0
  21. data/lib/headless_browser_tool/stdio_server.rb +73 -0
  22. data/lib/headless_browser_tool/strict_session_middleware.rb +88 -0
  23. data/lib/headless_browser_tool/tools/attach_file_tool.rb +40 -0
  24. data/lib/headless_browser_tool/tools/auto_narrate_tool.rb +155 -0
  25. data/lib/headless_browser_tool/tools/base_tool.rb +39 -0
  26. data/lib/headless_browser_tool/tools/check_tool.rb +35 -0
  27. data/lib/headless_browser_tool/tools/choose_tool.rb +56 -0
  28. data/lib/headless_browser_tool/tools/click_button_tool.rb +49 -0
  29. data/lib/headless_browser_tool/tools/click_link_tool.rb +48 -0
  30. data/lib/headless_browser_tool/tools/click_tool.rb +45 -0
  31. data/lib/headless_browser_tool/tools/close_window_tool.rb +31 -0
  32. data/lib/headless_browser_tool/tools/double_click_tool.rb +37 -0
  33. data/lib/headless_browser_tool/tools/drag_tool.rb +46 -0
  34. data/lib/headless_browser_tool/tools/evaluate_script_tool.rb +20 -0
  35. data/lib/headless_browser_tool/tools/execute_script_tool.rb +29 -0
  36. data/lib/headless_browser_tool/tools/fill_in_tool.rb +66 -0
  37. data/lib/headless_browser_tool/tools/find_all_tool.rb +42 -0
  38. data/lib/headless_browser_tool/tools/find_element_tool.rb +21 -0
  39. data/lib/headless_browser_tool/tools/find_elements_containing_text_tool.rb +259 -0
  40. data/lib/headless_browser_tool/tools/get_attribute_tool.rb +21 -0
  41. data/lib/headless_browser_tool/tools/get_current_path_tool.rb +16 -0
  42. data/lib/headless_browser_tool/tools/get_current_url_tool.rb +16 -0
  43. data/lib/headless_browser_tool/tools/get_narration_history_tool.rb +35 -0
  44. data/lib/headless_browser_tool/tools/get_page_context_tool.rb +188 -0
  45. data/lib/headless_browser_tool/tools/get_page_source_tool.rb +16 -0
  46. data/lib/headless_browser_tool/tools/get_page_title_tool.rb +16 -0
  47. data/lib/headless_browser_tool/tools/get_session_info_tool.rb +37 -0
  48. data/lib/headless_browser_tool/tools/get_text_tool.rb +20 -0
  49. data/lib/headless_browser_tool/tools/get_value_tool.rb +20 -0
  50. data/lib/headless_browser_tool/tools/get_window_handles_tool.rb +29 -0
  51. data/lib/headless_browser_tool/tools/go_back_tool.rb +29 -0
  52. data/lib/headless_browser_tool/tools/go_forward_tool.rb +29 -0
  53. data/lib/headless_browser_tool/tools/has_element_tool.rb +21 -0
  54. data/lib/headless_browser_tool/tools/has_text_tool.rb +21 -0
  55. data/lib/headless_browser_tool/tools/hover_tool.rb +38 -0
  56. data/lib/headless_browser_tool/tools/is_visible_tool.rb +20 -0
  57. data/lib/headless_browser_tool/tools/maximize_window_tool.rb +34 -0
  58. data/lib/headless_browser_tool/tools/open_new_window_tool.rb +25 -0
  59. data/lib/headless_browser_tool/tools/refresh_tool.rb +32 -0
  60. data/lib/headless_browser_tool/tools/resize_window_tool.rb +43 -0
  61. data/lib/headless_browser_tool/tools/right_click_tool.rb +37 -0
  62. data/lib/headless_browser_tool/tools/save_page_tool.rb +32 -0
  63. data/lib/headless_browser_tool/tools/screenshot_tool.rb +199 -0
  64. data/lib/headless_browser_tool/tools/search_page_tool.rb +224 -0
  65. data/lib/headless_browser_tool/tools/search_source_tool.rb +148 -0
  66. data/lib/headless_browser_tool/tools/select_tool.rb +44 -0
  67. data/lib/headless_browser_tool/tools/switch_to_window_tool.rb +30 -0
  68. data/lib/headless_browser_tool/tools/uncheck_tool.rb +35 -0
  69. data/lib/headless_browser_tool/tools/visit_tool.rb +27 -0
  70. data/lib/headless_browser_tool/tools/visual_diff_tool.rb +177 -0
  71. data/lib/headless_browser_tool/tools.rb +104 -0
  72. data/lib/headless_browser_tool/version.rb +5 -0
  73. data/lib/headless_browser_tool.rb +8 -0
  74. metadata +256 -0
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara"
4
+ require "capybara/dsl"
5
+ require "selenium-webdriver"
6
+
7
+ module HeadlessBrowserTool
8
+ class Browser
9
+ include Capybara::DSL
10
+
11
+ attr_reader :session
12
+ attr_accessor :previous_state
13
+
14
+ def initialize(headless: true)
15
+ configure_capybara(headless)
16
+ @session = Capybara.current_session
17
+ @previous_state = {}
18
+ end
19
+
20
+ def active?
21
+ !@session.driver.browser.nil?
22
+ rescue StandardError
23
+ false
24
+ end
25
+
26
+ # Navigation tools
27
+ def visit(url)
28
+ @session.visit(url)
29
+ { message: "Navigated to #{url}" }
30
+ end
31
+
32
+ def refresh
33
+ @session.refresh
34
+ { message: "Page refreshed" }
35
+ end
36
+
37
+ def go_back
38
+ @session.go_back
39
+ { message: "Navigated back" }
40
+ end
41
+
42
+ def go_forward
43
+ @session.go_forward
44
+ { message: "Navigated forward" }
45
+ end
46
+
47
+ # Interaction tools
48
+ def click(selector)
49
+ element = @session.find(selector)
50
+ element.click
51
+ { message: "Clicked element: #{selector}" }
52
+ end
53
+
54
+ def right_click(selector)
55
+ element = @session.find(selector)
56
+ element.right_click
57
+ { message: "Right-clicked element: #{selector}" }
58
+ end
59
+
60
+ def double_click(selector)
61
+ element = @session.find(selector)
62
+ element.double_click
63
+ { message: "Double-clicked element: #{selector}" }
64
+ end
65
+
66
+ def hover(selector)
67
+ element = @session.find(selector)
68
+ element.hover
69
+ { message: "Hovered over element: #{selector}" }
70
+ end
71
+
72
+ def drag(source_selector, target_selector)
73
+ source = @session.find(source_selector)
74
+ target = @session.find(target_selector)
75
+ source.drag_to(target)
76
+ { message: "Dragged from #{source_selector} to #{target_selector}" }
77
+ end
78
+
79
+ # Element tools
80
+ def find_element(selector)
81
+ element = @session.find(selector)
82
+ {
83
+ tag_name: element.tag_name,
84
+ text: element.text,
85
+ visible: element.visible?,
86
+ location: { x: element.native.location.x, y: element.native.location.y }
87
+ }
88
+ end
89
+
90
+ def find_all(selector)
91
+ elements = @session.all(selector)
92
+ elements.map do |element|
93
+ {
94
+ tag_name: element.tag_name,
95
+ text: element.text,
96
+ visible: element.visible?,
97
+ attributes: extract_attributes(element),
98
+ value: element.value
99
+ }
100
+ end
101
+ end
102
+
103
+ def get_text(selector)
104
+ element = @session.find(selector)
105
+ element.text
106
+ end
107
+
108
+ def get_attribute(selector, attribute_name)
109
+ element = @session.find(selector)
110
+ element[attribute_name]
111
+ end
112
+
113
+ def get_value(selector)
114
+ element = @session.find(selector)
115
+ element.value
116
+ end
117
+
118
+ def is_visible?(selector)
119
+ element = @session.find(selector)
120
+ element.visible?
121
+ rescue Capybara::ElementNotFound
122
+ false
123
+ end
124
+
125
+ def has_element?(selector, wait_seconds = nil)
126
+ if wait_seconds
127
+ @session.has_selector?(selector, wait: wait_seconds)
128
+ else
129
+ @session.has_selector?(selector)
130
+ end
131
+ end
132
+
133
+ def has_text?(text, wait_seconds = nil)
134
+ if wait_seconds
135
+ @session.has_text?(text, wait: wait_seconds)
136
+ else
137
+ @session.has_text?(text)
138
+ end
139
+ end
140
+
141
+ # Form tools
142
+ def fill_in(field, value)
143
+ @session.fill_in(field, with: value)
144
+ { message: "Filled '#{field}' with '#{value}'" }
145
+ end
146
+
147
+ def select(value, dropdown_selector)
148
+ @session.select(value, from: dropdown_selector)
149
+ { message: "Selected '#{value}' from '#{dropdown_selector}'" }
150
+ end
151
+
152
+ def check(checkbox_selector)
153
+ @session.check(checkbox_selector)
154
+ { message: "Checked '#{checkbox_selector}'" }
155
+ end
156
+
157
+ def uncheck(checkbox_selector)
158
+ @session.uncheck(checkbox_selector)
159
+ { message: "Unchecked '#{checkbox_selector}'" }
160
+ end
161
+
162
+ def choose(radio_button_selector)
163
+ @session.choose(radio_button_selector)
164
+ { message: "Chose '#{radio_button_selector}'" }
165
+ end
166
+
167
+ def attach_file(file_field_selector, file_path)
168
+ @session.attach_file(file_field_selector, file_path)
169
+ { message: "Attached '#{file_path}' to '#{file_field_selector}'" }
170
+ end
171
+
172
+ def click_button(button_text_or_selector)
173
+ @session.click_button(button_text_or_selector)
174
+ { message: "Clicked button: #{button_text_or_selector}" }
175
+ end
176
+
177
+ def click_link(link_text_or_selector)
178
+ @session.click_link(link_text_or_selector)
179
+ { message: "Clicked link: #{link_text_or_selector}" }
180
+ end
181
+
182
+ # Info tools
183
+ def get_current_url
184
+ @session.current_url
185
+ end
186
+
187
+ def get_current_path
188
+ @session.current_path
189
+ end
190
+
191
+ def get_page_title
192
+ @session.title
193
+ end
194
+
195
+ def get_page_source
196
+ @session.html
197
+ end
198
+
199
+ # JavaScript tools
200
+ def execute_script(javascript_code)
201
+ @session.execute_script(javascript_code)
202
+ { message: "Executed JavaScript" }
203
+ end
204
+
205
+ def evaluate_script(javascript_code)
206
+ @session.evaluate_script(javascript_code)
207
+ end
208
+
209
+ # Utility tools
210
+ def save_screenshot(file_path)
211
+ @session.save_screenshot(file_path)
212
+ { message: "Screenshot saved to #{file_path}" }
213
+ end
214
+
215
+ def save_page(file_path)
216
+ @session.save_page(file_path)
217
+ { message: "Page saved to #{file_path}" }
218
+ end
219
+
220
+ # Window tools
221
+ def switch_to_window(window_handle)
222
+ @session.switch_to_window(window_handle)
223
+ current_window = @session.current_window
224
+
225
+ {
226
+ status: "success",
227
+ window_handle: current_window.handle,
228
+ current_url: @session.current_url,
229
+ title: @session.title,
230
+ position: get_window_position,
231
+ size: get_window_size,
232
+ is_current: true,
233
+ total_windows: @session.windows.length
234
+ }
235
+ end
236
+
237
+ def open_new_window
238
+ initial_windows_count = @session.windows.length
239
+ window = @session.open_new_window
240
+
241
+ # Switch to the new window to get its info
242
+ @session.switch_to_window(window)
243
+
244
+ {
245
+ status: "success",
246
+ window_handle: window.handle,
247
+ current_url: @session.current_url,
248
+ title: @session.title,
249
+ position: get_window_position,
250
+ size: get_window_size,
251
+ is_current: true,
252
+ total_windows: @session.windows.length,
253
+ previous_windows_count: initial_windows_count
254
+ }
255
+ end
256
+
257
+ def close_window(window_handle)
258
+ initial_windows_count = @session.windows.length
259
+ current_handle = @session.current_window.handle
260
+ window = @session.windows.find { |w| w.handle == window_handle }
261
+
262
+ if window.nil?
263
+ return {
264
+ status: "error",
265
+ error: "Window not found",
266
+ window_handle: window_handle
267
+ }
268
+ end
269
+
270
+ # If closing the current window, switch to another first
271
+ if window_handle == current_handle && @session.windows.length > 1
272
+ other_window = @session.windows.find { |w| w.handle != window_handle }
273
+ @session.switch_to_window(other_window) if other_window
274
+ end
275
+
276
+ window.close
277
+
278
+ {
279
+ status: "success",
280
+ closed_window_handle: window_handle,
281
+ remaining_windows: @session.windows.length,
282
+ initial_windows_count: initial_windows_count,
283
+ current_window_handle: @session.windows.any? ? @session.current_window.handle : nil
284
+ }
285
+ end
286
+
287
+ def get_window_handles
288
+ @session.windows.map(&:handle)
289
+ end
290
+
291
+ def maximize_window
292
+ current_window = @session.current_window
293
+ size_before = get_window_size
294
+
295
+ current_window.maximize
296
+
297
+ size_after = get_window_size
298
+
299
+ {
300
+ status: "success",
301
+ window_handle: current_window.handle,
302
+ size_before: size_before,
303
+ size_after: size_after,
304
+ current_url: @session.current_url,
305
+ title: @session.title
306
+ }
307
+ end
308
+
309
+ def resize_window(width, height)
310
+ current_window = @session.current_window
311
+ size_before = get_window_size
312
+
313
+ current_window.resize_to(width, height)
314
+
315
+ size_after = get_window_size
316
+
317
+ {
318
+ status: "success",
319
+ window_handle: current_window.handle,
320
+ size_before: size_before,
321
+ size_after: size_after,
322
+ requested_size: { width: width, height: height },
323
+ actual_size: size_after,
324
+ current_url: @session.current_url,
325
+ title: @session.title
326
+ }
327
+ end
328
+
329
+ private
330
+
331
+ def extract_attributes(element)
332
+ # Get common attributes that are often useful
333
+ attrs = {}
334
+ %w[id class href src alt title name type value placeholder data-testid role aria-label].each do |attr|
335
+ value = element[attr]
336
+ attrs[attr] = value if value && !value.empty?
337
+ end
338
+ attrs
339
+ end
340
+
341
+ def get_window_size
342
+ size = @session.current_window.size
343
+ { width: size[0], height: size[1] }
344
+ rescue StandardError
345
+ { width: nil, height: nil }
346
+ end
347
+
348
+ def get_window_position
349
+ # Try to get window position, but this might not be supported by all drivers
350
+
351
+ browser = @session.driver.browser
352
+ position = browser.manage.window.position
353
+ { x: position.x, y: position.y }
354
+ rescue StandardError
355
+ { x: nil, y: nil }
356
+ end
357
+
358
+ def configure_capybara(headless)
359
+ Capybara.register_driver :selenium_chrome do |app|
360
+ options = Selenium::WebDriver::Chrome::Options.new
361
+ options.add_argument("--headless") if headless
362
+ options.add_argument("--no-sandbox")
363
+ options.add_argument("--disable-dev-shm-usage")
364
+ options.add_argument("--disable-gpu") if headless
365
+
366
+ Capybara::Selenium::Driver.new(app, browser: :chrome, options: options)
367
+ end
368
+
369
+ Capybara.default_driver = :selenium_chrome
370
+ Capybara.javascript_driver = :selenium_chrome
371
+ Capybara.default_max_wait_time = 10
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeadlessBrowserTool
4
+ # Adapter to make Capybara::Session compatible with our Browser interface
5
+ class BrowserAdapter
6
+ attr_reader :session, :session_id
7
+ attr_accessor :previous_state
8
+
9
+ def initialize(capybara_session, session_id)
10
+ @session = capybara_session
11
+ @session_id = session_id
12
+ @previous_state = {}
13
+ end
14
+
15
+ # Navigation methods
16
+ def visit(url)
17
+ @session.visit(url)
18
+ end
19
+
20
+ def refresh
21
+ @session.refresh
22
+ end
23
+
24
+ def go_back
25
+ @session.go_back
26
+ end
27
+
28
+ def go_forward
29
+ @session.go_forward
30
+ end
31
+
32
+ # Finding elements
33
+ def find(selector, **)
34
+ @session.find(selector, **)
35
+ end
36
+
37
+ # Alias for compatibility with our tools - just needs to not throw error
38
+ def find_element(selector)
39
+ @session.find(selector)
40
+ # Tool will return its own message
41
+ end
42
+
43
+ def all(selector, **)
44
+ @session.all(selector, **)
45
+ end
46
+
47
+ def has_selector?(selector, **)
48
+ @session.has_selector?(selector, **)
49
+ end
50
+
51
+ def has_no_selector?(selector, **)
52
+ @session.has_no_selector?(selector, **)
53
+ end
54
+
55
+ def has_text?(text, **)
56
+ @session.has_text?(text, **)
57
+ end
58
+
59
+ def has_no_text?(text, **)
60
+ @session.has_no_text?(text, **)
61
+ end
62
+
63
+ # Interaction methods
64
+ def click(selector)
65
+ element = @session.find(selector)
66
+ element.click
67
+ end
68
+
69
+ def click_on(locator, **)
70
+ @session.click_on(locator, **)
71
+ end
72
+
73
+ def fill_in(field, value)
74
+ # Match original Browser class signature
75
+ @session.fill_in(field, with: value)
76
+ end
77
+
78
+ def choose(locator, **)
79
+ @session.choose(locator, **)
80
+ end
81
+
82
+ def check(locator, **)
83
+ @session.check(locator, **)
84
+ end
85
+
86
+ def uncheck(locator, **)
87
+ @session.uncheck(locator, **)
88
+ end
89
+
90
+ def select(value, dropdown_selector = nil, from: nil, **)
91
+ # Support both signatures: select(value, dropdown) and select(value, from: dropdown)
92
+ from ||= dropdown_selector
93
+ @session.select(value, from: from, **)
94
+ end
95
+
96
+ def attach_file(locator, path, **)
97
+ @session.attach_file(locator, path, **)
98
+ end
99
+
100
+ # Additional methods to match Browser class
101
+ def get_text(selector)
102
+ @session.find(selector).text
103
+ end
104
+
105
+ def get_attribute(selector, attribute_name)
106
+ @session.find(selector)[attribute_name]
107
+ end
108
+
109
+ def get_value(selector)
110
+ @session.find(selector).value
111
+ end
112
+
113
+ def is_visible?(selector)
114
+ @session.find(selector).visible?
115
+ rescue Capybara::ElementNotFound
116
+ false
117
+ end
118
+
119
+ def has_element?(selector, wait = nil)
120
+ if wait
121
+ @session.has_selector?(selector, wait: wait)
122
+ else
123
+ @session.has_selector?(selector)
124
+ end
125
+ end
126
+
127
+ def click_button(button_text_or_selector)
128
+ @session.click_button(button_text_or_selector)
129
+ end
130
+
131
+ def click_link(link_text_or_selector)
132
+ @session.click_link(link_text_or_selector)
133
+ end
134
+
135
+ def right_click(selector)
136
+ @session.find(selector).right_click
137
+ end
138
+
139
+ def double_click(selector)
140
+ @session.find(selector).double_click
141
+ end
142
+
143
+ def hover(selector)
144
+ @session.find(selector).hover
145
+ end
146
+
147
+ def drag(source_selector, target_selector)
148
+ source = @session.find(source_selector)
149
+ target = @session.find(target_selector)
150
+ source.drag_to(target)
151
+ end
152
+
153
+ def find_all(selector)
154
+ @session.all(selector).map do |element|
155
+ {
156
+ tag_name: element.tag_name,
157
+ text: element.text,
158
+ visible: element.visible?,
159
+ attributes: extract_attributes(element),
160
+ value: element.value
161
+ }
162
+ end
163
+ end
164
+
165
+ def get_current_url
166
+ @session.current_url
167
+ end
168
+
169
+ def get_current_path
170
+ @session.current_path
171
+ end
172
+
173
+ def get_page_title
174
+ @session.title
175
+ end
176
+
177
+ def get_page_source
178
+ @session.html
179
+ end
180
+
181
+ def get_window_handles
182
+ @session.windows.map(&:handle)
183
+ end
184
+
185
+ def switch_to_window(window_handle)
186
+ @session.switch_to_window(window_handle)
187
+ end
188
+
189
+ def resize_window(width, height)
190
+ @session.current_window.resize_to(width, height)
191
+ end
192
+
193
+ # JavaScript execution
194
+ def execute_script(script, *)
195
+ @session.execute_script(script, *)
196
+ end
197
+
198
+ def evaluate_script(script, *)
199
+ @session.evaluate_script(script, *)
200
+ end
201
+
202
+ # Screenshots
203
+ def save_screenshot(path, **)
204
+ @session.save_screenshot(path, **)
205
+ end
206
+
207
+ def save_page(path, **)
208
+ @session.save_page(path, **)
209
+ end
210
+
211
+ # Window management
212
+ def current_window
213
+ @session.current_window
214
+ end
215
+
216
+ def windows
217
+ @session.windows
218
+ end
219
+
220
+ def open_new_window
221
+ new_window = @session.open_new_window
222
+ @session.switch_to_window(new_window)
223
+ new_window
224
+ end
225
+
226
+ def close_window(window)
227
+ current = @session.current_window
228
+ @session.switch_to_window(window)
229
+ window.close
230
+ @session.switch_to_window(current) if current.exists?
231
+ end
232
+
233
+ def window_handles
234
+ @session.windows.map(&:handle)
235
+ end
236
+
237
+ def maximize_window
238
+ @session.current_window.maximize
239
+ end
240
+
241
+ def resize_window_to(width, height)
242
+ @session.current_window.resize_to(width, height)
243
+ end
244
+
245
+ # Aliases to match our original Browser interface
246
+ alias resize_to resize_window_to
247
+
248
+ # Page information
249
+ def current_url
250
+ @session.current_url
251
+ end
252
+
253
+ def current_path
254
+ @session.current_path
255
+ end
256
+
257
+ def title
258
+ @session.title
259
+ end
260
+
261
+ def html
262
+ @session.html
263
+ end
264
+
265
+ def text
266
+ @session.text
267
+ end
268
+
269
+ # Capybara-specific delegations
270
+ def within(*, &)
271
+ @session.within(*, &)
272
+ end
273
+
274
+ def within_window(window, &)
275
+ @session.within_window(window, &)
276
+ end
277
+
278
+ def accept_alert(&)
279
+ @session.accept_alert(&)
280
+ end
281
+
282
+ def dismiss_alert(&)
283
+ @session.dismiss_alert(&)
284
+ end
285
+
286
+ def accept_confirm(&)
287
+ @session.accept_confirm(&)
288
+ end
289
+
290
+ def dismiss_confirm(&)
291
+ @session.dismiss_confirm(&)
292
+ end
293
+
294
+ # Driver access (for advanced operations)
295
+ def driver
296
+ @session.driver
297
+ end
298
+
299
+ # Cleanup
300
+ def quit
301
+ @session.quit
302
+ end
303
+
304
+ def reset!
305
+ @session.reset!
306
+ end
307
+
308
+ private
309
+
310
+ def extract_attributes(element)
311
+ # Get common attributes that are often useful
312
+ attrs = {}
313
+ %w[id class href src alt title name type value placeholder data-testid role aria-label].each do |attr|
314
+ value = element[attr]
315
+ attrs[attr] = value if value && !value.empty?
316
+ end
317
+ attrs
318
+ end
319
+ end
320
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module HeadlessBrowserTool
6
+ class CLI < Thor
7
+ desc "start", "Start the headless browser and MCP server"
8
+ option :port, type: :numeric, default: 4567, desc: "Port for the MCP server"
9
+ option :headless, type: :boolean, default: true, desc: "Run browser in headless mode"
10
+ option :single_session, type: :boolean, default: false, desc: "Use single shared browser session (legacy mode)"
11
+ option :show_headers, type: :boolean, default: false, desc: "Show HTTP request headers for debugging"
12
+ option :session_id, type: :string, desc: "Session ID for persistence (only with --single-session)"
13
+ def start
14
+ require_relative "server"
15
+ Server.start_server(options)
16
+ end
17
+
18
+ desc "stdio", "Start the MCP server in stdio mode"
19
+ option :headless, type: :boolean, default: true, desc: "Run browser in headless mode"
20
+ def stdio
21
+ require_relative "stdio_server"
22
+ StdioServer.start(options)
23
+ end
24
+
25
+ desc "version", "Display version"
26
+ def version
27
+ puts "HeadlessBrowserTool v#{VERSION}"
28
+ end
29
+
30
+ def self.exit_on_failure?
31
+ true
32
+ end
33
+ end
34
+ end