shared_tools 0.3.0 → 0.4.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -4
  3. data/README.md +257 -262
  4. data/lib/shared_tools/browser_tool.rb +5 -0
  5. data/lib/shared_tools/calculator_tool.rb +4 -0
  6. data/lib/shared_tools/clipboard_tool.rb +4 -0
  7. data/lib/shared_tools/composite_analysis_tool.rb +4 -0
  8. data/lib/shared_tools/computer_tool.rb +5 -0
  9. data/lib/shared_tools/cron_tool.rb +4 -0
  10. data/lib/shared_tools/current_date_time_tool.rb +4 -0
  11. data/lib/shared_tools/data_science_kit.rb +4 -0
  12. data/lib/shared_tools/database.rb +4 -0
  13. data/lib/shared_tools/database_query_tool.rb +4 -0
  14. data/lib/shared_tools/database_tool.rb +5 -0
  15. data/lib/shared_tools/disk_tool.rb +5 -0
  16. data/lib/shared_tools/dns_tool.rb +4 -0
  17. data/lib/shared_tools/doc_tool.rb +5 -0
  18. data/lib/shared_tools/error_handling_tool.rb +4 -0
  19. data/lib/shared_tools/eval_tool.rb +5 -0
  20. data/lib/shared_tools/mcp/brave_search_client.rb +37 -0
  21. data/lib/shared_tools/mcp/chart_client.rb +32 -0
  22. data/lib/shared_tools/mcp/github_client.rb +38 -0
  23. data/lib/shared_tools/mcp/hugging_face_client.rb +43 -0
  24. data/lib/shared_tools/mcp/memory_client.rb +33 -0
  25. data/lib/shared_tools/mcp/notion_client.rb +40 -0
  26. data/lib/shared_tools/mcp/sequential_thinking_client.rb +33 -0
  27. data/lib/shared_tools/mcp/slack_client.rb +54 -0
  28. data/lib/shared_tools/mcp/streamable_http_patch.rb +42 -0
  29. data/lib/shared_tools/mcp/tavily_client.rb +41 -0
  30. data/lib/shared_tools/mcp.rb +45 -16
  31. data/lib/shared_tools/system_info_tool.rb +4 -0
  32. data/lib/shared_tools/tools/browser/base_tool.rb +8 -12
  33. data/lib/shared_tools/tools/browser/click_tool.rb +4 -2
  34. data/lib/shared_tools/tools/browser/ferrum_driver.rb +119 -0
  35. data/lib/shared_tools/tools/browser/inspect_tool.rb +4 -2
  36. data/lib/shared_tools/tools/browser/page_inspect_tool.rb +4 -2
  37. data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +19 -7
  38. data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +4 -2
  39. data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +4 -2
  40. data/lib/shared_tools/tools/browser/visit_tool.rb +4 -2
  41. data/lib/shared_tools/tools/browser.rb +31 -2
  42. data/lib/shared_tools/tools/browser_tool.rb +14 -2
  43. data/lib/shared_tools/tools/clipboard_tool.rb +119 -0
  44. data/lib/shared_tools/tools/composite_analysis_tool.rb +60 -4
  45. data/lib/shared_tools/tools/computer/mac_driver.rb +37 -4
  46. data/lib/shared_tools/tools/computer_tool.rb +8 -2
  47. data/lib/shared_tools/tools/cron_tool.rb +332 -0
  48. data/lib/shared_tools/tools/current_date_time_tool.rb +88 -0
  49. data/lib/shared_tools/tools/data_science_kit.rb +63 -13
  50. data/lib/shared_tools/tools/database_tool.rb +8 -3
  51. data/lib/shared_tools/tools/dns_tool.rb +422 -0
  52. data/lib/shared_tools/tools/doc/docx_reader_tool.rb +107 -0
  53. data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +171 -0
  54. data/lib/shared_tools/tools/doc/text_reader_tool.rb +57 -0
  55. data/lib/shared_tools/tools/doc.rb +3 -0
  56. data/lib/shared_tools/tools/doc_tool.rb +101 -6
  57. data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
  58. data/lib/shared_tools/tools/enabler.rb +42 -0
  59. data/lib/shared_tools/tools/error_handling_tool.rb +3 -1
  60. data/lib/shared_tools/tools/notification/base_driver.rb +51 -0
  61. data/lib/shared_tools/tools/notification/linux_driver.rb +115 -0
  62. data/lib/shared_tools/tools/notification/mac_driver.rb +66 -0
  63. data/lib/shared_tools/tools/notification/null_driver.rb +29 -0
  64. data/lib/shared_tools/tools/notification.rb +12 -0
  65. data/lib/shared_tools/tools/notification_tool.rb +99 -0
  66. data/lib/shared_tools/tools/system_info_tool.rb +204 -0
  67. data/lib/shared_tools/tools/workflow_manager_tool.rb +32 -0
  68. data/lib/shared_tools/utilities.rb +193 -0
  69. data/lib/shared_tools/version.rb +1 -1
  70. data/lib/shared_tools/weather_tool.rb +4 -0
  71. data/lib/shared_tools/workflow_manager_tool.rb +4 -0
  72. data/lib/shared_tools.rb +42 -11
  73. metadata +79 -9
  74. data/lib/shared_tools/mcp/github_mcp_server.rb +0 -58
  75. data/lib/shared_tools/mcp/imcp.rb +0 -28
  76. data/lib/shared_tools/mcp/tavily_mcp_server.rb +0 -44
  77. data/lib/shared_tools/tools/devops_toolkit.rb +0 -420
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+
5
+ module SharedTools
6
+ module Tools
7
+ module Browser
8
+ # A browser driver backed by Ferrum (Chrome DevTools Protocol).
9
+ # No chromedriver binary required — Ferrum talks directly to Chrome.
10
+ #
11
+ # @example
12
+ # driver = SharedTools::Tools::Browser::FerrumDriver.new
13
+ # driver.goto(url: "https://example.com")
14
+ # driver.click(selector: "a.some-link")
15
+ # driver.close
16
+ class FerrumDriver < BaseDriver
17
+ # @param logger [Logger]
18
+ # @param network_idle_timeout [Numeric] seconds to wait for network idle after navigation
19
+ # @param ferrum_options [Hash] additional options passed directly to Ferrum::Browser
20
+ def initialize(logger: Logger.new(IO::NULL), network_idle_timeout: 5, **ferrum_options)
21
+ super(logger:)
22
+ @network_idle_timeout = network_idle_timeout
23
+ options = {
24
+ headless: true,
25
+ timeout: TIMEOUT,
26
+ browser_options: { 'disable-blink-features' => 'AutomationControlled' }
27
+ }.merge(ferrum_options)
28
+ @browser = Ferrum::Browser.new(**options)
29
+ end
30
+
31
+ def close
32
+ @browser.quit
33
+ end
34
+
35
+ # @return [String]
36
+ def url
37
+ @browser.current_url
38
+ end
39
+
40
+ # @return [String]
41
+ def title
42
+ @browser.title
43
+ end
44
+
45
+ # @return [String]
46
+ def html
47
+ @browser.evaluate('document.documentElement.outerHTML')
48
+ end
49
+
50
+ # @param url [String]
51
+ # @return [Hash]
52
+ def goto(url:)
53
+ @browser.go_to(url)
54
+ wait_for_network_idle
55
+ { status: :ok }
56
+ end
57
+
58
+ # @yield [file]
59
+ # @yieldparam file [File]
60
+ def screenshot
61
+ tempfile = Tempfile.new(['screenshot', '.png'])
62
+ @browser.screenshot(path: tempfile.path)
63
+ yield File.open(tempfile.path, 'rb')
64
+ ensure
65
+ tempfile&.close
66
+ tempfile&.unlink
67
+ end
68
+
69
+ # @param selector [String] CSS selector for an input or textarea
70
+ # @param text [String]
71
+ # @return [Hash]
72
+ def fill_in(selector:, text:)
73
+ element = wait_for_element { @browser.at_css(selector) }
74
+ return { status: :error, message: "unknown selector=#{selector.inspect}" } if element.nil?
75
+
76
+ element.evaluate("this.value = #{text.to_json}")
77
+ element.evaluate("this.dispatchEvent(new Event('input', {bubbles: true}))")
78
+ element.evaluate("this.dispatchEvent(new Event('change', {bubbles: true}))")
79
+ { status: :ok }
80
+ rescue => e
81
+ { status: :error, message: e.message }
82
+ end
83
+
84
+ # @param selector [String] CSS selector
85
+ # @return [Hash]
86
+ def click(selector:)
87
+ element = wait_for_element { @browser.at_css(selector) }
88
+ return { status: :error, message: "unknown selector=#{selector.inspect}" } if element.nil?
89
+
90
+ element.click
91
+ { status: :ok }
92
+ rescue => e
93
+ { status: :error, message: e.message }
94
+ end
95
+
96
+ private
97
+
98
+ # Wait until there are no pending network requests (JS-rendered pages).
99
+ # Falls back silently if Ferrum raises a timeout.
100
+ def wait_for_network_idle
101
+ @browser.network.wait_for_idle(duration: 0.3, timeout: @network_idle_timeout)
102
+ rescue Ferrum::TimeoutError
103
+ # Some requests never settle; accept whatever state the page is in.
104
+ end
105
+
106
+ def wait_for_element
107
+ deadline = Time.now + TIMEOUT
108
+ loop do
109
+ result = yield
110
+ return result if result
111
+ break if Time.now >= deadline
112
+ sleep 0.2
113
+ end
114
+ nil
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -44,10 +44,12 @@ module SharedTools
44
44
  private
45
45
 
46
46
  def default_driver
47
- if defined?(Watir)
47
+ if defined?(Ferrum)
48
+ FerrumDriver.new(logger: @logger)
49
+ elsif defined?(Watir)
48
50
  WatirDriver.new(logger: @logger)
49
51
  else
50
- raise LoadError, "Browser tools require a driver. Either install the 'watir' gem or pass a driver: parameter"
52
+ raise LoadError, "Browser tools require a driver. Install the 'ferrum' gem or pass a driver: parameter"
51
53
  end
52
54
  end
53
55
 
@@ -43,10 +43,12 @@ module SharedTools
43
43
  private
44
44
 
45
45
  def default_driver
46
- if defined?(Watir)
46
+ if defined?(Ferrum)
47
+ FerrumDriver.new(logger: @logger)
48
+ elsif defined?(Watir)
47
49
  WatirDriver.new(logger: @logger)
48
50
  else
49
- raise LoadError, "Browser tools require a driver. Either install the 'watir' gem or pass a driver: parameter"
51
+ raise LoadError, "Browser tools require a driver. Install the 'ferrum' gem or pass a driver: parameter"
50
52
  end
51
53
  end
52
54
  end
@@ -1,36 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
-
5
3
  module SharedTools
6
4
  module Tools
7
5
  module Browser
8
6
  # A browser automation tool for taking screenshots of the current page.
7
+ # Saves the screenshot to a file and returns the path — avoids injecting
8
+ # large base64 blobs into the conversation context.
9
9
  class PageScreenshotTool < ::RubyLLM::Tool
10
10
  def self.name = 'browser_page_screenshot'
11
11
 
12
- description "A browser automation tool for taking screenshots of the current page."
12
+ description "Take a screenshot of the current browser page and save it to a file."
13
+
14
+ params do
15
+ string :path, required: false,
16
+ description: "File path to save the screenshot (e.g. 'screenshot.png'). " \
17
+ "Defaults to a timestamped name in the current directory."
18
+ end
13
19
 
14
20
  def initialize(driver: nil, logger: nil)
15
21
  @driver = driver || default_driver
16
22
  @logger = logger || RubyLLM.logger
17
23
  end
18
24
 
19
- def execute
25
+ def execute(path: nil)
20
26
  @logger.info("#{self.class.name}##{__method__}")
21
27
 
28
+ save_path = path || "screenshot_#{Time.now.strftime('%Y%m%d_%H%M%S')}.png"
29
+
22
30
  @driver.screenshot do |file|
23
- "data:image/png;base64,#{Base64.strict_encode64(file.read)}"
31
+ File.binwrite(save_path, file.read)
24
32
  end
33
+
34
+ { status: :ok, saved_to: File.expand_path(save_path) }
25
35
  end
26
36
 
27
37
  private
28
38
 
29
39
  def default_driver
30
- if defined?(Watir)
40
+ if defined?(Ferrum)
41
+ FerrumDriver.new(logger: @logger)
42
+ elsif defined?(Watir)
31
43
  WatirDriver.new(logger: @logger)
32
44
  else
33
- raise LoadError, "Browser tools require a driver. Either install the 'watir' gem or pass a driver: parameter"
45
+ raise LoadError, "Browser tools require a driver. Install the 'ferrum' gem or pass a driver: parameter"
34
46
  end
35
47
  end
36
48
  end
@@ -43,10 +43,12 @@ module SharedTools
43
43
  private
44
44
 
45
45
  def default_driver
46
- if defined?(Watir)
46
+ if defined?(Ferrum)
47
+ FerrumDriver.new(logger: @logger)
48
+ elsif defined?(Watir)
47
49
  WatirDriver.new(logger: @logger)
48
50
  else
49
- raise LoadError, "Browser tools require a driver. Either install the 'watir' gem or pass a driver: parameter"
51
+ raise LoadError, "Browser tools require a driver. Install the 'ferrum' gem or pass a driver: parameter"
50
52
  end
51
53
  end
52
54
 
@@ -33,10 +33,12 @@ module SharedTools
33
33
  private
34
34
 
35
35
  def default_driver
36
- if defined?(Watir)
36
+ if defined?(Ferrum)
37
+ FerrumDriver.new(logger: @logger)
38
+ elsif defined?(Watir)
37
39
  WatirDriver.new(logger: @logger)
38
40
  else
39
- raise LoadError, "Browser tools require a driver. Either install the 'watir' gem or pass a driver: parameter"
41
+ raise LoadError, "Browser tools require a driver. Install the 'ferrum' gem or pass a driver: parameter"
40
42
  end
41
43
  end
42
44
  end
@@ -31,10 +31,12 @@ module SharedTools
31
31
  private
32
32
 
33
33
  def default_driver
34
- if defined?(Watir)
34
+ if defined?(Ferrum)
35
+ FerrumDriver.new(logger: @logger)
36
+ elsif defined?(Watir)
35
37
  WatirDriver.new(logger: @logger)
36
38
  else
37
- raise LoadError, "Browser tools require a driver. Either install the 'watir' gem or pass a driver: parameter"
39
+ raise LoadError, "Browser tools require a driver. Install the 'ferrum' gem or pass a driver: parameter"
38
40
  end
39
41
  end
40
42
  end
@@ -9,12 +9,41 @@ require 'shared_tools'
9
9
  require_relative 'browser/base_driver'
10
10
  require_relative 'browser/inspect_utils'
11
11
 
12
- # Try to load watir for browser automation
12
+ # Load formatters (used by inspect_tool and related components)
13
+ require_relative 'browser/formatters/action_formatter'
14
+ require_relative 'browser/formatters/data_entry_formatter'
15
+ require_relative 'browser/formatters/element_formatter'
16
+ require_relative 'browser/formatters/input_formatter'
17
+
18
+ # Load elements helpers (used by inspect_tool)
19
+ require_relative 'browser/elements/element_grouper'
20
+ require_relative 'browser/elements/nearby_element_detector'
21
+
22
+ # Load page_inspect helpers (used by page_inspect_tool)
23
+ require_relative 'browser/page_inspect/button_summarizer'
24
+ require_relative 'browser/page_inspect/form_summarizer'
25
+ require_relative 'browser/page_inspect/html_summarizer'
26
+ require_relative 'browser/page_inspect/link_summarizer'
27
+
28
+ # Load selector_generator and its sub-modules (used by click_tool and related)
29
+ require_relative 'browser/selector_generator/base_selectors'
30
+ require_relative 'browser/selector_generator/contextual_selectors'
31
+ require_relative 'browser/selector_generator'
32
+
33
+ # Try to load Ferrum (preferred) for browser automation via Chrome DevTools Protocol
34
+ begin
35
+ require 'ferrum'
36
+ require_relative 'browser/ferrum_driver'
37
+ rescue LoadError
38
+ # Ferrum gem not installed
39
+ end
40
+
41
+ # Fall back to Watir if available
13
42
  begin
14
43
  require 'watir'
15
44
  require_relative 'browser/watir_driver'
16
45
  rescue LoadError
17
- # Watir gem not installed, BrowserTools will require manual driver
46
+ # Watir gem not installed
18
47
  end
19
48
 
20
49
  # Load tools (order matters - utils loaded first)
@@ -146,10 +146,19 @@ module SharedTools
146
146
 
147
147
 
148
148
  # @param logger [Logger] optional logger
149
- # @param driver [SharedTools::Tools::Browser::BaseDriver] optional, will attempt to create WatirDriver if not provided
149
+ # @param driver [SharedTools::Tools::Browser::BaseDriver] optional, will attempt to create WatirDriver when execute is called
150
150
  def initialize(logger: nil, driver: nil)
151
151
  @logger = logger || RubyLLM.logger
152
- @driver = driver || default_driver
152
+ @driver = driver # Defer default_driver to execute time to support RubyLLM tool discovery
153
+ end
154
+
155
+ # Set driver after instantiation (useful when tool is discovered by RubyLLM)
156
+ attr_writer :driver
157
+
158
+ # Reports whether this tool can function.
159
+ # Returns true when a driver was injected or the watir gem is loaded.
160
+ def available?
161
+ !!@driver || defined?(Watir)
153
162
  end
154
163
 
155
164
  def cleanup!
@@ -166,6 +175,9 @@ module SharedTools
166
175
  #
167
176
  # @return [String]
168
177
  def execute(action:, url: nil, selector: nil, value: nil, context_size: 2, full_html: false, text_content: nil)
178
+ # Lazily resolve driver at execute time
179
+ @driver ||= default_driver
180
+
169
181
  case action.to_s.downcase
170
182
  when Action::VISIT
171
183
  require_param!(:url, url)
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../shared_tools'
4
+
5
+ module SharedTools
6
+ module Tools
7
+ # Read, write, and clear the system clipboard.
8
+ # Supports macOS (pbcopy/pbpaste), Linux (xclip or xsel), and Windows (clip/PowerShell).
9
+ #
10
+ # @example
11
+ # tool = SharedTools::Tools::ClipboardTool.new
12
+ # tool.execute(action: 'write', text: 'Hello!')
13
+ # tool.execute(action: 'read')
14
+ # tool.execute(action: 'clear')
15
+ class ClipboardTool < ::RubyLLM::Tool
16
+ def self.name = 'clipboard_tool'
17
+
18
+ description <<~DESC
19
+ Read from, write to, and clear the system clipboard.
20
+ Supports macOS, Linux (requires xclip or xsel), and Windows.
21
+
22
+ Actions:
23
+ - 'read' — Return the current clipboard contents
24
+ - 'write' — Replace clipboard contents with the given text
25
+ - 'clear' — Empty the clipboard
26
+ DESC
27
+
28
+ params do
29
+ string :action, description: "Action to perform: 'read', 'write', or 'clear'"
30
+ string :text, required: false, description: "Text to write. Required only for the 'write' action."
31
+ end
32
+
33
+ # @param logger [Logger] optional logger
34
+ def initialize(logger: nil)
35
+ @logger = logger || RubyLLM.logger
36
+ end
37
+
38
+ # @param action [String] 'read', 'write', or 'clear'
39
+ # @param text [String, nil] text for write action
40
+ # @return [Hash] result
41
+ def execute(action:, text: nil)
42
+ @logger.info("ClipboardTool#execute action=#{action}")
43
+
44
+ case action.to_s.downcase
45
+ when 'read' then read_clipboard
46
+ when 'write' then write_clipboard(text)
47
+ when 'clear' then clear_clipboard
48
+ else
49
+ { success: false, error: "Unknown action '#{action}'. Use: read, write, clear" }
50
+ end
51
+ rescue => e
52
+ @logger.error("ClipboardTool error: #{e.message}")
53
+ { success: false, error: e.message }
54
+ end
55
+
56
+ private
57
+
58
+ def read_clipboard
59
+ content = clipboard_read
60
+ { success: true, content: content, length: content.length }
61
+ end
62
+
63
+ def write_clipboard(text)
64
+ raise ArgumentError, "text is required for the write action" if text.nil?
65
+ clipboard_write(text)
66
+ { success: true, message: "Text written to clipboard", length: text.length }
67
+ end
68
+
69
+ def clear_clipboard
70
+ clipboard_write('')
71
+ { success: true, message: "Clipboard cleared" }
72
+ end
73
+
74
+ def clipboard_read
75
+ if macos?
76
+ `pbpaste`
77
+ elsif linux_xclip?
78
+ `xclip -selection clipboard -o 2>/dev/null`
79
+ elsif linux_xsel?
80
+ `xsel --clipboard --output 2>/dev/null`
81
+ elsif windows?
82
+ `powershell -command "Get-Clipboard" 2>/dev/null`.strip
83
+ else
84
+ raise "Clipboard not supported on this platform. Install xclip or xsel on Linux."
85
+ end
86
+ end
87
+
88
+ def clipboard_write(text)
89
+ if macos?
90
+ IO.popen('pbcopy', 'w') { |io| io.write(text) }
91
+ elsif linux_xclip?
92
+ IO.popen('xclip -selection clipboard', 'w') { |io| io.write(text) }
93
+ elsif linux_xsel?
94
+ IO.popen('xsel --clipboard --input', 'w') { |io| io.write(text) }
95
+ elsif windows?
96
+ IO.popen('clip', 'w') { |io| io.write(text) }
97
+ else
98
+ raise "Clipboard not supported on this platform. Install xclip or xsel on Linux."
99
+ end
100
+ end
101
+
102
+ def macos?
103
+ RUBY_PLATFORM.include?('darwin')
104
+ end
105
+
106
+ def windows?
107
+ RUBY_PLATFORM.match?(/mswin|mingw|cygwin/)
108
+ end
109
+
110
+ def linux_xclip?
111
+ @linux_xclip ||= system('which xclip > /dev/null 2>&1')
112
+ end
113
+
114
+ def linux_xsel?
115
+ @linux_xsel ||= system('which xsel > /dev/null 2>&1')
116
+ end
117
+ end
118
+ end
119
+ end
@@ -18,12 +18,22 @@ module SharedTools
18
18
  DESCRIPTION
19
19
 
20
20
  params do
21
- string :data_source, description: <<~DESC.strip, required: true
21
+ string :data_source, description: <<~DESC.strip, required: false
22
22
  Primary data source to analyze. Can be either a local file path or a web URL.
23
23
  For files: Use relative or absolute paths to CSV, JSON, XML, or text files.
24
24
  For URLs: Use complete HTTP/HTTPS URLs to accessible data endpoints or web pages.
25
25
  The tool automatically detects the source type and uses appropriate fetching methods.
26
26
  Examples: './data/sales.csv', '/home/user/data.json', 'https://api.example.com/data'
27
+ Either data_source or data must be provided.
28
+ DESC
29
+
30
+ string :data, description: <<~DESC.strip, required: false
31
+ Inline data to analyze, provided directly as a string. Accepts:
32
+ - Pipe-delimited markdown tables (header row with | separators)
33
+ - CSV text (comma-separated rows, first row treated as headers)
34
+ - JSON text (object or array)
35
+ - Plain text (one item per line)
36
+ Either data or data_source must be provided.
27
37
  DESC
28
38
 
29
39
  string :analysis_type, description: <<~DESC.strip, required: false
@@ -48,16 +58,23 @@ module SharedTools
48
58
  @logger = logger || RubyLLM.logger
49
59
  end
50
60
 
51
- def execute(data_source:, analysis_type: "standard", **options)
61
+ def execute(data_source: nil, data: nil, analysis_type: "standard", **options)
62
+ if data_source.nil? && data.nil?
63
+ return { success: false, error: "Either data_source or data must be provided." }
64
+ end
65
+
52
66
  results = {}
53
67
  analysis_start = Time.now
54
68
 
55
69
  begin
56
- @logger.info("CompositeAnalysisTool#execute data_source=#{data_source} analysis_type=#{analysis_type}")
70
+ @logger.info("CompositeAnalysisTool#execute data_source=#{data_source.inspect} analysis_type=#{analysis_type}")
57
71
 
58
72
  # Step 1: Fetch data using appropriate method
59
73
  @logger.debug("Fetching data from source...")
60
- if data_source.start_with?('http://', 'https://')
74
+ if data
75
+ results[:data] = parse_inline_data(data)
76
+ results[:source_type] = 'inline'
77
+ elsif data_source.start_with?('http://', 'https://')
61
78
  results[:data] = fetch_web_data(data_source)
62
79
  results[:source_type] = 'web'
63
80
  else
@@ -116,6 +133,45 @@ module SharedTools
116
133
 
117
134
  private
118
135
 
136
+ # Parse inline data string into a Ruby structure.
137
+ # Handles pipe-delimited markdown tables, CSV, JSON, and plain text.
138
+ def parse_inline_data(raw)
139
+ raw = raw.strip
140
+
141
+ # JSON
142
+ if raw.start_with?('{', '[')
143
+ begin
144
+ return JSON.parse(raw)
145
+ rescue JSON::ParserError
146
+ # fall through
147
+ end
148
+ end
149
+
150
+ lines = raw.lines.map(&:strip).reject(&:empty?)
151
+
152
+ # Pipe-delimited markdown table: "Col A | Col B | Col C"
153
+ if lines.first&.include?('|')
154
+ headers = lines.first.split('|').map(&:strip).reject(&:empty?)
155
+ data_lines = lines.drop(1).reject { |l| l.match?(/^\|?[-:\s|]+$/) }
156
+ return data_lines.map do |line|
157
+ values = line.split('|').map(&:strip).reject(&:empty?)
158
+ headers.zip(values).to_h
159
+ end
160
+ end
161
+
162
+ # CSV (comma-separated)
163
+ if lines.first&.include?(',')
164
+ headers = lines.first.split(',').map(&:strip)
165
+ return lines.drop(1).map do |line|
166
+ values = line.split(',').map(&:strip)
167
+ headers.zip(values).to_h
168
+ end
169
+ end
170
+
171
+ # Plain text — return as array of lines
172
+ lines
173
+ end
174
+
119
175
  # Fetch data from web URL
120
176
  def fetch_web_data(url)
121
177
  @logger.debug("Fetching web data from: #{url}")
@@ -21,7 +21,15 @@ module SharedTools
21
21
  # @param text [String]
22
22
  # @param duration [Integer]
23
23
  def hold_key(text:, duration:)
24
- raise NotImplementedError, "#{self.class.name}##{__method__} undefined"
24
+ options = text.to_s.split('+')
25
+ key = options.pop
26
+ mask = options.reduce(0) { |m, opt| m | Library::CoreGraphics::EventFlags.find(opt) }
27
+
28
+ @keyboard.key_down(key, mask: mask)
29
+ Kernel.sleep(duration.to_f)
30
+ @keyboard.key_up(key, mask: mask)
31
+
32
+ { success: true, key: text, duration: duration }
25
33
  end
26
34
 
27
35
  # @return [Hash<{ x: Integer, y: Integer }>]
@@ -86,10 +94,35 @@ module SharedTools
86
94
  @keyboard.type(text)
87
95
  end
88
96
 
89
- # @param amount [Integer]
90
- # @param direction [String] e.g. "up", "down", "left", "right"
97
+ # @param amount [Integer] number of scroll units
98
+ # @param direction [String] "up", "down", "left", or "right"
91
99
  def scroll(amount:, direction:)
92
- raise NotImplementedError, "#{self.class.name}##{__method__} undefined"
100
+ # Attach CGEventCreateScrollWheelEvent2 if not already done
101
+ unless Library::CoreGraphics.respond_to?(:CGEventCreateScrollWheelEvent2)
102
+ Library::CoreGraphics.module_eval do
103
+ attach_function :CGEventCreateScrollWheelEvent2,
104
+ [:pointer, :uint32, :uint32, :int32, :int32, :int32],
105
+ :pointer
106
+ end
107
+ end
108
+
109
+ amt = amount.to_i
110
+ # kCGScrollEventUnitLine = 1; wheel_count = 2 (vertical + horizontal)
111
+ delta_y, delta_x = case direction.to_s.downcase
112
+ when 'up' then [ amt, 0]
113
+ when 'down' then [-amt, 0]
114
+ when 'left' then [0, -amt]
115
+ when 'right' then [0, amt]
116
+ else [0, 0]
117
+ end
118
+
119
+ event = Library::CoreGraphics.CGEventCreateScrollWheelEvent2(nil, 1, 2, delta_y, delta_x, 0)
120
+ Library::CoreGraphics.CGEventPost(
121
+ Library::CoreGraphics::EventTapLocation::HID_EVENT_TAP, event
122
+ )
123
+ Library::CoreGraphics.CFRelease(event)
124
+
125
+ { success: true, direction: direction, amount: amt }
93
126
  end
94
127
 
95
128
  # @yield [file]
@@ -139,13 +139,16 @@ module SharedTools
139
139
  end
140
140
 
141
141
 
142
- # @param driver [Computer::BaseDriver] optional, will attempt to create platform-specific driver if not provided
142
+ # @param driver [Computer::BaseDriver] optional, will attempt to create platform-specific driver when execute is called
143
143
  # @param logger [Logger] optional logger
144
144
  def initialize(driver: nil, logger: nil)
145
145
  @logger = logger || RubyLLM.logger
146
- @driver = driver || default_driver
146
+ @driver = driver # Defer default_driver to execute time to support RubyLLM tool discovery
147
147
  end
148
148
 
149
+ # Set driver after instantiation (useful when tool is discovered by RubyLLM)
150
+ attr_writer :driver
151
+
149
152
  # @param action [String]
150
153
  # @param coordinate [Hash<{ width: Integer, height: Integer }>] the (x,y) coordinate
151
154
  # @param text [String]
@@ -162,6 +165,9 @@ module SharedTools
162
165
  scroll_direction: nil,
163
166
  scroll_amount: nil
164
167
  )
168
+ # Lazily resolve driver at execute time
169
+ @driver ||= default_driver
170
+
165
171
  @logger.info({
166
172
  action:,
167
173
  coordinate:,