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