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,422 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'resolv'
4
+ require 'socket'
5
+ require 'net/http'
6
+ require 'uri'
7
+ require_relative '../../shared_tools'
8
+
9
+ module SharedTools
10
+ module Tools
11
+ # DNS lookup tool supporting A, AAAA, MX, TXT, NS, CNAME, reverse, all-records,
12
+ # external IP detection, and WHOIS database queries.
13
+ #
14
+ # @example
15
+ # tool = SharedTools::Tools::DnsTool.new
16
+ # tool.execute(action: 'a', host: 'ruby-lang.org')
17
+ # tool.execute(action: 'mx', host: 'gmail.com')
18
+ # tool.execute(action: 'reverse', host: '8.8.8.8')
19
+ # tool.execute(action: 'external_ip')
20
+ # tool.execute(action: 'whois', host: 'github.com')
21
+ # tool.execute(action: 'whois', host: '8.8.8.8')
22
+ class DnsTool < ::RubyLLM::Tool
23
+ def self.name = 'dns_tool'
24
+
25
+ description <<~DESC
26
+ Perform DNS lookups, reverse lookups, record queries, external IP detection,
27
+ and WHOIS database queries for any hostname, domain name, or IP address.
28
+
29
+ Actions:
30
+ - 'a' — IPv4 address records (A)
31
+ - 'aaaa' — IPv6 address records (AAAA)
32
+ - 'mx' — Mail exchange records, sorted by priority
33
+ - 'txt' — TXT records (SPF, DKIM, verification tokens, etc.)
34
+ - 'ns' — Authoritative nameserver records
35
+ - 'cname' — Canonical name (alias) records
36
+ - 'reverse' — Reverse PTR lookup for an IP address
37
+ - 'all' — A, MX, TXT, NS, and CNAME records combined
38
+ - 'external_ip' — Detect the current machine's public-facing IP address
39
+ - 'ip_location' — Geolocate an IP address (city, region, country, lat/lon, timezone, ISP)
40
+ - 'whois' — Query the WHOIS database for a domain name or IP address
41
+ DESC
42
+
43
+ params do
44
+ string :action, description: <<~DESC.strip
45
+ The DNS or network operation to perform. One of:
46
+ - 'a' — Look up IPv4 A records for a hostname. Returns the IPv4 addresses
47
+ that the hostname resolves to. Useful for verifying DNS propagation,
48
+ checking load balancer IPs, or confirming a domain points to the
49
+ expected server.
50
+ - 'aaaa' — Look up IPv6 AAAA records for a hostname. Returns IPv6 addresses
51
+ that the hostname resolves to. Important for dual-stack network
52
+ verification and IPv6 connectivity testing.
53
+ - 'mx' — Look up Mail Exchange (MX) records for a domain, sorted by
54
+ priority (lowest number = highest priority). Essential for diagnosing
55
+ email delivery issues, verifying mail server configuration, and
56
+ confirming that a domain is using the expected mail provider.
57
+ - 'txt' — Look up TXT records for a domain. TXT records carry human-readable
58
+ text and machine-readable data including SPF policies (which servers
59
+ may send mail for the domain), DKIM public keys (for email signing),
60
+ DMARC policies, domain ownership verification tokens (Google, GitHub,
61
+ etc.), and BIMI brand indicators.
62
+ - 'ns' — Look up the authoritative Name Server (NS) records for a domain.
63
+ Returns the hostnames of the DNS servers that are authoritative for
64
+ the domain. Useful for verifying registrar settings, diagnosing DNS
65
+ delegation problems, and confirming a domain is using a specific
66
+ DNS provider.
67
+ - 'cname' — Look up Canonical Name (CNAME) alias records for a hostname.
68
+ Returns the target hostname that this alias points to. Common for
69
+ CDN configurations, third-party service integrations (e.g. Shopify,
70
+ Heroku), and subdomain aliases.
71
+ - 'reverse' — Perform a reverse PTR (pointer) lookup for an IP address. Returns
72
+ the hostname associated with the IP, if one is configured. Important
73
+ for mail server deliverability (forward-confirmed reverse DNS),
74
+ identifying unknown IP addresses, and network forensics.
75
+ - 'all' — Retrieve A, MX, TXT, NS, and CNAME records for a domain in a
76
+ single call. Provides a comprehensive snapshot of a domain's DNS
77
+ configuration. Useful for domain audits, migration planning, and
78
+ quick overviews.
79
+ - 'external_ip' — Detect the current machine's public-facing (external) IP address
80
+ as seen by the internet. Does not require a host parameter. Useful
81
+ for firewall rule generation, VPN verification, geolocation context,
82
+ abuse report submissions, and confirming that traffic is routing
83
+ through the expected network path (e.g. a VPN or proxy).
84
+ - 'ip_location' — Geolocate an IP address using a free geolocation API. Returns the
85
+ city, region, country, country code, latitude, longitude, timezone,
86
+ ISP name, and organisation. Accepts any public IPv4 address in the
87
+ host parameter; omit host (or pass the result of an 'external_ip'
88
+ call) to geolocate your own public IP. Useful for determining a
89
+ user's approximate location from their IP address, cross-referencing
90
+ IP ownership with physical geography, building location-aware
91
+ workflows (e.g. routing to the nearest server), and providing
92
+ contextual information such as local time and weather.
93
+ - 'whois' — Query the WHOIS database for a domain name or IP address. For
94
+ domain names, returns registrar information, registration and
95
+ expiry dates, name servers, registrant organization (when not
96
+ privacy-protected), and domain status flags. For IP addresses,
97
+ returns the network owner, ASN (Autonomous System Number), CIDR
98
+ netblock, country of allocation, and abuse contact information.
99
+ Useful for identifying who owns an IP attacking your server,
100
+ checking domain expiry dates, verifying registrar lock status,
101
+ finding abuse contacts, and threat intelligence workflows.
102
+ DESC
103
+
104
+ string :host, description: <<~DESC.strip, required: false
105
+ The hostname, domain name, or IP address to query. Required for all actions
106
+ except 'external_ip'. Examples:
107
+ - Hostname: 'ruby-lang.org', 'mail.google.com'
108
+ - Domain: 'github.com', 'cloudflare.com'
109
+ - IP address: '8.8.8.8', '2001:4860:4860::8888'
110
+ For the 'reverse' action, provide an IP address.
111
+ For the 'whois' action, provide either a domain name or an IP address.
112
+ For the 'external_ip' action, this parameter is ignored.
113
+ For the 'ip_location' action, provide a public IPv4 address, or omit to geolocate your own external IP.
114
+ DESC
115
+ end
116
+
117
+ WHOIS_PORT = 43
118
+ WHOIS_TIMEOUT = 10
119
+ IANA_WHOIS = 'whois.iana.org'
120
+ ARIN_WHOIS = 'whois.arin.net'
121
+ IP_SERVICES = %w[
122
+ https://api.ipify.org
123
+ https://ifconfig.me/ip
124
+ https://icanhazip.com
125
+ ].freeze
126
+
127
+ # @param logger [Logger] optional logger
128
+ def initialize(logger: nil)
129
+ @logger = logger || RubyLLM.logger
130
+ end
131
+
132
+ # @param action [String] lookup type
133
+ # @param host [String] hostname or IP (not required for external_ip)
134
+ # @return [Hash] results
135
+ def execute(action:, host: nil)
136
+ @logger.info("DnsTool#execute action=#{action} host=#{host}")
137
+
138
+ case action.to_s.downcase
139
+ when 'a' then lookup_a(host)
140
+ when 'aaaa' then lookup_aaaa(host)
141
+ when 'mx' then lookup_mx(host)
142
+ when 'txt' then lookup_txt(host)
143
+ when 'ns' then lookup_ns(host)
144
+ when 'cname' then lookup_cname(host)
145
+ when 'reverse' then lookup_reverse(host)
146
+ when 'all' then lookup_all(host)
147
+ when 'external_ip' then lookup_external_ip
148
+ when 'ip_location' then lookup_ip_location(host)
149
+ when 'whois' then lookup_whois(host)
150
+ else
151
+ { success: false, error: "Unknown action '#{action}'. Use: a, aaaa, mx, txt, ns, cname, reverse, all, external_ip, ip_location, whois" }
152
+ end
153
+ rescue => e
154
+ @logger.error("DnsTool error for #{host}: #{e.message}")
155
+ { success: false, host: host, error: e.message }
156
+ end
157
+
158
+ private
159
+
160
+ def lookup_a(host)
161
+ records = Resolv.getaddresses(host).select { |a| a.match?(/\A\d+\.\d+\.\d+\.\d+\z/) }
162
+ { success: true, host: host, type: 'A', records: records }
163
+ end
164
+
165
+ def lookup_aaaa(host)
166
+ records = Resolv.getaddresses(host).reject { |a| a.match?(/\A\d+\.\d+\.\d+\.\d+\z/) }
167
+ { success: true, host: host, type: 'AAAA', records: records }
168
+ end
169
+
170
+ def lookup_mx(host)
171
+ records = []
172
+ Resolv::DNS.open do |dns|
173
+ dns.getresources(host, Resolv::DNS::Resource::IN::MX).each do |r|
174
+ records << { priority: r.preference, exchange: r.exchange.to_s }
175
+ end
176
+ end
177
+ records.sort_by! { |r| r[:priority] }
178
+ { success: true, host: host, type: 'MX', records: records }
179
+ end
180
+
181
+ def lookup_txt(host)
182
+ records = []
183
+ Resolv::DNS.open do |dns|
184
+ dns.getresources(host, Resolv::DNS::Resource::IN::TXT).each do |r|
185
+ records << r.strings.join(' ')
186
+ end
187
+ end
188
+ { success: true, host: host, type: 'TXT', records: records }
189
+ end
190
+
191
+ def lookup_ns(host)
192
+ records = []
193
+ Resolv::DNS.open do |dns|
194
+ dns.getresources(host, Resolv::DNS::Resource::IN::NS).each do |r|
195
+ records << r.name.to_s
196
+ end
197
+ end
198
+ { success: true, host: host, type: 'NS', records: records.sort }
199
+ end
200
+
201
+ def lookup_cname(host)
202
+ records = []
203
+ Resolv::DNS.open do |dns|
204
+ dns.getresources(host, Resolv::DNS::Resource::IN::CNAME).each do |r|
205
+ records << r.name.to_s
206
+ end
207
+ end
208
+ { success: true, host: host, type: 'CNAME', records: records }
209
+ end
210
+
211
+ def lookup_reverse(ip)
212
+ hostname = Resolv.getname(ip)
213
+ { success: true, ip: ip, type: 'PTR', hostname: hostname }
214
+ rescue Resolv::ResolvError => e
215
+ { success: false, ip: ip, type: 'PTR', error: "No reverse DNS entry found: #{e.message}" }
216
+ end
217
+
218
+ def lookup_all(host)
219
+ {
220
+ success: true,
221
+ host: host,
222
+ a: lookup_a(host)[:records],
223
+ mx: lookup_mx(host)[:records],
224
+ txt: lookup_txt(host)[:records],
225
+ ns: lookup_ns(host)[:records],
226
+ cname: lookup_cname(host)[:records]
227
+ }
228
+ end
229
+
230
+ # Detect external IP by querying well-known public IP echo services.
231
+ # Tries each service in order and returns the first successful response.
232
+ def lookup_external_ip
233
+ IP_SERVICES.each do |url|
234
+ ip = http_get(url)&.strip
235
+ next unless ip&.match?(/\A[\d.:a-fA-F]+\z/)
236
+
237
+ @logger.info("External IP resolved via #{url}: #{ip}")
238
+ return {
239
+ success: true,
240
+ type: 'external_ip',
241
+ ip: ip,
242
+ source: url,
243
+ note: 'This is your public-facing IP address as seen by the internet.'
244
+ }
245
+ rescue => e
246
+ @logger.warn("IP service #{url} failed: #{e.message}")
247
+ next
248
+ end
249
+
250
+ { success: false, type: 'external_ip', error: 'All external IP services unreachable' }
251
+ end
252
+
253
+ # Geolocate an IP address using the ip-api.com free JSON endpoint.
254
+ # If no IP is supplied, geolocates the caller's own external IP.
255
+ def lookup_ip_location(ip = nil)
256
+ target = ip.to_s.strip.empty? ? '' : "/#{ip.strip}"
257
+ url = "http://ip-api.com/json#{target}?fields=status,message,country,countryCode,region,regionName,city,zip,lat,lon,timezone,isp,org,as,query"
258
+
259
+ @logger.info("IP geolocation query: #{url}")
260
+ raw = http_get(url)
261
+ data = JSON.parse(raw)
262
+
263
+ if data['status'] == 'fail'
264
+ return { success: false, type: 'ip_location', error: data['message'], ip: ip }
265
+ end
266
+
267
+ {
268
+ success: true,
269
+ type: 'ip_location',
270
+ ip: data['query'],
271
+ city: data['city'],
272
+ region: data['regionName'],
273
+ region_code: data['region'],
274
+ country: data['country'],
275
+ country_code: data['countryCode'],
276
+ zip: data['zip'],
277
+ latitude: data['lat'],
278
+ longitude: data['lon'],
279
+ timezone: data['timezone'],
280
+ isp: data['isp'],
281
+ organization: data['org'],
282
+ asn: data['as'],
283
+ note: 'Geolocation is approximate. Accuracy varies by ISP and region.'
284
+ }
285
+ rescue => e
286
+ @logger.error("IP geolocation failed for #{ip}: #{e.message}")
287
+ { success: false, type: 'ip_location', error: e.message, ip: ip }
288
+ end
289
+
290
+ # Query the WHOIS database for a domain name or IP address.
291
+ # For domains, queries IANA first to find the authoritative WHOIS server,
292
+ # then queries that server for full registration details.
293
+ # For IPs, queries ARIN (which redirects to the appropriate RIR).
294
+ def lookup_whois(host)
295
+ return { success: false, error: "host is required for whois lookup" } if host.nil? || host.strip.empty?
296
+
297
+ host = host.strip.downcase
298
+
299
+ if ip_address?(host)
300
+ whois_server = ARIN_WHOIS
301
+ raw = whois_query(whois_server, host)
302
+ parsed = parse_whois_ip(raw)
303
+ else
304
+ # Step 1: ask IANA which server is authoritative for this TLD
305
+ iana_response = whois_query(IANA_WHOIS, host)
306
+ whois_server = extract_whois_server(iana_response) || IANA_WHOIS
307
+
308
+ # Step 2: query the authoritative server
309
+ raw = whois_query(whois_server, host)
310
+ parsed = parse_whois_domain(raw)
311
+ end
312
+
313
+ {
314
+ success: true,
315
+ host: host,
316
+ type: ip_address?(host) ? 'whois_ip' : 'whois_domain',
317
+ whois_server: whois_server,
318
+ parsed: parsed,
319
+ raw: raw
320
+ }
321
+ rescue => e
322
+ @logger.error("WHOIS lookup failed for #{host}: #{e.message}")
323
+ { success: false, host: host, type: 'whois', error: e.message }
324
+ end
325
+
326
+ # Open a TCP connection to a WHOIS server on port 43, send the query,
327
+ # and return the full plain-text response.
328
+ def whois_query(server, query)
329
+ @logger.debug("WHOIS query: #{server} <- #{query}")
330
+ response = String.new(encoding: 'binary')
331
+
332
+ Socket.tcp(server, WHOIS_PORT, connect_timeout: WHOIS_TIMEOUT) do |sock|
333
+ sock.write("#{query}\r\n")
334
+ response << sock.read
335
+ end
336
+
337
+ response.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
338
+ end
339
+
340
+ # Extract the 'whois:' referral server from an IANA response.
341
+ def extract_whois_server(iana_response)
342
+ iana_response.each_line do |line|
343
+ return $1.strip if line.match?(/^whois:/i) && line =~ /^whois:\s+(\S+)/i
344
+ end
345
+ nil
346
+ end
347
+
348
+ # Parse key fields from a domain WHOIS response into a structured hash.
349
+ def parse_whois_domain(raw)
350
+ fields = {
351
+ registrar: extract_field(raw, /registrar:\s+(.+)/i),
352
+ registrar_url: extract_field(raw, /registrar url:\s+(.+)/i),
353
+ created: extract_field(raw, /creation date:\s+(.+)/i) ||
354
+ extract_field(raw, /registered:\s+(.+)/i),
355
+ updated: extract_field(raw, /updated date:\s+(.+)/i) ||
356
+ extract_field(raw, /last[\s-]updated?:\s+(.+)/i),
357
+ expires: extract_field(raw, /registry expiry date:\s+(.+)/i) ||
358
+ extract_field(raw, /expir(?:y|ation) date:\s+(.+)/i) ||
359
+ extract_field(raw, /paid[\s-]till:\s+(.+)/i),
360
+ status: extract_all_fields(raw, /domain status:\s+(.+)/i),
361
+ name_servers: extract_all_fields(raw, /name server:\s+(.+)/i),
362
+ registrant_org: extract_field(raw, /registrant\s+organization:\s+(.+)/i) ||
363
+ extract_field(raw, /registrant:\s+(.+)/i),
364
+ registrant_country: extract_field(raw, /registrant\s+country:\s+(.+)/i),
365
+ dnssec: extract_field(raw, /dnssec:\s+(.+)/i)
366
+ }.compact
367
+
368
+ fields[:name_servers] = fields[:name_servers]&.map(&:downcase)&.sort&.uniq
369
+ fields
370
+ end
371
+
372
+ # Parse key fields from an IP/network WHOIS response into a structured hash.
373
+ def parse_whois_ip(raw)
374
+ {
375
+ organization: extract_field(raw, /orgname:\s+(.+)/i) ||
376
+ extract_field(raw, /org-name:\s+(.+)/i) ||
377
+ extract_field(raw, /netname:\s+(.+)/i),
378
+ network: extract_field(raw, /(?:inetnum|netrange|cidr):\s+(.+)/i),
379
+ cidr: extract_field(raw, /cidr:\s+(.+)/i),
380
+ country: extract_field(raw, /country:\s+(.+)/i),
381
+ asn: extract_field(raw, /originas:\s+(.+)/i) ||
382
+ extract_field(raw, /aut-num:\s+(.+)/i),
383
+ abuse_email: extract_field(raw, /orgabuseemail:\s+(.+)/i) ||
384
+ extract_field(raw, /abuse-mailbox:\s+(.+)/i),
385
+ abuse_phone: extract_field(raw, /orgabusephone:\s+(.+)/i),
386
+ updated: extract_field(raw, /updated:\s+(.+)/i) ||
387
+ extract_field(raw, /last[\s-]modified:\s+(.+)/i)
388
+ }.compact
389
+ end
390
+
391
+ def extract_field(text, pattern)
392
+ text.each_line do |line|
393
+ m = line.match(pattern)
394
+ return m[1].strip if m
395
+ end
396
+ nil
397
+ end
398
+
399
+ def extract_all_fields(text, pattern)
400
+ results = []
401
+ text.each_line do |line|
402
+ m = line.match(pattern)
403
+ results << m[1].strip if m
404
+ end
405
+ results.empty? ? nil : results
406
+ end
407
+
408
+ def ip_address?(str)
409
+ str.match?(/\A\d{1,3}(\.\d{1,3}){3}\z/) ||
410
+ str.match?(/\A[0-9a-fA-F:]+\z/) && str.include?(':')
411
+ end
412
+
413
+ def http_get(url)
414
+ uri = URI(url)
415
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
416
+ open_timeout: 5, read_timeout: 5) do |http|
417
+ http.get(uri.path.empty? ? '/' : uri.path).body
418
+ end
419
+ end
420
+ end
421
+ end
422
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "docx"
5
+ rescue LoadError
6
+ # docx is optional - will raise error when tool is used without it
7
+ end
8
+
9
+ module SharedTools
10
+ module Tools
11
+ module Doc
12
+ # Read text content from Microsoft Word (.docx) documents.
13
+ #
14
+ # @example
15
+ # tool = SharedTools::Tools::Doc::DocxReaderTool.new
16
+ # tool.execute(doc_path: "./report.docx")
17
+ # tool.execute(doc_path: "./report.docx", paragraph_range: "1-10")
18
+ class DocxReaderTool < ::RubyLLM::Tool
19
+ def self.name = 'doc_docx_read'
20
+
21
+ description "Read the text content of a Microsoft Word (.docx) document."
22
+
23
+ params do
24
+ string :doc_path, description: "Path to the .docx file."
25
+
26
+ string :paragraph_range, description: <<~DESC.strip, required: false
27
+ Optional range of paragraphs to extract, 1-based.
28
+ Accepts the same notation as pdf_read page numbers:
29
+ - Single paragraph: "5"
30
+ - Multiple paragraphs: "1, 3, 5"
31
+ - Range: "1-20"
32
+ - Mixed: "1, 5-10, 15"
33
+ Omit to return the full document.
34
+ DESC
35
+ end
36
+
37
+ # @param logger [Logger] optional logger
38
+ def initialize(logger: nil)
39
+ @logger = logger || RubyLLM.logger
40
+ end
41
+
42
+ # @param doc_path [String] path to .docx file
43
+ # @param paragraph_range [String, nil] optional paragraph range
44
+ # @return [Hash] extraction result
45
+ def execute(doc_path:, paragraph_range: nil)
46
+ raise LoadError, "DocxReaderTool requires the 'docx' gem. Install it with: gem install docx" unless defined?(Docx)
47
+
48
+ @logger.info("DocxReaderTool#execute doc_path=#{doc_path} paragraph_range=#{paragraph_range}")
49
+
50
+ unless File.exist?(doc_path)
51
+ return { error: "File not found: #{doc_path}" }
52
+ end
53
+
54
+ unless File.extname(doc_path).downcase == '.docx'
55
+ return { error: "Expected a .docx file, got: #{File.extname(doc_path)}" }
56
+ end
57
+
58
+ doc = Docx::Document.open(doc_path)
59
+ paragraphs = doc.paragraphs.map(&:to_s).reject { |p| p.strip.empty? }
60
+ total = paragraphs.length
61
+
62
+ @logger.debug("Loaded #{total} non-empty paragraphs from #{doc_path}")
63
+
64
+ selected_indices = if paragraph_range
65
+ parse_range(paragraph_range, total)
66
+ else
67
+ (1..total).to_a
68
+ end
69
+
70
+ invalid = selected_indices.select { |n| n < 1 || n > total }
71
+ valid = selected_indices.select { |n| n >= 1 && n <= total }
72
+
73
+ extracted = valid.map { |n| { paragraph: n, text: paragraphs[n - 1] } }
74
+
75
+ @logger.info("Extracted #{extracted.size} paragraphs from #{doc_path}")
76
+
77
+ {
78
+ doc_path: doc_path,
79
+ total_paragraphs: total,
80
+ requested_range: paragraph_range || "all",
81
+ invalid_paragraphs: invalid,
82
+ paragraphs: extracted,
83
+ full_text: extracted.map { |p| p[:text] }.join("\n\n")
84
+ }
85
+ rescue => e
86
+ @logger.error("Failed to read DOCX '#{doc_path}': #{e.message}")
87
+ { error: e.message }
88
+ end
89
+
90
+ private
91
+
92
+ # Parse a range string like "1, 3-5, 10" into a sorted array of integers.
93
+ def parse_range(range_str, max)
94
+ range_str.split(',').flat_map do |part|
95
+ part.strip!
96
+ if part.include?('-')
97
+ lo, hi = part.split('-').map { |n| n.strip.to_i }
98
+ (lo..hi).to_a
99
+ else
100
+ [part.to_i]
101
+ end
102
+ end.uniq.sort
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end