rcrewai 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +108 -0
- data/LICENSE +21 -0
- data/README.md +328 -0
- data/Rakefile +130 -0
- data/bin/rcrewai +7 -0
- data/docs/_config.yml +59 -0
- data/docs/_layouts/api.html +16 -0
- data/docs/_layouts/default.html +78 -0
- data/docs/_layouts/example.html +24 -0
- data/docs/_layouts/tutorial.html +33 -0
- data/docs/api/configuration.md +327 -0
- data/docs/api/crew.md +345 -0
- data/docs/api/index.md +41 -0
- data/docs/api/tools.md +412 -0
- data/docs/assets/css/style.css +416 -0
- data/docs/examples/human-in-the-loop.md +382 -0
- data/docs/examples/index.md +78 -0
- data/docs/examples/production-ready-crew.md +485 -0
- data/docs/examples/simple-research-crew.md +297 -0
- data/docs/index.md +353 -0
- data/docs/tutorials/getting-started.md +341 -0
- data/examples/async_execution_example.rb +294 -0
- data/examples/hierarchical_crew_example.rb +193 -0
- data/examples/human_in_the_loop_example.rb +233 -0
- data/lib/rcrewai/agent.rb +636 -0
- data/lib/rcrewai/async_executor.rb +248 -0
- data/lib/rcrewai/cli.rb +39 -0
- data/lib/rcrewai/configuration.rb +100 -0
- data/lib/rcrewai/crew.rb +292 -0
- data/lib/rcrewai/human_input.rb +520 -0
- data/lib/rcrewai/llm_client.rb +41 -0
- data/lib/rcrewai/llm_clients/anthropic.rb +127 -0
- data/lib/rcrewai/llm_clients/azure.rb +158 -0
- data/lib/rcrewai/llm_clients/base.rb +82 -0
- data/lib/rcrewai/llm_clients/google.rb +158 -0
- data/lib/rcrewai/llm_clients/ollama.rb +199 -0
- data/lib/rcrewai/llm_clients/openai.rb +124 -0
- data/lib/rcrewai/memory.rb +194 -0
- data/lib/rcrewai/process.rb +421 -0
- data/lib/rcrewai/task.rb +376 -0
- data/lib/rcrewai/tools/base.rb +82 -0
- data/lib/rcrewai/tools/code_executor.rb +333 -0
- data/lib/rcrewai/tools/email_sender.rb +210 -0
- data/lib/rcrewai/tools/file_reader.rb +111 -0
- data/lib/rcrewai/tools/file_writer.rb +115 -0
- data/lib/rcrewai/tools/pdf_processor.rb +342 -0
- data/lib/rcrewai/tools/sql_database.rb +226 -0
- data/lib/rcrewai/tools/web_search.rb +131 -0
- data/lib/rcrewai/version.rb +5 -0
- data/lib/rcrewai.rb +36 -0
- data/rcrewai.gemspec +54 -0
- metadata +365 -0
@@ -0,0 +1,115 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
require 'pathname'
|
5
|
+
require 'fileutils'
|
6
|
+
|
7
|
+
module RCrewAI
|
8
|
+
module Tools
|
9
|
+
class FileWriter < Base
|
10
|
+
def initialize(**options)
|
11
|
+
super()
|
12
|
+
@name = 'filewriter'
|
13
|
+
@description = 'Write content to files'
|
14
|
+
@max_file_size = options.fetch(:max_file_size, 10_000_000) # 10MB
|
15
|
+
@allowed_extensions = options.fetch(:allowed_extensions, %w[.txt .md .json .yaml .yml .csv .log])
|
16
|
+
@create_directories = options.fetch(:create_directories, true)
|
17
|
+
end
|
18
|
+
|
19
|
+
def execute(**params)
|
20
|
+
validate_params!(params, required: [:file_path, :content], optional: [:mode, :encoding])
|
21
|
+
|
22
|
+
file_path = params[:file_path]
|
23
|
+
content = params[:content]
|
24
|
+
mode = params[:mode] || 'w' # 'w' for write, 'a' for append
|
25
|
+
encoding = params[:encoding] || 'utf-8'
|
26
|
+
|
27
|
+
begin
|
28
|
+
write_file(file_path, content, mode, encoding)
|
29
|
+
rescue => e
|
30
|
+
"File write failed: #{e.message}"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def write_file(file_path, content, mode, encoding)
|
37
|
+
path = Pathname.new(file_path)
|
38
|
+
|
39
|
+
# Security checks
|
40
|
+
validate_file_path!(path)
|
41
|
+
validate_content!(content)
|
42
|
+
validate_mode!(mode)
|
43
|
+
|
44
|
+
# Create directory if needed
|
45
|
+
create_parent_directories!(path) if @create_directories
|
46
|
+
|
47
|
+
# Write the file
|
48
|
+
File.open(path, mode, encoding: encoding) do |file|
|
49
|
+
file.write(content)
|
50
|
+
end
|
51
|
+
|
52
|
+
format_write_result(path, content, mode)
|
53
|
+
end
|
54
|
+
|
55
|
+
def validate_file_path!(path)
|
56
|
+
# Prevent directory traversal
|
57
|
+
resolved_path = path.expand_path.to_s
|
58
|
+
working_dir = Dir.pwd
|
59
|
+
unless resolved_path.start_with?(working_dir)
|
60
|
+
raise ToolError, "Access denied: file outside working directory"
|
61
|
+
end
|
62
|
+
|
63
|
+
# Check file extension
|
64
|
+
extension = path.extname.downcase
|
65
|
+
unless @allowed_extensions.include?(extension) || @allowed_extensions.include?('*')
|
66
|
+
raise ToolError, "File type not allowed: #{extension}"
|
67
|
+
end
|
68
|
+
|
69
|
+
# If file exists, check if it's writable
|
70
|
+
if path.exist?
|
71
|
+
raise ToolError, "Path is not a file: #{path}" unless path.file?
|
72
|
+
raise ToolError, "File is not writable: #{path}" unless path.writable?
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def validate_content!(content)
|
77
|
+
raise ToolError, "Content cannot be nil" if content.nil?
|
78
|
+
|
79
|
+
content_size = content.bytesize
|
80
|
+
if content_size > @max_file_size
|
81
|
+
raise ToolError, "Content too large: #{content_size} bytes (max: #{@max_file_size})"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def validate_mode!(mode)
|
86
|
+
valid_modes = %w[w a w+ a+ wb ab]
|
87
|
+
unless valid_modes.include?(mode)
|
88
|
+
raise ToolError, "Invalid file mode: #{mode}. Valid modes: #{valid_modes.join(', ')}"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def create_parent_directories!(path)
|
93
|
+
parent_dir = path.parent
|
94
|
+
unless parent_dir.exist?
|
95
|
+
FileUtils.mkdir_p(parent_dir)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def format_write_result(path, content, mode)
|
100
|
+
action = mode.start_with?('a') ? 'appended to' : 'written to'
|
101
|
+
size = content.bytesize
|
102
|
+
|
103
|
+
result = "Content successfully #{action} #{path.basename}\n"
|
104
|
+
result += "File size: #{size} bytes\n"
|
105
|
+
result += "Full path: #{path.expand_path}\n"
|
106
|
+
|
107
|
+
if path.exist?
|
108
|
+
result += "File modified: #{path.mtime.strftime('%Y-%m-%d %H:%M:%S')}"
|
109
|
+
end
|
110
|
+
|
111
|
+
result
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,342 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
require 'pdf-reader'
|
5
|
+
require 'pathname'
|
6
|
+
|
7
|
+
module RCrewAI
|
8
|
+
module Tools
|
9
|
+
class PdfProcessor < Base
|
10
|
+
def initialize(**options)
|
11
|
+
super()
|
12
|
+
@name = 'pdfprocessor'
|
13
|
+
@description = 'Read and extract text from PDF files'
|
14
|
+
@max_file_size = options.fetch(:max_file_size, 50_000_000) # 50MB
|
15
|
+
@max_pages = options.fetch(:max_pages, 100)
|
16
|
+
@extract_metadata = options.fetch(:extract_metadata, true)
|
17
|
+
@working_directory = options[:working_directory] || Dir.pwd
|
18
|
+
end
|
19
|
+
|
20
|
+
def execute(**params)
|
21
|
+
validate_params!(
|
22
|
+
params,
|
23
|
+
required: [:file_path],
|
24
|
+
optional: [:pages, :extract_text, :extract_metadata, :output_format]
|
25
|
+
)
|
26
|
+
|
27
|
+
file_path = params[:file_path]
|
28
|
+
pages = params[:pages] # Can be array [1,2,3] or range "1-5" or "all"
|
29
|
+
extract_text = params.fetch(:extract_text, true)
|
30
|
+
extract_metadata = params.fetch(:extract_metadata, @extract_metadata)
|
31
|
+
output_format = params.fetch(:output_format, 'text') # 'text', 'json', 'markdown'
|
32
|
+
|
33
|
+
begin
|
34
|
+
validate_pdf_file!(file_path)
|
35
|
+
result = process_pdf(file_path, pages, extract_text, extract_metadata)
|
36
|
+
format_pdf_result(result, output_format)
|
37
|
+
rescue => e
|
38
|
+
"PDF processing failed: #{e.message}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def validate_pdf_file!(file_path)
|
45
|
+
path = Pathname.new(file_path)
|
46
|
+
|
47
|
+
# Security and existence checks
|
48
|
+
unless path.exist?
|
49
|
+
raise ToolError, "PDF file does not exist: #{file_path}"
|
50
|
+
end
|
51
|
+
|
52
|
+
unless path.file?
|
53
|
+
raise ToolError, "Path is not a file: #{file_path}"
|
54
|
+
end
|
55
|
+
|
56
|
+
unless path.readable?
|
57
|
+
raise ToolError, "PDF file is not readable: #{file_path}"
|
58
|
+
end
|
59
|
+
|
60
|
+
# Check file extension
|
61
|
+
unless path.extname.downcase == '.pdf'
|
62
|
+
raise ToolError, "File is not a PDF: #{file_path}"
|
63
|
+
end
|
64
|
+
|
65
|
+
# Check file size
|
66
|
+
file_size = path.size
|
67
|
+
if file_size > @max_file_size
|
68
|
+
raise ToolError, "PDF file too large: #{file_size} bytes (max: #{@max_file_size})"
|
69
|
+
end
|
70
|
+
|
71
|
+
if file_size == 0
|
72
|
+
raise ToolError, "PDF file is empty: #{file_path}"
|
73
|
+
end
|
74
|
+
|
75
|
+
# Prevent directory traversal
|
76
|
+
resolved_path = path.realpath.to_s
|
77
|
+
unless resolved_path.start_with?(@working_directory)
|
78
|
+
raise ToolError, "Access denied: file outside working directory"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def process_pdf(file_path, pages_param, extract_text, extract_metadata)
|
83
|
+
reader = PDF::Reader.new(file_path)
|
84
|
+
|
85
|
+
result = {
|
86
|
+
file_path: file_path,
|
87
|
+
total_pages: reader.page_count,
|
88
|
+
processed_pages: 0,
|
89
|
+
text_content: [],
|
90
|
+
metadata: {},
|
91
|
+
processing_errors: []
|
92
|
+
}
|
93
|
+
|
94
|
+
# Check page count limit
|
95
|
+
if reader.page_count > @max_pages
|
96
|
+
raise ToolError, "PDF has too many pages: #{reader.page_count} (max: #{@max_pages})"
|
97
|
+
end
|
98
|
+
|
99
|
+
# Extract metadata if requested
|
100
|
+
if extract_metadata
|
101
|
+
result[:metadata] = extract_pdf_metadata(reader)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Determine which pages to process
|
105
|
+
pages_to_process = determine_pages_to_process(pages_param, reader.page_count)
|
106
|
+
|
107
|
+
# Extract text if requested
|
108
|
+
if extract_text
|
109
|
+
result[:text_content] = extract_text_from_pages(reader, pages_to_process, result)
|
110
|
+
end
|
111
|
+
|
112
|
+
result[:processed_pages] = pages_to_process.length
|
113
|
+
result
|
114
|
+
end
|
115
|
+
|
116
|
+
def extract_pdf_metadata(reader)
|
117
|
+
metadata = {}
|
118
|
+
|
119
|
+
begin
|
120
|
+
info = reader.info
|
121
|
+
metadata[:title] = info[:Title] if info[:Title]
|
122
|
+
metadata[:author] = info[:Author] if info[:Author]
|
123
|
+
metadata[:subject] = info[:Subject] if info[:Subject]
|
124
|
+
metadata[:keywords] = info[:Keywords] if info[:Keywords]
|
125
|
+
metadata[:creator] = info[:Creator] if info[:Creator]
|
126
|
+
metadata[:producer] = info[:Producer] if info[:Producer]
|
127
|
+
metadata[:creation_date] = info[:CreationDate] if info[:CreationDate]
|
128
|
+
metadata[:modification_date] = info[:ModDate] if info[:ModDate]
|
129
|
+
rescue => e
|
130
|
+
metadata[:extraction_error] = "Failed to extract metadata: #{e.message}"
|
131
|
+
end
|
132
|
+
|
133
|
+
# Add PDF version and security info
|
134
|
+
begin
|
135
|
+
metadata[:pdf_version] = reader.pdf_version
|
136
|
+
metadata[:encrypted] = reader.encrypted?
|
137
|
+
rescue => e
|
138
|
+
# Ignore errors for version/security info
|
139
|
+
end
|
140
|
+
|
141
|
+
metadata
|
142
|
+
end
|
143
|
+
|
144
|
+
def determine_pages_to_process(pages_param, total_pages)
|
145
|
+
return (1..total_pages).to_a if pages_param.nil? || pages_param == 'all'
|
146
|
+
|
147
|
+
case pages_param
|
148
|
+
when Array
|
149
|
+
# Array of page numbers: [1, 3, 5]
|
150
|
+
pages_param.select { |p| p.is_a?(Integer) && p >= 1 && p <= total_pages }
|
151
|
+
when String
|
152
|
+
# Range string: "1-5" or "2,4,6-8"
|
153
|
+
parse_page_range_string(pages_param, total_pages)
|
154
|
+
when Integer
|
155
|
+
# Single page number
|
156
|
+
pages_param >= 1 && pages_param <= total_pages ? [pages_param] : []
|
157
|
+
when Range
|
158
|
+
# Ruby range: 1..5
|
159
|
+
pages_param.select { |p| p >= 1 && p <= total_pages }
|
160
|
+
else
|
161
|
+
raise ToolError, "Invalid pages parameter: #{pages_param}"
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
def parse_page_range_string(range_string, total_pages)
|
166
|
+
pages = []
|
167
|
+
|
168
|
+
range_string.split(',').each do |part|
|
169
|
+
part = part.strip
|
170
|
+
|
171
|
+
if part.include?('-')
|
172
|
+
# Range like "1-5"
|
173
|
+
start_page, end_page = part.split('-').map(&:strip).map(&:to_i)
|
174
|
+
if start_page > 0 && end_page > 0 && start_page <= end_page
|
175
|
+
(start_page..end_page).each do |p|
|
176
|
+
pages << p if p <= total_pages
|
177
|
+
end
|
178
|
+
end
|
179
|
+
else
|
180
|
+
# Single page number
|
181
|
+
page_num = part.to_i
|
182
|
+
pages << page_num if page_num > 0 && page_num <= total_pages
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
pages.uniq.sort
|
187
|
+
end
|
188
|
+
|
189
|
+
def extract_text_from_pages(reader, pages_to_process, result)
|
190
|
+
text_content = []
|
191
|
+
|
192
|
+
pages_to_process.each do |page_num|
|
193
|
+
begin
|
194
|
+
page = reader.page(page_num)
|
195
|
+
page_text = page.text.strip
|
196
|
+
|
197
|
+
text_content << {
|
198
|
+
page_number: page_num,
|
199
|
+
text: page_text,
|
200
|
+
character_count: page_text.length,
|
201
|
+
word_count: page_text.split(/\s+/).length
|
202
|
+
}
|
203
|
+
|
204
|
+
rescue => e
|
205
|
+
error_msg = "Failed to extract text from page #{page_num}: #{e.message}"
|
206
|
+
result[:processing_errors] << error_msg
|
207
|
+
|
208
|
+
text_content << {
|
209
|
+
page_number: page_num,
|
210
|
+
text: "",
|
211
|
+
character_count: 0,
|
212
|
+
word_count: 0,
|
213
|
+
error: error_msg
|
214
|
+
}
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
text_content
|
219
|
+
end
|
220
|
+
|
221
|
+
def format_pdf_result(result, output_format)
|
222
|
+
case output_format.downcase
|
223
|
+
when 'json'
|
224
|
+
format_as_json(result)
|
225
|
+
when 'markdown'
|
226
|
+
format_as_markdown(result)
|
227
|
+
else
|
228
|
+
format_as_text(result)
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def format_as_text(result)
|
233
|
+
output = []
|
234
|
+
output << "PDF Processing Results"
|
235
|
+
output << "=" * 30
|
236
|
+
output << "File: #{File.basename(result[:file_path])}"
|
237
|
+
output << "Total Pages: #{result[:total_pages]}"
|
238
|
+
output << "Processed Pages: #{result[:processed_pages]}"
|
239
|
+
|
240
|
+
if result[:metadata].any?
|
241
|
+
output << ""
|
242
|
+
output << "Metadata:"
|
243
|
+
result[:metadata].each do |key, value|
|
244
|
+
output << " #{key.to_s.capitalize}: #{value}"
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
if result[:processing_errors].any?
|
249
|
+
output << ""
|
250
|
+
output << "Processing Errors:"
|
251
|
+
result[:processing_errors].each do |error|
|
252
|
+
output << " - #{error}"
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
if result[:text_content].any?
|
257
|
+
output << ""
|
258
|
+
output << "Extracted Text:"
|
259
|
+
output << "-" * 20
|
260
|
+
|
261
|
+
result[:text_content].each do |page_content|
|
262
|
+
if page_content[:error]
|
263
|
+
output << ""
|
264
|
+
output << "Page #{page_content[:page_number]} (ERROR): #{page_content[:error]}"
|
265
|
+
elsif page_content[:text].empty?
|
266
|
+
output << ""
|
267
|
+
output << "Page #{page_content[:page_number]}: [No text content]"
|
268
|
+
else
|
269
|
+
output << ""
|
270
|
+
output << "Page #{page_content[:page_number]} (#{page_content[:word_count]} words):"
|
271
|
+
|
272
|
+
# Truncate very long pages for readability
|
273
|
+
text = page_content[:text]
|
274
|
+
if text.length > 2000
|
275
|
+
text = text[0..1997] + "..."
|
276
|
+
end
|
277
|
+
|
278
|
+
output << text
|
279
|
+
end
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
output.join("\n")
|
284
|
+
end
|
285
|
+
|
286
|
+
def format_as_markdown(result)
|
287
|
+
output = []
|
288
|
+
output << "# PDF Processing Results"
|
289
|
+
output << ""
|
290
|
+
output << "**File:** `#{File.basename(result[:file_path])}`"
|
291
|
+
output << "**Total Pages:** #{result[:total_pages]}"
|
292
|
+
output << "**Processed Pages:** #{result[:processed_pages]}"
|
293
|
+
|
294
|
+
if result[:metadata].any?
|
295
|
+
output << ""
|
296
|
+
output << "## Metadata"
|
297
|
+
result[:metadata].each do |key, value|
|
298
|
+
output << "- **#{key.to_s.capitalize}:** #{value}"
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
if result[:text_content].any?
|
303
|
+
output << ""
|
304
|
+
output << "## Extracted Text"
|
305
|
+
|
306
|
+
result[:text_content].each do |page_content|
|
307
|
+
output << ""
|
308
|
+
output << "### Page #{page_content[:page_number]}"
|
309
|
+
|
310
|
+
if page_content[:error]
|
311
|
+
output << ""
|
312
|
+
output << "> ⚠️ **Error:** #{page_content[:error]}"
|
313
|
+
elsif page_content[:text].empty?
|
314
|
+
output << ""
|
315
|
+
output << "> ℹ️ No text content found on this page"
|
316
|
+
else
|
317
|
+
output << ""
|
318
|
+
output << "**Word Count:** #{page_content[:word_count]}"
|
319
|
+
output << ""
|
320
|
+
output << page_content[:text]
|
321
|
+
end
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
if result[:processing_errors].any?
|
326
|
+
output << ""
|
327
|
+
output << "## Processing Errors"
|
328
|
+
result[:processing_errors].each do |error|
|
329
|
+
output << "- #{error}"
|
330
|
+
end
|
331
|
+
end
|
332
|
+
|
333
|
+
output.join("\n")
|
334
|
+
end
|
335
|
+
|
336
|
+
def format_as_json(result)
|
337
|
+
require 'json'
|
338
|
+
JSON.pretty_generate(result)
|
339
|
+
end
|
340
|
+
end
|
341
|
+
end
|
342
|
+
end
|
@@ -0,0 +1,226 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
require 'uri'
|
5
|
+
|
6
|
+
module RCrewAI
|
7
|
+
module Tools
|
8
|
+
class SqlDatabase < Base
|
9
|
+
def initialize(**options)
|
10
|
+
super()
|
11
|
+
@name = 'sqldatabase'
|
12
|
+
@description = 'Execute SQL queries against databases (PostgreSQL, MySQL, SQLite)'
|
13
|
+
@connection_string = options[:connection_string]
|
14
|
+
@database_type = detect_database_type
|
15
|
+
@max_results = options.fetch(:max_results, 100)
|
16
|
+
@timeout = options.fetch(:timeout, 30)
|
17
|
+
setup_database_adapter
|
18
|
+
end
|
19
|
+
|
20
|
+
def execute(**params)
|
21
|
+
validate_params!(params, required: [:query], optional: [:limit])
|
22
|
+
|
23
|
+
query = params[:query].strip
|
24
|
+
limit = params[:limit] || @max_results
|
25
|
+
|
26
|
+
begin
|
27
|
+
validate_query_safety!(query)
|
28
|
+
result = execute_query(query, limit)
|
29
|
+
format_query_result(query, result)
|
30
|
+
rescue => e
|
31
|
+
"SQL execution failed: #{e.message}"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def detect_database_type
|
38
|
+
return :sqlite unless @connection_string
|
39
|
+
|
40
|
+
uri = URI.parse(@connection_string)
|
41
|
+
case uri.scheme
|
42
|
+
when 'postgres', 'postgresql'
|
43
|
+
:postgresql
|
44
|
+
when 'mysql', 'mysql2'
|
45
|
+
:mysql
|
46
|
+
when 'sqlite', 'sqlite3'
|
47
|
+
:sqlite
|
48
|
+
else
|
49
|
+
:unknown
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def setup_database_adapter
|
54
|
+
case @database_type
|
55
|
+
when :postgresql
|
56
|
+
require 'pg'
|
57
|
+
when :mysql
|
58
|
+
require 'mysql2'
|
59
|
+
when :sqlite
|
60
|
+
require 'sqlite3'
|
61
|
+
else
|
62
|
+
raise ToolError, "Unsupported database type: #{@database_type}"
|
63
|
+
end
|
64
|
+
rescue LoadError => e
|
65
|
+
raise ToolError, "Database adapter not available: #{e.message}. Install the appropriate gem."
|
66
|
+
end
|
67
|
+
|
68
|
+
def validate_query_safety!(query)
|
69
|
+
query_lower = query.downcase.strip
|
70
|
+
|
71
|
+
# Block dangerous operations
|
72
|
+
dangerous_keywords = %w[
|
73
|
+
drop truncate delete update insert create alter
|
74
|
+
grant revoke exec execute call load_file
|
75
|
+
]
|
76
|
+
|
77
|
+
dangerous_keywords.each do |keyword|
|
78
|
+
if query_lower.include?(keyword)
|
79
|
+
raise ToolError, "Unsafe SQL operation detected: #{keyword.upcase}"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Only allow SELECT statements and safe functions
|
84
|
+
unless query_lower.start_with?('select', 'show', 'describe', 'explain')
|
85
|
+
raise ToolError, "Only SELECT, SHOW, DESCRIBE, and EXPLAIN queries are allowed"
|
86
|
+
end
|
87
|
+
|
88
|
+
# Check for suspicious patterns
|
89
|
+
if query_lower.include?('--') || query_lower.include?('/*')
|
90
|
+
raise ToolError, "SQL comments not allowed for security"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def execute_query(query, limit)
|
95
|
+
case @database_type
|
96
|
+
when :postgresql
|
97
|
+
execute_postgresql_query(query, limit)
|
98
|
+
when :mysql
|
99
|
+
execute_mysql_query(query, limit)
|
100
|
+
when :sqlite
|
101
|
+
execute_sqlite_query(query, limit)
|
102
|
+
else
|
103
|
+
raise ToolError, "Database execution not implemented for #{@database_type}"
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def execute_postgresql_query(query, limit)
|
108
|
+
connection = PG.connect(@connection_string)
|
109
|
+
|
110
|
+
# Add LIMIT if not present
|
111
|
+
limited_query = add_limit_to_query(query, limit)
|
112
|
+
|
113
|
+
result = connection.exec(limited_query)
|
114
|
+
rows = result.to_a
|
115
|
+
columns = result.fields
|
116
|
+
|
117
|
+
{ rows: rows, columns: columns, row_count: rows.length }
|
118
|
+
ensure
|
119
|
+
connection&.close
|
120
|
+
end
|
121
|
+
|
122
|
+
def execute_mysql_query(query, limit)
|
123
|
+
connection = Mysql2::Client.new(@connection_string)
|
124
|
+
|
125
|
+
# Add LIMIT if not present
|
126
|
+
limited_query = add_limit_to_query(query, limit)
|
127
|
+
|
128
|
+
result = connection.query(limited_query)
|
129
|
+
rows = result.to_a
|
130
|
+
columns = result.fields
|
131
|
+
|
132
|
+
{ rows: rows, columns: columns, row_count: rows.length }
|
133
|
+
ensure
|
134
|
+
connection&.close
|
135
|
+
end
|
136
|
+
|
137
|
+
def execute_sqlite_query(query, limit)
|
138
|
+
db_path = @connection_string || ':memory:'
|
139
|
+
db = SQLite3::Database.new(db_path)
|
140
|
+
db.results_as_hash = true
|
141
|
+
|
142
|
+
# Add LIMIT if not present
|
143
|
+
limited_query = add_limit_to_query(query, limit)
|
144
|
+
|
145
|
+
rows = db.execute(limited_query)
|
146
|
+
columns = rows.first&.keys || []
|
147
|
+
|
148
|
+
{ rows: rows, columns: columns, row_count: rows.length }
|
149
|
+
ensure
|
150
|
+
db&.close
|
151
|
+
end
|
152
|
+
|
153
|
+
def add_limit_to_query(query, limit)
|
154
|
+
query_lower = query.downcase
|
155
|
+
|
156
|
+
# If query already has LIMIT, don't modify
|
157
|
+
return query if query_lower.include?('limit')
|
158
|
+
|
159
|
+
# Add LIMIT clause
|
160
|
+
"#{query.chomp(';')} LIMIT #{limit}"
|
161
|
+
end
|
162
|
+
|
163
|
+
def format_query_result(query, result)
|
164
|
+
output = []
|
165
|
+
output << "SQL Query: #{query}"
|
166
|
+
output << "Rows returned: #{result[:row_count]}"
|
167
|
+
output << ""
|
168
|
+
|
169
|
+
if result[:rows].empty?
|
170
|
+
output << "No results found."
|
171
|
+
else
|
172
|
+
# Create simple table format
|
173
|
+
output << format_table(result[:columns], result[:rows])
|
174
|
+
end
|
175
|
+
|
176
|
+
output.join("\n")
|
177
|
+
end
|
178
|
+
|
179
|
+
def format_table(columns, rows)
|
180
|
+
return "No data" if rows.empty?
|
181
|
+
|
182
|
+
# Calculate column widths
|
183
|
+
col_widths = {}
|
184
|
+
columns.each do |col|
|
185
|
+
col_widths[col] = [col.length, 20].max # Minimum width of 20
|
186
|
+
end
|
187
|
+
|
188
|
+
rows.first(10).each do |row| # Only check first 10 rows for width
|
189
|
+
row.each do |key, value|
|
190
|
+
value_str = value.to_s
|
191
|
+
col_widths[key] = [col_widths[key], value_str.length].min(50) # Max width of 50
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
# Build table
|
196
|
+
table = []
|
197
|
+
|
198
|
+
# Header
|
199
|
+
header = columns.map { |col| col.ljust(col_widths[col]) }.join(" | ")
|
200
|
+
table << header
|
201
|
+
table << "-" * header.length
|
202
|
+
|
203
|
+
# Rows (limit display to first 20 rows)
|
204
|
+
display_rows = rows.first(20)
|
205
|
+
display_rows.each do |row|
|
206
|
+
row_str = columns.map do |col|
|
207
|
+
value = row[col] || row[col.to_s] || ""
|
208
|
+
value_str = value.to_s
|
209
|
+
# Truncate long values
|
210
|
+
value_str = value_str[0..47] + "..." if value_str.length > 50
|
211
|
+
value_str.ljust(col_widths[col])
|
212
|
+
end.join(" | ")
|
213
|
+
table << row_str
|
214
|
+
end
|
215
|
+
|
216
|
+
# Add truncation notice if needed
|
217
|
+
if rows.length > 20
|
218
|
+
table << ""
|
219
|
+
table << "(Showing first 20 rows of #{rows.length} total rows)"
|
220
|
+
end
|
221
|
+
|
222
|
+
table.join("\n")
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|