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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +108 -0
  3. data/LICENSE +21 -0
  4. data/README.md +328 -0
  5. data/Rakefile +130 -0
  6. data/bin/rcrewai +7 -0
  7. data/docs/_config.yml +59 -0
  8. data/docs/_layouts/api.html +16 -0
  9. data/docs/_layouts/default.html +78 -0
  10. data/docs/_layouts/example.html +24 -0
  11. data/docs/_layouts/tutorial.html +33 -0
  12. data/docs/api/configuration.md +327 -0
  13. data/docs/api/crew.md +345 -0
  14. data/docs/api/index.md +41 -0
  15. data/docs/api/tools.md +412 -0
  16. data/docs/assets/css/style.css +416 -0
  17. data/docs/examples/human-in-the-loop.md +382 -0
  18. data/docs/examples/index.md +78 -0
  19. data/docs/examples/production-ready-crew.md +485 -0
  20. data/docs/examples/simple-research-crew.md +297 -0
  21. data/docs/index.md +353 -0
  22. data/docs/tutorials/getting-started.md +341 -0
  23. data/examples/async_execution_example.rb +294 -0
  24. data/examples/hierarchical_crew_example.rb +193 -0
  25. data/examples/human_in_the_loop_example.rb +233 -0
  26. data/lib/rcrewai/agent.rb +636 -0
  27. data/lib/rcrewai/async_executor.rb +248 -0
  28. data/lib/rcrewai/cli.rb +39 -0
  29. data/lib/rcrewai/configuration.rb +100 -0
  30. data/lib/rcrewai/crew.rb +292 -0
  31. data/lib/rcrewai/human_input.rb +520 -0
  32. data/lib/rcrewai/llm_client.rb +41 -0
  33. data/lib/rcrewai/llm_clients/anthropic.rb +127 -0
  34. data/lib/rcrewai/llm_clients/azure.rb +158 -0
  35. data/lib/rcrewai/llm_clients/base.rb +82 -0
  36. data/lib/rcrewai/llm_clients/google.rb +158 -0
  37. data/lib/rcrewai/llm_clients/ollama.rb +199 -0
  38. data/lib/rcrewai/llm_clients/openai.rb +124 -0
  39. data/lib/rcrewai/memory.rb +194 -0
  40. data/lib/rcrewai/process.rb +421 -0
  41. data/lib/rcrewai/task.rb +376 -0
  42. data/lib/rcrewai/tools/base.rb +82 -0
  43. data/lib/rcrewai/tools/code_executor.rb +333 -0
  44. data/lib/rcrewai/tools/email_sender.rb +210 -0
  45. data/lib/rcrewai/tools/file_reader.rb +111 -0
  46. data/lib/rcrewai/tools/file_writer.rb +115 -0
  47. data/lib/rcrewai/tools/pdf_processor.rb +342 -0
  48. data/lib/rcrewai/tools/sql_database.rb +226 -0
  49. data/lib/rcrewai/tools/web_search.rb +131 -0
  50. data/lib/rcrewai/version.rb +5 -0
  51. data/lib/rcrewai.rb +36 -0
  52. data/rcrewai.gemspec +54 -0
  53. 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