shared_tools 0.2.3 → 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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +3 -0
  3. data/README.md +594 -42
  4. data/lib/shared_tools/{ruby_llm/mcp → mcp}/github_mcp_server.rb +20 -3
  5. data/lib/shared_tools/mcp/imcp.rb +28 -0
  6. data/lib/shared_tools/mcp/tavily_mcp_server.rb +44 -0
  7. data/lib/shared_tools/mcp.rb +24 -0
  8. data/lib/shared_tools/tools/browser/base_driver.rb +64 -0
  9. data/lib/shared_tools/tools/browser/base_tool.rb +50 -0
  10. data/lib/shared_tools/tools/browser/click_tool.rb +54 -0
  11. data/lib/shared_tools/tools/browser/elements/element_grouper.rb +73 -0
  12. data/lib/shared_tools/tools/browser/elements/nearby_element_detector.rb +109 -0
  13. data/lib/shared_tools/tools/browser/formatters/action_formatter.rb +37 -0
  14. data/lib/shared_tools/tools/browser/formatters/data_entry_formatter.rb +135 -0
  15. data/lib/shared_tools/tools/browser/formatters/element_formatter.rb +52 -0
  16. data/lib/shared_tools/tools/browser/formatters/input_formatter.rb +59 -0
  17. data/lib/shared_tools/tools/browser/inspect_tool.rb +87 -0
  18. data/lib/shared_tools/tools/browser/inspect_utils.rb +51 -0
  19. data/lib/shared_tools/tools/browser/page_inspect/button_summarizer.rb +140 -0
  20. data/lib/shared_tools/tools/browser/page_inspect/form_summarizer.rb +98 -0
  21. data/lib/shared_tools/tools/browser/page_inspect/html_summarizer.rb +37 -0
  22. data/lib/shared_tools/tools/browser/page_inspect/link_summarizer.rb +103 -0
  23. data/lib/shared_tools/tools/browser/page_inspect_tool.rb +55 -0
  24. data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +39 -0
  25. data/lib/shared_tools/tools/browser/selector_generator/base_selectors.rb +28 -0
  26. data/lib/shared_tools/tools/browser/selector_generator/contextual_selectors.rb +140 -0
  27. data/lib/shared_tools/tools/browser/selector_generator.rb +73 -0
  28. data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +67 -0
  29. data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +45 -0
  30. data/lib/shared_tools/tools/browser/visit_tool.rb +43 -0
  31. data/lib/shared_tools/tools/browser/watir_driver.rb +132 -0
  32. data/lib/shared_tools/tools/browser.rb +27 -0
  33. data/lib/shared_tools/tools/browser_tool.rb +255 -0
  34. data/lib/shared_tools/tools/calculator_tool.rb +169 -0
  35. data/lib/shared_tools/tools/composite_analysis_tool.rb +520 -0
  36. data/lib/shared_tools/tools/computer/base_driver.rb +177 -0
  37. data/lib/shared_tools/tools/computer/mac_driver.rb +103 -0
  38. data/lib/shared_tools/tools/computer.rb +21 -0
  39. data/lib/shared_tools/tools/computer_tool.rb +207 -0
  40. data/lib/shared_tools/tools/data_science_kit.rb +707 -0
  41. data/lib/shared_tools/tools/database/base_driver.rb +17 -0
  42. data/lib/shared_tools/tools/database/postgres_driver.rb +30 -0
  43. data/lib/shared_tools/tools/database/sqlite_driver.rb +29 -0
  44. data/lib/shared_tools/tools/database.rb +9 -0
  45. data/lib/shared_tools/tools/database_query_tool.rb +313 -0
  46. data/lib/shared_tools/tools/database_tool.rb +99 -0
  47. data/lib/shared_tools/tools/devops_toolkit.rb +420 -0
  48. data/lib/shared_tools/tools/disk/base_driver.rb +91 -0
  49. data/lib/shared_tools/tools/disk/base_tool.rb +20 -0
  50. data/lib/shared_tools/tools/disk/directory_create_tool.rb +39 -0
  51. data/lib/shared_tools/tools/disk/directory_delete_tool.rb +39 -0
  52. data/lib/shared_tools/tools/disk/directory_list_tool.rb +37 -0
  53. data/lib/shared_tools/tools/disk/directory_move_tool.rb +40 -0
  54. data/lib/shared_tools/tools/disk/file_create_tool.rb +38 -0
  55. data/lib/shared_tools/tools/disk/file_delete_tool.rb +40 -0
  56. data/lib/shared_tools/tools/disk/file_move_tool.rb +43 -0
  57. data/lib/shared_tools/tools/disk/file_read_tool.rb +40 -0
  58. data/lib/shared_tools/tools/disk/file_replace_tool.rb +44 -0
  59. data/lib/shared_tools/tools/disk/file_write_tool.rb +40 -0
  60. data/lib/shared_tools/tools/disk/local_driver.rb +91 -0
  61. data/lib/shared_tools/tools/disk.rb +17 -0
  62. data/lib/shared_tools/tools/disk_tool.rb +132 -0
  63. data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +79 -0
  64. data/lib/shared_tools/tools/doc.rb +8 -0
  65. data/lib/shared_tools/tools/doc_tool.rb +109 -0
  66. data/lib/shared_tools/tools/docker/base_tool.rb +56 -0
  67. data/lib/shared_tools/tools/docker/compose_run_tool.rb +77 -0
  68. data/lib/shared_tools/tools/docker.rb +8 -0
  69. data/lib/shared_tools/tools/error_handling_tool.rb +403 -0
  70. data/lib/shared_tools/tools/eval/python_eval_tool.rb +209 -0
  71. data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +93 -0
  72. data/lib/shared_tools/tools/eval/shell_eval_tool.rb +64 -0
  73. data/lib/shared_tools/tools/eval.rb +10 -0
  74. data/lib/shared_tools/tools/eval_tool.rb +139 -0
  75. data/lib/shared_tools/tools/secure_tool_template.rb +353 -0
  76. data/lib/shared_tools/tools/version.rb +7 -0
  77. data/lib/shared_tools/tools/weather_tool.rb +197 -0
  78. data/lib/shared_tools/tools/workflow_manager_tool.rb +312 -0
  79. data/lib/shared_tools/tools.rb +16 -0
  80. data/lib/shared_tools/version.rb +1 -1
  81. data/lib/shared_tools.rb +9 -24
  82. metadata +189 -68
  83. data/lib/shared_tools/llm_rb/run_shell_command.rb +0 -23
  84. data/lib/shared_tools/llm_rb.rb +0 -9
  85. data/lib/shared_tools/omniai.rb +0 -9
  86. data/lib/shared_tools/raix/what_is_the_weather.rb +0 -18
  87. data/lib/shared_tools/raix.rb +0 -9
  88. data/lib/shared_tools/ruby_llm/edit_file.rb +0 -71
  89. data/lib/shared_tools/ruby_llm/incomplete/calculator_tool.rb +0 -70
  90. data/lib/shared_tools/ruby_llm/incomplete/composite_analysis_tool.rb +0 -89
  91. data/lib/shared_tools/ruby_llm/incomplete/data_science_kit.rb +0 -128
  92. data/lib/shared_tools/ruby_llm/incomplete/database_query_tool.rb +0 -100
  93. data/lib/shared_tools/ruby_llm/incomplete/devops_toolkit.rb +0 -112
  94. data/lib/shared_tools/ruby_llm/incomplete/error_handling_tool.rb +0 -109
  95. data/lib/shared_tools/ruby_llm/incomplete/secure_tool_template.rb +0 -117
  96. data/lib/shared_tools/ruby_llm/incomplete/weather_tool.rb +0 -110
  97. data/lib/shared_tools/ruby_llm/incomplete/workflow_manager_tool.rb +0 -145
  98. data/lib/shared_tools/ruby_llm/list_files.rb +0 -49
  99. data/lib/shared_tools/ruby_llm/mcp/imcp.rb +0 -15
  100. data/lib/shared_tools/ruby_llm/mcp.rb +0 -12
  101. data/lib/shared_tools/ruby_llm/pdf_page_reader.rb +0 -59
  102. data/lib/shared_tools/ruby_llm/python_eval.rb +0 -194
  103. data/lib/shared_tools/ruby_llm/read_file.rb +0 -40
  104. data/lib/shared_tools/ruby_llm/ruby_eval.rb +0 -77
  105. data/lib/shared_tools/ruby_llm/run_shell_command.rb +0 -49
  106. data/lib/shared_tools/ruby_llm.rb +0 -12
@@ -0,0 +1,353 @@
1
+ # secure_tool_template.rb - Comprehensive security best practices template
2
+ require 'ruby_llm/tool'
3
+ require 'timeout'
4
+ require 'securerandom'
5
+
6
+ module SharedTools
7
+ module Tools
8
+ class SecureToolTemplate < RubyLLM::Tool
9
+ def self.name = 'secure_tool_template'
10
+
11
+ description <<~'DESCRIPTION'
12
+ Reference template demonstrating comprehensive security best practices for safe tool development.
13
+ This tool serves as a complete security framework implementation that can be adapted for other
14
+ tools handling sensitive data or performing privileged operations. It demonstrates all essential
15
+ security mechanisms that should be considered when building production-ready AI tools.
16
+
17
+ Security features implemented:
18
+ - Input validation with whitelist filtering
19
+ - Output sanitization to prevent information leakage
20
+ - Permission and authorization checks
21
+ - Rate limiting to prevent abuse
22
+ - Comprehensive audit logging
23
+ - Timeout mechanisms for resource control
24
+ - Security violation tracking
25
+ - Error handling without information disclosure
26
+
27
+ This template can be used as a starting point for developing secure tools that interact with
28
+ sensitive systems, handle user data, or perform privileged operations.
29
+ DESCRIPTION
30
+
31
+ params do
32
+ string :user_input, description: <<~DESC.strip
33
+ User-provided input string that will be processed with comprehensive security validation.
34
+ The input undergoes multiple security checks:
35
+ - Length validation (maximum 1000 characters)
36
+ - Character whitelist (alphanumeric, spaces, hyphens, underscores, dots)
37
+ - Sanitization to remove potentially dangerous characters
38
+ - Rate limiting per user/session
39
+ All validation failures are logged for security monitoring and compliance.
40
+ DESC
41
+
42
+ string :operation_type, description: <<~DESC.strip, required: false
43
+ Type of operation to perform on the input. Options:
44
+ - 'read': Read-only operation (lowest security requirements)
45
+ - 'write': Data modification operation (moderate security requirements)
46
+ - 'admin': Administrative operation (highest security requirements)
47
+ Default is 'read'. Operations with higher security levels require additional validations.
48
+ DESC
49
+
50
+ integer :timeout_seconds, description: <<~DESC.strip, required: false
51
+ Maximum execution time in seconds for the operation. Range: 1-300 seconds.
52
+ Default: 30 seconds. Prevents resource exhaustion and ensures responsive operations.
53
+ Timeout enforces proper resource management and prevents hung operations.
54
+ DESC
55
+ end
56
+
57
+ # Rate limiting store (in production, use Redis or similar)
58
+ @rate_limit_store = {}
59
+ @rate_limit_mutex = Mutex.new
60
+
61
+ def initialize(logger: nil)
62
+ @logger = logger || RubyLLM.logger
63
+ @audit_log = []
64
+ end
65
+
66
+ def execute(user_input:, operation_type: 'read', timeout_seconds: 30)
67
+ execution_id = SecureRandom.uuid
68
+ start_time = Time.now
69
+
70
+ @logger.info("SecureToolTemplate#execute operation_type=#{operation_type} execution_id=#{execution_id}")
71
+
72
+ begin
73
+ # 1. Validate input length
74
+ validate_input_length(user_input)
75
+
76
+ # 2. Sanitize inputs
77
+ sanitized_input = sanitize_input(user_input)
78
+
79
+ # 3. Validate permissions for operation type
80
+ validate_permissions(operation_type)
81
+
82
+ # 4. Check rate limits
83
+ check_rate_limits(execution_id)
84
+
85
+ # 5. Audit logging
86
+ log_tool_usage(execution_id, operation_type, sanitized_input)
87
+
88
+ # 6. Execute with timeout
89
+ timeout = validate_timeout(timeout_seconds)
90
+ result = execute_with_timeout(sanitized_input, operation_type, timeout)
91
+
92
+ # 7. Sanitize outputs
93
+ sanitized_result = sanitize_output(result)
94
+
95
+ execution_time = (Time.now - start_time).round(3)
96
+ @logger.info("Operation completed successfully in #{execution_time}s")
97
+
98
+ {
99
+ success: true,
100
+ result: sanitized_result,
101
+ operation_type: operation_type,
102
+ execution_id: execution_id,
103
+ execution_time_seconds: execution_time,
104
+ executed_at: Time.now.iso8601
105
+ }
106
+
107
+ rescue SecurityError => e
108
+ @logger.error("Security violation: #{e.message}")
109
+ log_security_violation(e, execution_id, user_input)
110
+ {
111
+ success: false,
112
+ error: "Security violation: Access denied",
113
+ error_type: "security",
114
+ violation_logged: true,
115
+ execution_id: execution_id
116
+ }
117
+ rescue Timeout::Error => e
118
+ @logger.error("Operation timeout after #{timeout_seconds}s")
119
+ {
120
+ success: false,
121
+ error: "Operation exceeded timeout of #{timeout_seconds} seconds",
122
+ error_type: "timeout",
123
+ execution_id: execution_id
124
+ }
125
+ rescue ArgumentError => e
126
+ @logger.error("Validation error: #{e.message}")
127
+ {
128
+ success: false,
129
+ error: e.message,
130
+ error_type: "validation",
131
+ execution_id: execution_id
132
+ }
133
+ rescue => e
134
+ @logger.error("Tool execution failed: #{e.class} - #{e.message}")
135
+ {
136
+ success: false,
137
+ error: "Tool execution failed: #{e.message}",
138
+ error_type: e.class.name,
139
+ execution_id: execution_id
140
+ }
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ # Validate input length to prevent buffer overflow attacks
147
+ def validate_input_length(input)
148
+ if input.nil? || input.empty?
149
+ raise ArgumentError, "Input cannot be empty"
150
+ end
151
+
152
+ if input.length > 1000
153
+ raise ArgumentError, "Input too long: #{input.length} characters (maximum 1000)"
154
+ end
155
+
156
+ @logger.debug("Input length validation passed: #{input.length} characters")
157
+ end
158
+
159
+ # Sanitize input by removing potentially dangerous characters
160
+ def sanitize_input(input)
161
+ # Remove all characters except alphanumeric, spaces, hyphens, underscores, and dots
162
+ sanitized = input.gsub(/[^\w\s\-\.]/, '')
163
+
164
+ @logger.debug("Input sanitized (#{input.length} -> #{sanitized.length} characters)")
165
+ sanitized
166
+ end
167
+
168
+ # Validate user permissions based on operation type
169
+ def validate_permissions(operation_type)
170
+ # In production, this would check actual user permissions from auth system
171
+ valid_operations = ['read', 'write', 'admin']
172
+
173
+ unless valid_operations.include?(operation_type)
174
+ raise SecurityError, "Invalid operation type: #{operation_type}"
175
+ end
176
+
177
+ # Admin operations require elevated privileges
178
+ if operation_type == 'admin'
179
+ # In production: verify admin role from authentication context
180
+ @logger.warn("Admin operation requested - elevated privileges required")
181
+ end
182
+
183
+ @logger.debug("Permission validation passed for operation: #{operation_type}")
184
+ end
185
+
186
+ # Check rate limits to prevent abuse
187
+ def check_rate_limits(execution_id)
188
+ # Simple rate limiting (in production, use Redis with sliding windows)
189
+ self.class.instance_variable_get(:@rate_limit_mutex).synchronize do
190
+ store = self.class.instance_variable_get(:@rate_limit_store)
191
+ current_time = Time.now.to_i
192
+
193
+ # Clean old entries (older than 1 minute)
194
+ store.reject! { |k, v| v < current_time - 60 }
195
+
196
+ # Count requests in last minute (max 30 per minute)
197
+ recent_requests = store.values.count { |time| time > current_time - 60 }
198
+
199
+ if recent_requests >= 30
200
+ raise SecurityError, "Rate limit exceeded: #{recent_requests} requests in last minute"
201
+ end
202
+
203
+ # Record this request
204
+ store[execution_id] = current_time
205
+
206
+ @logger.debug("Rate limit check passed: #{recent_requests + 1}/30 requests")
207
+ end
208
+ end
209
+
210
+ # Log tool usage for audit trail
211
+ def log_tool_usage(execution_id, operation_type, sanitized_input)
212
+ audit_entry = {
213
+ execution_id: execution_id,
214
+ operation_type: operation_type,
215
+ input_preview: sanitized_input[0..50],
216
+ timestamp: Time.now.iso8601,
217
+ user_context: "system" # In production: actual user ID from auth context
218
+ }
219
+
220
+ @audit_log << audit_entry
221
+ @logger.debug("Audit log entry created: #{execution_id}")
222
+ end
223
+
224
+ # Validate and normalize timeout value
225
+ def validate_timeout(timeout)
226
+ timeout = timeout.to_i
227
+
228
+ if timeout < 1
229
+ @logger.warn("Timeout #{timeout} too low, adjusting to 1")
230
+ return 1
231
+ end
232
+
233
+ if timeout > 300
234
+ @logger.warn("Timeout #{timeout} too high, adjusting to 300")
235
+ return 300
236
+ end
237
+
238
+ timeout
239
+ end
240
+
241
+ # Execute operation with timeout protection
242
+ def execute_with_timeout(input, operation_type, timeout)
243
+ @logger.debug("Executing operation with #{timeout}s timeout")
244
+
245
+ Timeout::timeout(timeout) do
246
+ # Simulate actual tool logic based on operation type
247
+ case operation_type
248
+ when 'read'
249
+ perform_read_operation(input)
250
+ when 'write'
251
+ perform_write_operation(input)
252
+ when 'admin'
253
+ perform_admin_operation(input)
254
+ else
255
+ raise ArgumentError, "Unknown operation type: #{operation_type}"
256
+ end
257
+ end
258
+ end
259
+
260
+ # Simulate read operation
261
+ def perform_read_operation(input)
262
+ # In production: actual read logic here
263
+ {
264
+ operation: 'read',
265
+ data: {
266
+ input_received: input,
267
+ character_count: input.length,
268
+ word_count: input.split.length,
269
+ processed: true
270
+ }
271
+ }
272
+ end
273
+
274
+ # Simulate write operation
275
+ def perform_write_operation(input)
276
+ # In production: actual write logic here
277
+ # This would have additional security checks
278
+ {
279
+ operation: 'write',
280
+ data: {
281
+ written: true,
282
+ input_length: input.length,
283
+ timestamp: Time.now.iso8601
284
+ }
285
+ }
286
+ end
287
+
288
+ # Simulate admin operation
289
+ def perform_admin_operation(input)
290
+ # In production: actual admin logic here
291
+ # This would have the strictest security checks
292
+ {
293
+ operation: 'admin',
294
+ data: {
295
+ executed: true,
296
+ admin_action: 'completed',
297
+ requires_review: true
298
+ }
299
+ }
300
+ end
301
+
302
+ # Sanitize output to prevent information leakage
303
+ def sanitize_output(output)
304
+ # Remove any potentially sensitive information from output
305
+ # In production: redact PII, credentials, internal paths, etc.
306
+
307
+ if output.is_a?(Hash)
308
+ # Recursively sanitize hash values
309
+ output.transform_values { |v| sanitize_value(v) }
310
+ else
311
+ sanitize_value(output)
312
+ end
313
+ end
314
+
315
+ # Sanitize individual value
316
+ def sanitize_value(value)
317
+ case value
318
+ when Hash
319
+ value.transform_values { |v| sanitize_value(v) }
320
+ when Array
321
+ value.map { |v| sanitize_value(v) }
322
+ when String
323
+ # Remove any patterns that look like credentials, tokens, or keys
324
+ value.gsub(/\b[A-Za-z0-9_-]{20,}\b/, '[REDACTED]')
325
+ else
326
+ value
327
+ end
328
+ end
329
+
330
+ # Log security violations for monitoring
331
+ def log_security_violation(error, execution_id, input)
332
+ violation_entry = {
333
+ execution_id: execution_id,
334
+ violation_type: error.class.name,
335
+ message: error.message,
336
+ input_preview: input[0..50],
337
+ timestamp: Time.now.iso8601,
338
+ severity: 'high'
339
+ }
340
+
341
+ @audit_log << violation_entry
342
+ @logger.error("SECURITY VIOLATION: #{violation_entry.to_json}")
343
+
344
+ # In production: send alert to security monitoring system
345
+ end
346
+
347
+ # Accessor for audit log (for testing)
348
+ def audit_log
349
+ @audit_log
350
+ end
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharedTools
4
+ module Tools
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,197 @@
1
+ # weather_tool.rb - API integration example
2
+ require 'ruby_llm/tool'
3
+ require 'openweathermap'
4
+
5
+ module SharedTools
6
+ module Tools
7
+ class WeatherTool < RubyLLM::Tool
8
+ def self.name = 'weather_tool'
9
+
10
+ description <<~'DESCRIPTION'
11
+ Retrieve comprehensive current weather information for any city worldwide using the OpenWeatherMap API.
12
+ This tool provides real-time weather data including temperature, atmospheric conditions, humidity,
13
+ and wind information. It supports multiple temperature units and can optionally include extended
14
+ forecast data. The tool requires a valid OpenWeatherMap API key to be configured in the
15
+ OPENWEATHER_API_KEY environment variable. All weather data is fetched in real-time and includes
16
+ timestamps for accuracy verification.
17
+
18
+ Example usage:
19
+ tool = SharedTools::Tools::WeatherTool.new
20
+ result = tool.execute(city: "London,UK", units: "metric")
21
+ puts "Temperature: #{result[:current][:temperature]}°C"
22
+ DESCRIPTION
23
+
24
+ params do
25
+ string :city, description: <<~DESC.strip
26
+ Name of the city for weather lookup. Can include city name only (e.g., 'London')
27
+ or city with country code for better accuracy (e.g., 'London,UK' or 'Paris,FR').
28
+ For cities with common names in multiple countries, including the country code
29
+ is recommended to ensure accurate results. The API will attempt to find the
30
+ closest match if an exact match is not found.
31
+ DESC
32
+
33
+ string :units, description: <<~DESC.strip, required: false
34
+ Temperature unit system for the weather data. Options are:
35
+ - 'metric': Temperature in Celsius, wind speed in m/s, pressure in hPa
36
+ - 'imperial': Temperature in Fahrenheit, wind speed in mph, pressure in hPa
37
+ - 'kelvin': Temperature in Kelvin (scientific standard), wind speed in m/s
38
+ Default is 'metric' which is most commonly used internationally.
39
+ DESC
40
+
41
+ boolean :include_forecast, description: <<~DESC.strip, required: false
42
+ Boolean flag to include a 3-day weather forecast in addition to current conditions.
43
+ When set to true, the response will include forecast data with daily high/low temperatures,
44
+ precipitation probability, and general weather conditions for the next three days.
45
+ This requires additional API calls and may increase response time slightly.
46
+ DESC
47
+ end
48
+
49
+ # @param logger [Logger] optional logger
50
+ def initialize(logger: nil)
51
+ @logger = logger || RubyLLM.logger
52
+ end
53
+
54
+ # Execute weather lookup for specified city
55
+ #
56
+ # @param city [String] City name, optionally with country code
57
+ # @param units [String] Unit system: metric, imperial, or kelvin
58
+ # @param include_forecast [Boolean] Whether to include 3-day forecast
59
+ #
60
+ # @return [Hash] Weather data with success status
61
+ def execute(city:, units: "metric", include_forecast: false)
62
+ @logger.info("WeatherTool#execute city=#{city.inspect} units=#{units} include_forecast=#{include_forecast}")
63
+
64
+ begin
65
+ api_key = ENV['OPENWEATHER_API_KEY']
66
+ unless api_key
67
+ @logger.error("OpenWeather API key not configured in OPENWEATHER_API_KEY environment variable")
68
+ raise "OpenWeather API key not configured"
69
+ end
70
+
71
+ # Create API client with units mapping
72
+ api_units = map_units_to_api(units)
73
+ api = OpenWeatherMap::API.new(api_key, 'en', api_units)
74
+
75
+ current_weather = fetch_current_weather(api, city)
76
+ result = {
77
+ success: true,
78
+ city: city,
79
+ current: current_weather,
80
+ units: units,
81
+ timestamp: Time.now.iso8601
82
+ }
83
+
84
+ if include_forecast
85
+ @logger.debug("Fetching forecast data for #{city}")
86
+ forecast_data = fetch_forecast(api, city)
87
+ result[:forecast] = forecast_data
88
+ end
89
+
90
+ @logger.info("Weather data retrieved successfully for #{city}")
91
+ result
92
+ rescue => e
93
+ @logger.error("Weather lookup failed for #{city}: #{e.message}")
94
+ {
95
+ success: false,
96
+ error: e.message,
97
+ city: city,
98
+ suggestion: "Verify city name and API key configuration"
99
+ }
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ # Map our unit names to OpenWeatherMap API unit names
106
+ #
107
+ # @param units [String] Our unit system name
108
+ # @return [String] API unit system name
109
+ def map_units_to_api(units)
110
+ case units.to_s.downcase
111
+ when 'imperial'
112
+ 'imperial'
113
+ when 'kelvin'
114
+ 'standard'
115
+ else
116
+ 'metric'
117
+ end
118
+ end
119
+
120
+ # Fetch current weather conditions for a city
121
+ #
122
+ # @param api [OpenWeatherMap::API] API client
123
+ # @param city [String] City name with optional country code
124
+ #
125
+ # @return [Hash] Current weather data
126
+ def fetch_current_weather(api, city)
127
+ @logger.debug("Fetching current weather for #{city}")
128
+
129
+ data = api.current(city)
130
+ @logger.debug("Current weather data received for #{city}")
131
+
132
+ conditions = data.weather_conditions
133
+ wind = conditions.wind || {}
134
+
135
+ {
136
+ temperature: conditions.temperature,
137
+ feels_like: conditions.temperature, # API doesn't provide feels_like separately
138
+ description: conditions.description,
139
+ humidity: conditions.humidity,
140
+ pressure: conditions.pressure,
141
+ wind_speed: wind[:speed] || wind['speed'] || 0,
142
+ wind_direction: wind[:direction] || wind['direction'] || 0,
143
+ cloudiness: conditions.clouds,
144
+ visibility: 0 # Not provided by this gem
145
+ }
146
+ end
147
+
148
+ # Fetch 3-day weather forecast for a city
149
+ #
150
+ # @param api [OpenWeatherMap::API] API client
151
+ # @param city [String] City name with optional country code
152
+ #
153
+ # @return [Array<Hash>] Array of forecast data for next 3 days
154
+ def fetch_forecast(api, city)
155
+ @logger.debug("Fetching 3-day forecast for #{city}")
156
+
157
+ data = api.forecast(city)
158
+ @logger.debug("Forecast data received for #{city}")
159
+
160
+ # Group forecasts by date and extract daily summaries
161
+ forecasts_by_date = {}
162
+
163
+ data.forecast.each do |forecast|
164
+ date = forecast.time.strftime('%Y-%m-%d')
165
+
166
+ forecasts_by_date[date] ||= {
167
+ date: date,
168
+ temperatures: [],
169
+ conditions: [],
170
+ humidity: [],
171
+ wind_speeds: []
172
+ }
173
+
174
+ wind = forecast.wind || {}
175
+ forecasts_by_date[date][:temperatures] << forecast.temperature
176
+ forecasts_by_date[date][:conditions] << forecast.description
177
+ forecasts_by_date[date][:humidity] << forecast.humidity
178
+ forecasts_by_date[date][:wind_speeds] << (wind[:speed] || wind['speed'] || 0)
179
+ end
180
+
181
+ # Calculate daily summaries
182
+ forecasts_by_date.values.map do |day|
183
+ temps = day[:temperatures]
184
+ {
185
+ date: day[:date],
186
+ temp_min: temps.min.round(1),
187
+ temp_max: temps.max.round(1),
188
+ temp_avg: (temps.sum / temps.size).round(1),
189
+ conditions: day[:conditions].max_by { |c| day[:conditions].count(c) },
190
+ avg_humidity: (day[:humidity].sum / day[:humidity].size).round(0),
191
+ avg_wind_speed: (day[:wind_speeds].sum / day[:wind_speeds].size).round(1)
192
+ }
193
+ end.take(3) # Return only 3 days
194
+ end
195
+ end
196
+ end
197
+ end