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,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
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "roo"
5
+ rescue LoadError
6
+ # roo is optional - will raise an error when the tool is used without it
7
+ end
8
+
9
+ module SharedTools
10
+ module Tools
11
+ module Doc
12
+ # Read spreadsheet data from CSV, XLSX, ODS, and other formats supported by the roo gem.
13
+ #
14
+ # @example Read all rows from a CSV
15
+ # tool = SharedTools::Tools::Doc::SpreadsheetReaderTool.new
16
+ # tool.execute(doc_path: "./data.csv")
17
+ #
18
+ # @example Read a specific sheet from an Excel workbook
19
+ # tool.execute(doc_path: "./report.xlsx", sheet: "Q1 Sales")
20
+ #
21
+ # @example Read a row range from a worksheet
22
+ # tool.execute(doc_path: "./report.xlsx", sheet: "Summary", row_range: "2-50")
23
+ class SpreadsheetReaderTool < ::RubyLLM::Tool
24
+ def self.name = 'doc_spreadsheet_read'
25
+
26
+ description "Read tabular data from spreadsheet files (CSV, XLSX, ODS, and other formats)."
27
+
28
+ SUPPORTED_FORMATS = %w[.csv .xlsx .ods .xlsm].freeze
29
+
30
+ params do
31
+ string :doc_path, description: <<~DESC.strip
32
+ Path to the spreadsheet file. Supported formats:
33
+ - .csv — Comma-separated values (plain text, no gem beyond roo required)
34
+ - .xlsx — Microsoft Excel Open XML Workbook (Excel 2007+)
35
+ - .xlsm — Microsoft Excel Macro-Enabled Workbook
36
+ - .ods — OpenDocument Spreadsheet (LibreOffice / OpenOffice)
37
+ Note: Legacy .xls (Excel 97-2003) requires the additional 'roo-xls' gem.
38
+ DESC
39
+
40
+ string :sheet, description: <<~DESC.strip, required: false
41
+ Name or 1-based index of the worksheet to read. For multi-sheet workbooks
42
+ (XLSX, ODS), specify the exact sheet name (e.g. "Q1 Sales") or a number
43
+ (e.g. "2" for the second sheet). Defaults to the first sheet.
44
+ CSV files always have a single implicit sheet called "default".
45
+ DESC
46
+
47
+ string :row_range, description: <<~DESC.strip, required: false
48
+ Row range to extract, 1-based (row 1 is the header row in most spreadsheets).
49
+ Accepts the same notation as other doc tool parameters:
50
+ - Single row: "3"
51
+ - Multiple rows: "1, 3, 5"
52
+ - Range: "1-100"
53
+ - Mixed: "1, 5-20, 30"
54
+ Omit to return all rows.
55
+ DESC
56
+
57
+ boolean :headers, description: <<~DESC.strip, required: false
58
+ When true (default), treats the first row as column headers and returns
59
+ each subsequent row as a hash keyed by header name. When false, returns
60
+ each row as a plain array of values. Set to false when the spreadsheet
61
+ has no header row or when you want raw positional data.
62
+ DESC
63
+ end
64
+
65
+ # @param logger [Logger] optional logger
66
+ def initialize(logger: nil)
67
+ @logger = logger || RubyLLM.logger
68
+ end
69
+
70
+ # @param doc_path [String] path to spreadsheet file
71
+ # @param sheet [String, nil] sheet name or 1-based index
72
+ # @param row_range [String, nil] row range to extract
73
+ # @param headers [Boolean] whether first row is headers
74
+ # @return [Hash] extraction result
75
+ def execute(doc_path:, sheet: nil, row_range: nil, headers: true)
76
+ raise LoadError, "SpreadsheetReaderTool requires the 'roo' gem. Install it with: gem install roo" unless defined?(Roo)
77
+
78
+ @logger.info("SpreadsheetReaderTool#execute doc_path=#{doc_path} sheet=#{sheet} row_range=#{row_range}")
79
+
80
+ return { error: "File not found: #{doc_path}" } unless File.exist?(doc_path)
81
+
82
+ ext = File.extname(doc_path).downcase
83
+ unless SUPPORTED_FORMATS.include?(ext)
84
+ return { error: "Unsupported format '#{ext}'. Supported: #{SUPPORTED_FORMATS.join(', ')}" }
85
+ end
86
+
87
+ ss = Roo::Spreadsheet.open(doc_path)
88
+
89
+ # Select sheet
90
+ active_sheet = resolve_sheet(ss, sheet)
91
+ return active_sheet if active_sheet.is_a?(Hash) && active_sheet[:error]
92
+
93
+ ss.default_sheet = active_sheet
94
+
95
+ total_rows = ss.last_row.to_i
96
+ first_row = ss.first_row.to_i
97
+ header_row = headers ? ss.row(first_row).map { |h| h.to_s.strip } : nil
98
+
99
+ # Determine data rows (skip header if using headers)
100
+ data_start = headers ? first_row + 1 : first_row
101
+ all_indices = (data_start..total_rows).to_a
102
+
103
+ selected = row_range ? filter_rows(all_indices, row_range) : all_indices
104
+ invalid = selected.reject { |n| n >= first_row && n <= total_rows }
105
+ valid = selected.select { |n| n >= first_row && n <= total_rows }
106
+
107
+ rows = valid.map do |n|
108
+ raw = ss.row(n)
109
+ if headers && header_row
110
+ header_row.zip(raw).to_h
111
+ else
112
+ raw
113
+ end
114
+ end
115
+
116
+ @logger.info("SpreadsheetReaderTool: read #{rows.size} rows from '#{active_sheet}'")
117
+
118
+ {
119
+ doc_path: doc_path,
120
+ format: ext,
121
+ available_sheets: ss.sheets,
122
+ active_sheet: active_sheet,
123
+ total_rows: total_rows,
124
+ header_row: header_row,
125
+ requested_range: row_range || "all",
126
+ invalid_rows: invalid,
127
+ row_count: rows.size,
128
+ rows: rows
129
+ }
130
+ rescue => e
131
+ @logger.error("SpreadsheetReaderTool failed for '#{doc_path}': #{e.message}")
132
+ { error: e.message }
133
+ end
134
+
135
+ private
136
+
137
+ # Resolve sheet name or 1-based index to the actual sheet name.
138
+ def resolve_sheet(ss, sheet_param)
139
+ return ss.sheets.first if sheet_param.nil?
140
+
141
+ # Numeric string → treat as 1-based index
142
+ if sheet_param.match?(/\A\d+\z/)
143
+ idx = sheet_param.to_i - 1
144
+ return { error: "Sheet index #{sheet_param} out of range (workbook has #{ss.sheets.size} sheet(s))" } if idx < 0 || idx >= ss.sheets.size
145
+ return ss.sheets[idx]
146
+ end
147
+
148
+ # Named sheet
149
+ return sheet_param if ss.sheets.include?(sheet_param)
150
+
151
+ { error: "Sheet '#{sheet_param}' not found. Available sheets: #{ss.sheets.join(', ')}" }
152
+ end
153
+
154
+ # Parse a range string like "1, 3-5, 10" into a sorted array of row indices.
155
+ def filter_rows(all_indices, range_str)
156
+ requested = range_str.split(',').flat_map do |part|
157
+ part.strip!
158
+ if part.include?('-')
159
+ lo, hi = part.split('-').map { |n| n.strip.to_i }
160
+ (lo..hi).to_a
161
+ else
162
+ [part.to_i]
163
+ end
164
+ end.uniq.sort
165
+
166
+ all_indices & requested
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharedTools
4
+ module Tools
5
+ module Doc
6
+ # Read and return the full contents of a plain text file.
7
+ #
8
+ # @example
9
+ # tool = SharedTools::Tools::Doc::TextReaderTool.new
10
+ # tool.execute(doc_path: "./guide.txt")
11
+ class TextReaderTool < ::RubyLLM::Tool
12
+ def self.name = 'doc_text_read'
13
+
14
+ description "Read the full contents of a plain text file."
15
+
16
+ params do
17
+ string :doc_path, description: "Path to the text file to read."
18
+ end
19
+
20
+ # @param logger [Logger] optional logger
21
+ def initialize(logger: nil)
22
+ @logger = logger || RubyLLM.logger
23
+ end
24
+
25
+ # @param doc_path [String] path to the text file
26
+ # @return [Hash] file content and metadata
27
+ def execute(doc_path:)
28
+ @logger.info("TextReaderTool#execute doc_path=#{doc_path.inspect}")
29
+
30
+ raise ArgumentError, "doc_path is required" if doc_path.nil? || doc_path.strip.empty?
31
+ raise ArgumentError, "File not found: #{doc_path}" unless File.exist?(doc_path)
32
+ raise ArgumentError, "Not a file: #{doc_path}" unless File.file?(doc_path)
33
+
34
+ content = File.read(doc_path, encoding: 'utf-8')
35
+ line_count = content.lines.size
36
+ char_count = content.length
37
+ word_count = content.split.size
38
+
39
+ @logger.info("TextReaderTool read #{char_count} chars, #{line_count} lines from #{doc_path}")
40
+
41
+ {
42
+ doc_path: doc_path,
43
+ content: content,
44
+ line_count: line_count,
45
+ word_count: word_count,
46
+ char_count: char_count
47
+ }
48
+ rescue ArgumentError
49
+ raise
50
+ rescue => e
51
+ @logger.error("TextReaderTool failed to read #{doc_path}: #{e.message}")
52
+ { error: e.message, doc_path: doc_path }
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -6,3 +6,6 @@
6
6
  require 'shared_tools'
7
7
 
8
8
  require_relative 'doc/pdf_reader_tool'
9
+ require_relative 'doc/text_reader_tool'
10
+ require_relative 'doc/docx_reader_tool'
11
+ require_relative 'doc/spreadsheet_reader_tool'
@@ -9,19 +9,28 @@ module SharedTools
9
9
  def self.name = 'doc_tool'
10
10
 
11
11
  module Action
12
- PDF_READ = "pdf_read"
12
+ PDF_READ = "pdf_read"
13
+ TEXT_READ = "text_read"
14
+ DOCX_READ = "docx_read"
15
+ SPREADSHEET_READ = "spreadsheet_read"
13
16
  end
14
17
 
15
18
  ACTIONS = [
16
19
  Action::PDF_READ,
20
+ Action::TEXT_READ,
21
+ Action::DOCX_READ,
22
+ Action::SPREADSHEET_READ,
17
23
  ].freeze
18
24
 
19
25
  description <<~TEXT
20
- Read and process various document formats.
26
+ Read and process document files.
21
27
 
22
28
  ## Actions:
23
29
 
24
- 1. `#{Action::PDF_READ}` - Read specific pages from a PDF document
30
+ 1. `#{Action::TEXT_READ}` - Read the full contents of a plain text file (.txt, .md, etc.)
31
+ Required: "action": "text_read", "doc_path": "[path to text file]"
32
+
33
+ 2. `#{Action::PDF_READ}` - Read specific pages from a PDF document
25
34
  Required: "action": "pdf_read", "doc_path": "[path to PDF]", "page_numbers": "[comma-separated page numbers]"
26
35
 
27
36
  The page_numbers parameter accepts:
@@ -29,8 +38,18 @@ module SharedTools
29
38
  - Multiple pages: "1, 3, 5"
30
39
  - Range notation: "1-10" or "1, 3-5, 10"
31
40
 
41
+ 3. `#{Action::DOCX_READ}` - Read text content from a Microsoft Word (.docx) document
42
+ Required: "action": "docx_read", "doc_path": "[path to .docx file]"
43
+ Optional: "paragraph_range": "[comma-separated paragraph numbers or ranges]"
44
+
45
+ The paragraph_range parameter accepts the same notation as page_numbers.
46
+ Omit paragraph_range to return the full document.
47
+
32
48
  ## Examples:
33
49
 
50
+ Read a text file
51
+ {"action": "#{Action::TEXT_READ}", "doc_path": "./notes.txt"}
52
+
34
53
  Read single page from PDF
35
54
  {"action": "#{Action::PDF_READ}", "doc_path": "./document.pdf", "page_numbers": "1"}
36
55
 
@@ -42,17 +61,41 @@ module SharedTools
42
61
 
43
62
  Read specific pages with range
44
63
  {"action": "#{Action::PDF_READ}", "doc_path": "./manual.pdf", "page_numbers": "1, 5-8, 15, 20-25"}
64
+
65
+ Read a full Word document
66
+ {"action": "#{Action::DOCX_READ}", "doc_path": "./report.docx"}
67
+
68
+ Read first 20 paragraphs of a Word document
69
+ {"action": "#{Action::DOCX_READ}", "doc_path": "./report.docx", "paragraph_range": "1-20"}
70
+
71
+ 4. `#{Action::SPREADSHEET_READ}` - Read tabular data from a spreadsheet file
72
+ Supported formats: CSV, XLSX, ODS, XLSM
73
+ Required: "action": "spreadsheet_read", "doc_path": "[path to spreadsheet]"
74
+ Optional: "sheet": "[sheet name or 1-based index]"
75
+ "row_range": "[row range, e.g. '2-100']"
76
+ "headers": true/false (default true — first row treated as headers)
77
+
78
+ Read a full CSV file
79
+ {"action": "#{Action::SPREADSHEET_READ}", "doc_path": "./data.csv"}
80
+
81
+ Read a specific sheet from an Excel workbook
82
+ {"action": "#{Action::SPREADSHEET_READ}", "doc_path": "./report.xlsx", "sheet": "Q1 Sales"}
83
+
84
+ Read rows 2-50 from a worksheet without header treatment
85
+ {"action": "#{Action::SPREADSHEET_READ}", "doc_path": "./report.xlsx", "row_range": "2-50", "headers": false}
45
86
  TEXT
46
87
 
47
88
  params do
48
89
  string :action, description: <<~TEXT.strip
49
90
  The document action to perform. Options:
91
+ * `#{Action::TEXT_READ}`: Read a plain text file
50
92
  * `#{Action::PDF_READ}`: Read pages from a PDF document
93
+ * `#{Action::DOCX_READ}`: Read paragraphs from a Microsoft Word (.docx) document
94
+ * `#{Action::SPREADSHEET_READ}`: Read tabular data from a spreadsheet (CSV, XLSX, ODS)
51
95
  TEXT
52
96
 
53
97
  string :doc_path, description: <<~TEXT.strip, required: false
54
- Path to the document file. Required for the following actions:
55
- * `#{Action::PDF_READ}`
98
+ Path to the document file. Required for all actions.
56
99
  TEXT
57
100
 
58
101
  string :page_numbers, description: <<~TEXT.strip, required: false
@@ -61,6 +104,34 @@ module SharedTools
61
104
  Required for the following actions:
62
105
  * `#{Action::PDF_READ}`
63
106
  TEXT
107
+
108
+ string :paragraph_range, description: <<~TEXT.strip, required: false
109
+ Comma-separated paragraph numbers or ranges to read from a Word document (first paragraph is 1).
110
+ Examples: "1", "1, 3, 5", "1-20", "1, 5-8, 15"
111
+ Optional for the following actions (omit to return the full document):
112
+ * `#{Action::DOCX_READ}`
113
+ TEXT
114
+
115
+ string :sheet, description: <<~TEXT.strip, required: false
116
+ Sheet name or 1-based sheet index to read from a multi-sheet spreadsheet.
117
+ Examples: "Q1 Sales", "2"
118
+ Optional for the following actions (defaults to the first sheet):
119
+ * `#{Action::SPREADSHEET_READ}`
120
+ TEXT
121
+
122
+ string :row_range, description: <<~TEXT.strip, required: false
123
+ Comma-separated row numbers or ranges to read from a spreadsheet (first row is 1).
124
+ Examples: "1", "2-100", "1, 5-20, 30"
125
+ Optional for the following actions (omit to return all rows):
126
+ * `#{Action::SPREADSHEET_READ}`
127
+ TEXT
128
+
129
+ boolean :headers, description: <<~TEXT.strip, required: false
130
+ When true (default), treats the first row as column headers and returns each
131
+ subsequent row as a hash. When false, returns rows as plain arrays.
132
+ Optional for the following actions:
133
+ * `#{Action::SPREADSHEET_READ}`
134
+ TEXT
64
135
  end
65
136
 
66
137
  # @param logger [Logger] optional logger
@@ -73,14 +144,23 @@ module SharedTools
73
144
  # @param page_numbers [String, nil] page numbers to read
74
145
  #
75
146
  # @return [Hash] execution result
76
- def execute(action:, doc_path: nil, page_numbers: nil)
147
+ def execute(action:, doc_path: nil, page_numbers: nil, paragraph_range: nil, sheet: nil, row_range: nil, headers: true)
77
148
  @logger.info("DocTool#execute action=#{action}")
78
149
 
79
150
  case action.to_s.downcase
151
+ when Action::TEXT_READ
152
+ require_param!(:doc_path, doc_path)
153
+ text_reader_tool.execute(doc_path: doc_path)
80
154
  when Action::PDF_READ
81
155
  require_param!(:doc_path, doc_path)
82
156
  require_param!(:page_numbers, page_numbers)
83
157
  pdf_reader_tool.execute(doc_path: doc_path, page_numbers: page_numbers)
158
+ when Action::DOCX_READ
159
+ require_param!(:doc_path, doc_path)
160
+ docx_reader_tool.execute(doc_path: doc_path, paragraph_range: paragraph_range)
161
+ when Action::SPREADSHEET_READ
162
+ require_param!(:doc_path, doc_path)
163
+ spreadsheet_reader_tool.execute(doc_path: doc_path, sheet: sheet, row_range: row_range, headers: headers)
84
164
  else
85
165
  { error: "Unsupported action: #{action}. Supported actions are: #{ACTIONS.join(', ')}" }
86
166
  end
@@ -104,6 +184,21 @@ module SharedTools
104
184
  def pdf_reader_tool
105
185
  @pdf_reader_tool ||= Doc::PdfReaderTool.new(logger: @logger)
106
186
  end
187
+
188
+ # @return [Doc::TextReaderTool]
189
+ def text_reader_tool
190
+ @text_reader_tool ||= Doc::TextReaderTool.new(logger: @logger)
191
+ end
192
+
193
+ # @return [Doc::DocxReaderTool]
194
+ def docx_reader_tool
195
+ @docx_reader_tool ||= Doc::DocxReaderTool.new(logger: @logger)
196
+ end
197
+
198
+ # @return [Doc::SpreadsheetReaderTool]
199
+ def spreadsheet_reader_tool
200
+ @spreadsheet_reader_tool ||= Doc::SpreadsheetReaderTool.new(logger: @logger)
201
+ end
107
202
  end
108
203
  end
109
204
  end
@@ -16,7 +16,7 @@ module SharedTools
16
16
  params do
17
17
  string :service, description: "The service to run the command on (e.g. `app`).", required: false
18
18
  string :command, description: "The command to run (e.g. `rspec`)."
19
- array :args, description: "The arguments for the command.", required: false
19
+ array :args, of: :string, description: "The arguments for the command.", required: false
20
20
  end
21
21
 
22
22
  # @example
@@ -0,0 +1,42 @@
1
+ # lib/shared_tools/tools/enabler.rb
2
+ # frozen_string_literal: true
3
+ # Part of an idea to reduce the number of tokens used when there
4
+ # are a large number tools available. As part of the initial prompt
5
+ # send tool names, descriptions and parameters alone with instructions
6
+ # to call the enable tool with the tool name to use and a JSON
7
+ # formatted parameters.
8
+ #
9
+ # This could be something lie a tool dispatcher that can call the
10
+ # tool with the parameters and return the result.
11
+ #
12
+ # Maybe we have the DispatcherTool and a ToolBox tool where the ToolBox Tool
13
+ # returns to the LLM the list of tool names, parameters and descriptions.
14
+ # The DispatcherTool can then call the tool with the parameters and return the result.
15
+
16
+
17
+ require 'ruby_llm/tool'
18
+
19
+ module Tools
20
+ # A tool for reading the contents of a file.
21
+ class Enabler < RubyLLM::Tool
22
+ attr_reader :agent
23
+
24
+ description <<~DESCRIPTION
25
+ Enables a tool based on the request.
26
+ DESCRIPTION
27
+ param :tool, desc: 'Tool name to enable'
28
+ # param :params, desc: 'JSON formatted parameters'
29
+
30
+ def initialize(agent)
31
+ @agent = agent
32
+ end
33
+
34
+ def execute(tool:, params: '')
35
+ agent.add_tool(tool)
36
+ # TODO: call the tool with the params and return the result.
37
+ { success: true }
38
+ rescue StandardError => e
39
+ { error: e.message }
40
+ end
41
+ end
42
+ end
@@ -104,9 +104,11 @@ module SharedTools
104
104
  # @param max_retries [Integer] Maximum retry attempts
105
105
  #
106
106
  # @return [Hash] Operation result with success status
107
- def execute(operation:, simulate_error: nil, max_retries: 3, **data)
107
+ def execute(operation:, simulate_error: nil, max_retries: 3, data: {}, **_rest)
108
108
  @operation_start_time = Time.now
109
109
  @logger.info("ErrorHandlingTool#execute operation=#{operation} simulate_error=#{simulate_error}")
110
+ # Normalise data keys to symbols (JSON tool calls use string keys)
111
+ data = (data || {}).transform_keys(&:to_sym)
110
112
 
111
113
  begin
112
114
  # Validate inputs
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharedTools
4
+ module Tools
5
+ module Notification
6
+ # Abstract base class for platform-specific notification drivers.
7
+ # Subclasses must implement notify, alert, and speak.
8
+ class BaseDriver
9
+ # Show a non-blocking desktop banner notification.
10
+ #
11
+ # @param message [String]
12
+ # @param title [String, nil]
13
+ # @param subtitle [String, nil]
14
+ # @param sound [String, nil]
15
+ # @return [Hash]
16
+ def notify(message:, title: nil, subtitle: nil, sound: nil)
17
+ raise NotImplementedError, "#{self.class}##{__method__} undefined"
18
+ end
19
+
20
+ # Show a modal dialog and wait for the user to click a button.
21
+ #
22
+ # @param message [String]
23
+ # @param title [String, nil]
24
+ # @param buttons [Array<String>]
25
+ # @param default_button [String, nil]
26
+ # @return [Hash] includes :button with the label of the clicked button
27
+ def alert(message:, title: nil, buttons: ['OK'], default_button: nil)
28
+ raise NotImplementedError, "#{self.class}##{__method__} undefined"
29
+ end
30
+
31
+ # Speak text aloud using text-to-speech.
32
+ #
33
+ # @param text [String]
34
+ # @param voice [String, nil]
35
+ # @param rate [Integer, nil] words per minute
36
+ # @return [Hash]
37
+ def speak(text:, voice: nil, rate: nil)
38
+ raise NotImplementedError, "#{self.class}##{__method__} undefined"
39
+ end
40
+
41
+ protected
42
+
43
+ # @param cmd [String]
44
+ # @return [Boolean]
45
+ def command_available?(cmd)
46
+ system("which #{cmd} > /dev/null 2>&1")
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end