shared_tools 0.3.1 → 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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -16
  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 +6 -0
  43. data/lib/shared_tools/tools/clipboard_tool.rb +69 -144
  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/cron_tool.rb +237 -379
  47. data/lib/shared_tools/tools/current_date_time_tool.rb +54 -120
  48. data/lib/shared_tools/tools/data_science_kit.rb +63 -13
  49. data/lib/shared_tools/tools/dns_tool.rb +335 -269
  50. data/lib/shared_tools/tools/doc/docx_reader_tool.rb +107 -0
  51. data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +171 -0
  52. data/lib/shared_tools/tools/doc/text_reader_tool.rb +57 -0
  53. data/lib/shared_tools/tools/doc.rb +3 -0
  54. data/lib/shared_tools/tools/doc_tool.rb +101 -6
  55. data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
  56. data/lib/shared_tools/tools/enabler.rb +42 -0
  57. data/lib/shared_tools/tools/error_handling_tool.rb +3 -1
  58. data/lib/shared_tools/tools/notification/base_driver.rb +51 -0
  59. data/lib/shared_tools/tools/notification/linux_driver.rb +115 -0
  60. data/lib/shared_tools/tools/notification/mac_driver.rb +66 -0
  61. data/lib/shared_tools/tools/notification/null_driver.rb +29 -0
  62. data/lib/shared_tools/tools/notification.rb +12 -0
  63. data/lib/shared_tools/tools/notification_tool.rb +99 -0
  64. data/lib/shared_tools/tools/system_info_tool.rb +130 -343
  65. data/lib/shared_tools/tools/workflow_manager_tool.rb +32 -0
  66. data/lib/shared_tools/utilities.rb +193 -0
  67. data/lib/shared_tools/version.rb +1 -1
  68. data/lib/shared_tools/weather_tool.rb +4 -0
  69. data/lib/shared_tools/workflow_manager_tool.rb +4 -0
  70. data/lib/shared_tools.rb +28 -38
  71. metadata +74 -9
  72. data/lib/shared_tools/mcp/github_mcp_server.rb +0 -58
  73. data/lib/shared_tools/mcp/imcp.rb +0 -28
  74. data/lib/shared_tools/mcp/tavily_mcp_server.rb +0 -44
  75. 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)
@@ -155,6 +155,12 @@ module SharedTools
155
155
  # Set driver after instantiation (useful when tool is discovered by RubyLLM)
156
156
  attr_writer :driver
157
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)
162
+ end
163
+
158
164
  def cleanup!
159
165
  @driver.close
160
166
  end
@@ -1,59 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ruby_llm/tool'
3
+ require_relative '../../shared_tools'
4
4
 
5
5
  module SharedTools
6
6
  module Tools
7
- # A tool for reading from and writing to the system clipboard.
8
- # Supports macOS (pbcopy/pbpaste), Linux (xclip/xsel), and Windows (clip).
7
+ # Read, write, and clear the system clipboard.
8
+ # Supports macOS (pbcopy/pbpaste), Linux (xclip or xsel), and Windows (clip/PowerShell).
9
9
  #
10
10
  # @example
11
11
  # tool = SharedTools::Tools::ClipboardTool.new
12
- # tool.execute(action: 'write', content: 'Hello, World!')
13
- # result = tool.execute(action: 'read')
14
- # puts result[:content] # "Hello, World!"
15
- class ClipboardTool < RubyLLM::Tool
16
- def self.name = 'clipboard'
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
17
 
18
- description <<~'DESCRIPTION'
19
- Read from or write to the system clipboard.
20
-
21
- This tool provides cross-platform clipboard access:
22
- - macOS: Uses pbcopy/pbpaste
23
- - Linux: Uses xclip or xsel (must be installed)
24
- - Windows: Uses clip/powershell
18
+ description <<~DESC
19
+ Read from, write to, and clear the system clipboard.
20
+ Supports macOS, Linux (requires xclip or xsel), and Windows.
25
21
 
26
22
  Actions:
27
- - 'read': Get the current clipboard contents
28
- - 'write': Set the clipboard contents
29
- - 'clear': Clear the clipboard
30
-
31
- Example usage:
32
- tool = SharedTools::Tools::ClipboardTool.new
33
-
34
- # Write to clipboard
35
- tool.execute(action: 'write', content: 'Hello, World!')
36
-
37
- # Read from clipboard
38
- result = tool.execute(action: 'read')
39
- puts result[:content]
40
-
41
- # Clear clipboard
42
- tool.execute(action: 'clear')
43
- DESCRIPTION
23
+ - 'read' Return the current clipboard contents
24
+ - 'write' Replace clipboard contents with the given text
25
+ - 'clear' Empty the clipboard
26
+ DESC
44
27
 
45
28
  params do
46
- string :action, description: <<~DESC.strip
47
- The clipboard action to perform:
48
- - 'read': Get current clipboard contents
49
- - 'write': Set clipboard contents (requires 'content' parameter)
50
- - 'clear': Clear the clipboard
51
- DESC
52
-
53
- string :content, description: <<~DESC.strip, required: false
54
- The text content to write to the clipboard.
55
- Required when action is 'write'.
56
- DESC
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."
57
31
  end
58
32
 
59
33
  # @param logger [Logger] optional logger
@@ -61,133 +35,84 @@ module SharedTools
61
35
  @logger = logger || RubyLLM.logger
62
36
  end
63
37
 
64
- # Execute clipboard action
65
- #
66
38
  # @param action [String] 'read', 'write', or 'clear'
67
- # @param content [String, nil] content to write (required for 'write' action)
68
- # @return [Hash] result with success status and content/error
69
- def execute(action:, content: nil)
70
- @logger.info("ClipboardTool#execute action=#{action.inspect}")
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}")
71
43
 
72
44
  case action.to_s.downcase
73
- when 'read'
74
- read_clipboard
75
- when 'write'
76
- write_clipboard(content)
77
- when 'clear'
78
- clear_clipboard
45
+ when 'read' then read_clipboard
46
+ when 'write' then write_clipboard(text)
47
+ when 'clear' then clear_clipboard
79
48
  else
80
- {
81
- success: false,
82
- error: "Unknown action: #{action}. Valid actions are: read, write, clear"
83
- }
49
+ { success: false, error: "Unknown action '#{action}'. Use: read, write, clear" }
84
50
  end
85
51
  rescue => e
86
52
  @logger.error("ClipboardTool error: #{e.message}")
87
- {
88
- success: false,
89
- error: e.message
90
- }
53
+ { success: false, error: e.message }
91
54
  end
92
55
 
93
56
  private
94
57
 
95
58
  def read_clipboard
96
- content = case platform
97
- when :macos
98
- `pbpaste 2>/dev/null`
99
- when :linux
100
- if command_exists?('xclip')
101
- `xclip -selection clipboard -o 2>/dev/null`
102
- elsif command_exists?('xsel')
103
- `xsel --clipboard --output 2>/dev/null`
104
- else
105
- raise "No clipboard tool found. Install xclip or xsel."
106
- end
107
- when :windows
108
- `powershell -command "Get-Clipboard" 2>nul`.chomp
109
- else
110
- raise "Unsupported platform: #{RUBY_PLATFORM}"
111
- end
112
-
113
- {
114
- success: true,
115
- content: content,
116
- length: content.length
117
- }
59
+ content = clipboard_read
60
+ { success: true, content: content, length: content.length }
118
61
  end
119
62
 
120
- def write_clipboard(content)
121
- if content.nil? || content.empty?
122
- return {
123
- success: false,
124
- error: "Content is required for write action"
125
- }
126
- end
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
127
68
 
128
- case platform
129
- when :macos
130
- IO.popen('pbcopy', 'w') { |io| io.print content }
131
- when :linux
132
- if command_exists?('xclip')
133
- IO.popen('xclip -selection clipboard', 'w') { |io| io.print content }
134
- elsif command_exists?('xsel')
135
- IO.popen('xsel --clipboard --input', 'w') { |io| io.print content }
136
- else
137
- raise "No clipboard tool found. Install xclip or xsel."
138
- end
139
- when :windows
140
- IO.popen('clip', 'w') { |io| io.print content }
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
141
83
  else
142
- raise "Unsupported platform: #{RUBY_PLATFORM}"
84
+ raise "Clipboard not supported on this platform. Install xclip or xsel on Linux."
143
85
  end
144
-
145
- {
146
- success: true,
147
- message: "Content written to clipboard",
148
- length: content.length
149
- }
150
86
  end
151
87
 
152
- def clear_clipboard
153
- case platform
154
- when :macos
155
- IO.popen('pbcopy', 'w') { |io| io.print '' }
156
- when :linux
157
- if command_exists?('xclip')
158
- IO.popen('xclip -selection clipboard', 'w') { |io| io.print '' }
159
- elsif command_exists?('xsel')
160
- IO.popen('xsel --clipboard --input', 'w') { |io| io.print '' }
161
- else
162
- raise "No clipboard tool found. Install xclip or xsel."
163
- end
164
- when :windows
165
- IO.popen('clip', 'w') { |io| io.print '' }
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) }
166
97
  else
167
- raise "Unsupported platform: #{RUBY_PLATFORM}"
98
+ raise "Clipboard not supported on this platform. Install xclip or xsel on Linux."
168
99
  end
100
+ end
169
101
 
170
- {
171
- success: true,
172
- message: "Clipboard cleared"
173
- }
102
+ def macos?
103
+ RUBY_PLATFORM.include?('darwin')
174
104
  end
175
105
 
176
- def platform
177
- case RUBY_PLATFORM
178
- when /darwin/
179
- :macos
180
- when /linux/
181
- :linux
182
- when /mswin|mingw|cygwin/
183
- :windows
184
- else
185
- :unknown
186
- end
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')
187
112
  end
188
113
 
189
- def command_exists?(cmd)
190
- system("which #{cmd} > /dev/null 2>&1")
114
+ def linux_xsel?
115
+ @linux_xsel ||= system('which xsel > /dev/null 2>&1')
191
116
  end
192
117
  end
193
118
  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}")