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,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module SharedTools
6
+ module Tools
7
+ module Notification
8
+ # macOS notification driver using osascript and the say command.
9
+ class MacDriver < BaseDriver
10
+ # @param message [String]
11
+ # @param title [String, nil]
12
+ # @param subtitle [String, nil]
13
+ # @param sound [String, nil] e.g. 'Glass', 'Ping'
14
+ # @return [Hash]
15
+ def notify(message:, title: nil, subtitle: nil, sound: nil)
16
+ parts = ["display notification #{message.inspect}"]
17
+ parts << "with title #{title.inspect}" if title
18
+ parts << "subtitle #{subtitle.inspect}" if subtitle
19
+ parts << "sound name #{sound.inspect}" if sound
20
+ run_osascript(parts.join(' '))
21
+ .then { |r| r[:success] ? r.merge(action: 'notify') : r }
22
+ end
23
+
24
+ # @param message [String]
25
+ # @param title [String, nil]
26
+ # @param buttons [Array<String>]
27
+ # @param default_button [String, nil]
28
+ # @return [Hash] includes :button with label of clicked button
29
+ def alert(message:, title: nil, buttons: ['OK'], default_button: nil)
30
+ btn_list = buttons.map(&:inspect).join(', ')
31
+ script = "display dialog #{message.inspect}"
32
+ script += " with title #{title.inspect}" if title
33
+ script += " buttons {#{btn_list}}"
34
+ script += " default button #{default_button.inspect}" if default_button
35
+
36
+ stdout, stderr, status = Open3.capture3('osascript', '-e', script)
37
+ if status.success?
38
+ button = stdout.match(/button returned:(.+)/i)&.captures&.first&.strip
39
+ { success: true, button: button }
40
+ else
41
+ { success: false, error: stderr.strip }
42
+ end
43
+ end
44
+
45
+ # @param text [String]
46
+ # @param voice [String, nil] e.g. 'Samantha'
47
+ # @param rate [Integer, nil] words per minute
48
+ # @return [Hash]
49
+ def speak(text:, voice: nil, rate: nil)
50
+ cmd = ['say', text]
51
+ cmd += ['-v', voice] if voice
52
+ cmd += ['-r', rate.to_s] if rate
53
+ _, stderr, status = Open3.capture3(*cmd)
54
+ status.success? ? { success: true, action: 'speak' } : { success: false, error: stderr.strip }
55
+ end
56
+
57
+ private
58
+
59
+ def run_osascript(script)
60
+ _, stderr, status = Open3.capture3('osascript', '-e', script)
61
+ status.success? ? { success: true } : { success: false, error: stderr.strip }
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharedTools
4
+ module Tools
5
+ module Notification
6
+ # Fallback driver for unsupported platforms.
7
+ # All actions return a failure response with a clear error message.
8
+ class NullDriver < BaseDriver
9
+ def notify(message:, title: nil, subtitle: nil, sound: nil)
10
+ unsupported
11
+ end
12
+
13
+ def alert(message:, title: nil, buttons: ['OK'], default_button: nil)
14
+ unsupported
15
+ end
16
+
17
+ def speak(text:, voice: nil, rate: nil)
18
+ unsupported
19
+ end
20
+
21
+ private
22
+
23
+ def unsupported
24
+ { success: false, error: "NotificationTool is not supported on platform: #{RUBY_PLATFORM}" }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Collection loader for notification tools
4
+ # Usage: require 'shared_tools/tools/notification'
5
+
6
+ require 'shared_tools'
7
+
8
+ require_relative 'notification/base_driver'
9
+ require_relative 'notification/mac_driver'
10
+ require_relative 'notification/linux_driver'
11
+ require_relative 'notification/null_driver'
12
+ require_relative 'notification_tool'
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharedTools
4
+ module Tools
5
+ # Cross-platform notification tool for desktop banners, modal dialogs, and TTS.
6
+ #
7
+ # Supports macOS (osascript, say) and Linux (notify-send, zenity/terminal, espeak-ng/espeak).
8
+ # On unsupported platforms all actions return {success: false}.
9
+ #
10
+ # @example Desktop notification
11
+ # tool = SharedTools::Tools::NotificationTool.new
12
+ # tool.execute(action: 'notify', message: 'Build complete', title: 'CI')
13
+ #
14
+ # @example Modal alert
15
+ # result = tool.execute(action: 'alert', message: 'Deploy to production?', buttons: ['Yes', 'No'])
16
+ # result[:button] # => 'Yes' or 'No'
17
+ #
18
+ # @example Text-to-speech
19
+ # tool.execute(action: 'speak', message: 'Task finished')
20
+ class NotificationTool < ::RubyLLM::Tool
21
+ def self.name = 'notification_tool'
22
+
23
+ description <<~DESC.strip
24
+ Send desktop notifications, modal alerts, or text-to-speech messages.
25
+ Supports macOS and Linux. On macOS uses osascript and say.
26
+ On Linux uses notify-send, zenity (or terminal fallback), and espeak-ng/espeak.
27
+ DESC
28
+
29
+ params do
30
+ string :action, description: <<~TEXT.strip
31
+ The notification action to perform:
32
+ * `notify` — Non-blocking desktop banner notification.
33
+ * `alert` — Modal dialog; waits for the user to click a button. Returns the button label.
34
+ * `speak` — Speak text aloud using text-to-speech.
35
+ TEXT
36
+
37
+ string :message, description: "The message to display or speak. Required for all actions."
38
+
39
+ string :title, description: "Title for the notification or alert dialog. Optional.", required: false
40
+
41
+ string :subtitle, description: "Subtitle line (notify action, macOS and Linux). Optional.", required: false
42
+
43
+ string :sound, description: "Sound name to play with a notification (macOS only, e.g. 'Glass', 'Ping'). Optional.", required: false
44
+
45
+ array :buttons, of: :string, description: <<~TEXT.strip, required: false
46
+ Button labels for the alert dialog (e.g. ['Yes', 'No']). Defaults to ['OK'].
47
+ TEXT
48
+
49
+ string :default_button, description: "Default focused button label for the alert dialog. Optional.", required: false
50
+
51
+ string :voice, description: "TTS voice name for the speak action (e.g. 'Samantha' on macOS, 'en' on Linux). Optional.", required: false
52
+
53
+ integer :rate, description: "Speech rate in words per minute for the speak action. Optional.", required: false
54
+ end
55
+
56
+ # @param driver [Notification::BaseDriver] optional; auto-detected from platform if omitted
57
+ def initialize(driver: nil)
58
+ @driver = driver || default_driver
59
+ end
60
+
61
+ def execute(action:, message: nil, title: nil, subtitle: nil, sound: nil,
62
+ buttons: nil, default_button: nil, voice: nil, rate: nil)
63
+ buttons ||= ['OK']
64
+
65
+ case action
66
+ when 'notify'
67
+ return missing_param('message', 'notify') if blank?(message)
68
+ @driver.notify(message:, title:, subtitle:, sound:)
69
+ when 'alert'
70
+ return missing_param('message', 'alert') if blank?(message)
71
+ @driver.alert(message:, title:, buttons:, default_button:)
72
+ when 'speak'
73
+ return missing_param('message', 'speak') if blank?(message)
74
+ @driver.speak(text: message, voice:, rate:)
75
+ else
76
+ { success: false, error: "Unknown action: #{action.inspect}. Must be notify, alert, or speak." }
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def default_driver
83
+ case RUBY_PLATFORM
84
+ when /darwin/ then Notification::MacDriver.new
85
+ when /linux/ then Notification::LinuxDriver.new
86
+ else Notification::NullDriver.new
87
+ end
88
+ end
89
+
90
+ def blank?(value)
91
+ value.nil? || value.to_s.strip.empty?
92
+ end
93
+
94
+ def missing_param(param, action)
95
+ { success: false, error: "'#{param}' is required for the #{action} action" }
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../shared_tools'
4
+
5
+ module SharedTools
6
+ module Tools
7
+ # Returns OS, CPU, memory, disk, network, and Ruby runtime information.
8
+ #
9
+ # @example
10
+ # tool = SharedTools::Tools::SystemInfoTool.new
11
+ # tool.execute # all categories
12
+ # tool.execute(category: 'cpu') # CPU only
13
+ class SystemInfoTool < ::RubyLLM::Tool
14
+ def self.name = 'system_info_tool'
15
+
16
+ description <<~DESC
17
+ Retrieve system information from the local machine.
18
+
19
+ Categories:
20
+ - 'os' — Operating system name, version, hostname
21
+ - 'cpu' — CPU model, core count, load averages
22
+ - 'memory' — Total and available RAM in GB
23
+ - 'disk' — Mounted filesystems with used/available space
24
+ - 'network' — Active network interfaces and their IP addresses
25
+ - 'ruby' — Ruby version, platform, engine, RubyGems version
26
+ - 'all' (default) — All of the above combined
27
+ DESC
28
+
29
+ params do
30
+ string :category, required: false, description: <<~DESC.strip
31
+ Info category. Options: 'os', 'cpu', 'memory', 'disk', 'network', 'ruby', 'all' (default).
32
+ DESC
33
+ end
34
+
35
+ # @param logger [Logger] optional logger
36
+ def initialize(logger: nil)
37
+ @logger = logger || RubyLLM.logger
38
+ end
39
+
40
+ # @param category [String] which subsystem to query
41
+ # @return [Hash] system information
42
+ def execute(category: 'all')
43
+ @logger.info("SystemInfoTool#execute category=#{category}")
44
+
45
+ case category.to_s.downcase
46
+ when 'os' then { success: true }.merge(os_info)
47
+ when 'cpu' then { success: true }.merge(cpu_info)
48
+ when 'memory' then { success: true }.merge(memory_info)
49
+ when 'disk' then { success: true }.merge(disk_info)
50
+ when 'network' then { success: true }.merge(network_info)
51
+ when 'ruby' then { success: true }.merge(ruby_info)
52
+ else
53
+ { success: true }
54
+ .merge(os_info)
55
+ .merge(cpu_info)
56
+ .merge(memory_info)
57
+ .merge(disk_info)
58
+ .merge(network_info)
59
+ .merge(ruby_info)
60
+ end
61
+ rescue => e
62
+ @logger.error("SystemInfoTool error: #{e.message}")
63
+ { success: false, error: e.message }
64
+ end
65
+
66
+ private
67
+
68
+ def os_info
69
+ {
70
+ os_platform: RUBY_PLATFORM,
71
+ os_name: detect_os_name,
72
+ os_version: detect_os_version,
73
+ hostname: `hostname`.strip
74
+ }
75
+ end
76
+
77
+ def cpu_info
78
+ if RUBY_PLATFORM.include?('darwin')
79
+ model = `sysctl -n machdep.cpu.brand_string 2>/dev/null`.strip
80
+ cores = `sysctl -n hw.ncpu 2>/dev/null`.strip.to_i
81
+ load = `sysctl -n vm.loadavg 2>/dev/null`.strip
82
+ .gsub(/[{}]/, '').split.first(3).map(&:to_f)
83
+ else
84
+ model = File.read('/proc/cpuinfo')
85
+ .match(/model name\s*:\s*(.+)/)&.captures&.first&.strip rescue 'Unknown'
86
+ cores = `nproc 2>/dev/null`.strip.to_i
87
+ load = File.read('/proc/loadavg').split.first(3).map(&:to_f) rescue [0.0, 0.0, 0.0]
88
+ end
89
+
90
+ {
91
+ cpu_model: model.empty? ? 'Unknown' : model,
92
+ cpu_cores: cores,
93
+ load_avg_1m: load[0],
94
+ load_avg_5m: load[1],
95
+ load_avg_15m: load[2]
96
+ }
97
+ end
98
+
99
+ def memory_info
100
+ if RUBY_PLATFORM.include?('darwin')
101
+ total = `sysctl -n hw.memsize 2>/dev/null`.strip.to_i
102
+ vm_stat = `vm_stat 2>/dev/null`
103
+ pg_size = vm_stat.match(/page size of (\d+) bytes/)&.captures&.first&.to_i || 4096
104
+ free_pg = vm_stat.match(/Pages free:\s+(\d+)/)&.captures&.first&.to_i || 0
105
+ inact_pg = vm_stat.match(/Pages inactive:\s+(\d+)/)&.captures&.first&.to_i || 0
106
+ available = (free_pg + inact_pg) * pg_size
107
+ else
108
+ mem = File.read('/proc/meminfo') rescue ''
109
+ total = (mem.match(/MemTotal:\s+(\d+) kB/)&.captures&.first&.to_i || 0) * 1024
110
+ available = (mem.match(/MemAvailable:\s+(\d+) kB/)&.captures&.first&.to_i || 0) * 1024
111
+ end
112
+
113
+ gb = 1024.0**3
114
+ {
115
+ memory_total_gb: (total / gb).round(2),
116
+ memory_available_gb: (available / gb).round(2),
117
+ memory_used_gb: ((total - available) / gb).round(2)
118
+ }
119
+ end
120
+
121
+ def disk_info
122
+ lines = `df -k 2>/dev/null`.lines.drop(1)
123
+ mounts = lines.filter_map do |line|
124
+ parts = line.split
125
+ next unless parts.size >= 6
126
+
127
+ kb = 1024.0**2
128
+ {
129
+ filesystem: parts[0],
130
+ mount_point: parts[5],
131
+ size_gb: (parts[1].to_i / kb).round(2),
132
+ used_gb: (parts[2].to_i / kb).round(2),
133
+ available_gb: (parts[3].to_i / kb).round(2),
134
+ use_percent: parts[4]
135
+ }
136
+ end
137
+ { disks: mounts }
138
+ end
139
+
140
+ def network_info
141
+ interfaces = {}
142
+
143
+ if RUBY_PLATFORM.include?('darwin')
144
+ current = nil
145
+ `ifconfig 2>/dev/null`.lines.each do |line|
146
+ if (m = line.match(/^(\w[\w:]+\d+):/))
147
+ current = m.captures.first
148
+ interfaces[current] = []
149
+ elsif current && (m = line.match(/\s+inet6?\s+(\S+)/))
150
+ addr = m.captures.first.split('%').first
151
+ interfaces[current] << addr
152
+ end
153
+ end
154
+ else
155
+ current = nil
156
+ `ip addr 2>/dev/null`.lines.each do |line|
157
+ if (m = line.match(/^\d+: (\w+):/))
158
+ current = m.captures.first
159
+ interfaces[current] = []
160
+ elsif current && (m = line.match(/\s+inet6?\s+(\S+)/))
161
+ interfaces[current] << m.captures.first.split('/').first
162
+ end
163
+ end
164
+ end
165
+
166
+ { network_interfaces: interfaces.reject { |_, ips| ips.empty? } }
167
+ end
168
+
169
+ def ruby_info
170
+ {
171
+ ruby_version: RUBY_VERSION,
172
+ ruby_platform: RUBY_PLATFORM,
173
+ ruby_engine: RUBY_ENGINE,
174
+ ruby_description: RUBY_DESCRIPTION,
175
+ rubygems_version: Gem::VERSION
176
+ }
177
+ end
178
+
179
+ def detect_os_name
180
+ if RUBY_PLATFORM.include?('darwin')
181
+ `sw_vers -productName 2>/dev/null`.strip
182
+ elsif File.exist?('/etc/os-release')
183
+ File.read('/etc/os-release').match(/^NAME="?([^"\n]+)"?/)&.captures&.first || 'Linux'
184
+ else
185
+ 'Unknown'
186
+ end
187
+ rescue
188
+ 'Unknown'
189
+ end
190
+
191
+ def detect_os_version
192
+ if RUBY_PLATFORM.include?('darwin')
193
+ `sw_vers -productVersion 2>/dev/null`.strip
194
+ elsif File.exist?('/etc/os-release')
195
+ File.read('/etc/os-release').match(/^VERSION="?([^"\n]+)"?/)&.captures&.first || 'Unknown'
196
+ else
197
+ 'Unknown'
198
+ end
199
+ rescue
200
+ 'Unknown'
201
+ end
202
+ end
203
+ end
204
+ end
@@ -34,6 +34,7 @@ module SharedTools
34
34
  - 'step': Execute the next step in an existing workflow using provided step data
35
35
  - 'status': Check the current status and progress of an existing workflow
36
36
  - 'complete': Mark a workflow as finished and clean up associated resources
37
+ - 'list': List all existing workflows with their current status and summary information
37
38
  Each action requires different combinations of the other parameters.
38
39
  DESC
39
40
 
@@ -84,6 +85,8 @@ module SharedTools
84
85
  when "complete"
85
86
  return {success: false, error: "workflow_id required for 'complete' action"} unless workflow_id
86
87
  complete_workflow(workflow_id)
88
+ when "list"
89
+ list_workflows
87
90
  else
88
91
  {success: false, error: "Unknown action: #{action}"}
89
92
  end
@@ -279,6 +282,35 @@ module SharedTools
279
282
  }
280
283
  end
281
284
 
285
+ # List all workflows in the storage directory
286
+ def list_workflows
287
+ pattern = File.join(@storage_dir, "workflow_*.json")
288
+ files = Dir.glob(pattern)
289
+
290
+ workflows = files.filter_map do |file|
291
+ state = JSON.parse(File.read(file), symbolize_names: true)
292
+ {
293
+ workflow_id: state[:id],
294
+ status: state[:status],
295
+ step_count: state[:steps]&.length || 0,
296
+ created_at: state[:created_at],
297
+ updated_at: state[:updated_at],
298
+ completed_at: state[:completed_at]
299
+ }
300
+ rescue => e
301
+ @logger.warn("Skipping unreadable workflow file #{file}: #{e.message}")
302
+ nil
303
+ end
304
+
305
+ workflows.sort_by! { |w| w[:created_at] || "" }
306
+
307
+ {
308
+ success: true,
309
+ total: workflows.size,
310
+ workflows: workflows
311
+ }
312
+ end
313
+
282
314
  # Save workflow state to disk
283
315
  def save_workflow_state(workflow_id, state)
284
316
  file_path = workflow_file_path(workflow_id)
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # lib/shared_tools/utilities.rb
4
+ #
5
+ # General-purpose utility methods for SharedTools MCP clients and tools.
6
+ # Loaded automatically by lib/shared_tools.rb.
7
+
8
+ module SharedTools
9
+ MCP_LOG_MUTEX = Mutex.new
10
+ @mcp_load_log = {}
11
+
12
+ class << self
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # MCP load tracking
16
+ # ---------------------------------------------------------------------------
17
+
18
+ # Record the outcome of loading a single MCP client.
19
+ # Called by mcp.rb from within each loader thread.
20
+ #
21
+ # SharedTools.record_mcp_result("tavily") # success
22
+ # SharedTools.record_mcp_result("notion", error: e) # failure
23
+ def record_mcp_result(name, error: nil)
24
+ MCP_LOG_MUTEX.synchronize do
25
+ @mcp_load_log[name] = error ? { status: :failed, reason: error.message } : { status: :ok }
26
+ end
27
+ end
28
+
29
+ # Returns an array of client names that loaded successfully.
30
+ #
31
+ # SharedTools.mcp_loaded #=> ["github", "memory", "chart"]
32
+ def mcp_loaded
33
+ MCP_LOG_MUTEX.synchronize { @mcp_load_log.select { |_, v| v[:status] == :ok }.keys }
34
+ end
35
+
36
+ # Returns a hash of client names that failed to load, mapped to their error messages.
37
+ #
38
+ # SharedTools.mcp_failed #=> {"tavily" => "Missing envars: TAVILY_API_KEY", ...}
39
+ def mcp_failed
40
+ MCP_LOG_MUTEX.synchronize do
41
+ @mcp_load_log.select { |_, v| v[:status] == :failed }.transform_values { |v| v[:reason] }
42
+ end
43
+ end
44
+
45
+ # Prints a summary table of MCP client load results and returns the full log hash.
46
+ #
47
+ # SharedTools.mcp_status
48
+ # # MCP Client Status
49
+ # # ✓ github
50
+ # # ✓ memory
51
+ # # ✗ tavily — Missing envars: TAVILY_API_KEY
52
+ def mcp_status
53
+ log = MCP_LOG_MUTEX.synchronize { @mcp_load_log.dup }
54
+
55
+ if log.empty?
56
+ puts "No MCP clients have been loaded yet. Try: require 'shared_tools/mcp'"
57
+ return log
58
+ end
59
+
60
+ puts "MCP Client Status"
61
+ puts "-" * 40
62
+ log.sort.each do |name, entry|
63
+ if entry[:status] == :ok
64
+ puts " \u2713 #{name}"
65
+ else
66
+ puts " \u2717 #{name} \u2014 #{entry[:reason]}"
67
+ end
68
+ end
69
+ puts "-" * 40
70
+ puts " #{log.count { |_, v| v[:status] == :ok }} loaded, " \
71
+ "#{log.count { |_, v| v[:status] == :failed }} skipped"
72
+
73
+ log
74
+ end
75
+
76
+ # Returns true if all named environment variables are set and non-empty.
77
+ # Warns for each missing variable and returns false if any are absent.
78
+ #
79
+ # SharedTools.verify_envars("GITHUB_PERSONAL_ACCESS_TOKEN")
80
+ # SharedTools.verify_envars("TAVILY_API_KEY", "ANOTHER_KEY")
81
+ def verify_envars(*names)
82
+ missing = names.select { |n| ENV.fetch(n, "").empty? }
83
+ missing.each { |n| warn "SharedTools — #{n} is not set" }
84
+ unless missing.empty?
85
+ raise LoadError, "Missing envars: #{missing.join(', ')}"
86
+ end
87
+ end
88
+
89
+ # High-level package installer. Detects the current platform and calls the
90
+ # appropriate *_install method. Raises LoadError if any package cannot be installed.
91
+ #
92
+ # SharedTools.package_install("github-mcp-server")
93
+ # SharedTools.package_install("curl", "jq")
94
+ def package_install(*packages)
95
+ case RUBY_PLATFORM
96
+ when /darwin/
97
+ brew_install(*packages)
98
+ when /linux/
99
+ if system("which apt-get > /dev/null 2>&1")
100
+ apt_install(*packages)
101
+ elsif system("which dnf > /dev/null 2>&1")
102
+ dnf_install(*packages)
103
+ elsif system("which brew > /dev/null 2>&1")
104
+ brew_install(*packages)
105
+ else
106
+ raise LoadError, "No supported package manager found (apt-get, dnf, brew)"
107
+ end
108
+ else
109
+ raise LoadError, "Unsupported platform: #{RUBY_PLATFORM}"
110
+ end
111
+ end
112
+
113
+ # Ensures each named binary is available in PATH, installing via brew if missing.
114
+ # Raises LoadError if brew is not installed or any package install fails.
115
+ #
116
+ # SharedTools.brew_install("github-mcp-server")
117
+ # SharedTools.brew_install("gh", "jq")
118
+ def brew_install(*packages)
119
+ raise LoadError, "Homebrew is not installed (https://brew.sh)" unless system("which brew > /dev/null 2>&1")
120
+
121
+ packages.each do |pkg|
122
+ next unless `brew list --versions #{pkg} 2>/dev/null`.strip.empty?
123
+
124
+ warn "SharedTools — #{pkg} not found, installing via brew..."
125
+ raise LoadError, "#{pkg} could not be installed" unless system("brew install --quiet #{pkg} > /dev/null 2>&1")
126
+ end
127
+ end
128
+
129
+ # Ensures each named binary is available in PATH, installing via apt-get if missing.
130
+ # Raises LoadError if any package install fails.
131
+ #
132
+ # SharedTools.apt_install("curl")
133
+ # SharedTools.apt_install("curl", "jq")
134
+ def apt_install(*packages)
135
+ packages.each do |pkg|
136
+ # SMELL: what if package is a library?
137
+ next if system("which #{pkg} > /dev/null 2>&1")
138
+
139
+ warn "SharedTools — #{pkg} not found, installing via apt-get..."
140
+ raise LoadError, "#{pkg} could not be installed" unless system("sudo apt-get install -y -q #{pkg} > /dev/null 2>&1")
141
+ end
142
+ end
143
+
144
+ # Ensures each named binary is available in PATH, installing via dnf if missing.
145
+ # Raises LoadError if any package install fails.
146
+ #
147
+ # SharedTools.dnf_install("curl")
148
+ # SharedTools.dnf_install("curl", "jq")
149
+ def dnf_install(*packages)
150
+ packages.each do |pkg|
151
+ # SMELL: What if package is a library?
152
+ next if system("which #{pkg} > /dev/null 2>&1")
153
+
154
+ warn "SharedTools — #{pkg} not found, installing via dnf..."
155
+ raise LoadError, "#{pkg} could not be installed" unless system("sudo dnf install -y -q #{pkg} > /dev/null 2>&1")
156
+ end
157
+ end
158
+
159
+ # Ensures each named npm package binary is available in PATH, installing
160
+ # globally via npm if missing. Raises LoadError if npm is not found or any install fails.
161
+ #
162
+ # SharedTools.npm_install("typescript")
163
+ # SharedTools.npm_install("typescript", "ts-node")
164
+ def npm_install(*packages)
165
+ raise LoadError, "npm is not installed (https://nodejs.org)" unless system("which npm > /dev/null 2>&1")
166
+
167
+ packages.each do |pkg|
168
+ # SMELL: What if package is a library?
169
+ next if system("which #{pkg} > /dev/null 2>&1")
170
+
171
+ warn "SharedTools — #{pkg} not found, installing via npm..."
172
+ raise LoadError, "#{pkg} could not be installed" unless system("npm install -g --silent #{pkg} > /dev/null 2>&1")
173
+ end
174
+ end
175
+
176
+ # Ensures each named gem is available, installing via gem install if missing.
177
+ # Raises LoadError if gem is not available or any install fails.
178
+ #
179
+ # SharedTools.gem_install("nokogiri")
180
+ # SharedTools.gem_install("nokogiri", "oj")
181
+ def gem_install(*packages)
182
+ raise LoadError, "gem is not available" unless system("which gem > /dev/null 2>&1")
183
+
184
+ packages.each do |pkg|
185
+ next if system("gem list -i #{pkg} > /dev/null 2>&1")
186
+
187
+ warn "SharedTools — #{pkg} not found, installing via gem..."
188
+ raise LoadError, "#{pkg} could not be installed" unless system("gem install --silent #{pkg} > /dev/null 2>&1")
189
+ end
190
+ end
191
+
192
+ end
193
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SharedTools
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.1"
5
5
  end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/weather_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/weather_tool'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/workflow_manager_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/workflow_manager_tool'