From 082559fa9550e0fa6b895c14d859a60c1166c3a7 Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Wed, 25 Mar 2026 14:30:23 -0500 Subject: [PATCH 01/17] feat(browser_tools): add demo of LLM-powered web automation This commit adds a new example script `browser_tool_demo.rb` that demonstrates how an LLM can automate web browser interactions through natural language prompts using the BrowserTool. The script covers various use cases such as navigating to a website, inspecting page content, performing search workflows, capturing screenshots, multi-step navigation, form interactions, and conversational browsing. The key changes include: - Added a new file `examples/browser_tool_demo.rb` that showcases the capabilities of the BrowserTool. - Removed the older `browser_tool_example.rb` file, as the new demo script provides a more comprehensive and up-to-date example. - Updated the `docs/index.md` file to include an image for the SharedTools project. This commit helps improve the documentation and examples for the BrowserTool, making it easier for users to understand and utilize the tool's functionality when integrating with LLMs. --- docs/index.md | 2 + examples/browser_tool_demo.rb | 75 +++++++ examples/browser_tool_example.rb | 146 ------------- ...ool_example.rb => calculator_tool_demo.rb} | 41 ++-- examples/clipboard_tool_demo.rb | 34 ++++ examples/common.rb | 158 ++++++++++++++ examples/composite_analysis_tool_demo.rb | 75 +++++++ examples/composite_analysis_tool_example.rb | 110 ---------- examples/comprehensive_workflow_demo.rb | 115 +++++++++++ examples/comprehensive_workflow_example.rb | 192 ------------------ examples/computer_tool_demo.rb | 75 +++++++ examples/computer_tool_example.rb | 163 --------------- examples/cron_tool_demo.rb | 34 ++++ examples/current_date_time_tool_demo.rb | 34 ++++ examples/data_science_kit_demo.rb | 66 ++++++ examples/data_science_kit_example.rb | 133 ------------ examples/database_query_tool_demo.rb | 79 +++++++ examples/database_query_tool_example.rb | 184 ----------------- ..._tool_example.rb => database_tool_demo.rb} | 45 ++-- examples/devops_toolkit_demo.rb | 56 +++++ examples/devops_toolkit_example.rb | 143 ------------- examples/disk_tool_demo.rb | 83 ++++++++ examples/disk_tool_example.rb | 127 ------------ examples/dns_tool_demo.rb | 34 ++++ examples/doc_tool_demo.rb | 120 +++++++++++ examples/doc_tool_example.rb | 107 ---------- examples/error_handling_tool_demo.rb | 52 +++++ examples/error_handling_tool_example.rb | 155 -------------- ...eval_tool_example.rb => eval_tool_demo.rb} | 35 ++-- examples/mcp_client_demo.rb | 76 +++++++ examples/mcp_client_example.rb | 188 ----------------- examples/system_info_tool_demo.rb | 34 ++++ examples/weather_tool_demo.rb | 71 +++++++ examples/weather_tool_example.rb | 133 ------------ examples/workflow_manager_tool_demo.rb | 58 ++++++ examples/workflow_manager_tool_example.rb | 139 ------------- lib/shared_tools.rb | 33 ++- lib/shared_tools/browser_tool.rb | 5 + lib/shared_tools/calculator_tool.rb | 4 + lib/shared_tools/clipboard_tool.rb | 4 + lib/shared_tools/composite_analysis_tool.rb | 4 + lib/shared_tools/computer_tool.rb | 5 + lib/shared_tools/cron_tool.rb | 4 + lib/shared_tools/current_date_time_tool.rb | 4 + lib/shared_tools/data_science_kit.rb | 4 + lib/shared_tools/database_query_tool.rb | 4 + lib/shared_tools/database_tool.rb | 5 + lib/shared_tools/devops_toolkit.rb | 4 + lib/shared_tools/disk_tool.rb | 5 + lib/shared_tools/dns_tool.rb | 4 + lib/shared_tools/doc_tool.rb | 5 + lib/shared_tools/error_handling_tool.rb | 4 + lib/shared_tools/eval_tool.rb | 5 + lib/shared_tools/system_info_tool.rb | 4 + lib/shared_tools/tools/browser.rb | 21 ++ lib/shared_tools/version.rb | 2 +- lib/shared_tools/weather_tool.rb | 4 + lib/shared_tools/workflow_manager_tool.rb | 4 + 58 files changed, 1525 insertions(+), 1985 deletions(-) create mode 100755 examples/browser_tool_demo.rb delete mode 100755 examples/browser_tool_example.rb rename examples/{calculator_tool_example.rb => calculator_tool_demo.rb} (70%) create mode 100755 examples/clipboard_tool_demo.rb create mode 100755 examples/common.rb create mode 100755 examples/composite_analysis_tool_demo.rb delete mode 100755 examples/composite_analysis_tool_example.rb create mode 100755 examples/comprehensive_workflow_demo.rb delete mode 100755 examples/comprehensive_workflow_example.rb create mode 100755 examples/computer_tool_demo.rb delete mode 100755 examples/computer_tool_example.rb create mode 100755 examples/cron_tool_demo.rb create mode 100755 examples/current_date_time_tool_demo.rb create mode 100755 examples/data_science_kit_demo.rb delete mode 100755 examples/data_science_kit_example.rb create mode 100755 examples/database_query_tool_demo.rb delete mode 100755 examples/database_query_tool_example.rb rename examples/{database_tool_example.rb => database_tool_demo.rb} (80%) create mode 100755 examples/devops_toolkit_demo.rb delete mode 100755 examples/devops_toolkit_example.rb create mode 100755 examples/disk_tool_demo.rb delete mode 100755 examples/disk_tool_example.rb create mode 100755 examples/dns_tool_demo.rb create mode 100755 examples/doc_tool_demo.rb delete mode 100755 examples/doc_tool_example.rb create mode 100755 examples/error_handling_tool_demo.rb delete mode 100755 examples/error_handling_tool_example.rb rename examples/{eval_tool_example.rb => eval_tool_demo.rb} (72%) create mode 100755 examples/mcp_client_demo.rb delete mode 100755 examples/mcp_client_example.rb create mode 100755 examples/system_info_tool_demo.rb create mode 100755 examples/weather_tool_demo.rb delete mode 100755 examples/weather_tool_example.rb create mode 100755 examples/workflow_manager_tool_demo.rb delete mode 100755 examples/workflow_manager_tool_example.rb create mode 100644 lib/shared_tools/browser_tool.rb create mode 100644 lib/shared_tools/calculator_tool.rb create mode 100644 lib/shared_tools/clipboard_tool.rb create mode 100644 lib/shared_tools/composite_analysis_tool.rb create mode 100644 lib/shared_tools/computer_tool.rb create mode 100644 lib/shared_tools/cron_tool.rb create mode 100644 lib/shared_tools/current_date_time_tool.rb create mode 100644 lib/shared_tools/data_science_kit.rb create mode 100644 lib/shared_tools/database_query_tool.rb create mode 100644 lib/shared_tools/database_tool.rb create mode 100644 lib/shared_tools/devops_toolkit.rb create mode 100644 lib/shared_tools/disk_tool.rb create mode 100644 lib/shared_tools/dns_tool.rb create mode 100644 lib/shared_tools/doc_tool.rb create mode 100644 lib/shared_tools/error_handling_tool.rb create mode 100644 lib/shared_tools/eval_tool.rb create mode 100644 lib/shared_tools/system_info_tool.rb create mode 100644 lib/shared_tools/weather_tool.rb create mode 100644 lib/shared_tools/workflow_manager_tool.rb diff --git a/docs/index.md b/docs/index.md index 41a8999..07c39a3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,7 @@ # SharedTools +![SharedTools](assets/images/shared_tools.png) + A comprehensive Ruby gem providing LLM-callable tools for browser automation, file operations, code evaluation, database operations, and document processing. ## Overview diff --git a/examples/browser_tool_demo.rb b/examples/browser_tool_demo.rb new file mode 100755 index 0000000..29125e1 --- /dev/null +++ b/examples/browser_tool_demo.rb @@ -0,0 +1,75 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: BrowserTool +# +# Shows how an LLM automates web browser interactions through natural language +# using the BrowserTool (requires Watir + Chrome). +# +# Run: +# bundle exec ruby -I examples examples/browser_tool_demo.rb + +require_relative 'common' +require 'shared_tools/browser_tool' + + +unless defined?(Watir) + puts "ERROR: Watir gem not loaded. Install with: gem install watir webdrivers" + exit 1 +end + +title "BrowserTool Demo — LLM-Powered Web Automation" +puts "NOTE: Requires Watir with Chrome driver and Chrome browser installed." +puts + +@chat = @chat.with_tools( + SharedTools::Tools::Browser::VisitTool.new, + SharedTools::Tools::Browser::InspectTool.new, + SharedTools::Tools::Browser::ClickTool.new, + SharedTools::Tools::Browser::TextFieldAreaSetTool.new, + SharedTools::Tools::Browser::PageScreenshotTool.new +) + +begin + title "Example 1: Navigate to Website", char: '-' + ask "Visit the example.com website using a headless Chrome browser." + + title "Example 2: Inspect Page Content", char: '-' + ask "What's the main heading on this page?" + + title "Example 3: Search Workflow", char: '-' + ask <<~PROMPT + Go to duckduckgo.com and search for "Ruby programming language". + Tell me what the first result is. + PROMPT + + title "Example 4: Capture Screenshot", char: '-' + ask "Take a screenshot of the current page and save it as 'search_results.png'." + + title "Example 5: Multi-Step Navigation", char: '-' + ask <<~PROMPT + 1. Go to example.org + 2. Find and click on the "More information..." link + 3. Tell me what page you end up on + PROMPT + + title "Example 6: Form Interaction", char: '-' + ask <<~PROMPT + Go to httpbin.org/forms/post and fill out the form: + - Customer name: John Doe + - Telephone: 555-1234 + - Comments: Testing browser automation + Then submit the form. + PROMPT + + title "Example 7: Conversational Browsing", char: '-' + ask "Navigate to github.com" + ask "Click on the 'Explore' link in the navigation." + ask "What's the page title now?" + +rescue => e + puts "Error during browser automation: #{e.message}" +end + +title "Done", char: '-' +puts "BrowserTool let the LLM control a real browser through natural language." diff --git a/examples/browser_tool_example.rb b/examples/browser_tool_example.rb deleted file mode 100755 index 95502eb..0000000 --- a/examples/browser_tool_example.rb +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Example: Using BrowserTool with LLM Integration -# -# This example demonstrates how an LLM can automate web browser interactions -# through natural language prompts using the BrowserTool. - -require_relative 'ruby_llm_config' - -begin - require 'shared_tools/tools/browser' -rescue LoadError => e - title "ERROR: Missing required dependencies for BrowserTool" - - puts <<~ERROR_MSG - - This example requires the 'watir' and 'webdrivers' gems: - gem install watir webdrivers - - Or add to your Gemfile: - gem 'watir' - gem 'webdrivers' - - Then run: bundle install - #{'=' * 80} - ERROR_MSG - - exit 1 -end - -# Check if Watir is available -unless defined?(Watir) - puts <<~WATIR_ERROR - #{'=' * 80} - ERROR: Watir gem not loaded - #{'=' * 80} - - Please install: gem install watir webdrivers - #{'=' * 80} - WATIR_ERROR - - exit 1 -end - -title("BrowserTool Example - LLM-Powered Web Automation") -puts -puts "NOTE: This example uses Watir with Chrome driver" -puts " Make sure Chrome browser is installed" -puts - -# Register the BrowserTools with RubyLLM -tools = [ - SharedTools::Tools::Browser::VisitTool.new, - SharedTools::Tools::Browser::InspectTool.new, - SharedTools::Tools::Browser::ClickTool.new, - SharedTools::Tools::Browser::TextFieldAreaSetTool.new, - SharedTools::Tools::Browser::PageScreenshotTool.new -] - -# Create a chat instance using ollama_chat helper -@chat = ollama_chat() - -# Add tools to the chat -tools.each { |tool| @chat = @chat.with_tool(tool) } - -begin - # Example 1: Visit a website - title "Example 1: Navigate to Website", bc: '-' - prompt = "Visit the example.com website using a headless Chrome browser." - test_with_prompt prompt - - - # Example 2: Inspect page content - title "Example 2: Inspect Page Content", bc: '-' - prompt = "What's the main heading on this page?" - test_with_prompt prompt - - - # Example 3: Search workflow - title "Example 3: Search Workflow", bc: '-' - prompt = <<~PROMPT - Go to duckduckgo.com and search for "Ruby programming language". - Tell me what the first result is. - PROMPT - test_with_prompt prompt - - - # Example 4: Take screenshot - title "Example 4: Capture Screenshot", bc: '-' - prompt = "Take a screenshot of the current page and save it as 'search_results.png'." - test_with_prompt prompt - - - # Example 5: Multi-step navigation - title "Example 5: Multi-Step Navigation", bc: '-' - prompt = <<~PROMPT - I need you to: - 1. Go to example.org - 2. Find and click on the "More information..." link - 3. Tell me what page you end up on - PROMPT - test_with_prompt prompt - - - # Example 6: Form interaction - title "Example 6: Form Interaction", bc: '-' - prompt = <<~PROMPT - Go to httpbin.org/forms/post and fill out the form: - - Customer name: John Doe - - Telephone: 555-1234 - - Comments: Testing browser automation - Then submit the form. - PROMPT - test_with_prompt prompt - - - # Example 7: Conversational browsing - title "Example 7: Conversational Browsing", bc: '-' - prompt = "Navigate to github.com" - test_with_prompt prompt - - - prompt = "Click on the 'Explore' link in the navigation." - test_with_prompt prompt - - prompt = "What's the page title now?" - test_with_prompt prompt - -rescue => e - puts "Error during browser automation: #{e.message}" - puts "Make sure you have a compatible browser and webdriver installed." -end - -title "Example completed!" - -puts <<~TAKEAWAYS - - Key Takeaways: - - The LLM can control web browsers through natural language - - Complex multi-step workflows are automated intelligently - - Page inspection and interaction happen seamlessly - - Screenshots and data extraction are conversational - - Browser automation becomes accessible without writing code - -TAKEAWAYS diff --git a/examples/calculator_tool_example.rb b/examples/calculator_tool_demo.rb similarity index 70% rename from examples/calculator_tool_example.rb rename to examples/calculator_tool_demo.rb index cc574b4..a9e0a81 100755 --- a/examples/calculator_tool_example.rb +++ b/examples/calculator_tool_demo.rb @@ -1,22 +1,26 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# Example: Using CalculatorTool with LLM Integration +# Demo: Using CalculatorTool with LLM Integration # -# This example demonstrates how an LLM can use the CalculatorTool to perform +# This demo demonstrates how an LLM can use the CalculatorTool to perform # safe mathematical calculations through natural language prompts. +# +# Requires: +# require_relative 'common' +# require 'shared_tools/calculator_tool' -require_relative 'ruby_llm_config' +require_relative 'common' begin require 'dentaku' - require 'shared_tools/tools/calculator_tool' + require 'shared_tools/calculator_tool' rescue LoadError => e title "ERROR: Missing required dependencies for CalculatorTool" puts <<~ERROR_MSG - This example requires the 'dentaku' gem: + This demo requires the 'dentaku' gem: gem install dentaku Or add to your Gemfile: @@ -29,57 +33,58 @@ exit 1 end -title "CalculatorTool Example - LLM-Powered Calculations" + +title "CalculatorTool Demo - LLM-Powered Calculations" # Register the CalculatorTool with RubyLLM tools = [ SharedTools::Tools::CalculatorTool.new ] -# Create a chat instance using ollama_chat helper -@chat = ollama_chat() +# Create a chat instance using new_chat helper +@chat = new_chat() # Add tools to the chat tools.each { |tool| @chat = @chat.with_tool(tool) } # Example 1: Basic arithmetic -title "Example 1: Basic Arithmetic", bc: '-' +title "Example 1: Basic Arithmetic", char: '-' prompt = "What is 127 plus 349?" test_with_prompt prompt # Example 2: Complex expression -title "Example 2: Complex Expression with Parentheses", bc: '-' +title "Example 2: Complex Expression with Parentheses", char: '-' prompt = "Calculate (15 + 7) * 3 - 10 / 2" test_with_prompt prompt # Example 3: Square root -title "Example 3: Square Root Calculation", bc: '-' +title "Example 3: Square Root Calculation", char: '-' prompt = "What is the square root of 256?" test_with_prompt prompt # Example 4: Percentage calculation -title "Example 4: Percentage Calculation", bc: '-' +title "Example 4: Percentage Calculation", char: '-' prompt = "If a product costs $120 and there's a 15% discount, how much is the discount amount? Calculate 120 * 0.15" test_with_prompt prompt # Example 5: Precision control -title "Example 5: Division with Specific Precision", bc: '-' +title "Example 5: Division with Specific Precision", char: '-' prompt = "Divide 100 by 3 and give me the result with 4 decimal places" test_with_prompt prompt # Example 6: Scientific calculation -title "Example 6: Exponentiation", bc: '-' +title "Example 6: Exponentiation", char: '-' prompt = "Calculate 2 to the power of 10" test_with_prompt prompt # Example 7: Multiple operations -title "Example 7: Multi-Step Calculation", bc: '-' +title "Example 7: Multi-Step Calculation", char: '-' prompt = <<~PROMPT I need to calculate the total cost: - Item 1: 3 units at $12.50 each @@ -90,13 +95,13 @@ # Example 8: Rounding -title "Example 8: Rounding Numbers", bc: '-' +title "Example 8: Rounding Numbers", char: '-' prompt = "Round 3.14159 to 2 decimal places" test_with_prompt prompt # Example 9: Conversational context -title "Example 9: Conversational Calculations", bc: '-' +title "Example 9: Conversational Calculations", char: '-' prompt = "What is 50 multiplied by 8?" test_with_prompt prompt @@ -107,7 +112,7 @@ prompt = "Finally, divide that by 5" test_with_prompt prompt -title "Example completed!" +title "Demo completed!" puts <<~TAKEAWAYS diff --git a/examples/clipboard_tool_demo.rb b/examples/clipboard_tool_demo.rb new file mode 100755 index 0000000..81a244f --- /dev/null +++ b/examples/clipboard_tool_demo.rb @@ -0,0 +1,34 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: ClipboardTool +# +# Read from and write to the system clipboard (macOS/Linux/Windows). +# +# Run: +# bundle exec ruby -I examples examples/clipboard_tool_demo.rb + +require_relative 'common' +require 'shared_tools/tools/clipboard_tool' + + +title "ClipboardTool Demo — cross-platform clipboard read/write/clear" + +@chat = @chat.with_tool(SharedTools::Tools::ClipboardTool.new) + +ask "Write the text 'Hello from SharedTools!' to the clipboard." + +ask "Read the current clipboard contents and tell me what is there." + +ask "Now write a multi-line note to the clipboard with this exact content: 'Line 1: Ruby is great\nLine 2: SharedTools makes it easier\nLine 3: ClipboardTool bridges the gap'." + +ask "Read the clipboard again and confirm all three lines are present." + +ask "How many characters are currently stored in the clipboard?" + +ask "Clear the clipboard, then immediately read it back and confirm it is now empty." + +ask "Write today's date in ISO 8601 format (YYYY-MM-DD) to the clipboard." + +title "Done", char: '-' +puts "ClipboardTool demonstrated write, read, multi-line storage, length reporting, and clear operations." diff --git a/examples/common.rb b/examples/common.rb new file mode 100755 index 0000000..a0fb423 --- /dev/null +++ b/examples/common.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true +# +# Common setup for all SharedTools demo applications. +# +# Provides: +# - Load path configuration (lib/ and examples/ directories) +# - RubyLLM configuration via configure! (defaults to Ollama + gpt-oss:latest) +# - Shared helper methods used by every demo +# +# Usage — run from anywhere (two equivalent forms): +# cd examples && ./calculator_tool_demo.rb +# bundle exec ruby -I examples examples/_demo.rb +# +# Each demo file needs exactly two require statements: +# require_relative 'common' +# require 'shared_tools/' +# +# To override the provider/model in a demo, call configure! after the requires: +# configure!(provider: :anthropic, model: 'claude-sonnet-4-6') +# configure!(model: 'claude-sonnet-4-6') # provider auto-detected from model name +# configure! # Ollama + gpt-oss:latest (default) +# +# Environment variables (all optional): +# OLLAMA_URL Ollama server base URL (default: http://localhost:11434/v1) +# DEMO_MODEL Default model name (default: claude-haiku-4-5) +# RUBY_LLM_DEBUG Set to 'true' for verbose LLM logging + +# --------------------------------------------------------------------------- +# Load path — makes 'require shared_tools/...' work without gem installation +# --------------------------------------------------------------------------- +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) +$LOAD_PATH.unshift __dir__ + +require 'shared_tools' +require 'ruby_llm' +require 'logger' + +# Allow all tool operations without prompting — appropriate for demos only. +SharedTools.auto_execute(true) + +# Active provider and model — updated by configure! +@_provider = nil +@_model = nil + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Print a decorated section banner. +# +# @param text [String] banner text +# @param char [String] border fill character +def title(text, char: '=') + border = char * (text.size + 6) + puts + puts border + puts "#{char * 2} #{text} #{char * 2}" + puts border + puts +end + +# Configure RubyLLM with the given provider and model. +# Call this at the top of a demo to override the defaults. +# +# @param provider [Symbol, nil] :ollama, :anthropic, :openai, etc. +# nil lets RubyLLM infer the provider from the model name +# @param model [String] model name (defaults to DEMO_MODEL env var or claude-haiku-4-5) +def configure!(provider: nil, model: ENV.fetch('DEMO_MODEL', 'claude-haiku-4-5')) + @_provider = provider + @_model = model + + RubyLLM.configure do |config| + config.anthropic_api_key = ENV['ANTHROPIC_API_KEY'] if ENV['ANTHROPIC_API_KEY'] + config.openai_api_key = ENV['OPENAI_API_KEY'] if ENV['OPENAI_API_KEY'] + config.gemini_api_key = ENV['GEMINI_API_KEY'] if ENV['GEMINI_API_KEY'] + config.ollama_api_base = ENV.fetch('OLLAMA_URL', 'http://localhost:11434/v1') + config.default_model = model + config.request_timeout = 300 + config.max_retries = 3 + config.log_level = ENV.fetch('RUBY_LLM_DEBUG', 'false') == 'true' \ + ? Logger::DEBUG \ + : Logger::WARN + end + + @chat = new_chat +end + +# Create a new RubyLLM chat instance using the active provider and model. +# +# @param model [String, nil] override the active model for this chat +# @param provider [Symbol, nil] override the active provider for this chat +# @return [RubyLLM::Chat] +def new_chat(model: nil, provider: nil) + m = model || @_model + p = provider || @_provider + + opts = { model: m } + opts[:provider] = p unless p.nil? + opts[:assume_model_exists] = true if p == :ollama + + RubyLLM.chat(**opts) +end + +# Build a chat instance pre-loaded with one or more tool objects. +# Sets the module-level @chat used by ask(). +# +# @param tools [Array] tool instances to register +# @param model [String, nil] model override +# @param provider [Symbol, nil] provider override +# @return [RubyLLM::Chat] +def build_chat(*tools, model: nil, provider: nil) + @chat = new_chat(model: model, provider: provider) + tools.flatten.each { |t| @chat = @chat.with_tool(t) } + @chat +end + +# Send a prompt to @chat, print the exchange, and return the response. +# Creates a plain chat (no tools) if build_chat has not been called yet. +# +# @param prompt [String] the user message +# @param model [String, nil] model override when @chat is not yet initialised +# @param provider [Symbol, nil] provider override when @chat is not yet initialised +# @return [RubyLLM::Response] +def ask(prompt, model: nil, provider: nil) + @chat ||= new_chat(model: model, provider: provider) + + puts "Prompt:" + puts " #{prompt.strip.gsub("\n", "\n ")}" + puts + + @response = @chat.ask(prompt) + + puts "Response:" + puts " #{@response.content.to_s.strip.gsub("\n", "\n ")}" + puts + + @response +end + +# Convenience alias matching the naming used in the existing example files. +alias test_with_prompt ask + +# --------------------------------------------------------------------------- +# Default configuration — Ollama provider with gpt-oss:latest +# Override in demo files by calling configure! after require_relative 'common' +# --------------------------------------------------------------------------- +configure! + +# --------------------------------------------------------------------------- +# Startup banner +# --------------------------------------------------------------------------- +title "SharedTools Demo Runner" +puts <<~INFO + Provider : #{@_provider || '(auto-detect from model name)'} + Model : #{@_model} + Timeout : #{RubyLLM.config.request_timeout}s + Auto-execute : enabled (demo mode — never use in production) +INFO diff --git a/examples/composite_analysis_tool_demo.rb b/examples/composite_analysis_tool_demo.rb new file mode 100755 index 0000000..7f77a2a --- /dev/null +++ b/examples/composite_analysis_tool_demo.rb @@ -0,0 +1,75 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: CompositeAnalysisTool +# +# Shows how an LLM uses the CompositeAnalysisTool to combine multiple +# analysis strategies — structure detection, pattern recognition, and +# cross-source synthesis — in a single tool call. +# +# Run: +# bundle exec ruby -I examples examples/composite_analysis_tool_demo.rb + +require_relative 'common' +require 'shared_tools/composite_analysis_tool' + + +title "CompositeAnalysisTool Demo" + +@chat = @chat.with_tool(SharedTools::Tools::CompositeAnalysisTool.new) + +title "Sales Data Analysis", char: '-' +ask <<~PROMPT + Analyse the following quarterly sales data for three product lines: + + Quarter | Widget A | Widget B | Widget C + Q1 2025 | 12,400 | 8,200 | 3,100 + Q2 2025 | 14,100 | 7,900 | 4,800 + Q3 2025 | 13,800 | 9,400 | 6,200 + Q4 2025 | 16,500 | 10,100 | 7,900 + + Identify growth trends for each product, which product has the strongest + momentum, and any patterns that suggest strategic action. +PROMPT + +title "Text Pattern Analysis", char: '-' +ask <<~PROMPT + Analyse the following customer support ticket subjects for common patterns, + themes, and urgency signals: + + - "Login button not working on mobile" + - "Can't export data to CSV" + - "Payment failed three times" + - "Dashboard loads slowly" + - "Billing overcharge — urgent" + - "Login issues since yesterday's update" + - "How do I export my data?" + - "App crashes when uploading large files" + - "Wrong amount charged to my card" + - "Performance degraded after update" + + What are the top 3 issue categories? Which need immediate attention? +PROMPT + +title "Cross-Source Synthesis", char: '-' +ask <<~PROMPT + Given these two datasets about the same product launch: + + Dataset A — User Acquisition (week 1-4): + Week 1: 230 signups, 18% conversion + Week 2: 410 signups, 22% conversion + Week 3: 380 signups, 19% conversion + Week 4: 520 signups, 25% conversion + + Dataset B — Support Tickets (week 1-4): + Week 1: 12 tickets, avg resolution 4h + Week 2: 28 tickets, avg resolution 6h + Week 3: 22 tickets, avg resolution 5h + Week 4: 41 tickets, avg resolution 8h + + Synthesise both datasets. As acquisition grows, is support keeping pace? + What is the ticket-per-user ratio trend and what does it indicate? +PROMPT + +title "Done", char: '-' +puts "CompositeAnalysisTool synthesised multiple data sources into actionable insights." diff --git a/examples/composite_analysis_tool_example.rb b/examples/composite_analysis_tool_example.rb deleted file mode 100755 index 9f15049..0000000 --- a/examples/composite_analysis_tool_example.rb +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Example: Using CompositeAnalysisTool with LLM Integration -# -# This example demonstrates how an LLM can perform comprehensive data analysis -# through natural language prompts, orchestrating multiple analysis steps. - -require_relative 'ruby_llm_config' -require 'shared_tools/tools/composite_analysis_tool' - -title "CompositeAnalysisTool Example - LLM-Powered Data Analysis" - -# Register the CompositeAnalysisTool with RubyLLM -tools = [ - SharedTools::Tools::CompositeAnalysisTool.new -] - -# Create a chat instance using ollama_chat helper -@chat = ollama_chat() - -# Add tools to the chat -tools.each { |tool| @chat = @chat.with_tool(tool) } - -# Example 1: Quick analysis of simulated CSV data -title "Example 1: Quick Analysis of Sales Data", bc: '-' -prompt = <<~PROMPT - Perform a quick analysis on 'sales_data.csv' to get basic structure and summary statistics. - Use analysis_type: quick -PROMPT -test_with_prompt prompt - - -# Example 2: Standard analysis with insights -title "Example 2: Standard Analysis with Insights", bc: '-' -prompt = <<~PROMPT - Analyze the data source 'products.csv' with standard analysis. - I want to see the data structure, insights, and visualization suggestions. -PROMPT -test_with_prompt prompt - - -# Example 3: Comprehensive analysis with correlations -title "Example 3: Comprehensive Analysis", bc: '-' -prompt = <<~PROMPT - Perform a comprehensive analysis on 'customer_metrics.csv' including: - - Full data structure analysis - - Statistical insights - - Correlation analysis - - Visualization recommendations -PROMPT -test_with_prompt prompt - - -# Example 4: Web data source analysis -title "Example 4: Analyze Web API Data", bc: '-' -prompt = <<~PROMPT - Analyze data from 'https://api.example.com/data.json' with standard analysis. - Show me the data structure and key insights. -PROMPT -test_with_prompt prompt - - -# Example 5: JSON file analysis -title "Example 5: JSON Data Analysis", bc: '-' -prompt = "Analyze 'user_data.json' and tell me what insights you can find" -test_with_prompt prompt - - -# Example 6: Analysis with custom options -title "Example 6: Custom Analysis Options", bc: '-' -prompt = <<~PROMPT - Analyze 'metrics.csv' with these options: - - Use comprehensive analysis type - - Include correlations - - Limit visualizations to 3 suggestions -PROMPT -test_with_prompt prompt - - -# Example 7: Conversational analysis -title "Example 7: Conversational Data Exploration", bc: '-' - -prompt = "I have a file called 'sales.csv'. What can you tell me about its structure?" -test_with_prompt prompt - -prompt = "Are there any correlations between the numeric columns?" -test_with_prompt prompt - -prompt = "What visualizations would you recommend for this data?" -test_with_prompt prompt - -title "Example completed!" - -puts <<~TAKEAWAYS - - Key Takeaways: - - The LLM performs multi-stage data analysis automatically - - Supports both file paths and web URLs as data sources - - Three analysis levels: quick, standard, comprehensive - - Automatically detects data format (CSV, JSON, text) - - Provides structure analysis, insights, and visualization suggestions - - Includes correlation analysis for comprehensive mode - - The LLM maintains conversational context about the data - - Perfect for exploratory data analysis workflows - - Note: This example uses simulated data for demonstration purposes. - In production, it would read actual files or fetch from web APIs. - -TAKEAWAYS diff --git a/examples/comprehensive_workflow_demo.rb b/examples/comprehensive_workflow_demo.rb new file mode 100755 index 0000000..12f903a --- /dev/null +++ b/examples/comprehensive_workflow_demo.rb @@ -0,0 +1,115 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: Comprehensive Multi-Tool Workflow +# +# Shows how an LLM orchestrates multiple tools together: +# database storage → data analysis → report generation → CSV export. +# +# Run: +# bundle exec ruby -I examples examples/comprehensive_workflow_demo.rb + +require_relative 'common' +require 'shared_tools/database_tool' +require 'shared_tools/disk_tool' +require 'shared_tools/eval_tool' + + +begin + require 'sqlite3' +rescue LoadError + puts "ERROR: Missing sqlite3 gem. Install with: gem install sqlite3" + exit 1 +end + +require 'tmpdir' +require 'fileutils' + +title "Comprehensive Workflow Demo — Multi-Tool LLM Orchestration" +puts "DatabaseTool + DiskTool + EvalTool working together" +puts + +db = SQLite3::Database.new(':memory:') + +class SimpleSqliteDriver < SharedTools::Tools::Database::BaseDriver + def initialize(db:) + @db = db + end + + def perform(statement:) + if statement.match?(/^\s*SELECT/i) + rows = @db.execute(statement) + { status: :ok, result: rows } + else + @db.execute(statement) + { status: :ok, result: "Success (#{@db.changes} rows)" } + end + rescue SQLite3::Exception => e + { status: :error, result: e.message } + end +end + +temp_dir = Dir.mktmpdir('llm_workflow') +db_driver = SimpleSqliteDriver.new(db: db) +disk_driver = SharedTools::Tools::Disk::LocalDriver.new(root: temp_dir) + +@chat = @chat.with_tools( + SharedTools::Tools::DatabaseTool.new(driver: db_driver), + SharedTools::Tools::Disk::FileCreateTool.new(driver: disk_driver), + SharedTools::Tools::Disk::FileWriteTool.new(driver: disk_driver), + SharedTools::Tools::Disk::FileReadTool.new(driver: disk_driver), + SharedTools::Tools::Disk::DirectoryCreateTool.new(driver: disk_driver), + SharedTools::Tools::Disk::DirectoryListTool.new(driver: disk_driver), + SharedTools::Tools::Eval::RubyEvalTool.new +) + +begin + title "Phase 1: Data Preparation", char: '-' + ask <<~PROMPT + Set up a product database: + 1. Create a table 'products' with columns: id (primary key), name (text), price (integer), category (text) + 2. Insert: Laptop Pro $1299 Electronics, Wireless Mouse $29 Electronics, Office Chair $249 Furniture, Desk Lamp $45 Furniture + 3. Tell me how many products you added + PROMPT + + title "Phase 2: Data Analysis", char: '-' + ask <<~PROMPT + Analyse the products database: + 1. Total number of products + 2. Average price by category + 3. Most expensive and cheapest products + PROMPT + + title "Phase 3: Report Generation", char: '-' + ask <<~PROMPT + Generate a markdown report: + 1. Create a 'reports' directory + 2. Create 'product_report.md' in it + 3. Write a report with header "Product Inventory Report", current date, summary stats, and products by category + 4. Show me the report contents + PROMPT + + title "Phase 4: CSV Export", char: '-' + ask <<~PROMPT + Export product data to CSV: + 1. Query all products + 2. Create 'products.csv' in the reports directory + 3. Write CSV with headers: Name,Price,Category + 4. Confirm how many rows were exported + PROMPT + + title "Phase 5: Conversational Multi-Tool Workflow", char: '-' + ask "Find all electronics products and show their names and prices." + ask "Create a file 'electronics_summary.txt' with this information." + ask "List all files in the reports directory." + +rescue => e + puts "\nError: #{e.message}" + puts e.backtrace.first(5) +ensure + db.close + FileUtils.rm_rf(temp_dir) if temp_dir +end + +title "Done", char: '-' +puts "The LLM coordinated DatabaseTool, DiskTool, and EvalTool across a multi-phase workflow." diff --git a/examples/comprehensive_workflow_example.rb b/examples/comprehensive_workflow_example.rb deleted file mode 100755 index 5cff335..0000000 --- a/examples/comprehensive_workflow_example.rb +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Example: Comprehensive Workflow with LLM Integration -# -# This example demonstrates using multiple SharedTools together through -# natural language prompts. The LLM orchestrates: web scraping → database -# storage → data analysis → report generation. - -require_relative 'ruby_llm_config' -require 'tmpdir' - -begin - require 'sqlite3' - require 'shared_tools/tools/database' - require 'shared_tools/tools/disk' - require 'shared_tools/tools/eval' -rescue LoadError => e - title "ERROR: Missing required dependencies for this workflow" - - puts <<~ERROR_MSG - - This example requires the 'sqlite3' gem: - gem install sqlite3 - - Or add to your Gemfile: - gem 'sqlite3' - - Then run: bundle install - #{'=' * 80} - ERROR_MSG - - exit 1 -end - -title "Comprehensive Workflow Example - LLM-Powered" -puts "Web Scraping → Database Storage → Analysis → Report Generation" -puts - -title "Setup: Create database and file system" - -# Create an in-memory SQLite database -db = SQLite3::Database.new(':memory:') - -# Simple SQLite driver -class SimpleSqliteDriver < SharedTools::Tools::Database::BaseDriver - def initialize(db:) - @db = db - end - - def perform(statement:) - if statement.match?(/^\s*SELECT/i) - rows = @db.execute(statement) - { status: :ok, result: rows } - else - @db.execute(statement) - { status: :ok, result: "Success (#{@db.changes} rows)" } - end - rescue SQLite3::Exception => e - { status: :error, result: e.message } - end -end - -db_driver = SimpleSqliteDriver.new(db: db) - -# Create temporary directory for reports -temp_dir = Dir.mktmpdir('llm_workflow') -disk_driver = SharedTools::Tools::Disk::LocalDriver.new(root: temp_dir) - -# Register ALL tools with RubyLLM -tools = [ - # Database tools - SharedTools::Tools::DatabaseTool.new(driver: db_driver), - - # File system tools - SharedTools::Tools::Disk::FileCreateTool.new(driver: disk_driver), - SharedTools::Tools::Disk::FileWriteTool.new(driver: disk_driver), - SharedTools::Tools::Disk::FileReadTool.new(driver: disk_driver), - SharedTools::Tools::Disk::DirectoryCreateTool.new(driver: disk_driver), - SharedTools::Tools::Disk::DirectoryListTool.new(driver: disk_driver), - - # Code evaluation tools - SharedTools::Tools::Eval::RubyEvalTool.new -] - -# Create a chat instance using ollama_chat helper -@chat = ollama_chat() - -# Add tools to the chat -tools.each { |tool| @chat = @chat.with_tool(tool) } - -begin - title "Phase 1: Data Preparation", bc: '-' - prompt = <<~PROMPT - I need you to set up a product database for me: - 1. Create a table called 'products' with columns: id (primary key), name (text), price (integer), category (text) - 2. Insert these products: - - Laptop Pro, $1299, Electronics - - Wireless Mouse, $29, Electronics - - Office Chair, $249, Furniture - - Desk Lamp, $45, Furniture - 3. Tell me how many products you added - PROMPT - test_with_prompt prompt - - # Phase 2: Data Analysis - title "Phase 2: Data Analysis", bc: '-' - prompt = <<~PROMPT - Analyze the products database: - 1. What's the total number of products? - 2. What's the average price by category? - 3. Which product is the most expensive and which is the cheapest? - PROMPT - test_with_prompt prompt - - # Phase 3: Report Generation - title "Phase 3: Report Generation", bc: '-' - prompt = <<~PROMPT - Generate a markdown report about the products: - 1. Create a directory called 'reports' - 2. Create a file called 'product_report.md' in that directory - 3. Write a report that includes: - - A header "Product Inventory Report" - - The current date - - Summary statistics (total products, price range) - - A section for each category listing the products - 4. Show me the report contents when done - PROMPT - test_with_prompt prompt - - # Phase 4: Data Export - title "Phase 4: Data Export", bc: '-' - prompt = <<~PROMPT - Export the product data to CSV format: - 1. Query all products from the database - 2. Create a file called 'products.csv' in the reports directory - 3. Write the data as CSV with headers: Name,Price,Category - 4. Tell me how many products were exported - PROMPT - test_with_prompt prompt - - # Phase 5: Advanced Analysis - title "Phase 5: Advanced Analysis", bc: '-' - prompt = <<~PROMPT - I want to understand the price distribution: - 1. Calculate the price difference between the most and least expensive items - 2. Create a simple price category (budget: <$50, mid: $50-$500, premium: >$500) - 3. Tell me how many products fall into each price category - Use Ruby code evaluation to help with the calculations. - PROMPT - test_with_prompt prompt - - # Phase 6: Conversational Workflow - title "Phase 6: Conversational Multi-Tool Workflow", bc: '-' - - prompt = "Find all electronics products and show me their names and prices." - test_with_prompt prompt - - prompt = "Create a file called 'electronics_summary.txt' with this information." - test_with_prompt prompt - - prompt = "Now list all files in the reports directory." - test_with_prompt prompt - -rescue => e - puts "\nError during workflow: #{e.message}" - puts e.backtrace.first(5) -ensure - # Cleanup - db.close - FileUtils.rm_rf(temp_dir) if temp_dir -end - -title "Workflow Summary", bc: '=' - -puts <<~SUMMARY - - This example demonstrated: - ✓ Multi-phase workflow orchestration through natural language - ✓ Database operations (create, insert, query, analyze) - ✓ File system operations (create dirs, write files, read files) - ✓ Code evaluation for calculations and data processing - ✓ Report generation in multiple formats (Markdown, CSV) - ✓ Conversational context maintenance across operations - - Key Takeaway: - The LLM intelligently coordinates multiple tools to complete - complex workflows that would normally require extensive scripting. - -SUMMARY - -title "Example completed!" diff --git a/examples/computer_tool_demo.rb b/examples/computer_tool_demo.rb new file mode 100755 index 0000000..f943bf7 --- /dev/null +++ b/examples/computer_tool_demo.rb @@ -0,0 +1,75 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: ComputerTool +# +# Shows how an LLM automates mouse movements, clicks, and keyboard input +# through natural language (macOS only — requires accessibility permissions). +# +# Run: +# bundle exec ruby -I examples examples/computer_tool_demo.rb + +require_relative 'common' +require 'shared_tools/computer_tool' + + +unless RUBY_PLATFORM.include?('darwin') + puts "ERROR: ComputerTool currently only supports macOS." + exit 1 +end + +title "ComputerTool Demo — LLM-Powered Desktop Automation" +puts "NOTE: Requires accessibility permissions." +puts " System Preferences > Security & Privacy > Privacy > Accessibility" +puts + +@chat = @chat.with_tool(SharedTools::Tools::ComputerTool.new) + +begin + title "Example 1: Check Mouse Position", char: '-' + ask "Where is my mouse cursor currently located?" + + title "Example 2: Move Mouse and Click", char: '-' + ask "Move the mouse to coordinates (500, 300) and click there." + + title "Example 3: Automated Typing", char: '-' + ask "Type 'Hello, World!' for me." + + title "Example 4: Keyboard Shortcuts", char: '-' + ask "Press Command+C to copy the selected text." + + title "Example 5: Automated Form Filling", char: '-' + ask <<~PROMPT + Fill out a form: + 1. Click at position (400, 200) to focus the first field + 2. Type "John Doe" + 3. Press Tab to move to the next field + 4. Type "john@example.com" + 5. Press Enter to submit + PROMPT + + title "Example 6: Text Selection", char: '-' + ask "Double-click at position (300, 400) to select a word, then copy it with Cmd+C." + + title "Example 7: Page Scrolling", char: '-' + ask "Scroll down the page by 5 clicks, then scroll back up by 2 clicks." + + title "Example 8: Drag and Drop", char: '-' + ask <<~PROMPT + Perform a drag and drop: + 1. Press the mouse button down at (100, 100) + 2. Drag to position (300, 300) + 3. Release the mouse button + PROMPT + + title "Example 9: Conversational Desktop Control", char: '-' + ask "Click at position (500, 500)" + ask "Now type 'test message' there" + ask "Press Enter to submit" + +rescue => e + puts "\nError during computer automation: #{e.message}" +end + +title "Done", char: '-' +puts "ComputerTool let the LLM control mouse and keyboard through natural language." diff --git a/examples/computer_tool_example.rb b/examples/computer_tool_example.rb deleted file mode 100755 index ae1c303..0000000 --- a/examples/computer_tool_example.rb +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Example: Using ComputerTool with LLM Integration -# -# This example demonstrates how an LLM can automate computer interactions -# like mouse movements, clicks, and keyboard input through natural language. -# -# Note: Requires platform-specific automation capabilities (macOS, Linux, Windows) - -require_relative 'ruby_llm_config' - -begin - require 'shared_tools/tools/computer' -rescue LoadError => e - title "ERROR: Missing required dependencies for ComputerTool" - - if RUBY_PLATFORM.include?('darwin') - puts <<~ERROR_MSG - - This example requires the 'macos' gem for macOS: - gem install macos - - Note: You may also need to grant accessibility permissions - System Preferences > Security & Privacy > Privacy > Accessibility - #{'=' * 80} - ERROR_MSG - else - puts <<~ERROR_MSG - - ComputerTool currently only supports macOS. - Manual driver implementation required for other platforms. - #{'=' * 80} - ERROR_MSG - end - - exit 1 -end - -# Check if MacOS automation is available -if RUBY_PLATFORM.include?('darwin') && !defined?(MacOS) - title "ERROR: MacOS gem not loaded" - - puts <<~ERROR_MSG - - Please install: gem install macos - #{'=' * 80} - ERROR_MSG - - exit 1 -end - -title "ComputerTool Example - LLM-Powered Desktop Automation" - -puts <<~NOTE - - NOTE: This example requires accessibility permissions on macOS - System Preferences > Security & Privacy > Privacy > Accessibility - -NOTE - -# Register the ComputerTool with RubyLLM -# Note: ComputerTool is a single tool that handles all computer actions -tools = [ - SharedTools::Tools::ComputerTool.new -] - -# Create a chat instance using ollama_chat helper -@chat = ollama_chat() - -# Add tools to the chat -tools.each { |tool| @chat = @chat.with_tool(tool) } - -begin - # Example 1: Get mouse position - title "Example 1: Check Mouse Position", bc: '-' - prompt = "Where is my mouse cursor currently located?" - test_with_prompt prompt - - # Example 2: Move and click - title "Example 2: Move Mouse and Click", bc: '-' - prompt = "Move the mouse to coordinates (500, 300) and click there." - test_with_prompt prompt - - # Example 3: Type text - title "Example 3: Automated Typing", bc: '-' - prompt = "Type 'Hello, World!' for me." - test_with_prompt prompt - - # Example 4: Keyboard shortcuts - title "Example 4: Keyboard Shortcuts", bc: '-' - prompt = "Press Command+C to copy the selected text." - test_with_prompt prompt - - # Example 5: Form filling workflow - title "Example 5: Automated Form Filling", bc: '-' - prompt = <<~PROMPT - I need to fill out a form: - 1. Click at position (400, 200) to focus the first field - 2. Type "John Doe" - 3. Press Tab to move to the next field - 4. Type "john@example.com" - 5. Press Enter to submit - PROMPT - test_with_prompt prompt - - # Example 6: Text selection - title "Example 6: Text Selection", bc: '-' - prompt = "Double-click at position (300, 400) to select a word, then copy it with Cmd+C." - test_with_prompt prompt - - # Example 7: Scrolling - title "Example 7: Page Scrolling", bc: '-' - prompt = "Scroll down the page by 5 clicks, then scroll back up by 2 clicks." - test_with_prompt prompt - - # Example 8: Right-click context menu - title "Example 8: Context Menu", bc: '-' - prompt = "Right-click at position (600, 400) to open the context menu." - test_with_prompt prompt - - # Example 9: Drag and drop - title "Example 9: Drag and Drop", bc: '-' - prompt = <<~PROMPT - Perform a drag and drop operation: - 1. Press the mouse button down at (100, 100) - 2. Drag to position (300, 300) - 3. Release the mouse button - PROMPT - test_with_prompt prompt - - # Example 10: Conversational automation - title "Example 10: Conversational Desktop Control", bc: '-' - - prompt = "Click at position (500, 500)" - test_with_prompt prompt - - prompt = "Now type 'test message' there" - test_with_prompt prompt - - prompt = "Press Enter to submit" - test_with_prompt prompt - -rescue => e - puts "\nError during computer automation: #{e.message}" - puts "Make sure you have the necessary platform permissions and dependencies." - puts e.backtrace.first(3) -end - -title "Example completed!" - -puts <<~TAKEAWAYS - - Key Takeaways: - - The LLM can control mouse and keyboard through natural language - - Complex desktop automation workflows are simplified - - Form filling and data entry become conversational - - Keyboard shortcuts and text manipulation are intuitive - - Desktop automation is accessible without scripting - - Note: Platform-specific permissions may be required for automation - -TAKEAWAYS diff --git a/examples/cron_tool_demo.rb b/examples/cron_tool_demo.rb new file mode 100755 index 0000000..84efbb9 --- /dev/null +++ b/examples/cron_tool_demo.rb @@ -0,0 +1,34 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: CronTool +# +# Parse, validate, explain, and generate cron expressions. +# +# Run: +# bundle exec ruby -I examples examples/cron_tool_demo.rb + +require_relative 'common' +require 'shared_tools/tools/cron_tool' + + +title "CronTool Demo — parse, validate, explain, and generate cron expressions" + +@chat = @chat.with_tool(SharedTools::Tools::CronTool.new) + +ask "Parse and explain the cron expression '0 9 * * 1-5'." + +ask "Is the expression '*/15 6-22 * * *' valid? If so, what does it mean?" + +ask "What are the next 5 execution times for the cron expression '0 * * * *'?" + +ask "Generate a cron expression for 'every day at 9am'." + +ask "Generate a cron expression for 'every monday at noon'." + +ask "Parse the expression '30 8,12,18 * * 1-5' and describe when it runs." + +ask "Show me the next 3 execution times for '0 0 1 * *' and explain what schedule that represents." + +title "Done", char: '-' +puts "CronTool demonstrated parsing, validation, next-execution calculation, and expression generation." diff --git a/examples/current_date_time_tool_demo.rb b/examples/current_date_time_tool_demo.rb new file mode 100755 index 0000000..a7b958d --- /dev/null +++ b/examples/current_date_time_tool_demo.rb @@ -0,0 +1,34 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: CurrentDateTimeTool +# +# Returns the current date, time, timezone, and related calendar metadata from the system. +# +# Run: +# bundle exec ruby -I examples examples/current_date_time_tool_demo.rb + +require_relative 'common' +require 'shared_tools/tools/current_date_time_tool' + + +title "CurrentDateTimeTool Demo — system date, time, and timezone information" + +@chat = @chat.with_tool(SharedTools::Tools::CurrentDateTimeTool.new) + +ask "What is the current date and time?" + +ask "What day of the week is it today, and what week number of the year is this?" + +ask "Give me only the date-related information: year, month, day, and quarter." + +ask "Give me only the time-related information including the UTC offset." + +ask "What is the current datetime in ISO 8601 format, and what is the equivalent UTC time?" + +ask "What is the current Unix timestamp?" + +ask "Is daylight saving time currently in effect on this system?" + +title "Done", char: '-' +puts "CurrentDateTimeTool demonstrated full, date-only, time-only, ISO 8601, and UTC offset output formats." diff --git a/examples/data_science_kit_demo.rb b/examples/data_science_kit_demo.rb new file mode 100755 index 0000000..66e7e9d --- /dev/null +++ b/examples/data_science_kit_demo.rb @@ -0,0 +1,66 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: DataScienceKit +# +# Shows how an LLM uses the DataScienceKit to perform statistical +# analysis, identify trends, and generate insights from structured data. +# +# Run: +# bundle exec ruby -I examples examples/data_science_kit_demo.rb + +require_relative 'common' +require 'shared_tools/data_science_kit' + + +title "DataScienceKit Demo" + +@chat = @chat.with_tool(SharedTools::Tools::DataScienceKit.new) + +title "Descriptive Statistics", char: '-' +ask <<~PROMPT + Analyse this monthly revenue dataset (in thousands USD) for a SaaS company: + Jan: 42, Feb: 45, Mar: 51, Apr: 48, May: 55, Jun: 62, + Jul: 58, Aug: 67, Sep: 71, Oct: 74, Nov: 69, Dec: 83 + + Calculate: mean, median, standard deviation, min, max, and the + coefficient of variation. Identify any outliers. +PROMPT + +title "Trend Analysis", char: '-' +ask <<~PROMPT + Using the same 12-month revenue series from the previous question, + identify the trend direction, calculate month-over-month growth rates, + and predict revenue for January of the following year using linear regression. +PROMPT + +title "Correlation Analysis", char: '-' +ask <<~PROMPT + Examine the correlation between marketing spend and revenue: + Marketing spend (USD thousands): 8, 9, 11, 10, 12, 15, 13, 17, 18, 19, 16, 22 + Revenue (USD thousands): 42, 45, 51, 48, 55, 62, 58, 67, 71, 74, 69, 83 + + Calculate the correlation coefficient and explain whether marketing + spend is a strong predictor of revenue. +PROMPT + +title "Segmentation", char: '-' +ask <<~PROMPT + Group these 12 months into quarters and calculate: + - Total and average revenue per quarter + - Which quarter showed the strongest growth + - Quarter-over-quarter growth rate +PROMPT + +title "Anomaly Detection", char: '-' +ask <<~PROMPT + Look at this daily user signup data for April (30 days): + 120,135,128,141,118,95,102,156,163,147,138,142,129,88,91, + 172,168,154,161,149,143,137,85,94,178,182,169,175,163,158 + + Identify any anomalous days (potential bot traffic or outages) using + statistical methods, and explain what thresholds you used. +PROMPT + +title "Done", char: '-' +puts "DataScienceKit provided statistical analysis and business insights through natural language." diff --git a/examples/data_science_kit_example.rb b/examples/data_science_kit_example.rb deleted file mode 100755 index e4f1cb1..0000000 --- a/examples/data_science_kit_example.rb +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Example: Using DataScienceKit with LLM Integration -# -# This example demonstrates how an LLM can perform advanced data science -# operations through natural language prompts. - -require_relative 'ruby_llm_config' -require 'shared_tools/tools/data_science_kit' - -title "DataScienceKit Example - LLM-Powered Data Science" - -# Register the DataScienceKit with RubyLLM -tools = [ - SharedTools::Tools::DataScienceKit.new -] - -# Create a chat instance using ollama_chat helper -@chat = ollama_chat() - -# Add tools to the chat -tools.each { |tool| @chat = @chat.with_tool(tool) } - -# Example 1: Statistical summary -title "Example 1: Statistical Summary Analysis", bc: '-' -prompt = <<~PROMPT - Perform a statistical summary analysis on 'customer_data.csv'. - I want to see descriptive statistics, distributions, and outlier detection. -PROMPT -test_with_prompt prompt - - -# Example 2: Correlation analysis -title "Example 2: Correlation Analysis", bc: '-' -prompt = <<~PROMPT - Analyze correlations in 'sales_metrics.csv'. - Use Pearson correlation method and show me the correlation matrix. -PROMPT -test_with_prompt prompt - - -# Example 3: Time series analysis -title "Example 3: Time Series Analysis", bc: '-' -prompt = <<~PROMPT - Perform time series analysis on 'daily_revenue.csv'. - The date column is 'date' and value column is 'revenue'. - Detect trends, seasonality, and forecast the next 7 periods. -PROMPT -test_with_prompt prompt - - -# Example 4: Clustering analysis -title "Example 4: K-Means Clustering", bc: '-' -prompt = <<~PROMPT - Cluster the data in 'customer_segments.csv' into 3 groups. - Use k-means algorithm with Euclidean distance metric. -PROMPT -test_with_prompt prompt - - -# Example 5: Predictive modeling -title "Example 5: Prediction and Regression", bc: '-' -prompt = <<~PROMPT - Build a prediction model using 'housing_data.csv'. - The target column is 'price' and I want to predict house prices. - Use 80% of data for training and 20% for validation. -PROMPT -test_with_prompt prompt - - -# Example 6: Statistical summary with custom parameters -title "Example 6: Custom Statistical Analysis", bc: '-' -prompt = <<~PROMPT - Generate a statistical summary for 'product_performance.csv' with: - - 95% confidence level - - Include quartiles in the analysis - - Use IQR method for outlier detection -PROMPT -test_with_prompt prompt - - -# Example 7: Spearman correlation -title "Example 7: Spearman Correlation Analysis", bc: '-' -prompt = <<~PROMPT - Analyze correlations in 'rankings.csv' using Spearman method - since the data might have non-linear relationships. - Set significance level to 0.01. -PROMPT -test_with_prompt prompt - - -# Example 8: Hierarchical clustering -title "Example 8: Hierarchical Clustering", bc: '-' -prompt = <<~PROMPT - Cluster 'gene_expression.csv' using hierarchical clustering. - Create 5 clusters and use complete linkage method. -PROMPT -test_with_prompt prompt - - -# Example 9: Conversational data science workflow -title "Example 9: Conversational Analysis", bc: '-' - -prompt = "What statistical insights can you provide about 'experiment_results.csv'?" -test_with_prompt prompt - -prompt = "Are there any strong correlations in that data?" -test_with_prompt prompt - -prompt = "Can you cluster the data into meaningful groups?" -test_with_prompt prompt - -title "Example completed!" - -puts <<~TAKEAWAYS - - Key Takeaways: - - The LLM performs comprehensive data science operations - - Supports statistical analysis, correlations, time series, and ML - - Five analysis types: statistical_summary, correlation_analysis, - time_series, clustering, and prediction - - Automatically handles data loading and preprocessing - - Provides detailed results with visualizations recommendations - - Custom parameters for fine-tuned analysis - - The LLM maintains context across analysis steps - - Perfect for exploratory and production ML workflows - - Note: This example uses simulated data for demonstration purposes. - In production, it would work with real datasets and potentially - require additional ML libraries for advanced features. - -TAKEAWAYS diff --git a/examples/database_query_tool_demo.rb b/examples/database_query_tool_demo.rb new file mode 100755 index 0000000..29edfd6 --- /dev/null +++ b/examples/database_query_tool_demo.rb @@ -0,0 +1,79 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: DatabaseQueryTool +# +# Shows how an LLM executes safe, read-only SQL queries through natural +# language (requires sequel + sqlite3 gems). +# +# Run: +# bundle exec ruby -I examples examples/database_query_tool_demo.rb + +require_relative 'common' +require 'shared_tools/database_query_tool' + + +begin + require 'sequel' + require 'sqlite3' +rescue LoadError => e + puts "ERROR: #{e.message}. Install with: gem install sequel sqlite3" + exit 1 +end + +title "DatabaseQueryTool Demo — LLM-Powered Safe SQL Queries" + +db_file = '/tmp/shared_tools_db_demo.sqlite' +File.delete(db_file) if File.exist?(db_file) +db = Sequel.sqlite(db_file) + +db.create_table(:employees) { primary_key :id; String :name; String :department; Integer :salary; Boolean :active } +db.create_table(:departments) { primary_key :id; String :name; String :location } + +db[:departments].insert(name: 'Engineering', location: 'Building A') +db[:departments].insert(name: 'Sales', location: 'Building B') +db[:departments].insert(name: 'Marketing', location: 'Building A') +db[:employees].insert(name: 'Alice Smith', department: 'Engineering', salary: 95000, active: true) +db[:employees].insert(name: 'Bob Johnson', department: 'Engineering', salary: 85000, active: true) +db[:employees].insert(name: 'Carol White', department: 'Sales', salary: 75000, active: true) +db[:employees].insert(name: 'David Brown', department: 'Marketing', salary: 70000, active: true) +db[:employees].insert(name: 'Eve Davis', department: 'Engineering', salary: 105000, active: true) +db[:employees].insert(name: 'Frank Miller', department: 'Sales', salary: 65000, active: false) + +ENV['DATABASE_URL'] = "sqlite://#{db_file}" +@chat = @chat.with_tool(SharedTools::Tools::DatabaseQueryTool.new) + +begin + title "Example 1: List All Employees", char: '-' + ask "Show me all employees in the database using SQL" + + title "Example 2: Filter Active Employees", char: '-' + ask "Query the database for all active employees. Show their names and departments." + + title "Example 3: Average Salary", char: '-' + ask "What's the average salary across all employees? Use SQL to calculate it." + + title "Example 4: Salary by Department", char: '-' + ask "Show me the average salary for each department, grouped by department name" + + title "Example 5: Join Query", char: '-' + ask "Join employees and departments to show each employee's name and department location." + + title "Example 6: Top Earners", char: '-' + ask "Show me the top 3 highest-paid employees ordered by salary descending" + + title "Example 7: Conversational Queries", char: '-' + ask "How many total employees are in the database?" + ask "Which department has the most employees?" + ask "Show me employees earning more than $80,000" + +rescue => e + puts "\nError: #{e.message}" +ensure + db.disconnect if db + ENV.delete('DATABASE_URL') + File.delete(db_file) if File.exist?(db_file) +end + +title "Done", char: '-' +puts "DatabaseQueryTool let the LLM run safe read-only SQL through natural language." diff --git a/examples/database_query_tool_example.rb b/examples/database_query_tool_example.rb deleted file mode 100755 index 18099bc..0000000 --- a/examples/database_query_tool_example.rb +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Example: Using DatabaseQueryTool with LLM Integration -# -# This example demonstrates how an LLM can execute safe, read-only database -# queries through natural language prompts using the Sequel gem. -# -# Note: This example requires the 'sequel' and 'sqlite3' gems: -# gem install sequel sqlite3 - -require_relative 'ruby_llm_config' - -begin - require 'sequel' - require 'sqlite3' - require 'shared_tools/tools/database_query_tool' -rescue LoadError => e - title "ERROR: Missing required dependencies for DatabaseQueryTool" - - puts <<~ERROR_MSG - - This example requires the 'sequel' and 'sqlite3' gems: - gem install sequel sqlite3 - - Or add to your Gemfile: - gem 'sequel' - gem 'sqlite3' - - Then run: bundle install - #{'=' * 80} - ERROR_MSG - - exit 1 -end - -title "DatabaseQueryTool Example - LLM-Powered Safe Queries" - -# Create a SQLite database and populate with sample data -# Using a temporary file so the tool can connect to the same database -db_file = '/tmp/shared_tools_db_example.sqlite' -File.delete(db_file) if File.exist?(db_file) -db = Sequel.sqlite(db_file) - -# Create tables -db.create_table :employees do - primary_key :id - String :name - String :department - Integer :salary - Boolean :active -end - -db.create_table :departments do - primary_key :id - String :name - String :location -end - -# Insert sample data -db[:departments].insert(name: 'Engineering', location: 'Building A') -db[:departments].insert(name: 'Sales', location: 'Building B') -db[:departments].insert(name: 'Marketing', location: 'Building A') - -db[:employees].insert(name: 'Alice Smith', department: 'Engineering', salary: 95000, active: true) -db[:employees].insert(name: 'Bob Johnson', department: 'Engineering', salary: 85000, active: true) -db[:employees].insert(name: 'Carol White', department: 'Sales', salary: 75000, active: true) -db[:employees].insert(name: 'David Brown', department: 'Marketing', salary: 70000, active: true) -db[:employees].insert(name: 'Eve Davis', department: 'Engineering', salary: 105000, active: true) -db[:employees].insert(name: 'Frank Miller', department: 'Sales', salary: 65000, active: false) - -# Set DATABASE_URL for the tool to use -ENV['DATABASE_URL'] = "sqlite://#{db_file}" - -# Register the DatabaseQueryTool with RubyLLM -tools = [ - SharedTools::Tools::DatabaseQueryTool.new -] - -# Create a chat instance using ollama_chat helper -@chat = ollama_chat() - -# Add tools to the chat -tools.each { |tool| @chat = @chat.with_tool(tool) } - -begin - # Example 1: Simple SELECT query - title "Example 1: List All Employees", bc: '-' - prompt = "Show me all employees in the database using SQL" - test_with_prompt prompt - - - # Example 2: Filtered query - title "Example 2: Filter Active Employees", bc: '-' - prompt = "Query the database for all active employees. Show their names and departments." - test_with_prompt prompt - - - # Example 3: Aggregate query - title "Example 3: Calculate Average Salary", bc: '-' - prompt = "What's the average salary across all employees? Use SQL to calculate it." - test_with_prompt prompt - - - # Example 4: Grouped aggregation - title "Example 4: Salary Statistics by Department", bc: '-' - prompt = "Show me the average salary for each department, grouped by department name" - test_with_prompt prompt - - - # Example 5: Parameterized query for security - title "Example 5: Parameterized Query", bc: '-' - prompt = <<~PROMPT - Query employees in the Engineering department using a parameterized query - to prevent SQL injection. Use a placeholder for the department name. - PROMPT - test_with_prompt prompt - - - # Example 6: Join query - title "Example 6: Join Employees with Departments", bc: '-' - prompt = <<~PROMPT - Join the employees and departments tables to show employee names - with their department location. - PROMPT - test_with_prompt prompt - - - # Example 7: Complex query with ordering - title "Example 7: Top Earners", bc: '-' - prompt = "Show me the top 3 highest-paid employees ordered by salary descending" - test_with_prompt prompt - - - # Example 8: Query with LIMIT - title "Example 8: Limited Results", bc: '-' - prompt = "Get the first 5 employees from the database, but limit the results to 5 rows" - test_with_prompt prompt - - - # Example 9: COUNT query - title "Example 9: Count Records", bc: '-' - prompt = "How many employees do we have in each department? Use COUNT and GROUP BY." - test_with_prompt prompt - - - # Example 10: Conversational queries - title "Example 10: Conversational Database Interaction", bc: '-' - - prompt = "How many total employees are in the database?" - test_with_prompt prompt - - prompt = "Which department has the most employees?" - test_with_prompt prompt - - prompt = "Show me employees earning more than $80,000" - test_with_prompt prompt - -rescue => e - puts "\nError during database operations: #{e.message}" - puts e.backtrace.first(3) -ensure - # Cleanup - db.disconnect if db - ENV.delete('DATABASE_URL') - File.delete(db_file) if File.exist?(db_file) -end - -title "Example completed!" - -puts <<~TAKEAWAYS - - Key Takeaways: - - The LLM executes safe, read-only SQL queries via natural language - - Only SELECT statements are allowed (no INSERT, UPDATE, DELETE) - - Supports parameterized queries for SQL injection prevention - - Automatic LIMIT enforcement to prevent excessive results - - Query timeout support to prevent long-running queries - - Works with any database supported by Sequel - - Connection management is handled automatically - - The LLM maintains conversational context about queries - - Perfect for AI-assisted data analysis and reporting - -TAKEAWAYS diff --git a/examples/database_tool_example.rb b/examples/database_tool_demo.rb similarity index 80% rename from examples/database_tool_example.rb rename to examples/database_tool_demo.rb index 50b9d26..9aeffba 100755 --- a/examples/database_tool_example.rb +++ b/examples/database_tool_demo.rb @@ -1,25 +1,29 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# Example: Using DatabaseTool with LLM Integration +# Demo: Using DatabaseTool with LLM Integration # -# This example demonstrates how an LLM can execute SQL operations +# This demo demonstrates how an LLM can execute SQL operations # through natural language prompts using the DatabaseTool. # -# Note: This example requires the 'sqlite3' gem: +# Requires: +# require_relative 'common' +# require 'shared_tools/database' +# +# Note: This demo requires the 'sqlite3' gem: # gem install sqlite3 -require_relative 'ruby_llm_config' +require_relative 'common' begin require 'sqlite3' - require 'shared_tools/tools/database' + require 'shared_tools/database' rescue LoadError => e title "ERROR: Missing required dependencies for DatabaseTool" puts <<~ERROR_MSG - This example requires the 'sqlite3' gem: + This demo requires the 'sqlite3' gem: gem install sqlite3 Or add to your Gemfile: @@ -32,7 +36,8 @@ exit 1 end -title "DatabaseTool Example - LLM-Powered SQL Operations" + +title "DatabaseTool Demo - LLM-Powered SQL Operations" # Create a SQLite3 database driver class SimpleSqliteDriver < SharedTools::Tools::Database::BaseDriver @@ -88,20 +93,20 @@ def format_results(rows) SharedTools::Tools::DatabaseTool.new(driver: driver) ] -# Create a chat instance using ollama_chat helper -@chat = ollama_chat() +# Create a chat instance using new_chat helper +@chat = new_chat() # Add tools to the chat tools.each { |tool| @chat = @chat.with_tool(tool) } begin # Example 1: Create a table - title "Example 1: Create a Database Table", bc: '-' + title "Example 1: Create a Database Table", char: '-' prompt = "Create a table called 'users' with columns: id (primary key), name (text), email (text), and age (integer)." test_with_prompt prompt # Example 2: Insert data - title "Example 2: Insert Records", bc: '-' + title "Example 2: Insert Records", char: '-' prompt = <<~PROMPT Insert three users into the users table: - Alice Smith, alice@example.com, age 30 @@ -111,22 +116,22 @@ def format_results(rows) test_with_prompt prompt # Example 3: Query data - title "Example 3: Retrieve All Users", bc: '-' + title "Example 3: Retrieve All Users", char: '-' prompt = "Show me all the users in the database." test_with_prompt prompt # Example 4: Filtered query - title "Example 4: Filtered Query", bc: '-' + title "Example 4: Filtered Query", char: '-' prompt = "Find all users who are older than 25 and show their names and ages, ordered by age." test_with_prompt prompt # Example 5: Update data - title "Example 5: Update Records", bc: '-' + title "Example 5: Update Records", char: '-' prompt = "Update Alice Smith's age to 31." test_with_prompt prompt # Example 6: Create related table - title "Example 6: Create Related Table", bc: '-' + title "Example 6: Create Related Table", char: '-' prompt = <<~PROMPT Create a posts table with: - id (primary key) @@ -137,7 +142,7 @@ def format_results(rows) test_with_prompt prompt # Example 7: Insert related data - title "Example 7: Insert Related Data", bc: '-' + title "Example 7: Insert Related Data", char: '-' prompt = <<~PROMPT Add some posts to the posts table: - User 1: "Hello World", "My first post" @@ -147,17 +152,17 @@ def format_results(rows) test_with_prompt prompt # Example 8: Join query - title "Example 8: Join Query", bc: '-' + title "Example 8: Join Query", char: '-' prompt = "Show me all posts with the author's name." test_with_prompt prompt # Example 9: Aggregate query - title "Example 9: Aggregate Calculations", bc: '-' + title "Example 9: Aggregate Calculations", char: '-' prompt = "How many users are in the database and what's their average age?" test_with_prompt prompt # Example 10: Conversational database interaction - title "Example 10: Conversational Database Operations", bc: '-' + title "Example 10: Conversational Database Operations", char: '-' prompt = "Count how many posts each user has written." test_with_prompt prompt @@ -177,7 +182,7 @@ def format_results(rows) puts "\nDatabase connection closed." end -title "Example completed!" +title "Demo completed!" puts <<~TAKEAWAYS diff --git a/examples/devops_toolkit_demo.rb b/examples/devops_toolkit_demo.rb new file mode 100755 index 0000000..9284e50 --- /dev/null +++ b/examples/devops_toolkit_demo.rb @@ -0,0 +1,56 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: DevopsToolkit +# +# Shows how an LLM performs DevOps operations — deployments, health checks, +# log analysis, metrics collection, and rollbacks — with built-in safety +# mechanisms for production environments. +# +# Run: +# bundle exec ruby -I examples examples/devops_toolkit_demo.rb + +require_relative 'common' +require 'shared_tools/devops_toolkit' + + +title "DevopsToolkit Demo — LLM-Powered DevOps Operations" + +@chat = @chat.with_tool(SharedTools::Tools::DevopsToolkit.new) + +title "Example 1: Health Check — Development", char: '-' +ask "Perform a health check on the development environment and report service status." + +title "Example 2: Deploy to Staging", char: '-' +ask "Deploy version 2.5.0 to the staging environment using the 'main' branch with rollback on failure." + +title "Example 3: Analyze Application Logs", char: '-' +ask "Analyze logs for the staging environment over the last 24 hours. Look for errors and database connection issues." + +title "Example 4: Collect System Metrics", char: '-' +ask "Collect CPU, memory, and request metrics from staging over the last hour in JSON format." + +title "Example 5: Rollback to Previous Version", char: '-' +ask "Rollback the staging deployment to the previous stable version due to a critical bug." + +title "Example 6: Production Health Check", char: '-' +ask "Check the health status of the production environment and verify all services are running." + +title "Example 7: Production Deploy — Safety Check", char: '-' +ask "Deploy version 2.5.1 to production using the 'release' branch." + +title "Example 8: Production Deploy — With Confirmation", char: '-' +ask <<~PROMPT + Deploy version 2.5.1 to production with explicit confirmation. + I confirm this is a production operation. + Use: production_confirmed: true, branch: 'release', rollback_on_failure: true +PROMPT + +title "Example 9: Conversational DevOps Workflow", char: '-' +ask "What's the current health status of staging?" +ask "Are there any recent errors in the staging logs?" +ask "Deploy the latest version to staging" +ask "Verify the deployment was successful by checking health again" + +title "Done", char: '-' +puts "DevopsToolkit performed DevOps operations safely through natural language." diff --git a/examples/devops_toolkit_example.rb b/examples/devops_toolkit_example.rb deleted file mode 100755 index ee20668..0000000 --- a/examples/devops_toolkit_example.rb +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Example: Using DevopsToolkit with LLM Integration -# -# This example demonstrates how an LLM can perform DevOps operations -# through natural language prompts with built-in safety mechanisms. - -require_relative 'ruby_llm_config' -require 'shared_tools/tools/devops_toolkit' - -title "DevopsToolkit Example - LLM-Powered DevOps Operations" - -# Register the DevopsToolkit with RubyLLM -tools = [ - SharedTools::Tools::DevopsToolkit.new -] - -# Create a chat instance using ollama_chat helper -@chat = ollama_chat() - -# Add tools to the chat -tools.each { |tool| @chat = @chat.with_tool(tool) } - -# Example 1: Health check in development -title "Example 1: Health Check - Development Environment", bc: '-' -prompt = <<~PROMPT - Perform a health check on the development environment. - Check all critical services and report their status. -PROMPT -test_with_prompt prompt - - -# Example 2: Deploy to staging -title "Example 2: Deploy to Staging", bc: '-' -prompt = <<~PROMPT - Deploy version 2.5.0 to the staging environment. - Use the 'main' branch and enable rollback on failure. -PROMPT -test_with_prompt prompt - - -# Example 3: Log analysis -title "Example 3: Analyze Application Logs", bc: '-' -prompt = <<~PROMPT - Analyze logs for the staging environment over the last 24 hours. - Look for error-level messages and search for any database connection issues. -PROMPT -test_with_prompt prompt - - -# Example 4: Collect metrics -title "Example 4: Collect System Metrics", bc: '-' -prompt = <<~PROMPT - Collect system and application metrics from the staging environment. - Get CPU, memory, and request metrics over the last hour in JSON format. -PROMPT -test_with_prompt prompt - - -# Example 5: Rollback operation -title "Example 5: Rollback to Previous Version", bc: '-' -prompt = <<~PROMPT - Rollback the staging deployment to the previous stable version. - This is needed because we found a critical bug. -PROMPT -test_with_prompt prompt - - -# Example 6: Production health check (safe operation) -title "Example 6: Production Health Check", bc: '-' -prompt = <<~PROMPT - Check the health status of the production environment. - I need to verify all services are running properly. -PROMPT -test_with_prompt prompt - - -# Example 7: Attempting production deploy without confirmation (will fail safely) -title "Example 7: Production Deploy - Safety Check", bc: '-' -prompt = <<~PROMPT - Deploy version 2.5.1 to production using the 'release' branch. -PROMPT -test_with_prompt prompt - - -# Example 8: Production deploy with proper confirmation -title "Example 8: Production Deploy - With Confirmation", bc: '-' -prompt = <<~PROMPT - Deploy version 2.5.1 to production with explicit confirmation. - I understand this is a production operation and I confirm it. - Use options: production_confirmed: true, branch: 'release', rollback_on_failure: true -PROMPT -test_with_prompt prompt - - -# Example 9: Development operations (no restrictions) -title "Example 9: Development Environment Operations", bc: '-' -prompt = "Deploy the latest code from 'feature-branch' to development" -test_with_prompt prompt - -prompt = "Check the deployment logs for development" -test_with_prompt prompt - - -# Example 10: Conversational DevOps workflow -title "Example 10: Conversational DevOps", bc: '-' - -prompt = "What's the current health status of staging?" -test_with_prompt prompt - -prompt = "Are there any recent errors in the staging logs?" -test_with_prompt prompt - -prompt = "Deploy the latest version to staging" -test_with_prompt prompt - -prompt = "Verify the deployment was successful by checking health again" -test_with_prompt prompt - -title "Example completed!" - -puts <<~TAKEAWAYS - - Key Takeaways: - - The LLM performs DevOps operations through natural language - - Safety mechanisms protect production environments - - Production operations require explicit confirmation - - All operations are logged with unique operation IDs - - Supports deployments, rollbacks, health checks, logs, and metrics - - Environment-specific restrictions prevent accidents - - The LLM maintains context across DevOps operations - - Perfect for AI-assisted infrastructure management - - Built-in audit trail for compliance requirements - - Safety Features Demonstrated: - - Production confirmation requirement - - Environment validation - - Operation logging and tracking - - Rollback capabilities - - Health check integration - -TAKEAWAYS diff --git a/examples/disk_tool_demo.rb b/examples/disk_tool_demo.rb new file mode 100755 index 0000000..7e3cd30 --- /dev/null +++ b/examples/disk_tool_demo.rb @@ -0,0 +1,83 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: DiskTool +# +# Shows how an LLM performs file system operations — create, read, write, +# move, and delete files and directories — through natural language. +# All operations are sandboxed to a temporary directory for safety. +# +# Run: +# bundle exec ruby -I examples examples/disk_tool_demo.rb + +require_relative 'common' +require 'shared_tools/disk_tool' + + +require 'tmpdir' +require 'fileutils' + +title "DiskTool Demo — LLM-Powered File Operations" + +temp_dir = Dir.mktmpdir('disk_tool_demo') +puts "Working directory: #{temp_dir}" +puts + +disk_driver = SharedTools::Tools::Disk::LocalDriver.new(root: temp_dir) + +@chat = @chat.with_tools( + SharedTools::Tools::Disk::FileCreateTool.new(driver: disk_driver), + SharedTools::Tools::Disk::FileReadTool.new(driver: disk_driver), + SharedTools::Tools::Disk::FileWriteTool.new(driver: disk_driver), + SharedTools::Tools::Disk::FileReplaceTool.new(driver: disk_driver), + SharedTools::Tools::Disk::FileMoveTool.new(driver: disk_driver), + SharedTools::Tools::Disk::FileDeleteTool.new(driver: disk_driver), + SharedTools::Tools::Disk::DirectoryCreateTool.new(driver: disk_driver), + SharedTools::Tools::Disk::DirectoryListTool.new(driver: disk_driver), + SharedTools::Tools::Disk::DirectoryMoveTool.new(driver: disk_driver), + SharedTools::Tools::Disk::DirectoryDeleteTool.new(driver: disk_driver) +) + +begin + title "Example 1: Create Project Structure", char: '-' + ask <<~PROMPT + Create a Ruby project structure: + - A directory called 'my_app' with lib, spec, and bin subdirectories + - A README.md with "# My App" as content + - A Gemfile with just "source 'https://rubygems.org'" + PROMPT + + title "Example 2: Create and Write Content", char: '-' + ask "Create a file called 'notes.txt' and write 'Meeting at 3pm tomorrow' in it." + + title "Example 3: Read File Contents", char: '-' + ask "What's in the notes.txt file?" + + title "Example 4: Update File Content", char: '-' + ask "In notes.txt, change '3pm' to '4pm'." + + title "Example 5: List Directory", char: '-' + ask "Show me what files and directories are in the my_app folder." + + title "Example 6: Organize Files", char: '-' + ask "Move notes.txt into the my_app directory." + + title "Example 7: Multi-Step Workflow", char: '-' + ask <<~PROMPT + 1. Create a directory called 'docs' + 2. Create a file called 'setup.md' inside it + 3. Write installation instructions in that file + PROMPT + + title "Example 8: Conversational File Management", char: '-' + ask "Create a file called 'todo.txt' with the text 'Buy groceries'" + ask "Add 'Call dentist' to that file." + ask "Show me what's in the file now." + +ensure + FileUtils.rm_rf(temp_dir) + puts "Temporary directory removed." +end + +title "Done", char: '-' +puts "DiskTool let the LLM manage files and directories through natural language." diff --git a/examples/disk_tool_example.rb b/examples/disk_tool_example.rb deleted file mode 100755 index a2761ad..0000000 --- a/examples/disk_tool_example.rb +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Example: Using DiskTool with LLM Integration -# -# This example demonstrates how an LLM can perform file system operations -# through natural language prompts using the DiskTool. - -require_relative 'ruby_llm_config' -require 'tmpdir' -require 'fileutils' - -title "DiskTool Example - LLM-Powered File Operations" - -# Create a temporary directory for our examples -temp_dir = Dir.mktmpdir('disk_tool_demo') -puts "Working in temporary directory: #{temp_dir}" -puts - -# Initialize the disk tool with a local driver -# The LocalDriver is sandboxed to the specified root directory for security -disk_driver = SharedTools::Tools::Disk::LocalDriver.new(root: temp_dir) - -# Register the DiskTools with RubyLLM -tools = [ - SharedTools::Tools::Disk::FileCreateTool.new(driver: disk_driver), - SharedTools::Tools::Disk::FileReadTool.new(driver: disk_driver), - SharedTools::Tools::Disk::FileWriteTool.new(driver: disk_driver), - SharedTools::Tools::Disk::FileReplaceTool.new(driver: disk_driver), - SharedTools::Tools::Disk::FileMoveTool.new(driver: disk_driver), - SharedTools::Tools::Disk::FileDeleteTool.new(driver: disk_driver), - SharedTools::Tools::Disk::DirectoryCreateTool.new(driver: disk_driver), - SharedTools::Tools::Disk::DirectoryListTool.new(driver: disk_driver), - SharedTools::Tools::Disk::DirectoryMoveTool.new(driver: disk_driver), - SharedTools::Tools::Disk::DirectoryDeleteTool.new(driver: disk_driver) -] - -# Create a chat instance using ollama_chat helper -@chat = ollama_chat() - -# Add tools to the chat -tools.each { |tool| @chat = @chat.with_tool(tool) } - -begin - # Example 1: Create a project structure - title "Example 1: Create Project Structure", bc: '-' - prompt = <<~PROMPT - Create a new Ruby project structure with the following: - - A directory called 'my_app' - - Inside it: lib, spec, and bin directories - - A README.md file with "# My App" as content - - A Gemfile with just "source 'https://rubygems.org'" - PROMPT - test_with_prompt prompt - - # Example 2: Create and write a file - title "Example 2: Create and Write Content", bc: '-' - prompt = "Create a file called 'notes.txt' and write 'Meeting at 3pm tomorrow' in it." - test_with_prompt prompt - - # Example 3: Read file contents - title "Example 3: Read File Contents", bc: '-' - prompt = "What's in the notes.txt file?" - test_with_prompt prompt - - # Example 4: Update file content - title "Example 4: Update File Content", bc: '-' - prompt = "In the notes.txt file, change '3pm' to '4pm'." - test_with_prompt prompt - - # Example 5: List directory contents - title "Example 5: List Directory", bc: '-' - prompt = "Show me what files and directories are in the my_app folder." - test_with_prompt prompt - - # Example 6: Organize files - title "Example 6: Organize Files", bc: '-' - prompt = "Move the notes.txt file into the my_app directory." - test_with_prompt prompt - - # Example 7: Multi-step workflow - title "Example 7: Multi-Step Workflow", bc: '-' - prompt = <<~PROMPT - I need to: - 1. Create a new directory called 'docs' - 2. Create a file in it called 'setup.md' - 3. Write installation instructions in that file - Can you do that for me? - PROMPT - test_with_prompt prompt - - # Example 8: Conversational context - title "Example 8: Conversational File Management", bc: '-' - - prompt = "Create a file called 'todo.txt' with the text 'Buy groceries'" - test_with_prompt prompt - - prompt = "Now add 'Call dentist' to that file." - test_with_prompt prompt - - prompt = "Show me what's in the file now." - test_with_prompt prompt - - # Example 9: List all created files - title "Example 9: Review All Files", bc: '-' - prompt = "Can you list all the files and directories we've created in this session?" - test_with_prompt prompt - -ensure - # Cleanup: Remove temporary directory - title "Cleaning up temporary directory...", bc: '-' - FileUtils.rm_rf(temp_dir) - puts "Temporary directory removed: #{temp_dir}" -end - -title "Example completed!" - -puts <<~TAKEAWAYS - - Key Takeaways: - - The LLM can perform complex file operations through natural language - - Security is built-in with sandboxed directory access - - Multi-step workflows are handled intelligently - - The LLM maintains context about files and operations - - File management becomes conversational and intuitive - -TAKEAWAYS diff --git a/examples/dns_tool_demo.rb b/examples/dns_tool_demo.rb new file mode 100755 index 0000000..8232511 --- /dev/null +++ b/examples/dns_tool_demo.rb @@ -0,0 +1,34 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: DnsTool +# +# Perform DNS lookups, reverse lookups, and record queries (A, MX, TXT, NS, CNAME). +# +# Run: +# bundle exec ruby -I examples examples/dns_tool_demo.rb + +require_relative 'common' +require 'shared_tools/tools/dns_tool' + + +title "DnsTool Demo — DNS lookups, reverse queries, and record inspection" + +@chat = @chat.with_tool(SharedTools::Tools::DnsTool.new) + +ask "Look up the A records for 'ruby-lang.org'." + +ask "Look up the IPv6 (AAAA) addresses for 'google.com'." + +ask "What are the MX (mail exchange) records for 'gmail.com'? List them in priority order." + +ask "Get the TXT records for 'github.com' and explain what they are used for." + +ask "Who are the authoritative nameservers (NS records) for 'cloudflare.com'?" + +ask "Perform a reverse DNS lookup on the IP address 8.8.8.8 and tell me the hostname." + +ask "Get all available DNS records for 'example.com'." + +title "Done", char: '-' +puts "DnsTool demonstrated A, AAAA, MX, TXT, NS, reverse, and all-records DNS queries." diff --git a/examples/doc_tool_demo.rb b/examples/doc_tool_demo.rb new file mode 100755 index 0000000..b73caa2 --- /dev/null +++ b/examples/doc_tool_demo.rb @@ -0,0 +1,120 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: DocTool +# +# Shows how an LLM uses the DocTool to read and reason over document +# content. This demo uses the text_read action on a synthesised reference +# document (written to a temp file) to demonstrate Q&A and summarisation. +# +# For PDF support install: gem install pdf-reader +# +# Run: +# bundle exec ruby -I examples examples/doc_tool_demo.rb + +require_relative 'common' +require 'shared_tools/doc_tool' + + +require 'tmpdir' +require 'fileutils' + +title "DocTool Demo" + +work_dir = Dir.mktmpdir('doc_tool_demo_') +doc_path = File.join(work_dir, 'ruby_style_guide.txt') + +File.write(doc_path, <<~DOCUMENT) + Ruby Style Guide — Quick Reference + ==================================== + + 1. NAMING CONVENTIONS + - Classes and modules: CamelCase (e.g. MyClass, HttpClient) + - Methods and variables: snake_case (e.g. my_method, user_name) + - Constants: SCREAMING_SNAKE_CASE (e.g. MAX_RETRIES, BASE_URL) + - Predicates should end with ? (e.g. valid?, empty?) + - Dangerous methods should end with ! (e.g. save!, destroy!) + + 2. INDENTATION & FORMATTING + - Use 2 spaces per indentation level (never tabs) + - Maximum line length: 120 characters + - One blank line between method definitions + - Two blank lines between class definitions + + 3. STRINGS + - Prefer single quotes unless interpolation is needed + - Use double quotes when the string contains escape sequences + - Use heredocs (<<~TEXT) for multi-line strings + - Freeze string literals: # frozen_string_literal: true + + 4. CONDITIONALS + - Prefer guard clauses over deeply nested if/else + - Use unless for simple negations (avoid unless ... else) + - Use ternary operator only for trivial expressions + - One-line if/unless for single-line bodies + + 5. METHODS + - Keep methods short — ideally under 10 lines + - Use keyword arguments for methods with more than 2 parameters + - Return early rather than wrapping code in an if block + - Avoid using return in the last line (implicit return) + + 6. BLOCKS & COLLECTIONS + - Use { } for single-line blocks, do...end for multi-line + - Prefer map/select/reject over manual array building + - Use each_with_object or inject for complex accumulations + - Avoid mutation where functional alternatives exist + + 7. CLASSES + - Use attr_accessor, attr_reader, attr_writer appropriately + - Keep initialize simple — extract complex setup to private methods + - Order: class methods, initialize, public, protected, private + - Prefer composition over inheritance where practical + + 8. ERROR HANDLING + - Rescue StandardError, not Exception + - Be specific about what you rescue + - Always log or re-raise in rescue blocks + - Avoid rescuing in a loop — rescue once at the boundary + + 9. TESTING + - Name tests clearly: describes what and under what condition + - One assertion per test where possible + - Use factories over fixtures for test data + - Test behaviour, not implementation details + + 10. TOOLS + - Run RuboCop before every commit + - Use Bundler for dependency management + - Keep the Gemfile.lock in version control + - Use semantic versioning for gem releases +DOCUMENT + +@chat = @chat.with_tool(SharedTools::Tools::DocTool.new) + +begin + title "Read & Summarise", char: '-' + ask "Read the document at '#{doc_path}' and give me a one-paragraph summary." + + title "Naming Rules", char: '-' + ask "Read '#{doc_path}'. What naming convention should I use for constants? Give three examples." + + title "Method Guidelines", char: '-' + ask "From '#{doc_path}', what are the recommendations for writing methods? List each point." + + title "Error Handling", char: '-' + ask "Summarise the error handling section of '#{doc_path}' in plain English." + + title "Quick Reference Card", char: '-' + ask <<~PROMPT + Read '#{doc_path}' and produce a cheat-sheet with one key rule from each of + the 10 sections. Format it as a numbered list. + PROMPT + +ensure + FileUtils.rm_rf(work_dir) + puts "\nTemporary document removed." +end + +title "Done", char: '-' +puts "DocTool let the LLM read, comprehend, and answer questions about a text document." diff --git a/examples/doc_tool_example.rb b/examples/doc_tool_example.rb deleted file mode 100755 index 03d9d92..0000000 --- a/examples/doc_tool_example.rb +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Example: Using DocTool with LLM Integration -# -# This example demonstrates how an LLM can use the DocTool to read and -# extract information from PDF documents through natural language prompts. - -require_relative 'ruby_llm_config' - -begin - require 'pdf-reader' - require 'shared_tools/tools/doc' -rescue LoadError => e - title "ERROR: Missing required dependencies for DocTool" - - puts <<~ERROR_MSG - - This example requires the 'pdf-reader' gem: - gem install pdf-reader - - Or add to your Gemfile: - gem 'pdf-reader' - - Then run: bundle install - #{'=' * 80} - ERROR_MSG - - exit 1 -end - -title "DocTool Example - LLM-Powered PDF Processing" - -# Path to a sample PDF (using the test fixture) -sample_pdf = File.expand_path('../test/fixtures/test.pdf', __dir__) - -unless File.exist?(sample_pdf) - puts "ERROR: Sample PDF not found at #{sample_pdf}" - puts "Please ensure a PDF file exists at that location to run this example." - exit 1 -end - -puts "Using sample PDF: #{sample_pdf}" -puts - -# Register the PdfReaderTool with RubyLLM -tools = [ - SharedTools::Tools::Doc::PdfReaderTool.new -] - -# Create a chat instance using ollama_chat helper -@chat = ollama_chat() - -# Add tools to the chat -tools.each { |tool| @chat = @chat.with_tool(tool) } - -# Example 1: Extract content from first page -title "Example 1: Read First Page", bc: '-' -prompt = "Please read the first page of the PDF document at '#{sample_pdf}' and tell me what it's about." -test_with_prompt prompt - -# Example 2: Search for specific information -title "Example 2: Search for Specific Content", bc: '-' -prompt = "Read pages 1-3 of '#{sample_pdf}' and tell me if there are any section headers or important titles." -test_with_prompt prompt - -# Example 3: Count pages -title "Example 3: Document Statistics", bc: '-' -prompt = "How many total pages are in the PDF document at '#{sample_pdf}'?" -test_with_prompt prompt - -# Example 4: Extract and summarize -title "Example 4: Content Summarization", bc: '-' -prompt = "Read the first 2 pages of '#{sample_pdf}' and give me a brief summary of the main topics." -test_with_prompt prompt - -# Example 5: Find specific keywords -title "Example 5: Keyword Search", bc: '-' -prompt = "Search the first 3 pages of '#{sample_pdf}' for any mentions of numbers or statistics." -test_with_prompt prompt - -# Example 6: Conversational context -title "Example 6: Multi-Turn Conversation", bc: '-' - -prompt = "Read page 1 of '#{sample_pdf}' for me." -test_with_prompt prompt - -prompt = "Based on what you just read, what are the key takeaways?" -test_with_prompt prompt - -# Example 7: Compare pages -title "Example 7: Compare Content Across Pages", bc: '-' -prompt = "Read pages 1 and 2 of '#{sample_pdf}' and tell me how they differ in content or structure." -test_with_prompt prompt - -title "Example completed!" - -puts <<~TAKEAWAYS - - Key Takeaways: - - The LLM can extract and understand PDF content through natural language - - Complex document analysis tasks are simplified with conversational prompts - - The LLM maintains context about the document across multiple queries - - Page-specific or multi-page extraction is handled intelligently - - Document understanding goes beyond simple text extraction - -TAKEAWAYS diff --git a/examples/error_handling_tool_demo.rb b/examples/error_handling_tool_demo.rb new file mode 100755 index 0000000..bcdcaf6 --- /dev/null +++ b/examples/error_handling_tool_demo.rb @@ -0,0 +1,52 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: ErrorHandlingTool +# +# Shows comprehensive error handling patterns: input validation, network +# retries with exponential backoff, authorization checks, and operation +# tracking — through natural language prompts. +# +# Run: +# bundle exec ruby -I examples examples/error_handling_tool_demo.rb + +require_relative 'common' +require 'shared_tools/error_handling_tool' + + +title "ErrorHandlingTool Demo — Robust Error Handling Patterns" + +@chat = @chat.with_tool(SharedTools::Tools::ErrorHandlingTool.new) + +title "Example 1: Successful Data Validation", char: '-' +ask "Validate data with name: 'Product Alpha', value: 100 using the 'validate' operation." + +title "Example 2: Process Data Successfully", char: '-' +ask "Process data with name: 'Customer Record', value: 250 using the 'process' operation." + +title "Example 3: Validation Error Handling", char: '-' +ask "Simulate a validation error using operation 'validate' with simulate_error: 'validation'" + +title "Example 4: Network Error with Retry", char: '-' +ask "Process data but simulate a network error to test retry logic. Use max_retries: 3" + +title "Example 5: Authorization Error Handling", char: '-' +ask "Test authorization error handling using operation 'authorize' with simulate_error: 'authorization'" + +title "Example 6: Retryable Error Pattern", char: '-' +ask "Test retryable error handling with exponential backoff. Simulate a retryable error with 5 max retries." + +title "Example 7: Resource Not Found", char: '-' +ask "Simulate a resource not found error to demonstrate how the tool handles missing resources." + +title "Example 8: Operation with Metadata Tracking", char: '-' +ask "Validate data with name: 'Financial Transaction', value: 5000, optional_field: 'Quarterly Report'" + +title "Example 9: Conversational Error Recovery", char: '-' +ask "Validate data with name: 'Test Item' and value: 75" +ask "Now try to process that same data" +ask "What happens if we simulate a network error?" +ask "Process the data successfully without errors" + +title "Done", char: '-' +puts "ErrorHandlingTool demonstrated validation, retry, and error categorisation patterns." diff --git a/examples/error_handling_tool_example.rb b/examples/error_handling_tool_example.rb deleted file mode 100755 index 66fea39..0000000 --- a/examples/error_handling_tool_example.rb +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Example: Using ErrorHandlingTool with LLM Integration -# -# This example demonstrates comprehensive error handling patterns and -# resilience strategies through natural language prompts. - -require_relative 'ruby_llm_config' -require 'shared_tools/tools/error_handling_tool' - -title "ErrorHandlingTool Example - Robust Error Handling Patterns" - -# Register the ErrorHandlingTool with RubyLLM -tools = [ - SharedTools::Tools::ErrorHandlingTool.new -] - -# Create a chat instance using ollama_chat helper -@chat = ollama_chat() - -# Add tools to the chat -tools.each { |tool| @chat = @chat.with_tool(tool) } - -# Example 1: Successful validation -title "Example 1: Successful Data Validation", bc: '-' -prompt = <<~PROMPT - Validate this data using the error handling tool: - - name: "Product Alpha" - - value: 100 - Use the 'validate' operation. -PROMPT -test_with_prompt prompt - - -# Example 2: Successful processing -title "Example 2: Process Data Successfully", bc: '-' -prompt = <<~PROMPT - Process this data: - - name: "Customer Record" - - value: 250 - Use the 'process' operation. -PROMPT -test_with_prompt prompt - - -# Example 3: Simulate validation error -title "Example 3: Validation Error Handling", bc: '-' -prompt = <<~PROMPT - Test error handling by simulating a validation error. - Use operation 'validate' and simulate_error: 'validation' -PROMPT -test_with_prompt prompt - - -# Example 4: Simulate network error -title "Example 4: Network Error with Retry", bc: '-' -prompt = <<~PROMPT - Process data but simulate a network error to test retry logic. - Use operation 'process', simulate_error: 'network', with max_retries: 3 -PROMPT -test_with_prompt prompt - - -# Example 5: Simulate authorization error -title "Example 5: Authorization Error Handling", bc: '-' -prompt = <<~PROMPT - Test authorization error handling. - Use operation 'authorize' with simulate_error: 'authorization' -PROMPT -test_with_prompt prompt - - -# Example 6: Simulate retryable error -title "Example 6: Retryable Error Pattern", bc: '-' -prompt = <<~PROMPT - Test retryable error handling with exponential backoff. - Simulate a retryable error with 5 max retries. -PROMPT -test_with_prompt prompt - - -# Example 7: Resource not found error -title "Example 7: Resource Not Found", bc: '-' -prompt = <<~PROMPT - Simulate a resource not found error scenario. - This demonstrates how the tool handles missing resources. -PROMPT -test_with_prompt prompt - - -# Example 8: Normal operation with metadata -title "Example 8: Operation with Metadata Tracking", bc: '-' -prompt = <<~PROMPT - Perform a normal validation with detailed data: - - name: "Financial Transaction" - - value: 5000 - - optional_field: "Quarterly Report" -PROMPT -test_with_prompt prompt - - -# Example 9: Zero retries configuration -title "Example 9: Disable Retry Mechanism", bc: '-' -prompt = <<~PROMPT - Process data with retries disabled (max_retries: 0). - This shows immediate failure without retry attempts. -PROMPT -test_with_prompt prompt - - -# Example 10: Conversational error handling -title "Example 10: Conversational Error Recovery", bc: '-' - -prompt = "Validate data with name: 'Test Item' and value: 75" -test_with_prompt prompt - -prompt = "Now try to process that same data" -test_with_prompt prompt - -prompt = "What happens if we simulate a network error?" -test_with_prompt prompt - -prompt = "Process the data successfully without errors" -test_with_prompt prompt - -title "Example completed!" - -puts <<~TAKEAWAYS - - Key Takeaways: - - Demonstrates comprehensive error handling patterns - - Shows input validation with helpful error suggestions - - Implements retry mechanism with exponential backoff - - Provides detailed error categorization and messages - - Includes proper resource cleanup in ensure blocks - - Tracks operation metadata for debugging - - Generates unique reference IDs for error tracking - - The LLM understands different error types and responses - - Perfect reference for building robust tools - - Error Handling Patterns Demonstrated: - - Input validation with suggestions - - Network retry with backoff - - Authorization checks - - Resource cleanup - - Detailed error messages - - Operation tracking - - Configurable retry limits - - Different error type categorization - - This tool serves as a reference implementation for - building robust, production-ready AI tools. - -TAKEAWAYS diff --git a/examples/eval_tool_example.rb b/examples/eval_tool_demo.rb similarity index 72% rename from examples/eval_tool_example.rb rename to examples/eval_tool_demo.rb index 1371711..20fd97b 100755 --- a/examples/eval_tool_example.rb +++ b/examples/eval_tool_demo.rb @@ -1,14 +1,19 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# Example: Using EvalTool with LLM Integration +# Demo: Using EvalTool with LLM Integration # -# This example demonstrates how an LLM can use the EvalTool to evaluate +# This demo demonstrates how an LLM can use the EvalTool to evaluate # Ruby, Python, and shell commands through natural language prompts. +# +# Requires: +# require_relative 'common' +# require 'shared_tools/eval_tool' + +require_relative 'common' -require_relative 'ruby_llm_config' -title "EvalTool Example - LLM-Powered Code Evaluation" +title "EvalTool Demo - LLM-Powered Code Evaluation" # Register the EvalTool with RubyLLM tools = [ @@ -17,50 +22,50 @@ SharedTools::Tools::Eval::ShellEvalTool.new ] -# Create a chat instance using ollama_chat helper -@chat = ollama_chat() +# Create a chat instance using new_chat helper +@chat = new_chat() # Add tools to the chat tools.each { |tool| @chat = @chat.with_tool(tool) } # Example 1: Simple arithmetic via Ruby -title "Example 1: Simple Math Calculation", bc: '-' +title "Example 1: Simple Math Calculation", char: '-' prompt = "What is 15 multiplied by 23? Use Ruby to calculate this." test_with_prompt prompt # Example 2: Python for mathematical operations -title "Example 2: Scientific Calculation with Python", bc: '-' +title "Example 2: Scientific Calculation with Python", char: '-' prompt = "Calculate the square root of 144 plus pi. Use Python with the math library." test_with_prompt prompt # Example 3: Shell command for system information -title "Example 3: System Information via Shell", bc: '-' +title "Example 3: System Information via Shell", char: '-' prompt = "What is the current date and time? Use a shell command to find out." test_with_prompt prompt # Example 4: Ruby code with data processing -title "Example 4: Data Processing with Ruby", bc: '-' +title "Example 4: Data Processing with Ruby", char: '-' prompt = "Create an array of numbers from 1 to 10, then calculate their sum and average using Ruby." test_with_prompt prompt # Example 5: Python for text manipulation -title "Example 5: Text Processing with Python", bc: '-' +title "Example 5: Text Processing with Python", char: '-' prompt = "Reverse the string 'Hello World' and convert it to uppercase using Python." test_with_prompt prompt # Example 6: Shell command with pipes -title "Example 6: Shell Command with Pipes", bc: '-' +title "Example 6: Shell Command with Pipes", char: '-' prompt = "List all Ruby files in the current directory and count them. Use shell commands." test_with_prompt prompt # Example 7: Multi-step calculation -title "Example 7: Multi-Step Calculation", bc: '-' +title "Example 7: Multi-Step Calculation", char: '-' prompt = <<~PROMPT I need to calculate compound interest. If I invest $1000 at 5% annual interest for 3 years, @@ -72,7 +77,7 @@ # Example 8: Conversation with context -title "Example 8: Conversational Context", bc: '-' +title "Example 8: Conversational Context", char: '-' prompt = "Calculate 100 divided by 4 using Ruby." test_with_prompt prompt @@ -80,7 +85,7 @@ prompt = "Now multiply that result by 3." test_with_prompt prompt -title "Example completed!" +title "Demo completed!" puts <<~TAKEAWAYS diff --git a/examples/mcp_client_demo.rb b/examples/mcp_client_demo.rb new file mode 100755 index 0000000..d16c9d4 --- /dev/null +++ b/examples/mcp_client_demo.rb @@ -0,0 +1,76 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: MCP Client Integration +# +# Shows how to use MCP (Model Context Protocol) clients with RubyLLM. +# Demonstrates Tavily (web search), GitHub, and iMCP (macOS) clients. +# +# Prerequisites: +# export TAVILY_API_KEY=your_key # for Tavily web search +# export GITHUB_PERSONAL_ACCESS_TOKEN=token # for GitHub MCP +# brew install github-mcp-server # for GitHub MCP +# brew install --cask loopwork/tap/iMCP # for iMCP (macOS only) +# +# Run: +# bundle exec ruby -I examples examples/mcp_client_demo.rb + +require_relative 'common' + + +title "MCP Client Demo — Model Context Protocol Integration" + +title "Example 1: Tavily Web Search MCP Client", char: '-' +if ENV['TAVILY_API_KEY'] + begin + require 'shared_tools/mcp/tavily_mcp_server' + client = RubyLLM::MCP.clients["tavily"] + puts "Tavily client loaded — #{client.tools.count} tools available." + rescue => e + puts "Error loading Tavily client: #{e.message}" + end +else + puts "Skipping — TAVILY_API_KEY not set. Export it to enable this example." +end + +title "Example 2: GitHub MCP Server", char: '-' +if ENV['GITHUB_PERSONAL_ACCESS_TOKEN'] && File.exist?("/opt/homebrew/bin/github-mcp-server") + begin + require 'shared_tools/mcp/github_mcp_server' + client = RubyLLM::MCP.clients["github-mcp-server"] + puts "GitHub MCP client loaded — #{client.tools.count} tools available." + puts "Sample tools: #{client.tools.take(5).map(&:name).join(', ')}" + rescue => e + puts "Error loading GitHub client: #{e.message}" + end +else + puts "Skipping — GITHUB_PERSONAL_ACCESS_TOKEN not set or github-mcp-server not installed." +end + +title "Example 3: iMCP — macOS Integration", char: '-' +if RUBY_PLATFORM.include?('darwin') && File.exist?("/Applications/iMCP.app") + begin + require 'shared_tools/mcp/imcp' + client = RubyLLM::MCP.clients["imcp-server"] + puts "iMCP client loaded — #{client.tools.count} tools available (Notes, Calendar, Contacts, Reminders)." + rescue => e + puts "Error loading iMCP client: #{e.message}" + end +else + puts "Skipping — iMCP.app not installed or not running on macOS." +end + +title "Example 4: MCP Tools with LLM Chat", char: '-' +loaded = %w[tavily github-mcp-server imcp-server].filter_map do |name| + RubyLLM::MCP.clients[name] rescue nil +end + +if loaded.any? + @chat = @chat.with_tools(*loaded.flat_map(&:tools)) + ask "What MCP tools are available and what can you do with them?" +else + puts "No MCP clients loaded — set API keys and install servers to run this example." +end + +title "Done", char: '-' +puts "MCP clients let the LLM reach external services via the Model Context Protocol." diff --git a/examples/mcp_client_example.rb b/examples/mcp_client_example.rb deleted file mode 100755 index aab87c5..0000000 --- a/examples/mcp_client_example.rb +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env ruby -# examples/mcp_client_example.rb -# -# Demonstrates how to use MCP (Model Context Protocol) clients with RubyLLM -# Compatible with ruby_llm-mcp v0.7.0+ -# -# Prerequisites: -# 1. Install required gems: -# bundle install -# -# 2. For Tavily (web search): -# export TAVILY_API_KEY=your_api_key_here -# -# 3. For GitHub operations: -# export GITHUB_PERSONAL_ACCESS_TOKEN=your_token_here -# brew install github-mcp-server -# -# 4. For iMCP (macOS only): -# brew install --cask loopwork/tap/iMCP - -require 'bundler/setup' -require 'ruby_llm' - -puts <<~BANNER - ═══════════════════════════════════════════════════════════════ - MCP Client Example - ruby_llm-mcp v0.7.0+ - ═══════════════════════════════════════════════════════════════ -BANNER - -# Example 1: Using Tavily for Web Search -puts "\n📚 Example 1: Using Tavily MCP Client for Web Search" -puts "─" * 70 - -if ENV['TAVILY_API_KEY'] - begin - # Load the Tavily MCP client - require 'shared_tools/mcp/tavily_mcp_server' - - # Get the client (clients are automatically registered when you require the file) - tavily_client = RubyLLM::MCP.clients["tavily"] - - puts "✓ Tavily client loaded successfully" - puts " Available tools: #{tavily_client.tools.count}" - puts " Client name: #{tavily_client.name}" - - # Example: Use with RubyLLM chat - chat = RubyLLM.chat( - model: "claude-sonnet-4", - provider: :anthropic - ) - - # Add MCP tools to the chat - chat.with_tools(*tavily_client.tools) - - puts "\n Example query: 'What are the latest news about Ruby programming language?'" - puts " (Skipping actual API call to save time)" - - # Uncomment to make actual call: - # response = chat.ask("What are the latest news about Ruby programming language?") - # puts "\n Response: #{response}" - - rescue => e - puts "✗ Error loading Tavily client: #{e.message}" - end -else - puts "⚠️ Skipping Tavily example (TAVILY_API_KEY not set)" - puts " Set it with: export TAVILY_API_KEY=your_api_key" -end - -# Example 2: Using GitHub MCP Server -puts "\n\n🐙 Example 2: Using GitHub MCP Server" -puts "─" * 70 - -if ENV['GITHUB_PERSONAL_ACCESS_TOKEN'] && File.exist?("/opt/homebrew/bin/github-mcp-server") - begin - require 'shared_tools/mcp/github_mcp_server' - - github_client = RubyLLM::MCP.clients["github-mcp-server"] - - puts "✓ GitHub MCP client loaded successfully" - puts " Available tools: #{github_client.tools.count}" - puts " Client name: #{github_client.name}" - - # List some available tools - puts "\n Sample tools available:" - github_client.tools.take(5).each do |tool| - puts " • #{tool.name}" - end - - rescue => e - puts "✗ Error loading GitHub client: #{e.message}" - end -elsif !ENV['GITHUB_PERSONAL_ACCESS_TOKEN'] - puts "⚠️ Skipping GitHub example (GITHUB_PERSONAL_ACCESS_TOKEN not set)" - puts " Set it with: export GITHUB_PERSONAL_ACCESS_TOKEN=your_token" -else - puts "⚠️ Skipping GitHub example (github-mcp-server not installed)" - puts " Install it with: brew install github-mcp-server" -end - -# Example 3: Using iMCP (macOS only) -puts "\n\n🍎 Example 3: Using iMCP for macOS Integration" -puts "─" * 70 - -if RUBY_PLATFORM.include?('darwin') && File.exist?("/Applications/iMCP.app") - begin - require 'shared_tools/mcp/imcp' - - imcp_client = RubyLLM::MCP.clients["imcp-server"] - - puts "✓ iMCP client loaded successfully" - puts " Available tools: #{imcp_client.tools.count}" - puts " Client name: #{imcp_client.name}" - - puts "\n This client provides access to:" - puts " • macOS Notes" - puts " • Calendar events" - puts " • Contacts" - puts " • Reminders" - - rescue => e - puts "✗ Error loading iMCP client: #{e.message}" - end -elsif !RUBY_PLATFORM.include?('darwin') - puts "⚠️ Skipping iMCP example (not running on macOS)" -else - puts "⚠️ Skipping iMCP example (iMCP.app not installed)" - puts " Install it with: brew install --cask loopwork/tap/iMCP" -end - -# Example 4: Working with Multiple MCP Clients -puts "\n\n🔗 Example 4: Working with Multiple MCP Clients" -puts "─" * 70 - -puts "You can use multiple MCP clients together in a single conversation:" -puts <<~EXAMPLE - - # Load all clients - require 'shared_tools/mcp/tavily_mcp_server' - require 'shared_tools/mcp/github_mcp_server' - require 'shared_tools/mcp/imcp' - - # Create a chat with all MCP tools - chat = RubyLLM.chat(model: "claude-sonnet-4", provider: :anthropic) - - # Add tools from all clients - chat.with_tools( - *RubyLLM::MCP.clients["tavily"].tools, - *RubyLLM::MCP.clients["github-mcp-server"].tools, - *RubyLLM::MCP.clients["imcp-server"].tools - ) - - # Now the AI can use tools from all three services! - response = chat.ask("Search for Ruby news and add a reminder about it") -EXAMPLE - -# Example 5: Accessing MCP Resources and Prompts -puts "\n\n📦 Example 5: MCP Resources and Prompts" -puts "─" * 70 - -puts "MCP clients can provide resources and prompts in addition to tools:" -puts <<~EXAMPLE - - client = RubyLLM::MCP.clients["tavily"] - - # Check available resources - if client.resources.any? - puts "Available resources:" - client.resources.each { |r| puts " • \#{r.name}" } - end - - # Check available prompts - if client.prompts.any? - puts "Available prompts:" - client.prompts.each { |p| puts " • \#{p.name}" } - end - - # Use a resource - resource = client.resource("resource-name") - - # Use a prompt - prompt = client.prompt("prompt-name", arg1: "value1") -EXAMPLE - -puts "\n\n" + "═" * 70 -puts "Example completed! Check the code for more details." -puts "Documentation: https://www.rubyllm-mcp.com" -puts "═" * 70 diff --git a/examples/system_info_tool_demo.rb b/examples/system_info_tool_demo.rb new file mode 100755 index 0000000..41a599b --- /dev/null +++ b/examples/system_info_tool_demo.rb @@ -0,0 +1,34 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: SystemInfoTool +# +# Retrieve OS, CPU, memory, disk, and network information from the local system. +# +# Run: +# bundle exec ruby -I examples examples/system_info_tool_demo.rb + +require_relative 'common' +require 'shared_tools/tools/system_info_tool' + + +title "SystemInfoTool Demo — OS, CPU, memory, disk, and network details" + +@chat = @chat.with_tool(SharedTools::Tools::SystemInfoTool.new) + +ask "What operating system and version is this machine running?" + +ask "Tell me about the CPU: model, core count, and current load average." + +ask "How much total RAM does this machine have, and how much is currently available?" + +ask "What disks are mounted on this system and how much space is used vs available on each?" + +ask "List the active network interfaces and their IP addresses." + +ask "What version of Ruby is running, and on what platform?" + +ask "Give me a full system summary covering OS, CPU, memory, and disk at once." + +title "Done", char: '-' +puts "SystemInfoTool demonstrated OS, CPU, memory, disk, network, Ruby runtime, and full-summary queries." diff --git a/examples/weather_tool_demo.rb b/examples/weather_tool_demo.rb new file mode 100755 index 0000000..7acf3b2 --- /dev/null +++ b/examples/weather_tool_demo.rb @@ -0,0 +1,71 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: WeatherTool +# +# Shows how an LLM uses the WeatherTool to answer weather-related questions +# and make recommendations — a travel planning assistant that retrieves +# live data for multiple cities. +# +# Requires: OPENWEATHER_API_KEY environment variable +# export OPENWEATHER_API_KEY=your_key_here +# (free tier key at https://openweathermap.org/api) +# +# Run: +# OPENWEATHER_API_KEY=xxx bundle exec ruby -I examples examples/weather_tool_demo.rb + +require_relative 'common' +require 'shared_tools/weather_tool' + + +title "WeatherTool Demo" + +unless ENV['OPENWEATHER_API_KEY'] + puts "WARNING: OPENWEATHER_API_KEY is not set." + puts "Set it with: export OPENWEATHER_API_KEY=your_api_key" + puts "The demo will run but weather lookups will fail." + puts +end + +@chat = @chat.with_tool(SharedTools::Tools::WeatherTool.new) + +title "Current Conditions — New York", char: '-' +ask "What is the current weather in New York, US? Use imperial units." + +title "Metric Lookup — London", char: '-' +ask "What are the current conditions in London, UK? Use metric units." + +title "With Forecast", char: '-' +ask "Get the current weather and a 3-day forecast for Tokyo, JP using metric units." + +title "Travel Recommendation", char: '-' +ask <<~PROMPT + I am planning a weekend trip and considering three cities: + - Paris, FR + - Barcelona, ES + - Amsterdam, NL + + Fetch the current weather for each city (metric units) and recommend + which destination has the best weather for outdoor sightseeing. + Explain your reasoning. +PROMPT + +title "Packing Advice", char: '-' +ask <<~PROMPT + I'm travelling to Sydney, AU tomorrow. Get the current weather with a + 3-day forecast and advise me what to pack: clothing layers, + whether to bring an umbrella, and any other weather-related tips. +PROMPT + +title "Temperature Comparison", char: '-' +ask <<~PROMPT + Fetch the current temperature in metric units for: + - Reykjavik, IS + - Dubai, AE + - Singapore, SG + + What is the temperature difference between the coldest and warmest city? +PROMPT + +title "Done", char: '-' +puts "WeatherTool retrieved live weather data and enabled real-world travel planning." diff --git a/examples/weather_tool_example.rb b/examples/weather_tool_example.rb deleted file mode 100755 index a2068d3..0000000 --- a/examples/weather_tool_example.rb +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Example: Using WeatherTool with LLM Integration -# -# This example demonstrates how an LLM can fetch weather information -# through natural language prompts using the OpenWeatherMap API. -# -# Note: This example requires: -# - The 'openweathermap' gem: gem install openweathermap -# - An OpenWeatherMap API key set in OPENWEATHER_API_KEY environment variable -# - Get a free API key at: https://openweathermap.org/api - -require_relative 'ruby_llm_config' - -begin - require 'openweathermap' - require 'shared_tools/tools/weather_tool' -rescue LoadError => e - title "ERROR: Missing required dependencies for WeatherTool" - - puts <<~ERROR_MSG - - This example requires the 'openweathermap' gem: - gem install openweathermap - - Or add to your Gemfile: - gem 'openweathermap' - - Then run: bundle install - #{'=' * 80} - ERROR_MSG - - exit 1 -end - -# Check for API key -unless ENV['OPENWEATHER_API_KEY'] - title "ERROR: OpenWeatherMap API key not configured" - - puts <<~ERROR_MSG - - This example requires an OpenWeatherMap API key. - - Steps to get started: - 1. Sign up for a free API key at: https://openweathermap.org/api - 2. Set the environment variable: - export OPENWEATHER_API_KEY="your_api_key_here" - - Or add to your .bashrc or .zshrc: - export OPENWEATHER_API_KEY="your_api_key_here" - #{'=' * 80} - ERROR_MSG - - exit 1 -end - -title "WeatherTool Example - LLM-Powered Weather Data" - -# Register the WeatherTool with RubyLLM -tools = [ - SharedTools::Tools::WeatherTool.new -] - -# Create a chat instance using ollama_chat helper -@chat = ollama_chat() - -# Add tools to the chat -tools.each { |tool| @chat = @chat.with_tool(tool) } - -# Example 1: Simple weather query -title "Example 1: Current Weather for a City", bc: '-' -prompt = "What's the current weather in London, UK?" -test_with_prompt prompt - - -# Example 2: Weather with specific units -title "Example 2: Weather in Imperial Units", bc: '-' -prompt = "Get the weather for New York, USA in Fahrenheit" -test_with_prompt prompt - - -# Example 3: Multiple cities comparison -title "Example 3: Compare Weather Across Cities", bc: '-' -prompt = "What's the temperature in Tokyo, Japan?" -test_with_prompt prompt - -prompt = "Now check the temperature in Sydney, Australia" -test_with_prompt prompt - - -# Example 4: Detailed weather information -title "Example 4: Detailed Weather Data", bc: '-' -prompt = "Tell me about the weather in Paris, France. I want to know temperature, humidity, and wind speed." -test_with_prompt prompt - - -# Example 5: Weather with forecast -title "Example 5: Current Weather with 3-Day Forecast", bc: '-' -prompt = "Get the current weather for Seattle,US and include a 3-day forecast" -test_with_prompt prompt - - -# Example 6: Metric units -title "Example 6: Weather in Metric Units", bc: '-' -prompt = "What's the weather like in Berlin, Germany? Use metric units." -test_with_prompt prompt - - -# Example 7: Conversational weather queries -title "Example 7: Conversational Weather Context", bc: '-' - -prompt = "Is it raining in Mumbai, India right now?" -test_with_prompt prompt - -prompt = "What about the wind conditions there?" -test_with_prompt prompt - -title "Example completed!" - -puts <<~TAKEAWAYS - - Key Takeaways: - - The LLM fetches real-time weather data using OpenWeatherMap API - - Supports multiple unit systems (metric, imperial, kelvin) - - Can retrieve current conditions and forecasts - - Natural language queries are converted to proper API calls - - The LLM maintains conversational context - - Weather data includes temperature, humidity, wind, and more - - Note: This tool requires an active internet connection and valid API key. - -TAKEAWAYS diff --git a/examples/workflow_manager_tool_demo.rb b/examples/workflow_manager_tool_demo.rb new file mode 100755 index 0000000..30fcbd9 --- /dev/null +++ b/examples/workflow_manager_tool_demo.rb @@ -0,0 +1,58 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: WorkflowManagerTool +# +# Shows how an LLM uses the WorkflowManagerTool to create, track, and +# manage structured multi-step workflows — demonstrated here with a +# software release pipeline. +# +# Run: +# bundle exec ruby -I examples examples/workflow_manager_tool_demo.rb + +require_relative 'common' +require 'shared_tools/workflow_manager_tool' + + +title "WorkflowManagerTool Demo" + +@chat = @chat.with_tool(SharedTools::Tools::WorkflowManagerTool.new) + +title "Create Release Workflow", char: '-' +ask <<~PROMPT + Create a workflow named "v2.0.0-release" with the following steps: + 1. run_tests — Execute the full test suite + 2. security_scan — Run dependency vulnerability scan + 3. build_artifacts — Compile and package the release artifacts + 4. staging_deploy — Deploy to staging environment + 5. qa_sign_off — QA team reviews and approves staging + 6. production_deploy — Deploy to production + 7. notify_stakeholders — Send release announcement +PROMPT + +title "List All Workflows", char: '-' +ask "List all existing workflows and their current status." + +title "Start the Workflow", char: '-' +ask "Start the v2.0.0-release workflow and mark the first step (run_tests) as completed." + +title "Progress Update", char: '-' +ask "Mark security_scan as completed and build_artifacts as in-progress." + +title "Check Status", char: '-' +ask "What is the current status of the v2.0.0-release workflow? Which steps are done and which are pending?" + +title "Block on QA", char: '-' +ask "Complete build_artifacts and staging_deploy. Mark qa_sign_off as blocked with the note: 'Waiting for QA team availability'." + +title "Resume After QA", char: '-' +ask "QA has approved. Mark qa_sign_off as completed and production_deploy as in-progress." + +title "Final Steps", char: '-' +ask "Complete production_deploy and notify_stakeholders. Mark the entire workflow as complete." + +title "Post-Release Summary", char: '-' +ask "Give me a full summary of the v2.0.0-release workflow: all steps, their final status, and overall completion." + +title "Done", char: '-' +puts "WorkflowManagerTool tracked an entire release pipeline from creation to completion." diff --git a/examples/workflow_manager_tool_example.rb b/examples/workflow_manager_tool_example.rb deleted file mode 100755 index e8c6b9e..0000000 --- a/examples/workflow_manager_tool_example.rb +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Example: Using WorkflowManagerTool with LLM Integration -# -# This example demonstrates how an LLM can manage stateful workflows -# through natural language prompts, maintaining state across multiple steps. - -require_relative 'ruby_llm_config' -require 'shared_tools/tools/workflow_manager_tool' -require 'fileutils' - -title "WorkflowManagerTool Example - LLM-Powered Workflow Management" - -# Create a temporary storage directory for workflows -workflow_storage = File.join(Dir.tmpdir, "llm_workflows_#{Time.now.to_i}") -FileUtils.mkdir_p(workflow_storage) - -# Register the WorkflowManagerTool with RubyLLM -tools = [ - SharedTools::Tools::WorkflowManagerTool.new(storage_dir: workflow_storage) -] - -# Create a chat instance using ollama_chat helper -@chat = ollama_chat() - -# Add tools to the chat -tools.each { |tool| @chat = @chat.with_tool(tool) } - -begin - # Example 1: Start a new workflow - title "Example 1: Start a New Workflow", bc: '-' - prompt = <<~PROMPT - Start a new workflow for processing customer orders with the following initial data: - - customer_name: "Alice Johnson" - - order_type: "premium" - - items_count: 5 - PROMPT - test_with_prompt prompt - - - # Example 2: Add workflow steps - title "Example 2: Process Workflow Steps", bc: '-' - prompt = <<~PROMPT - Add a step to the workflow to validate the inventory. - Use the workflow_id from the previous response. - Include step data: action: "inventory_check", status: "validated" - PROMPT - test_with_prompt prompt - - prompt = <<~PROMPT - Add another step for payment processing with data: - action: "process_payment", amount: 299.99, status: "completed" - PROMPT - test_with_prompt prompt - - - # Example 3: Check workflow status - title "Example 3: Check Workflow Status", bc: '-' - prompt = "What's the current status of the workflow? Show me all the steps completed so far." - test_with_prompt prompt - - - # Example 4: Add more steps - title "Example 4: Continue Workflow Processing", bc: '-' - prompt = <<~PROMPT - Add a shipping step to the workflow with this information: - action: "prepare_shipment", carrier: "FedEx", tracking: "123456789" - PROMPT - test_with_prompt prompt - - prompt = <<~PROMPT - Now add a final notification step: - action: "send_confirmation", email: "alice@example.com", sent: true - PROMPT - test_with_prompt prompt - - - # Example 5: Complete the workflow - title "Example 5: Complete the Workflow", bc: '-' - prompt = "The workflow is finished. Mark it as complete and show me the summary." - test_with_prompt prompt - - - # Example 6: Start a new workflow for different use case - title "Example 6: New Workflow - Data Pipeline", bc: '-' - prompt = <<~PROMPT - Start a new workflow for a data processing pipeline: - - source: "customer_database" - - target: "analytics_warehouse" - - record_count: 10000 - PROMPT - test_with_prompt prompt - - - # Example 7: Multi-step data pipeline - title "Example 7: Process Data Pipeline Steps", bc: '-' - prompt = "Add a data extraction step with status: extracted, records: 10000" - test_with_prompt prompt - - prompt = "Add a data transformation step with status: transformed, records: 9850, invalid: 150" - test_with_prompt prompt - - prompt = "Add a data loading step with status: loaded, records: 9850, duration: 45" - test_with_prompt prompt - - - # Example 8: Check and complete second workflow - title "Example 8: Review and Complete Pipeline", bc: '-' - prompt = "Check the status of this data pipeline workflow" - test_with_prompt prompt - - prompt = "Complete this workflow and show me the final summary" - test_with_prompt prompt - -rescue => e - puts "\nError during workflow operations: #{e.message}" - puts e.backtrace.first(3) -ensure - # Cleanup - FileUtils.rm_rf(workflow_storage) if workflow_storage && Dir.exist?(workflow_storage) - puts "\nWorkflow storage cleaned up: #{workflow_storage}" -end - -title "Example completed!" - -puts <<~TAKEAWAYS - - Key Takeaways: - - The LLM manages stateful workflows across multiple interactions - - Workflows persist state between steps and can be resumed - - Each workflow has a unique ID for tracking and management - - Supports complex multi-step processes with metadata - - The LLM maintains context about workflow state and progress - - Workflows can be checked, updated, and completed programmatically - - Perfect for coordinating complex automation tasks - - State is persisted to disk and survives process restarts - -TAKEAWAYS diff --git a/lib/shared_tools.rb b/lib/shared_tools.rb index 415763a..5c2bd77 100644 --- a/lib/shared_tools.rb +++ b/lib/shared_tools.rb @@ -4,17 +4,28 @@ require 'io/console' require "zeitwerk" -loader = Zeitwerk::Loader.for_gem -# Ignore aggregate loader files that don't define constants -loader.ignore("#{__dir__}/shared_tools/ruby_llm.rb") -loader.ignore("#{__dir__}/shared_tools/tools/browser.rb") -loader.ignore("#{__dir__}/shared_tools/tools/computer.rb") -loader.ignore("#{__dir__}/shared_tools/tools/database.rb") -loader.ignore("#{__dir__}/shared_tools/tools/disk.rb") -loader.ignore("#{__dir__}/shared_tools/tools/doc.rb") -loader.ignore("#{__dir__}/shared_tools/tools/docker.rb") -loader.ignore("#{__dir__}/shared_tools/tools/eval.rb") -loader.setup + +# Set up Zeitwerk loader outside module, then pass reference in +SharedToolsLoader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false) +SharedToolsLoader.ignore("#{__dir__}/shared_tools/ruby_llm.rb") +SharedToolsLoader.ignore("#{__dir__}/shared_tools/mcp.rb") # Documentation/loader file only +SharedToolsLoader.ignore("#{__dir__}/shared_tools/mcp") # Entire mcp directory (naming issues) +SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/browser.rb") +SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/computer.rb") +SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/database.rb") +SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/disk.rb") +SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/doc.rb") +SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/docker.rb") +SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/eval.rb") +SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/version.rb") # Defines VERSION constant, not Version class +SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/incomplete") # Empty/incomplete tools directory + +# Ignore per-tool shim files (require-path shortcuts: require 'shared_tools/') +Dir.glob("#{__dir__}/shared_tools/*_tool.rb").each { |f| SharedToolsLoader.ignore(f) } +SharedToolsLoader.ignore("#{__dir__}/shared_tools/data_science_kit.rb") +SharedToolsLoader.ignore("#{__dir__}/shared_tools/devops_toolkit.rb") + +SharedToolsLoader.setup module SharedTools @auto_execute ||= true # Auto-execute by default, no human-in-the-loop diff --git a/lib/shared_tools/browser_tool.rb b/lib/shared_tools/browser_tool.rb new file mode 100644 index 0000000..522aa1a --- /dev/null +++ b/lib/shared_tools/browser_tool.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/browser_tool' +require 'shared_tools' +require 'shared_tools/tools/browser' # sub-tools (VisitTool, ClickTool, etc.) +require 'shared_tools/tools/browser_tool' # facade (BrowserTool) diff --git a/lib/shared_tools/calculator_tool.rb b/lib/shared_tools/calculator_tool.rb new file mode 100644 index 0000000..4f28c4e --- /dev/null +++ b/lib/shared_tools/calculator_tool.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/calculator_tool' +require 'shared_tools' +require 'shared_tools/tools/calculator_tool' diff --git a/lib/shared_tools/clipboard_tool.rb b/lib/shared_tools/clipboard_tool.rb new file mode 100644 index 0000000..5a80765 --- /dev/null +++ b/lib/shared_tools/clipboard_tool.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/clipboard_tool' +require 'shared_tools' +require 'shared_tools/tools/clipboard_tool' diff --git a/lib/shared_tools/composite_analysis_tool.rb b/lib/shared_tools/composite_analysis_tool.rb new file mode 100644 index 0000000..6d9f632 --- /dev/null +++ b/lib/shared_tools/composite_analysis_tool.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/composite_analysis_tool' +require 'shared_tools' +require 'shared_tools/tools/composite_analysis_tool' diff --git a/lib/shared_tools/computer_tool.rb b/lib/shared_tools/computer_tool.rb new file mode 100644 index 0000000..59cfaf5 --- /dev/null +++ b/lib/shared_tools/computer_tool.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/computer_tool' +# Note: computer.rb already loads computer_tool.rb +require 'shared_tools' +require 'shared_tools/tools/computer' # platform driver + ComputerTool facade diff --git a/lib/shared_tools/cron_tool.rb b/lib/shared_tools/cron_tool.rb new file mode 100644 index 0000000..65b139b --- /dev/null +++ b/lib/shared_tools/cron_tool.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/cron_tool' +require 'shared_tools' +require 'shared_tools/tools/cron_tool' diff --git a/lib/shared_tools/current_date_time_tool.rb b/lib/shared_tools/current_date_time_tool.rb new file mode 100644 index 0000000..280f1d8 --- /dev/null +++ b/lib/shared_tools/current_date_time_tool.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/current_date_time_tool' +require 'shared_tools' +require 'shared_tools/tools/current_date_time_tool' diff --git a/lib/shared_tools/data_science_kit.rb b/lib/shared_tools/data_science_kit.rb new file mode 100644 index 0000000..47b0187 --- /dev/null +++ b/lib/shared_tools/data_science_kit.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/data_science_kit' +require 'shared_tools' +require 'shared_tools/tools/data_science_kit' diff --git a/lib/shared_tools/database_query_tool.rb b/lib/shared_tools/database_query_tool.rb new file mode 100644 index 0000000..44aaff1 --- /dev/null +++ b/lib/shared_tools/database_query_tool.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/database_query_tool' +require 'shared_tools' +require 'shared_tools/tools/database_query_tool' diff --git a/lib/shared_tools/database_tool.rb b/lib/shared_tools/database_tool.rb new file mode 100644 index 0000000..f25b99a --- /dev/null +++ b/lib/shared_tools/database_tool.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/database_tool' +# Note: database.rb already loads database_tool.rb +require 'shared_tools' +require 'shared_tools/tools/database' # drivers + DatabaseTool facade diff --git a/lib/shared_tools/devops_toolkit.rb b/lib/shared_tools/devops_toolkit.rb new file mode 100644 index 0000000..4216ef8 --- /dev/null +++ b/lib/shared_tools/devops_toolkit.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/devops_toolkit' +require 'shared_tools' +require 'shared_tools/tools/devops_toolkit' diff --git a/lib/shared_tools/disk_tool.rb b/lib/shared_tools/disk_tool.rb new file mode 100644 index 0000000..ef9b262 --- /dev/null +++ b/lib/shared_tools/disk_tool.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/disk_tool' +require 'shared_tools' +require 'shared_tools/tools/disk' # sub-tools (FileReadTool, DirectoryListTool, etc.) +require 'shared_tools/tools/disk_tool' # facade (DiskTool) diff --git a/lib/shared_tools/dns_tool.rb b/lib/shared_tools/dns_tool.rb new file mode 100644 index 0000000..5fef0cd --- /dev/null +++ b/lib/shared_tools/dns_tool.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/dns_tool' +require 'shared_tools' +require 'shared_tools/tools/dns_tool' diff --git a/lib/shared_tools/doc_tool.rb b/lib/shared_tools/doc_tool.rb new file mode 100644 index 0000000..b83572e --- /dev/null +++ b/lib/shared_tools/doc_tool.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/doc_tool' +require 'shared_tools' +require 'shared_tools/tools/doc' # sub-tools (PdfReaderTool, TextReaderTool) +require 'shared_tools/tools/doc_tool' # facade (DocTool) diff --git a/lib/shared_tools/error_handling_tool.rb b/lib/shared_tools/error_handling_tool.rb new file mode 100644 index 0000000..2f77249 --- /dev/null +++ b/lib/shared_tools/error_handling_tool.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/error_handling_tool' +require 'shared_tools' +require 'shared_tools/tools/error_handling_tool' diff --git a/lib/shared_tools/eval_tool.rb b/lib/shared_tools/eval_tool.rb new file mode 100644 index 0000000..0a45e49 --- /dev/null +++ b/lib/shared_tools/eval_tool.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/eval_tool' +require 'shared_tools' +require 'shared_tools/tools/eval' # sub-tools (RubyEvalTool, PythonEvalTool, ShellEvalTool) +require 'shared_tools/tools/eval_tool' # facade (EvalTool) diff --git a/lib/shared_tools/system_info_tool.rb b/lib/shared_tools/system_info_tool.rb new file mode 100644 index 0000000..88b9479 --- /dev/null +++ b/lib/shared_tools/system_info_tool.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/system_info_tool' +require 'shared_tools' +require 'shared_tools/tools/system_info_tool' diff --git a/lib/shared_tools/tools/browser.rb b/lib/shared_tools/tools/browser.rb index e6b4138..ccc0a29 100644 --- a/lib/shared_tools/tools/browser.rb +++ b/lib/shared_tools/tools/browser.rb @@ -9,6 +9,27 @@ require_relative 'browser/base_driver' require_relative 'browser/inspect_utils' +# Load formatters (used by inspect_tool and related components) +require_relative 'browser/formatters/action_formatter' +require_relative 'browser/formatters/data_entry_formatter' +require_relative 'browser/formatters/element_formatter' +require_relative 'browser/formatters/input_formatter' + +# Load elements helpers (used by inspect_tool) +require_relative 'browser/elements/element_grouper' +require_relative 'browser/elements/nearby_element_detector' + +# Load page_inspect helpers (used by page_inspect_tool) +require_relative 'browser/page_inspect/button_summarizer' +require_relative 'browser/page_inspect/form_summarizer' +require_relative 'browser/page_inspect/html_summarizer' +require_relative 'browser/page_inspect/link_summarizer' + +# Load selector_generator and its sub-modules (used by click_tool and related) +require_relative 'browser/selector_generator/base_selectors' +require_relative 'browser/selector_generator/contextual_selectors' +require_relative 'browser/selector_generator' + # Try to load watir for browser automation begin require 'watir' diff --git a/lib/shared_tools/version.rb b/lib/shared_tools/version.rb index 92543ec..67b2efb 100644 --- a/lib/shared_tools/version.rb +++ b/lib/shared_tools/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module SharedTools - VERSION = "0.3.0" + VERSION = "0.4.0" end diff --git a/lib/shared_tools/weather_tool.rb b/lib/shared_tools/weather_tool.rb new file mode 100644 index 0000000..9de9b6f --- /dev/null +++ b/lib/shared_tools/weather_tool.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/weather_tool' +require 'shared_tools' +require 'shared_tools/tools/weather_tool' diff --git a/lib/shared_tools/workflow_manager_tool.rb b/lib/shared_tools/workflow_manager_tool.rb new file mode 100644 index 0000000..2b16a0b --- /dev/null +++ b/lib/shared_tools/workflow_manager_tool.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/workflow_manager_tool' +require 'shared_tools' +require 'shared_tools/tools/workflow_manager_tool' From 6ee2adb5f532c8e8808470655fef9ae3d4ebf338 Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Wed, 25 Mar 2026 23:20:12 -0500 Subject: [PATCH 02/17] feat: add examples/all.rb script to run all demos This commit adds a new script, examples/all.rb, that runs all the *_demo.rb files in the examples directory sequentially. This will make it easier to execute all the demonstration scripts for the shared_tools gem. The script simply iterates through the demo files, prints out some formatting, and then runs each one using the system command. --- Gemfile.lock | 135 ------ examples/all.rb | 28 ++ examples/browser_tool_demo.rb | 91 ++-- examples/calculator_tool_demo.rb | 2 + examples/clipboard_tool_demo.rb | 4 + examples/composite_analysis_tool_demo.rb | 85 ++-- examples/comprehensive_workflow_demo.rb | 2 + examples/cron_tool_demo.rb | 2 + examples/data_science_kit_demo.rb | 139 +++++- examples/devops_toolkit_demo.rb | 56 --- examples/dns_tool_demo.rb | 35 +- examples/doc_tool_demo.rb | 201 ++++++++- examples/weather_tool_demo.rb | 29 ++ lib/shared_tools.rb | 1 + lib/shared_tools/database.rb | 4 + lib/shared_tools/devops_toolkit.rb | 4 - lib/shared_tools/tools/browser.rb | 12 +- lib/shared_tools/tools/browser/base_tool.rb | 20 +- lib/shared_tools/tools/browser/click_tool.rb | 6 +- .../tools/browser/ferrum_driver.rb | 119 +++++ .../tools/browser/inspect_tool.rb | 6 +- .../tools/browser/page_inspect_tool.rb | 6 +- .../tools/browser/page_screenshot_tool.rb | 26 +- .../tools/browser/selector_inspect_tool.rb | 6 +- .../tools/browser/text_field_area_set_tool.rb | 6 +- lib/shared_tools/tools/browser/visit_tool.rb | 6 +- lib/shared_tools/tools/clipboard_tool.rb | 119 +++++ .../tools/composite_analysis_tool.rb | 64 ++- lib/shared_tools/tools/computer/mac_driver.rb | 41 +- lib/shared_tools/tools/cron_tool.rb | 332 ++++++++++++++ .../tools/current_date_time_tool.rb | 88 ++++ lib/shared_tools/tools/data_science_kit.rb | 76 +++- lib/shared_tools/tools/dns_tool.rb | 422 ++++++++++++++++++ lib/shared_tools/tools/doc.rb | 3 + .../tools/doc/docx_reader_tool.rb | 107 +++++ .../tools/doc/spreadsheet_reader_tool.rb | 171 +++++++ .../tools/doc/text_reader_tool.rb | 57 +++ lib/shared_tools/tools/doc_tool.rb | 107 ++++- lib/shared_tools/tools/error_handling_tool.rb | 4 +- lib/shared_tools/tools/system_info_tool.rb | 204 +++++++++ .../tools/workflow_manager_tool.rb | 32 ++ shared_tools.gemspec | 5 +- 42 files changed, 2500 insertions(+), 363 deletions(-) delete mode 100644 Gemfile.lock create mode 100755 examples/all.rb delete mode 100755 examples/devops_toolkit_demo.rb create mode 100644 lib/shared_tools/database.rb delete mode 100644 lib/shared_tools/devops_toolkit.rb create mode 100644 lib/shared_tools/tools/browser/ferrum_driver.rb create mode 100644 lib/shared_tools/tools/clipboard_tool.rb create mode 100644 lib/shared_tools/tools/cron_tool.rb create mode 100644 lib/shared_tools/tools/current_date_time_tool.rb create mode 100644 lib/shared_tools/tools/dns_tool.rb create mode 100644 lib/shared_tools/tools/doc/docx_reader_tool.rb create mode 100644 lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb create mode 100644 lib/shared_tools/tools/doc/text_reader_tool.rb create mode 100644 lib/shared_tools/tools/system_info_tool.rb diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 4745338..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,135 +0,0 @@ -PATH - remote: . - specs: - shared_tools (0.3.0) - dentaku - nokogiri - openweathermap - ruby_llm - ruby_llm-mcp - sequel - zeitwerk - -GEM - remote: https://rubygems.org/ - specs: - Ascii85 (2.0.1) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) - afm (1.0.0) - base64 (0.3.0) - bigdecimal (3.3.1) - concurrent-ruby (1.3.5) - debug_me (1.1.1) - dentaku (3.5.6) - bigdecimal - concurrent-ruby - docile (1.4.1) - event_stream_parser (1.0.0) - faraday (2.14.0) - faraday-net_http (>= 2.0, < 3.5) - json - logger - faraday-multipart (1.1.1) - multipart-post (~> 2.0) - faraday-net_http (3.4.1) - net-http (>= 0.5.0) - faraday-retry (2.3.2) - faraday (~> 2.0) - ffi (1.17.2-arm64-darwin) - fiddle (1.1.8) - hashery (2.1.2) - http-2 (1.1.1) - httpx (1.6.3) - http-2 (>= 1.0.0) - json (2.16.0) - json-schema (5.2.2) - addressable (~> 2.8) - bigdecimal (~> 3.1) - logger (1.7.0) - macos (0.1.8) - ffi - fiddle - zeitwerk - marcel (1.1.0) - minitest (5.26.0) - multipart-post (2.4.1) - net-http (0.7.0) - uri - nokogiri (1.18.10-arm64-darwin) - racc (~> 1.4) - openweathermap (0.2.3) - pdf-reader (2.15.0) - Ascii85 (>= 1.0, < 3.0, != 2.0.0) - afm (>= 0.2.1, < 2) - hashery (~> 2.0) - ruby-rc4 - ttfunk - public_suffix (6.0.2) - racc (1.8.1) - rake (13.3.1) - regexp_parser (2.11.3) - rexml (3.4.4) - ruby-rc4 (0.1.5) - ruby_llm (1.9.1) - base64 - event_stream_parser (~> 1) - faraday (>= 1.10.0) - faraday-multipart (>= 1) - faraday-net_http (>= 1) - faraday-retry (>= 1) - marcel (~> 1.0) - ruby_llm-schema (~> 0.2.1) - zeitwerk (~> 2) - ruby_llm-mcp (0.7.0) - httpx (~> 1.4) - json-schema (~> 5.0) - ruby_llm (~> 1.9) - zeitwerk (~> 2) - ruby_llm-schema (0.2.1) - rubyzip (2.4.1) - selenium-webdriver (4.10.0) - rexml (~> 3.2, >= 3.2.5) - rubyzip (>= 1.2.2, < 3.0) - websocket (~> 1.0) - sequel (5.98.0) - bigdecimal - simplecov (0.22.0) - docile (~> 1.1) - simplecov-html (~> 0.11) - simplecov_json_formatter (~> 0.1) - simplecov-html (0.13.2) - simplecov_json_formatter (0.1.4) - sqlite3 (2.8.0-arm64-darwin) - ttfunk (1.8.0) - bigdecimal (~> 3.1) - uri (1.1.1) - watir (7.3.0) - regexp_parser (>= 1.2, < 3) - selenium-webdriver (~> 4.2) - webdrivers (5.3.1) - nokogiri (~> 1.6) - rubyzip (>= 1.3.0) - selenium-webdriver (~> 4.0, < 4.11) - websocket (1.2.11) - zeitwerk (2.7.3) - -PLATFORMS - arm64-darwin-24 - arm64-darwin-25 - -DEPENDENCIES - bundler - debug_me - macos - minitest - pdf-reader - rake - shared_tools! - simplecov - sqlite3 - watir - webdrivers - -BUNDLED WITH - 2.7.2 diff --git a/examples/all.rb b/examples/all.rb new file mode 100755 index 0000000..e8948b5 --- /dev/null +++ b/examples/all.rb @@ -0,0 +1,28 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Run every *_demo.rb file in the examples directory sequentially. +# +# Usage: +# bundle exec ruby examples/all.rb +# ruby examples/all.rb # from project root + +demos = Dir[File.join(__dir__, '*_demo.rb')].sort + +puts "Found #{demos.size} demo(s) to run." +puts + +demos.each do |demo| + name = File.basename(demo) + puts "#{'=' * 60}" + puts "Running: #{name}" + puts "#{'=' * 60}" + + system(RbConfig.ruby, demo) + + puts +end + +puts "#{'=' * 60}" +puts "All #{demos.size} demo(s) finished." +puts "#{'=' * 60}" diff --git a/examples/browser_tool_demo.rb b/examples/browser_tool_demo.rb index 29125e1..c565de2 100755 --- a/examples/browser_tool_demo.rb +++ b/examples/browser_tool_demo.rb @@ -4,68 +4,87 @@ # Demo: BrowserTool # # Shows how an LLM automates web browser interactions through natural language -# using the BrowserTool (requires Watir + Chrome). +# using the BrowserTool (requires Ferrum + Chrome; no chromedriver binary needed). # # Run: # bundle exec ruby -I examples examples/browser_tool_demo.rb require_relative 'common' require 'shared_tools/browser_tool' +require 'ferrum' - -unless defined?(Watir) - puts "ERROR: Watir gem not loaded. Install with: gem install watir webdrivers" +unless defined?(Ferrum) + puts "ERROR: Ferrum gem not loaded. Install with: gem install ferrum" exit 1 end title "BrowserTool Demo — LLM-Powered Web Automation" -puts "NOTE: Requires Watir with Chrome driver and Chrome browser installed." +puts "NOTE: Requires Chrome/Chromium browser installed. No chromedriver needed — uses Ferrum (CDP)." puts -@chat = @chat.with_tools( - SharedTools::Tools::Browser::VisitTool.new, - SharedTools::Tools::Browser::InspectTool.new, - SharedTools::Tools::Browser::ClickTool.new, - SharedTools::Tools::Browser::TextFieldAreaSetTool.new, - SharedTools::Tools::Browser::PageScreenshotTool.new -) - begin - title "Example 1: Navigate to Website", char: '-' - ask "Visit the example.com website using a headless Chrome browser." + # All tools must share one driver so navigation in VisitTool is visible to + # InspectTool, ClickTool, etc. — each pointing at the same browser session. + driver = SharedTools::Tools::Browser::FerrumDriver.new +rescue => e + puts "ERROR: Could not initialise Ferrum browser: #{e.message}" + puts "Make sure Chrome/Chromium is installed." + exit 1 +end - title "Example 2: Inspect Page Content", char: '-' - ask "What's the main heading on this page?" +# Rebuild the chat with browser tools, resetting context between examples to +# prevent tool-response HTML from accumulating and overflowing the context window. +def browser_chat(driver) + new_chat.with_tools( + SharedTools::Tools::Browser::VisitTool.new(driver: driver), + SharedTools::Tools::Browser::InspectTool.new(driver: driver), + SharedTools::Tools::Browser::ClickTool.new(driver: driver), + SharedTools::Tools::Browser::TextFieldAreaSetTool.new(driver: driver), + SharedTools::Tools::Browser::PageScreenshotTool.new(driver: driver) + ) +end - title "Example 3: Search Workflow", char: '-' +begin + title "Example 1: Navigate and Read Title", char: '-' + @chat = browser_chat(driver) ask <<~PROMPT - Go to duckduckgo.com and search for "Ruby programming language". - Tell me what the first result is. + Use browser_visit to go to https://example.com. + Then use browser_inspect with text_content "Example Domain" to read the page. + What is the page title and main heading? PROMPT - title "Example 4: Capture Screenshot", char: '-' - ask "Take a screenshot of the current page and save it as 'search_results.png'." + title "Example 2: Find a Specific Link", char: '-' + @chat = browser_chat(driver) + ask <<~PROMPT + Use browser_visit to go to https://example.com. + Then use browser_inspect with text_content "Learn more" to find the link on the page. + What does the link say and what URL does it point to? + PROMPT - title "Example 5: Multi-Step Navigation", char: '-' + title "Example 3: Click a Link and Read the Result", char: '-' + @chat = browser_chat(driver) ask <<~PROMPT - 1. Go to example.org - 2. Find and click on the "More information..." link - 3. Tell me what page you end up on + Use browser_visit to go to https://example.com. + Then use browser_click with selector "a" to click the only link on the page. + Then use browser_inspect with text_content "IANA" to read the resulting page. + What page did the click take you to? PROMPT - title "Example 6: Form Interaction", char: '-' + title "Example 4: Compare Two Pages", char: '-' + @chat = browser_chat(driver) ask <<~PROMPT - Go to httpbin.org/forms/post and fill out the form: - - Customer name: John Doe - - Telephone: 555-1234 - - Comments: Testing browser automation - Then submit the form. + Use browser_visit to go to https://example.org. + Then use browser_inspect with text_content "Example" to read the page. + How does the content compare to example.com? PROMPT - title "Example 7: Conversational Browsing", char: '-' - ask "Navigate to github.com" - ask "Click on the 'Explore' link in the navigation." - ask "What's the page title now?" + title "Example 5: Capture Screenshot", char: '-' + @chat = browser_chat(driver) + ask <<~PROMPT + Use browser_visit to go to https://example.com. + Then use browser_page_screenshot with path "example_com.png" to save a screenshot. + What file path was it saved to? + PROMPT rescue => e puts "Error during browser automation: #{e.message}" diff --git a/examples/calculator_tool_demo.rb b/examples/calculator_tool_demo.rb index a9e0a81..da9c087 100755 --- a/examples/calculator_tool_demo.rb +++ b/examples/calculator_tool_demo.rb @@ -10,6 +10,8 @@ # require_relative 'common' # require 'shared_tools/calculator_tool' +ENV['RUBY_LLM_DEBUG'] = 'true' + require_relative 'common' begin diff --git a/examples/clipboard_tool_demo.rb b/examples/clipboard_tool_demo.rb index 81a244f..1a0a077 100755 --- a/examples/clipboard_tool_demo.rb +++ b/examples/clipboard_tool_demo.rb @@ -8,6 +8,8 @@ # Run: # bundle exec ruby -I examples examples/clipboard_tool_demo.rb +ENV['RUBY_LLM_DEBUG'] = 'true' + require_relative 'common' require 'shared_tools/tools/clipboard_tool' @@ -24,6 +26,8 @@ ask "Read the clipboard again and confirm all three lines are present." +@chat = new_chat.with_tool(SharedTools::Tools::ClipboardTool.new) + ask "How many characters are currently stored in the clipboard?" ask "Clear the clipboard, then immediately read it back and confirm it is now empty." diff --git a/examples/composite_analysis_tool_demo.rb b/examples/composite_analysis_tool_demo.rb index 7f77a2a..f659214 100755 --- a/examples/composite_analysis_tool_demo.rb +++ b/examples/composite_analysis_tool_demo.rb @@ -10,6 +10,8 @@ # Run: # bundle exec ruby -I examples examples/composite_analysis_tool_demo.rb +ENV['RUBY_LLM_DEBUG'] = 'true' + require_relative 'common' require 'shared_tools/composite_analysis_tool' @@ -20,55 +22,62 @@ title "Sales Data Analysis", char: '-' ask <<~PROMPT - Analyse the following quarterly sales data for three product lines: + Use the composite_analysis tool with the following inline data to analyse + quarterly sales for three product lines. Pass this table as the data parameter: Quarter | Widget A | Widget B | Widget C - Q1 2025 | 12,400 | 8,200 | 3,100 - Q2 2025 | 14,100 | 7,900 | 4,800 - Q3 2025 | 13,800 | 9,400 | 6,200 - Q4 2025 | 16,500 | 10,100 | 7,900 + Q1 2025 | 12400 | 8200 | 3100 + Q2 2025 | 14100 | 7900 | 4800 + Q3 2025 | 13800 | 9400 | 6200 + Q4 2025 | 16500 | 10100 | 7900 - Identify growth trends for each product, which product has the strongest - momentum, and any patterns that suggest strategic action. + Use analysis_type "comprehensive". Then identify growth trends for each product, + which has the strongest momentum, and any patterns that suggest strategic action. PROMPT title "Text Pattern Analysis", char: '-' ask <<~PROMPT - Analyse the following customer support ticket subjects for common patterns, - themes, and urgency signals: - - - "Login button not working on mobile" - - "Can't export data to CSV" - - "Payment failed three times" - - "Dashboard loads slowly" - - "Billing overcharge — urgent" - - "Login issues since yesterday's update" - - "How do I export my data?" - - "App crashes when uploading large files" - - "Wrong amount charged to my card" - - "Performance degraded after update" - - What are the top 3 issue categories? Which need immediate attention? + Use the composite_analysis tool with the following inline data to analyse + customer support ticket subjects. Pass this as the data parameter (one per line): + + Login button not working on mobile + Can't export data to CSV + Payment failed three times + Dashboard loads slowly + Billing overcharge urgent + Login issues since yesterday's update + How do I export my data + App crashes when uploading large files + Wrong amount charged to my card + Performance degraded after update + + Use analysis_type "standard". Then identify the top 3 issue categories and + which need immediate attention. PROMPT title "Cross-Source Synthesis", char: '-' ask <<~PROMPT - Given these two datasets about the same product launch: - - Dataset A — User Acquisition (week 1-4): - Week 1: 230 signups, 18% conversion - Week 2: 410 signups, 22% conversion - Week 3: 380 signups, 19% conversion - Week 4: 520 signups, 25% conversion - - Dataset B — Support Tickets (week 1-4): - Week 1: 12 tickets, avg resolution 4h - Week 2: 28 tickets, avg resolution 6h - Week 3: 22 tickets, avg resolution 5h - Week 4: 41 tickets, avg resolution 8h - - Synthesise both datasets. As acquisition grows, is support keeping pace? - What is the ticket-per-user ratio trend and what does it indicate? + Use the composite_analysis tool twice — once for each dataset below. + + First call — pass this as the data parameter for user acquisition: + + Week | Signups | Conversion + 1 | 230 | 18 + 2 | 410 | 22 + 3 | 380 | 19 + 4 | 520 | 25 + + Second call — pass this as the data parameter for support tickets: + + Week | Tickets | ResolutionHours + 1 | 12 | 4 + 2 | 28 | 6 + 3 | 22 | 5 + 4 | 41 | 8 + + Use analysis_type "comprehensive" for both. Then synthesise the results: + as acquisition grows, is support keeping pace? What does the ticket-per-user + ratio trend indicate? PROMPT title "Done", char: '-' diff --git a/examples/comprehensive_workflow_demo.rb b/examples/comprehensive_workflow_demo.rb index 12f903a..e3460a3 100755 --- a/examples/comprehensive_workflow_demo.rb +++ b/examples/comprehensive_workflow_demo.rb @@ -9,6 +9,8 @@ # Run: # bundle exec ruby -I examples examples/comprehensive_workflow_demo.rb +ENV['RUBY_LLM_DEBUG'] = 'true' + require_relative 'common' require 'shared_tools/database_tool' require 'shared_tools/disk_tool' diff --git a/examples/cron_tool_demo.rb b/examples/cron_tool_demo.rb index 84efbb9..ad4f7e3 100755 --- a/examples/cron_tool_demo.rb +++ b/examples/cron_tool_demo.rb @@ -8,6 +8,8 @@ # Run: # bundle exec ruby -I examples examples/cron_tool_demo.rb +ENV['RUBY_LLM_DEBUG'] = 'true' + require_relative 'common' require 'shared_tools/tools/cron_tool' diff --git a/examples/data_science_kit_demo.rb b/examples/data_science_kit_demo.rb index 66e7e9d..6a4ea74 100755 --- a/examples/data_science_kit_demo.rb +++ b/examples/data_science_kit_demo.rb @@ -9,6 +9,8 @@ # Run: # bundle exec ruby -I examples examples/data_science_kit_demo.rb +ENV['RUBY_LLM_DEBUG'] = 'true' + require_relative 'common' require 'shared_tools/data_science_kit' @@ -19,47 +21,140 @@ title "Descriptive Statistics", char: '-' ask <<~PROMPT - Analyse this monthly revenue dataset (in thousands USD) for a SaaS company: - Jan: 42, Feb: 45, Mar: 51, Apr: 48, May: 55, Jun: 62, - Jul: 58, Aug: 67, Sep: 71, Oct: 74, Nov: 69, Dec: 83 + Use the data_science_kit tool with analysis_type "statistical_summary" and pass + this monthly revenue data (in thousands USD) as the data parameter (pipe-delimited): + + Month | Revenue + Jan | 42 + Feb | 45 + Mar | 51 + Apr | 48 + May | 55 + Jun | 62 + Jul | 58 + Aug | 67 + Sep | 71 + Oct | 74 + Nov | 69 + Dec | 83 - Calculate: mean, median, standard deviation, min, max, and the - coefficient of variation. Identify any outliers. + Report: mean, median, standard deviation, min, max, coefficient of variation, + and any outliers. PROMPT title "Trend Analysis", char: '-' +@chat = new_chat.with_tool(SharedTools::Tools::DataScienceKit.new) ask <<~PROMPT - Using the same 12-month revenue series from the previous question, - identify the trend direction, calculate month-over-month growth rates, - and predict revenue for January of the following year using linear regression. + Use the data_science_kit tool with analysis_type "time_series" and pass + this data as the data parameter (pipe-delimited): + + Month | Revenue + Jan | 42 + Feb | 45 + Mar | 51 + Apr | 48 + May | 55 + Jun | 62 + Jul | 58 + Aug | 67 + Sep | 71 + Oct | 74 + Nov | 69 + Dec | 83 + + Identify trend direction, month-over-month growth rates, and predict + revenue for January of the following year. PROMPT title "Correlation Analysis", char: '-' +@chat = new_chat.with_tool(SharedTools::Tools::DataScienceKit.new) ask <<~PROMPT - Examine the correlation between marketing spend and revenue: - Marketing spend (USD thousands): 8, 9, 11, 10, 12, 15, 13, 17, 18, 19, 16, 22 - Revenue (USD thousands): 42, 45, 51, 48, 55, 62, 58, 67, 71, 74, 69, 83 + Use the data_science_kit tool with analysis_type "correlation_analysis" and + pass this data as the data parameter (pipe-delimited): - Calculate the correlation coefficient and explain whether marketing - spend is a strong predictor of revenue. + Month | Marketing | Revenue + Jan | 8 | 42 + Feb | 9 | 45 + Mar | 11 | 51 + Apr | 10 | 48 + May | 12 | 55 + Jun | 15 | 62 + Jul | 13 | 58 + Aug | 17 | 67 + Sep | 18 | 71 + Oct | 19 | 74 + Nov | 16 | 69 + Dec | 22 | 83 + + Calculate the correlation coefficient and explain whether marketing spend + is a strong predictor of revenue. PROMPT title "Segmentation", char: '-' +@chat = new_chat.with_tool(SharedTools::Tools::DataScienceKit.new) ask <<~PROMPT - Group these 12 months into quarters and calculate: - - Total and average revenue per quarter - - Which quarter showed the strongest growth - - Quarter-over-quarter growth rate + Use the data_science_kit tool with analysis_type "clustering" and pass + this data as the data parameter (pipe-delimited): + + Month | Quarter | Revenue + Jan | Q1 | 42 + Feb | Q1 | 45 + Mar | Q1 | 51 + Apr | Q2 | 48 + May | Q2 | 55 + Jun | Q2 | 62 + Jul | Q3 | 58 + Aug | Q3 | 67 + Sep | Q3 | 71 + Oct | Q4 | 74 + Nov | Q4 | 69 + Dec | Q4 | 83 + + Group the months into their quarters and calculate total and average revenue + per quarter, identify which quarter had the strongest growth, and compute + quarter-over-quarter growth rates. PROMPT title "Anomaly Detection", char: '-' +@chat = new_chat.with_tool(SharedTools::Tools::DataScienceKit.new) ask <<~PROMPT - Look at this daily user signup data for April (30 days): - 120,135,128,141,118,95,102,156,163,147,138,142,129,88,91, - 172,168,154,161,149,143,137,85,94,178,182,169,175,163,158 + Use the data_science_kit tool with analysis_type "statistical_summary" and pass + this daily signup data for April as the data parameter (pipe-delimited): + + Day | Signups + 1 | 120 + 2 | 135 + 3 | 128 + 4 | 141 + 5 | 118 + 6 | 95 + 7 | 102 + 8 | 156 + 9 | 163 + 10 | 147 + 11 | 138 + 12 | 142 + 13 | 129 + 14 | 88 + 15 | 91 + 16 | 172 + 17 | 168 + 18 | 154 + 19 | 161 + 20 | 149 + 21 | 143 + 22 | 137 + 23 | 85 + 24 | 94 + 25 | 178 + 26 | 182 + 27 | 169 + 28 | 175 + 29 | 163 + 30 | 158 - Identify any anomalous days (potential bot traffic or outages) using - statistical methods, and explain what thresholds you used. + Identify anomalous days (potential bot traffic or outages) using statistical + methods, and explain the thresholds used. PROMPT title "Done", char: '-' diff --git a/examples/devops_toolkit_demo.rb b/examples/devops_toolkit_demo.rb deleted file mode 100755 index 9284e50..0000000 --- a/examples/devops_toolkit_demo.rb +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true -# -# Demo: DevopsToolkit -# -# Shows how an LLM performs DevOps operations — deployments, health checks, -# log analysis, metrics collection, and rollbacks — with built-in safety -# mechanisms for production environments. -# -# Run: -# bundle exec ruby -I examples examples/devops_toolkit_demo.rb - -require_relative 'common' -require 'shared_tools/devops_toolkit' - - -title "DevopsToolkit Demo — LLM-Powered DevOps Operations" - -@chat = @chat.with_tool(SharedTools::Tools::DevopsToolkit.new) - -title "Example 1: Health Check — Development", char: '-' -ask "Perform a health check on the development environment and report service status." - -title "Example 2: Deploy to Staging", char: '-' -ask "Deploy version 2.5.0 to the staging environment using the 'main' branch with rollback on failure." - -title "Example 3: Analyze Application Logs", char: '-' -ask "Analyze logs for the staging environment over the last 24 hours. Look for errors and database connection issues." - -title "Example 4: Collect System Metrics", char: '-' -ask "Collect CPU, memory, and request metrics from staging over the last hour in JSON format." - -title "Example 5: Rollback to Previous Version", char: '-' -ask "Rollback the staging deployment to the previous stable version due to a critical bug." - -title "Example 6: Production Health Check", char: '-' -ask "Check the health status of the production environment and verify all services are running." - -title "Example 7: Production Deploy — Safety Check", char: '-' -ask "Deploy version 2.5.1 to production using the 'release' branch." - -title "Example 8: Production Deploy — With Confirmation", char: '-' -ask <<~PROMPT - Deploy version 2.5.1 to production with explicit confirmation. - I confirm this is a production operation. - Use: production_confirmed: true, branch: 'release', rollback_on_failure: true -PROMPT - -title "Example 9: Conversational DevOps Workflow", char: '-' -ask "What's the current health status of staging?" -ask "Are there any recent errors in the staging logs?" -ask "Deploy the latest version to staging" -ask "Verify the deployment was successful by checking health again" - -title "Done", char: '-' -puts "DevopsToolkit performed DevOps operations safely through natural language." diff --git a/examples/dns_tool_demo.rb b/examples/dns_tool_demo.rb index 8232511..3428d57 100755 --- a/examples/dns_tool_demo.rb +++ b/examples/dns_tool_demo.rb @@ -3,16 +3,19 @@ # # Demo: DnsTool # -# Perform DNS lookups, reverse lookups, and record queries (A, MX, TXT, NS, CNAME). +# Perform DNS lookups, reverse lookups, record queries (A, AAAA, MX, TXT, NS, CNAME), +# external IP detection, and WHOIS database queries. # # Run: # bundle exec ruby -I examples examples/dns_tool_demo.rb +ENV['RUBY_LLM_DEBUG'] = 'true' + require_relative 'common' require 'shared_tools/tools/dns_tool' -title "DnsTool Demo — DNS lookups, reverse queries, and record inspection" +title "DnsTool Demo — DNS lookups, WHOIS queries, and external IP detection" @chat = @chat.with_tool(SharedTools::Tools::DnsTool.new) @@ -30,5 +33,31 @@ ask "Get all available DNS records for 'example.com'." +title "External IP", char: '-' +@chat = new_chat.with_tool(SharedTools::Tools::DnsTool.new) +ask "What is my current external (public-facing) IP address?" + +ask "Now do a reverse DNS lookup on that IP address to see if it has a hostname." + +title "WHOIS — Domain Lookup", char: '-' +@chat = new_chat.with_tool(SharedTools::Tools::DnsTool.new) +ask "Do a WHOIS lookup on 'github.com'. Who is the registrar, when does it expire, and what nameservers is it using?" + +ask "Do a WHOIS lookup on 'ruby-lang.org'. When was it registered and when does it expire?" + +title "WHOIS — IP Address Lookup", char: '-' +@chat = new_chat.with_tool(SharedTools::Tools::DnsTool.new) +ask "Do a WHOIS lookup on the IP 8.8.8.8. Who owns this IP, what netblock is it in, and what is the abuse contact?" + +title "Combined Workflow", char: '-' +@chat = new_chat.with_tool(SharedTools::Tools::DnsTool.new) +ask <<~PROMPT + I want a full investigation of 'cloudflare.com': + 1. Look up its A records + 2. Do a WHOIS lookup on the domain + 3. Do a WHOIS lookup on one of the IP addresses you found + Summarize what you learn about who owns and operates this domain and infrastructure. +PROMPT + title "Done", char: '-' -puts "DnsTool demonstrated A, AAAA, MX, TXT, NS, reverse, and all-records DNS queries." +puts "DnsTool demonstrated DNS records, reverse lookups, external IP detection, and WHOIS queries." diff --git a/examples/doc_tool_demo.rb b/examples/doc_tool_demo.rb index b73caa2..385f27f 100755 --- a/examples/doc_tool_demo.rb +++ b/examples/doc_tool_demo.rb @@ -4,21 +4,133 @@ # Demo: DocTool # # Shows how an LLM uses the DocTool to read and reason over document -# content. This demo uses the text_read action on a synthesised reference -# document (written to a temp file) to demonstrate Q&A and summarisation. +# content — plain text, PDF, and Microsoft Word (.docx) files. # -# For PDF support install: gem install pdf-reader +# For PDF support install: gem install pdf-reader +# For DOCX support install: gem install docx # # Run: # bundle exec ruby -I examples examples/doc_tool_demo.rb +ENV['RUBY_LLM_DEBUG'] = 'true' + require_relative 'common' require 'shared_tools/doc_tool' - require 'tmpdir' require 'fileutils' +# Build a minimal but valid .docx file from an array of paragraph strings. +# A DOCX is a ZIP containing XML files; this uses rubyzip (pulled in by the +# docx gem) to create one from scratch without needing Word or caracal. +def build_docx(path, paragraphs) + require 'zip' + + para_xml = paragraphs.map do |text| + escaped = text.gsub('&', '&').gsub('<', '<').gsub('>', '>') + "#{escaped}" + end.join + + ns = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"' + pkg = 'xmlns="http://schemas.openxmlformats.org/package/2006/content-types"' + rel = 'xmlns="http://schemas.openxmlformats.org/package/2006/relationships"' + off = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' + + content_types = %() + pkg_rels = %() + word_rels = %() + styles = %() + doc = %(#{para_xml}) + + Zip::File.open(path, create: true) do |zip| + zip.get_output_stream('[Content_Types].xml') { |f| f.write(content_types) } + zip.get_output_stream('_rels/.rels') { |f| f.write(pkg_rels) } + zip.get_output_stream('word/_rels/document.xml.rels') { |f| f.write(word_rels) } + zip.get_output_stream('word/styles.xml') { |f| f.write(styles) } + zip.get_output_stream('word/document.xml') { |f| f.write(doc) } + end +end + +# Build a multi-sheet .xlsx file from a hash of { sheet_name => [[row], [row], ...] }. +# Values may be strings or numbers; strings are stored in the sharedStrings table. +def build_xlsx(path, sheets_data) + require 'zip' + + # Collect all unique string values across all sheets + all_strings = [] + sheets_data.each_value do |rows| + rows.each { |row| row.each { |v| all_strings << v.to_s if v.is_a?(String) } } + end + all_strings = all_strings.uniq + + str_index = all_strings.each_with_index.to_h # string => index + + ns_main = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"' + ns_r = 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"' + pkg_ns = 'xmlns="http://schemas.openxmlformats.org/package/2006/content-types"' + rel_ns = 'xmlns="http://schemas.openxmlformats.org/package/2006/relationships"' + off = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' + + sheet_names = sheets_data.keys + sheet_xmls = {} + override_xml = sheet_names.each_with_index.map do |_, i| + %() + end.join + + content_types = %(#{override_xml}) + + pkg_rels = %() + + wb_rels_entries = sheet_names.each_with_index.map do |_, i| + %() + end + wb_rels_entries << %() + wb_rels_entries << %() + wb_rels = %(#{wb_rels_entries.join}) + + sheet_els = sheet_names.each_with_index.map do |name, i| + %() + end.join + workbook = %(#{sheet_els}) + + sst_items = all_strings.map { |s| "#{s.gsub('&','&').gsub('<','<').gsub('>','>')}" }.join + shared_strings = %(#{sst_items}) + + styles = %() + + col_letters = ('A'..'Z').to_a + + sheet_names.each_with_index do |name, si| + rows = sheets_data[name] + row_xml = rows.each_with_index.map do |row, ri| + row_num = ri + 1 + cells = row.each_with_index.map do |val, ci| + col = col_letters[ci] + ref = "#{col}#{row_num}" + if val.is_a?(String) + %(#{str_index[val]}) + else + %(#{val}) + end + end.join + %(#{cells}) + end.join + sheet_xmls[name] = %(#{row_xml}) + end + + Zip::File.open(path, create: true) do |zip| + zip.get_output_stream('[Content_Types].xml') { |f| f.write(content_types) } + zip.get_output_stream('_rels/.rels') { |f| f.write(pkg_rels) } + zip.get_output_stream('xl/_rels/workbook.xml.rels') { |f| f.write(wb_rels) } + zip.get_output_stream('xl/workbook.xml') { |f| f.write(workbook) } + zip.get_output_stream('xl/sharedStrings.xml') { |f| f.write(shared_strings) } + zip.get_output_stream('xl/styles.xml') { |f| f.write(styles) } + sheet_names.each_with_index do |name, i| + zip.get_output_stream("xl/worksheets/sheet#{i+1}.xml") { |f| f.write(sheet_xmls[name]) } + end + end +end + title "DocTool Demo" work_dir = Dir.mktmpdir('doc_tool_demo_') @@ -111,10 +223,87 @@ the 10 sections. Format it as a numbered list. PROMPT + title "DOCX — Read a Word Document", char: '-' + docx_path = File.join(work_dir, 'meeting_notes.docx') + build_docx(docx_path, [ + "Q1 2026 Engineering Planning Meeting", + "Date: March 25, 2026 | Attendees: Alice, Bob, Carol, Dave", + "Agenda Item 1: Release v3.0 Timeline", + "Target release date confirmed as April 30, 2026. Alice will own the release checklist. Bob flagged three open P1 bugs that must be resolved before the release branch is cut.", + "Agenda Item 2: Infrastructure Migration", + "The team agreed to migrate staging from AWS us-east-1 to us-west-2 by April 10. Carol will coordinate with DevOps. Estimated downtime: 2 hours on a Saturday morning.", + "Agenda Item 3: Hiring", + "Two senior engineer requisitions approved. Dave will post the job descriptions by March 28. Target start date for new hires: June 1, 2026.", + "Action Items", + "Alice: Publish release checklist by March 27.", + "Bob: Fix P1 bugs by April 15.", + "Carol: Schedule infrastructure migration for April 5.", + "Dave: Post job descriptions by March 28.", + "Next Meeting: April 8, 2026 at 10am PST." + ]) + + @chat = new_chat.with_tool(SharedTools::Tools::DocTool.new) + ask "Read the Word document at '#{docx_path}' and summarise the key decisions and action items from this meeting." + + ask "Who owns the infrastructure migration, and when is it scheduled?" + + ask "How many action items are there, and who is responsible for each one?" + + title "Spreadsheet — Read a CSV file", char: '-' + csv_path = File.join(work_dir, 'expenses.csv') + File.write(csv_path, <<~CSV) + Month,Category,Amount,Approved + January,Travel,1240.50,true + January,Software,89.99,true + February,Travel,875.00,true + February,Hardware,2400.00,false + March,Travel,1100.75,true + March,Software,179.98,true + March,Office Supplies,342.00,true + April,Travel,650.00,true + April,Hardware,3200.00,true + April,Software,89.99,true + CSV + + @chat = new_chat.with_tool(SharedTools::Tools::DocTool.new) + ask "Read the spreadsheet at '#{csv_path}'. What is the total spend by category? Which month had the highest travel expenses?" + + ask "Are there any unapproved expenses in '#{csv_path}'? Show me the details." + + title "Spreadsheet — Read a Multi-Sheet XLSX", char: '-' + xlsx_path = File.join(work_dir, 'quarterly_sales.xlsx') + build_xlsx(xlsx_path, { + "Q1" => [ + ["Product", "Jan", "Feb", "Mar", "Total"], + ["Widget A", 12400, 11800, 13200, 37400], + ["Widget B", 8200, 7600, 9100, 24900], + ["Widget C", 3100, 2900, 3800, 9800], + ], + "Q2" => [ + ["Product", "Apr", "May", "Jun", "Total"], + ["Widget A", 14100, 15300, 16200, 45600], + ["Widget B", 7900, 8400, 9200, 25500], + ["Widget C", 4800, 5100, 6300, 16200], + ], + "Summary" => [ + ["Product", "Q1 Total", "Q2 Total", "Growth %"], + ["Widget A", 37400, 45600, 21.9], + ["Widget B", 24900, 25500, 2.4], + ["Widget C", 9800, 16200, 65.3], + ] + }) + + @chat = new_chat.with_tool(SharedTools::Tools::DocTool.new) + ask "Read the 'Summary' sheet from '#{xlsx_path}'. Which product had the strongest growth from Q1 to Q2?" + + ask "Now read the 'Q2' sheet from '#{xlsx_path}'. What was the best-performing product in June?" + + ask "Read both the 'Q1' and 'Q2' sheets from '#{xlsx_path}' and compare Widget C's trajectory. Is the growth sustainable?" + ensure FileUtils.rm_rf(work_dir) - puts "\nTemporary document removed." + puts "\nTemporary documents removed." end title "Done", char: '-' -puts "DocTool let the LLM read, comprehend, and answer questions about a text document." +puts "DocTool let the LLM read and reason over text, PDF, Word, and spreadsheet documents." diff --git a/examples/weather_tool_demo.rb b/examples/weather_tool_demo.rb index 7acf3b2..3a2c555 100755 --- a/examples/weather_tool_demo.rb +++ b/examples/weather_tool_demo.rb @@ -7,6 +7,9 @@ # and make recommendations — a travel planning assistant that retrieves # live data for multiple cities. # +# Also demonstrates combining the DnsTool (for IP geolocation) with the +# WeatherTool to auto-detect the user's location and fetch a local forecast. +# # Requires: OPENWEATHER_API_KEY environment variable # export OPENWEATHER_API_KEY=your_key_here # (free tier key at https://openweathermap.org/api) @@ -14,8 +17,12 @@ # Run: # OPENWEATHER_API_KEY=xxx bundle exec ruby -I examples examples/weather_tool_demo.rb +ENV['RUBY_LLM_DEBUG'] = 'true' + require_relative 'common' require 'shared_tools/weather_tool' +require 'shared_tools/tools/dns_tool' +require 'shared_tools/tools/current_date_time_tool' title "WeatherTool Demo" @@ -67,5 +74,27 @@ What is the temperature difference between the coldest and warmest city? PROMPT +title "My Local Forecast", char: '-' +@chat = new_chat.with_tools( + SharedTools::Tools::DnsTool.new, + SharedTools::Tools::WeatherTool.new, + SharedTools::Tools::CurrentDateTimeTool.new +) +ask <<~PROMPT + I want to know the weather where I currently am. + + Use these tools in order: + 1. current_date_time_tool (format: 'date') — get today's actual date and day of week + 2. dns_tool (action: 'external_ip') — get my public IP address + 3. dns_tool (action: 'ip_location') — geolocate that IP to find my city and country + 4. weather_tool — fetch current weather and a 3-day forecast for that city, imperial units + + In your response, use the real date and day of week from the tool (not your training data) + when labelling today, tomorrow, and the following days. + + Tell me: where am I, what are the current conditions, and what should I expect + over the next three days? +PROMPT + title "Done", char: '-' puts "WeatherTool retrieved live weather data and enabled real-world travel planning." diff --git a/lib/shared_tools.rb b/lib/shared_tools.rb index 5c2bd77..8335e0d 100644 --- a/lib/shared_tools.rb +++ b/lib/shared_tools.rb @@ -24,6 +24,7 @@ Dir.glob("#{__dir__}/shared_tools/*_tool.rb").each { |f| SharedToolsLoader.ignore(f) } SharedToolsLoader.ignore("#{__dir__}/shared_tools/data_science_kit.rb") SharedToolsLoader.ignore("#{__dir__}/shared_tools/devops_toolkit.rb") +SharedToolsLoader.ignore("#{__dir__}/shared_tools/database.rb") SharedToolsLoader.setup diff --git a/lib/shared_tools/database.rb b/lib/shared_tools/database.rb new file mode 100644 index 0000000..99eb2c5 --- /dev/null +++ b/lib/shared_tools/database.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# Shim: require 'shared_tools/database' +require 'shared_tools' +require 'shared_tools/tools/database' diff --git a/lib/shared_tools/devops_toolkit.rb b/lib/shared_tools/devops_toolkit.rb deleted file mode 100644 index 4216ef8..0000000 --- a/lib/shared_tools/devops_toolkit.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true -# Shim: require 'shared_tools/devops_toolkit' -require 'shared_tools' -require 'shared_tools/tools/devops_toolkit' diff --git a/lib/shared_tools/tools/browser.rb b/lib/shared_tools/tools/browser.rb index ccc0a29..9f43c72 100644 --- a/lib/shared_tools/tools/browser.rb +++ b/lib/shared_tools/tools/browser.rb @@ -30,12 +30,20 @@ require_relative 'browser/selector_generator/contextual_selectors' require_relative 'browser/selector_generator' -# Try to load watir for browser automation +# Try to load Ferrum (preferred) for browser automation via Chrome DevTools Protocol +begin + require 'ferrum' + require_relative 'browser/ferrum_driver' +rescue LoadError + # Ferrum gem not installed +end + +# Fall back to Watir if available begin require 'watir' require_relative 'browser/watir_driver' rescue LoadError - # Watir gem not installed, BrowserTools will require manual driver + # Watir gem not installed end # Load tools (order matters - utils loaded first) diff --git a/lib/shared_tools/tools/browser/base_tool.rb b/lib/shared_tools/tools/browser/base_tool.rb index 3af2cae..305880a 100644 --- a/lib/shared_tools/tools/browser/base_tool.rb +++ b/lib/shared_tools/tools/browser/base_tool.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "watir" - module SharedTools module Tools module Browser @@ -9,7 +7,7 @@ module Browser # class SeleniumTool < BaseTool # # ... # end - class BaseTool + class BaseTool # @param logger [Logger] # @param driver [BaseDriver] def initialize(driver:, logger: Logger.new(IO::NULL)) @@ -20,15 +18,16 @@ def initialize(driver:, logger: Logger.new(IO::NULL)) protected - def wait_for_element + def wait_for_element(timeout: 10) return yield if defined?(RSpec) # Skip waiting in tests - Watir::Wait.until(timeout: 10) do + deadline = Time.now + timeout + loop do element = yield - element if element && element_visible?(element) + return element if element && element_visible?(element) + break if Time.now >= deadline + sleep 0.2 end - rescue Watir::Wait::TimeoutError - log_element_timeout nil end @@ -39,10 +38,7 @@ def element_visible?(element) end def log_element_timeout - return unless @browser.respond_to?(:elements) - - visible_elements = @browser.elements.select(&:visible?).map(&:text).compact.first(10) - @logger.error("Element not found after 10s. Sample visible elements: #{visible_elements}") + @logger.error("Element not found after timeout.") end end end diff --git a/lib/shared_tools/tools/browser/click_tool.rb b/lib/shared_tools/tools/browser/click_tool.rb index 098b529..b8e7ca1 100644 --- a/lib/shared_tools/tools/browser/click_tool.rb +++ b/lib/shared_tools/tools/browser/click_tool.rb @@ -42,10 +42,12 @@ def execute(selector:) private def default_driver - if defined?(Watir) + if defined?(Ferrum) + FerrumDriver.new(logger: @logger) + elsif defined?(Watir) WatirDriver.new(logger: @logger) else - raise LoadError, "Browser tools require a driver. Either install the 'watir' gem or pass a driver: parameter" + raise LoadError, "Browser tools require a driver. Install the 'ferrum' gem or pass a driver: parameter" end end end diff --git a/lib/shared_tools/tools/browser/ferrum_driver.rb b/lib/shared_tools/tools/browser/ferrum_driver.rb new file mode 100644 index 0000000..84850b4 --- /dev/null +++ b/lib/shared_tools/tools/browser/ferrum_driver.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'tempfile' + +module SharedTools + module Tools + module Browser + # A browser driver backed by Ferrum (Chrome DevTools Protocol). + # No chromedriver binary required — Ferrum talks directly to Chrome. + # + # @example + # driver = SharedTools::Tools::Browser::FerrumDriver.new + # driver.goto(url: "https://example.com") + # driver.click(selector: "a.some-link") + # driver.close + class FerrumDriver < BaseDriver + # @param logger [Logger] + # @param network_idle_timeout [Numeric] seconds to wait for network idle after navigation + # @param ferrum_options [Hash] additional options passed directly to Ferrum::Browser + def initialize(logger: Logger.new(IO::NULL), network_idle_timeout: 5, **ferrum_options) + super(logger:) + @network_idle_timeout = network_idle_timeout + options = { + headless: true, + timeout: TIMEOUT, + browser_options: { 'disable-blink-features' => 'AutomationControlled' } + }.merge(ferrum_options) + @browser = Ferrum::Browser.new(**options) + end + + def close + @browser.quit + end + + # @return [String] + def url + @browser.current_url + end + + # @return [String] + def title + @browser.title + end + + # @return [String] + def html + @browser.evaluate('document.documentElement.outerHTML') + end + + # @param url [String] + # @return [Hash] + def goto(url:) + @browser.go_to(url) + wait_for_network_idle + { status: :ok } + end + + # @yield [file] + # @yieldparam file [File] + def screenshot + tempfile = Tempfile.new(['screenshot', '.png']) + @browser.screenshot(path: tempfile.path) + yield File.open(tempfile.path, 'rb') + ensure + tempfile&.close + tempfile&.unlink + end + + # @param selector [String] CSS selector for an input or textarea + # @param text [String] + # @return [Hash] + def fill_in(selector:, text:) + element = wait_for_element { @browser.at_css(selector) } + return { status: :error, message: "unknown selector=#{selector.inspect}" } if element.nil? + + element.evaluate("this.value = #{text.to_json}") + element.evaluate("this.dispatchEvent(new Event('input', {bubbles: true}))") + element.evaluate("this.dispatchEvent(new Event('change', {bubbles: true}))") + { status: :ok } + rescue => e + { status: :error, message: e.message } + end + + # @param selector [String] CSS selector + # @return [Hash] + def click(selector:) + element = wait_for_element { @browser.at_css(selector) } + return { status: :error, message: "unknown selector=#{selector.inspect}" } if element.nil? + + element.click + { status: :ok } + rescue => e + { status: :error, message: e.message } + end + + private + + # Wait until there are no pending network requests (JS-rendered pages). + # Falls back silently if Ferrum raises a timeout. + def wait_for_network_idle + @browser.network.wait_for_idle(duration: 0.3, timeout: @network_idle_timeout) + rescue Ferrum::TimeoutError + # Some requests never settle; accept whatever state the page is in. + end + + def wait_for_element + deadline = Time.now + TIMEOUT + loop do + result = yield + return result if result + break if Time.now >= deadline + sleep 0.2 + end + nil + end + end + end + end +end diff --git a/lib/shared_tools/tools/browser/inspect_tool.rb b/lib/shared_tools/tools/browser/inspect_tool.rb index eb5bbc7..8506765 100644 --- a/lib/shared_tools/tools/browser/inspect_tool.rb +++ b/lib/shared_tools/tools/browser/inspect_tool.rb @@ -44,10 +44,12 @@ def execute(text_content:, selector: nil, context_size: 2) private def default_driver - if defined?(Watir) + if defined?(Ferrum) + FerrumDriver.new(logger: @logger) + elsif defined?(Watir) WatirDriver.new(logger: @logger) else - raise LoadError, "Browser tools require a driver. Either install the 'watir' gem or pass a driver: parameter" + raise LoadError, "Browser tools require a driver. Install the 'ferrum' gem or pass a driver: parameter" end end diff --git a/lib/shared_tools/tools/browser/page_inspect_tool.rb b/lib/shared_tools/tools/browser/page_inspect_tool.rb index cf0d2e2..ff481b6 100644 --- a/lib/shared_tools/tools/browser/page_inspect_tool.rb +++ b/lib/shared_tools/tools/browser/page_inspect_tool.rb @@ -43,10 +43,12 @@ def execute(summarize: false) private def default_driver - if defined?(Watir) + if defined?(Ferrum) + FerrumDriver.new(logger: @logger) + elsif defined?(Watir) WatirDriver.new(logger: @logger) else - raise LoadError, "Browser tools require a driver. Either install the 'watir' gem or pass a driver: parameter" + raise LoadError, "Browser tools require a driver. Install the 'ferrum' gem or pass a driver: parameter" end end end diff --git a/lib/shared_tools/tools/browser/page_screenshot_tool.rb b/lib/shared_tools/tools/browser/page_screenshot_tool.rb index 5fe422f..5b6a76e 100644 --- a/lib/shared_tools/tools/browser/page_screenshot_tool.rb +++ b/lib/shared_tools/tools/browser/page_screenshot_tool.rb @@ -1,36 +1,48 @@ # frozen_string_literal: true -require "base64" - module SharedTools module Tools module Browser # A browser automation tool for taking screenshots of the current page. + # Saves the screenshot to a file and returns the path — avoids injecting + # large base64 blobs into the conversation context. class PageScreenshotTool < ::RubyLLM::Tool def self.name = 'browser_page_screenshot' - description "A browser automation tool for taking screenshots of the current page." + description "Take a screenshot of the current browser page and save it to a file." + + params do + string :path, required: false, + description: "File path to save the screenshot (e.g. 'screenshot.png'). " \ + "Defaults to a timestamped name in the current directory." + end def initialize(driver: nil, logger: nil) @driver = driver || default_driver @logger = logger || RubyLLM.logger end - def execute + def execute(path: nil) @logger.info("#{self.class.name}##{__method__}") + save_path = path || "screenshot_#{Time.now.strftime('%Y%m%d_%H%M%S')}.png" + @driver.screenshot do |file| - "data:image/png;base64,#{Base64.strict_encode64(file.read)}" + File.binwrite(save_path, file.read) end + + { status: :ok, saved_to: File.expand_path(save_path) } end private def default_driver - if defined?(Watir) + if defined?(Ferrum) + FerrumDriver.new(logger: @logger) + elsif defined?(Watir) WatirDriver.new(logger: @logger) else - raise LoadError, "Browser tools require a driver. Either install the 'watir' gem or pass a driver: parameter" + raise LoadError, "Browser tools require a driver. Install the 'ferrum' gem or pass a driver: parameter" end end end diff --git a/lib/shared_tools/tools/browser/selector_inspect_tool.rb b/lib/shared_tools/tools/browser/selector_inspect_tool.rb index 326139f..ba76e2b 100644 --- a/lib/shared_tools/tools/browser/selector_inspect_tool.rb +++ b/lib/shared_tools/tools/browser/selector_inspect_tool.rb @@ -43,10 +43,12 @@ def execute(selector:, context_size: 2) private def default_driver - if defined?(Watir) + if defined?(Ferrum) + FerrumDriver.new(logger: @logger) + elsif defined?(Watir) WatirDriver.new(logger: @logger) else - raise LoadError, "Browser tools require a driver. Either install the 'watir' gem or pass a driver: parameter" + raise LoadError, "Browser tools require a driver. Install the 'ferrum' gem or pass a driver: parameter" end end diff --git a/lib/shared_tools/tools/browser/text_field_area_set_tool.rb b/lib/shared_tools/tools/browser/text_field_area_set_tool.rb index a9f21b9..d985182 100644 --- a/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +++ b/lib/shared_tools/tools/browser/text_field_area_set_tool.rb @@ -33,10 +33,12 @@ def execute(selector:, text:) private def default_driver - if defined?(Watir) + if defined?(Ferrum) + FerrumDriver.new(logger: @logger) + elsif defined?(Watir) WatirDriver.new(logger: @logger) else - raise LoadError, "Browser tools require a driver. Either install the 'watir' gem or pass a driver: parameter" + raise LoadError, "Browser tools require a driver. Install the 'ferrum' gem or pass a driver: parameter" end end end diff --git a/lib/shared_tools/tools/browser/visit_tool.rb b/lib/shared_tools/tools/browser/visit_tool.rb index 8b9ff0b..e9016fc 100644 --- a/lib/shared_tools/tools/browser/visit_tool.rb +++ b/lib/shared_tools/tools/browser/visit_tool.rb @@ -31,10 +31,12 @@ def execute(url:) private def default_driver - if defined?(Watir) + if defined?(Ferrum) + FerrumDriver.new(logger: @logger) + elsif defined?(Watir) WatirDriver.new(logger: @logger) else - raise LoadError, "Browser tools require a driver. Either install the 'watir' gem or pass a driver: parameter" + raise LoadError, "Browser tools require a driver. Install the 'ferrum' gem or pass a driver: parameter" end end end diff --git a/lib/shared_tools/tools/clipboard_tool.rb b/lib/shared_tools/tools/clipboard_tool.rb new file mode 100644 index 0000000..d130385 --- /dev/null +++ b/lib/shared_tools/tools/clipboard_tool.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require_relative '../../shared_tools' + +module SharedTools + module Tools + # Read, write, and clear the system clipboard. + # Supports macOS (pbcopy/pbpaste), Linux (xclip or xsel), and Windows (clip/PowerShell). + # + # @example + # tool = SharedTools::Tools::ClipboardTool.new + # tool.execute(action: 'write', text: 'Hello!') + # tool.execute(action: 'read') + # tool.execute(action: 'clear') + class ClipboardTool < ::RubyLLM::Tool + def self.name = 'clipboard_tool' + + description <<~DESC + Read from, write to, and clear the system clipboard. + Supports macOS, Linux (requires xclip or xsel), and Windows. + + Actions: + - 'read' — Return the current clipboard contents + - 'write' — Replace clipboard contents with the given text + - 'clear' — Empty the clipboard + DESC + + params do + string :action, description: "Action to perform: 'read', 'write', or 'clear'" + string :text, required: false, description: "Text to write. Required only for the 'write' action." + end + + # @param logger [Logger] optional logger + def initialize(logger: nil) + @logger = logger || RubyLLM.logger + end + + # @param action [String] 'read', 'write', or 'clear' + # @param text [String, nil] text for write action + # @return [Hash] result + def execute(action:, text: nil) + @logger.info("ClipboardTool#execute action=#{action}") + + case action.to_s.downcase + when 'read' then read_clipboard + when 'write' then write_clipboard(text) + when 'clear' then clear_clipboard + else + { success: false, error: "Unknown action '#{action}'. Use: read, write, clear" } + end + rescue => e + @logger.error("ClipboardTool error: #{e.message}") + { success: false, error: e.message } + end + + private + + def read_clipboard + content = clipboard_read + { success: true, content: content, length: content.length } + end + + def write_clipboard(text) + raise ArgumentError, "text is required for the write action" if text.nil? + clipboard_write(text) + { success: true, message: "Text written to clipboard", length: text.length } + end + + def clear_clipboard + clipboard_write('') + { success: true, message: "Clipboard cleared" } + end + + def clipboard_read + if macos? + `pbpaste` + elsif linux_xclip? + `xclip -selection clipboard -o 2>/dev/null` + elsif linux_xsel? + `xsel --clipboard --output 2>/dev/null` + elsif windows? + `powershell -command "Get-Clipboard" 2>/dev/null`.strip + else + raise "Clipboard not supported on this platform. Install xclip or xsel on Linux." + end + end + + def clipboard_write(text) + if macos? + IO.popen('pbcopy', 'w') { |io| io.write(text) } + elsif linux_xclip? + IO.popen('xclip -selection clipboard', 'w') { |io| io.write(text) } + elsif linux_xsel? + IO.popen('xsel --clipboard --input', 'w') { |io| io.write(text) } + elsif windows? + IO.popen('clip', 'w') { |io| io.write(text) } + else + raise "Clipboard not supported on this platform. Install xclip or xsel on Linux." + end + end + + def macos? + RUBY_PLATFORM.include?('darwin') + end + + def windows? + RUBY_PLATFORM.match?(/mswin|mingw|cygwin/) + end + + def linux_xclip? + @linux_xclip ||= system('which xclip > /dev/null 2>&1') + end + + def linux_xsel? + @linux_xsel ||= system('which xsel > /dev/null 2>&1') + end + end + end +end diff --git a/lib/shared_tools/tools/composite_analysis_tool.rb b/lib/shared_tools/tools/composite_analysis_tool.rb index a8d345a..207d4a4 100644 --- a/lib/shared_tools/tools/composite_analysis_tool.rb +++ b/lib/shared_tools/tools/composite_analysis_tool.rb @@ -18,12 +18,22 @@ def self.name = "composite_analysis" DESCRIPTION params do - string :data_source, description: <<~DESC.strip, required: true + string :data_source, description: <<~DESC.strip, required: false Primary data source to analyze. Can be either a local file path or a web URL. For files: Use relative or absolute paths to CSV, JSON, XML, or text files. For URLs: Use complete HTTP/HTTPS URLs to accessible data endpoints or web pages. The tool automatically detects the source type and uses appropriate fetching methods. Examples: './data/sales.csv', '/home/user/data.json', 'https://api.example.com/data' + Either data_source or data must be provided. + DESC + + string :data, description: <<~DESC.strip, required: false + Inline data to analyze, provided directly as a string. Accepts: + - Pipe-delimited markdown tables (header row with | separators) + - CSV text (comma-separated rows, first row treated as headers) + - JSON text (object or array) + - Plain text (one item per line) + Either data or data_source must be provided. DESC string :analysis_type, description: <<~DESC.strip, required: false @@ -48,16 +58,23 @@ def initialize(logger: nil) @logger = logger || RubyLLM.logger end - def execute(data_source:, analysis_type: "standard", **options) + def execute(data_source: nil, data: nil, analysis_type: "standard", **options) + if data_source.nil? && data.nil? + return { success: false, error: "Either data_source or data must be provided." } + end + results = {} analysis_start = Time.now begin - @logger.info("CompositeAnalysisTool#execute data_source=#{data_source} analysis_type=#{analysis_type}") + @logger.info("CompositeAnalysisTool#execute data_source=#{data_source.inspect} analysis_type=#{analysis_type}") # Step 1: Fetch data using appropriate method @logger.debug("Fetching data from source...") - if data_source.start_with?('http://', 'https://') + if data + results[:data] = parse_inline_data(data) + results[:source_type] = 'inline' + elsif data_source.start_with?('http://', 'https://') results[:data] = fetch_web_data(data_source) results[:source_type] = 'web' else @@ -116,6 +133,45 @@ def execute(data_source:, analysis_type: "standard", **options) private + # Parse inline data string into a Ruby structure. + # Handles pipe-delimited markdown tables, CSV, JSON, and plain text. + def parse_inline_data(raw) + raw = raw.strip + + # JSON + if raw.start_with?('{', '[') + begin + return JSON.parse(raw) + rescue JSON::ParserError + # fall through + end + end + + lines = raw.lines.map(&:strip).reject(&:empty?) + + # Pipe-delimited markdown table: "Col A | Col B | Col C" + if lines.first&.include?('|') + headers = lines.first.split('|').map(&:strip).reject(&:empty?) + data_lines = lines.drop(1).reject { |l| l.match?(/^\|?[-:\s|]+$/) } + return data_lines.map do |line| + values = line.split('|').map(&:strip).reject(&:empty?) + headers.zip(values).to_h + end + end + + # CSV (comma-separated) + if lines.first&.include?(',') + headers = lines.first.split(',').map(&:strip) + return lines.drop(1).map do |line| + values = line.split(',').map(&:strip) + headers.zip(values).to_h + end + end + + # Plain text — return as array of lines + lines + end + # Fetch data from web URL def fetch_web_data(url) @logger.debug("Fetching web data from: #{url}") diff --git a/lib/shared_tools/tools/computer/mac_driver.rb b/lib/shared_tools/tools/computer/mac_driver.rb index 910484e..f4a210c 100644 --- a/lib/shared_tools/tools/computer/mac_driver.rb +++ b/lib/shared_tools/tools/computer/mac_driver.rb @@ -21,7 +21,15 @@ def key(text:) # @param text [String] # @param duration [Integer] def hold_key(text:, duration:) - raise NotImplementedError, "#{self.class.name}##{__method__} undefined" + options = text.to_s.split('+') + key = options.pop + mask = options.reduce(0) { |m, opt| m | Library::CoreGraphics::EventFlags.find(opt) } + + @keyboard.key_down(key, mask: mask) + Kernel.sleep(duration.to_f) + @keyboard.key_up(key, mask: mask) + + { success: true, key: text, duration: duration } end # @return [Hash<{ x: Integer, y: Integer }>] @@ -86,10 +94,35 @@ def type(text:) @keyboard.type(text) end - # @param amount [Integer] - # @param direction [String] e.g. "up", "down", "left", "right" + # @param amount [Integer] number of scroll units + # @param direction [String] "up", "down", "left", or "right" def scroll(amount:, direction:) - raise NotImplementedError, "#{self.class.name}##{__method__} undefined" + # Attach CGEventCreateScrollWheelEvent2 if not already done + unless Library::CoreGraphics.respond_to?(:CGEventCreateScrollWheelEvent2) + Library::CoreGraphics.module_eval do + attach_function :CGEventCreateScrollWheelEvent2, + [:pointer, :uint32, :uint32, :int32, :int32, :int32], + :pointer + end + end + + amt = amount.to_i + # kCGScrollEventUnitLine = 1; wheel_count = 2 (vertical + horizontal) + delta_y, delta_x = case direction.to_s.downcase + when 'up' then [ amt, 0] + when 'down' then [-amt, 0] + when 'left' then [0, -amt] + when 'right' then [0, amt] + else [0, 0] + end + + event = Library::CoreGraphics.CGEventCreateScrollWheelEvent2(nil, 1, 2, delta_y, delta_x, 0) + Library::CoreGraphics.CGEventPost( + Library::CoreGraphics::EventTapLocation::HID_EVENT_TAP, event + ) + Library::CoreGraphics.CFRelease(event) + + { success: true, direction: direction, amount: amt } end # @yield [file] diff --git a/lib/shared_tools/tools/cron_tool.rb b/lib/shared_tools/tools/cron_tool.rb new file mode 100644 index 0000000..4dabd7b --- /dev/null +++ b/lib/shared_tools/tools/cron_tool.rb @@ -0,0 +1,332 @@ +# frozen_string_literal: true + +require 'time' +require_relative '../../shared_tools' + +module SharedTools + module Tools + # Parse, validate, explain, and generate cron expressions. + # + # @example + # tool = SharedTools::Tools::CronTool.new + # tool.execute(action: 'parse', expression: '0 9 * * 1-5') + # tool.execute(action: 'validate', expression: '*/15 * * * *') + # tool.execute(action: 'next_times', expression: '0 * * * *', count: 5) + # tool.execute(action: 'generate', description: 'every day at 9am') + class CronTool < ::RubyLLM::Tool + def self.name = 'cron_tool' + + description <<~DESC + Parse, validate, explain, and generate cron expressions (standard 5-field format). + + Actions: + - 'parse' — Parse and explain a cron expression + - 'validate' — Check whether a cron expression is valid + - 'next_times' — List the next N execution times (default 5) + - 'generate' — Generate a cron expression from a natural language description + + Cron format: minute hour day month weekday + - Each field accepts: number, range (1-5), list (1,3,5), step (*/15), or wildcard (*) + - Weekday: 0-7 (0 and 7 both mean Sunday) + + Generate examples: 'every day at 9am', 'every monday at noon', 'every 15 minutes', + 'every weekday', 'first day of every month at midnight' + DESC + + params do + string :action, description: "Action: 'parse', 'validate', 'next_times', 'generate'" + string :expression, required: false, description: "5-field cron expression. Required for parse, validate, next_times." + integer :count, required: false, description: "Number of next execution times. Default: 5." + string :description, required: false, description: "Natural language schedule description. Required for generate." + end + + # @param logger [Logger] optional logger + def initialize(logger: nil) + @logger = logger || RubyLLM.logger + end + + # @param action [String] action to perform + # @param expression [String, nil] cron expression + # @param count [Integer, nil] number of times for next_times + # @param description [String, nil] natural language description for generate + # @return [Hash] result + def execute(action:, expression: nil, count: nil, description: nil) + @logger.info("CronTool#execute action=#{action}") + + case action.to_s.downcase + when 'parse' then parse_expression(expression) + when 'validate' then validate_expression(expression) + when 'next_times' then next_times(expression, (count || 5).to_i) + when 'generate' then generate_expression(description) + else + { success: false, error: "Unknown action '#{action}'. Use: parse, validate, next_times, generate" } + end + rescue => e + @logger.error("CronTool error: #{e.message}") + { success: false, error: e.message } + end + + private + + FIELD_NAMES = %w[minute hour day month weekday].freeze + FIELD_RANGES = { + 'minute' => 0..59, + 'hour' => 0..23, + 'day' => 1..31, + 'month' => 1..12, + 'weekday' => 0..7 + }.freeze + DAY_NAMES = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze + MONTH_NAMES = %w[January February March April May June July August + September October November December].freeze + + # ------------------------------------------------------------------------- + # Action implementations + # ------------------------------------------------------------------------- + + def parse_expression(expr) + require_expr!(expr) + parts = split_expr(expr) + fields = {} + FIELD_NAMES.zip(parts).each do |name, raw| + fields[name] = { raw: raw, values: expand_field(raw, FIELD_RANGES[name]) } + end + { success: true, valid: true, expression: expr, fields: fields, explanation: explain(parts) } + rescue ArgumentError => e + { success: false, valid: false, expression: expr, error: e.message } + end + + def validate_expression(expr) + require_expr!(expr) + parts = split_expr(expr) + FIELD_NAMES.zip(parts).each { |name, raw| expand_field(raw, FIELD_RANGES[name]) } + { success: true, valid: true, expression: expr, explanation: explain(parts) } + rescue ArgumentError => e + { success: true, valid: false, expression: expr, error: e.message } + end + + def next_times(expr, count) + require_expr!(expr) + parts = split_expr(expr) + + sets = FIELD_NAMES.map.with_index { |name, i| expand_field(parts[i], FIELD_RANGES[name]) } + mins_set, hrs_set, days_set, months_set, wdays_set = sets + + # Normalise Sunday: weekday 7 == 0 + wdays_set = wdays_set.map { |d| d == 7 ? 0 : d }.uniq.sort + + times = [] + t = Time.now + # Advance to the next minute boundary + t = Time.new(t.year, t.month, t.day, t.hour, t.min + 1, 0) + limit = 527_040 # 1 year of minutes — safety cap + + while times.size < count && limit > 0 + limit -= 1 + if months_set.include?(t.month) && + days_set.include?(t.day) && + wdays_set.include?(t.wday) && + hrs_set.include?(t.hour) && + mins_set.include?(t.min) + times << t.strftime('%Y-%m-%d %H:%M (%A)') + end + t += 60 + end + + { success: true, expression: expr, explanation: explain(parts), next_times: times } + end + + def generate_expression(desc) + raise ArgumentError, "description is required for the generate action" if desc.nil? || desc.strip.empty? + + d = desc.downcase + expr = match_pattern(d) + + if expr + parts = expr.split + { success: true, description: desc, expression: expr, explanation: explain(parts) } + else + { + success: false, + description: desc, + error: "Could not generate an expression from that description. " \ + "Try: 'every day at 9am', 'every monday at noon', 'every 15 minutes', " \ + "'every weekday', 'first day of every month at midnight'." + } + end + end + + # ------------------------------------------------------------------------- + # Pattern matching for generate + # ------------------------------------------------------------------------- + + def match_pattern(d) + return '* * * * *' if d.include?('every minute') + + if (m = d.match(/every\s+(\d+)\s+minutes?/)) + return "*/#{m[1]} * * * *" + end + + if d.match?(/every\s+hour\b/) && !d.match?(/\d+\s+hours?/) + return '0 * * * *' + end + + if (m = d.match(/every\s+(\d+)\s+hours?/)) + return "0 */#{m[1]} * * *" + end + + return '0 9 * * 1-5' if d.include?('weekday') + return '0 0 * * 0,6' if d.include?('weekend') + + day_pattern = DAY_NAMES.map(&:downcase).join('|') + + if (m = d.match(/every\s+(#{day_pattern})\s+at\s+(\d+)(?::(\d+))?\s*(am|pm)?/)) + day_num = DAY_NAMES.map(&:downcase).index(m[1]) + return "#{hm_to_cron(m[2], m[3], m[4])} * * #{day_num}" + end + + if (m = d.match(/every\s+(#{day_pattern})\s+at\s+noon/)) + day_num = DAY_NAMES.map(&:downcase).index(m[1]) + return "0 12 * * #{day_num}" + end + + if (m = d.match(/every\s+(#{day_pattern})/)) + day_num = DAY_NAMES.map(&:downcase).index(m[1]) + return "0 0 * * #{day_num}" + end + + return '0 12 * * *' if d.include?('noon') + return '0 0 * * *' if d.include?('midnight') + + if (m = d.match(/every\s+day\s+at\s+(\d+)(?::(\d+))?\s*(am|pm)?/)) + return "#{hm_to_cron(m[1], m[2], m[3])} * * *" + end + + if (m = d.match(/first\s+day\s+(?:of\s+(?:every\s+)?month\s+)?at\s+(\d+)(?::(\d+))?\s*(am|pm)?/)) + return "#{hm_to_cron(m[1], m[2], m[3])} 1 * *" + end + + return '0 0 1 * *' if d.match?(/first\s+day/) + + nil + end + + # Convert hour/minute/ampm strings to "min hour" cron tokens. + def hm_to_cron(h, m, ap) + hour = h.to_i + min = (m || '0').to_i + hour += 12 if ap == 'pm' && hour != 12 + hour = 0 if ap == 'am' && hour == 12 + "#{min} #{hour}" + end + + # ------------------------------------------------------------------------- + # Field expansion + # ------------------------------------------------------------------------- + + # Expand one cron field to a sorted array of integers. + def expand_field(value, range) + return range.to_a if value == '*' + + result = [] + value.split(',').each do |part| + if part.include?('/') + base_str, step_str = part.split('/') + step = step_str.to_i + raise ArgumentError, "Step must be >= 1, got #{step}" if step < 1 + base_set = base_str == '*' ? range.to_a : expand_range_part(base_str, range) + result.concat(base_set.each_with_index.filter_map { |v, i| v if (i % step).zero? }) + elsif part.include?('-') + a, b = part.split('-').map(&:to_i) + unless range_with_sunday(range).cover?(a) && range_with_sunday(range).cover?(b) + raise ArgumentError, "Range #{part} is out of bounds for #{range}" + end + result.concat((a..b).to_a) + else + v = part.to_i + unless range_with_sunday(range).cover?(v) + raise ArgumentError, "Value #{v} is out of range #{range}" + end + result << v + end + end + + result.uniq.sort + end + + def expand_range_part(str, range) + if str.include?('-') + a, b = str.split('-').map(&:to_i) + (a..b).to_a + else + v = str.to_i + (v..range.last).to_a + end + end + + # Allow weekday 7 (Sunday alias) + def range_with_sunday(range) + range == FIELD_RANGES['weekday'] ? (0..7) : range + end + + # ------------------------------------------------------------------------- + # Human-readable explanation + # ------------------------------------------------------------------------- + + def explain(parts) + min, hour, day, month, weekday = parts + segments = [] + + segments << if min == '*' then 'every minute' + elsif min.start_with?('*/') then "every #{min[2..]} minutes" + else "at minute #{min}" + end + + segments << if hour == '*' then 'of every hour' + elsif hour.start_with?('*/') then "every #{hour[2..]} hours" + else "at #{fmt_hour(hour)}" + end + + segments << "on day #{day} of the month" unless day == '*' + segments << "in #{fmt_month(month)}" unless month == '*' + segments << "on #{fmt_weekday(weekday)}" unless weekday == '*' + + segments.join(', ') + end + + def fmt_hour(h) + return h if h.match?(/[,\-\/]/) + n = h.to_i + disp = n == 0 ? 12 : (n > 12 ? n - 12 : n) + ampm = n < 12 ? 'AM' : 'PM' + "#{disp}:00 #{ampm}" + end + + def fmt_month(m) + return m if m.match?(/[,\-\/]/) + MONTH_NAMES[m.to_i - 1] || m + end + + def fmt_weekday(w) + return 'weekdays (Mon–Fri)' if w == '1-5' + return w if w.match?(/[,\/]/) + return w if w.include?('-') + DAY_NAMES[w.to_i % 7] || w + end + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + + def require_expr!(expr) + raise ArgumentError, "expression is required for this action" if expr.nil? || expr.strip.empty? + end + + def split_expr(expr) + parts = expr.strip.split(/\s+/) + raise ArgumentError, "Cron expression must have 5 fields, got #{parts.size}" unless parts.size == 5 + parts + end + end + end +end diff --git a/lib/shared_tools/tools/current_date_time_tool.rb b/lib/shared_tools/tools/current_date_time_tool.rb new file mode 100644 index 0000000..2e5e4b9 --- /dev/null +++ b/lib/shared_tools/tools/current_date_time_tool.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'time' +require_relative '../../shared_tools' + +module SharedTools + module Tools + # Returns the current date, time, and timezone from the local system. + # + # @example + # tool = SharedTools::Tools::CurrentDateTimeTool.new + # tool.execute # full output + # tool.execute(format: 'date') # date fields only + class CurrentDateTimeTool < ::RubyLLM::Tool + def self.name = 'current_date_time_tool' + + description <<~DESC + Returns the current date, time, timezone, and calendar metadata from the system clock. + + Supported formats: + - 'full' (default) — all fields: date, time, timezone, ISO 8601, unix timestamp, DST flag + - 'date' — year, month, day, day_of_week, week_of_year, quarter, ordinal_day + - 'time' — hour, minute, second, timezone, utc_offset + - 'iso8601' — iso8601, iso8601_utc, unix_timestamp + DESC + + params do + string :format, required: false, description: <<~DESC.strip + Output format. Options: 'full' (default), 'date', 'time', 'iso8601'. + DESC + end + + # @param logger [Logger] optional logger + def initialize(logger: nil) + @logger = logger || RubyLLM.logger + end + + # @param format [String] output format + # @return [Hash] date/time information + def execute(format: 'full') + @logger.info("CurrentDateTimeTool#execute format=#{format}") + + now = Time.now + utc = now.utc + + date_info = { + year: now.year, + month: now.month, + day: now.day, + day_of_week: now.strftime('%A'), + day_of_week_num: now.wday, + week_of_year: now.strftime('%U').to_i, + quarter: ((now.month - 1) / 3) + 1, + ordinal_day: now.yday + } + + time_info = { + hour: now.hour, + minute: now.min, + second: now.sec, + timezone: now.zone, + utc_offset: now.strftime('%z'), + utc_offset_hours: (now.utc_offset / 3600.0).round(2) + } + + iso_info = { + iso8601: now.iso8601, + iso8601_utc: utc.iso8601, + unix_timestamp: now.to_i + } + + case format.to_s.downcase + when 'date' + { success: true }.merge(date_info) + when 'time' + { success: true }.merge(time_info) + when 'iso8601' + { success: true }.merge(iso_info) + else + { success: true, dst: now.dst? } + .merge(date_info) + .merge(time_info) + .merge(iso_info) + end + end + end + end +end diff --git a/lib/shared_tools/tools/data_science_kit.rb b/lib/shared_tools/tools/data_science_kit.rb index 88a9c8a..cb19779 100644 --- a/lib/shared_tools/tools/data_science_kit.rb +++ b/lib/shared_tools/tools/data_science_kit.rb @@ -28,13 +28,22 @@ def self.name = "data_science_kit" Each analysis type requires specific data formats and optional parameters. DESC - string :data_source, description: <<~DESC.strip, required: true + string :data_source, description: <<~DESC.strip, required: false Data source specification for analysis. Can be: - File path: Relative or absolute path to CSV, JSON, Excel, or Parquet files - Database query: SQL SELECT statement for database-sourced data - API endpoint: HTTP URL for REST API data sources The tool automatically detects the format and applies appropriate parsing. Examples: './sales_data.csv', 'SELECT * FROM transactions', 'https://api.company.com/data' + Either data_source or data must be provided. + DESC + + string :data, description: <<~DESC.strip, required: false + Inline data to analyse, provided directly as a JSON string. Accepted formats: + - Array of hashes: '[{"month":"Jan","value":42},{"month":"Feb","value":45}]' + - Pipe-delimited table string: "Col A | Col B\n1 | 2\n3 | 4" + - Comma-separated numbers (single series): "42,45,51,48,55" — parsed as [{value: n}] + Either data or data_source must be provided. DESC object :parameters, description: <<~DESC.strip, required: false do @@ -82,11 +91,18 @@ def initialize(logger: nil) @logger = logger || RubyLLM.logger end - def execute(analysis_type:, data_source:, **parameters) + def execute(analysis_type:, data_source: nil, data: nil, **parameters) analysis_start = Time.now begin - @logger.info("DataScienceKit#execute analysis_type=#{analysis_type} data_source=#{data_source}") + if data_source.nil? && data.nil? + return { + success: false, + error: "Either data_source or data must be provided." + } + end + + @logger.info("DataScienceKit#execute analysis_type=#{analysis_type}") # Validate analysis type unless VALID_ANALYSIS_TYPES.include?(analysis_type) @@ -99,21 +115,21 @@ def execute(analysis_type:, data_source:, **parameters) end # Load and validate data - data = load_data(data_source) - validate_data_for_analysis(data, analysis_type, parameters) + loaded_data = data ? parse_inline_data(data) : load_data(data_source) + validate_data_for_analysis(loaded_data, analysis_type, parameters) # Perform analysis result = case analysis_type when "statistical_summary" - generate_statistical_summary(data, parameters) + generate_statistical_summary(loaded_data, parameters) when "correlation_analysis" - perform_correlation_analysis(data, parameters) + perform_correlation_analysis(loaded_data, parameters) when "time_series" - analyze_time_series(data, parameters) + analyze_time_series(loaded_data, parameters) when "clustering" - perform_clustering(data, parameters) + perform_clustering(loaded_data, parameters) when "prediction" - generate_predictions(data, parameters) + generate_predictions(loaded_data, parameters) end analysis_duration = (Time.now - analysis_start).round(3) @@ -123,7 +139,7 @@ def execute(analysis_type:, data_source:, **parameters) success: true, analysis_type: analysis_type, result: result, - data_summary: summarize_data(data), + data_summary: summarize_data(loaded_data), analyzed_at: Time.now.iso8601, duration_seconds: analysis_duration } @@ -133,8 +149,7 @@ def execute(analysis_type:, data_source:, **parameters) success: false, error: e.message, error_type: e.class.name, - analysis_type: analysis_type, - data_source: data_source + analysis_type: analysis_type } end end @@ -189,6 +204,41 @@ def load_from_file(file_path) end end + def parse_inline_data(raw) + raw = raw.strip + # JSON array or object + if raw.start_with?('{', '[') + begin + return JSON.parse(raw) + rescue JSON::ParserError + end + end + lines = raw.lines.map(&:strip).reject(&:empty?) + # Pipe-delimited table + if lines.first&.include?('|') + headers = lines.first.split('|').map(&:strip).reject(&:empty?) + data_lines = lines.drop(1).reject { |l| l.match?(/^\|?[-:\s|]+$/) } + return data_lines.map do |line| + values = line.split('|').map(&:strip).reject(&:empty?) + headers.zip(values).to_h + end + end + # CSV header row + if lines.size > 1 && lines.first.include?(',') && lines.first.match?(/[a-zA-Z]/) + headers = lines.first.split(',').map(&:strip) + return lines.drop(1).map do |line| + values = line.split(',').map(&:strip) + headers.zip(values).to_h + end + end + # Comma-separated numbers — single series + if lines.size == 1 && lines.first.match?(/^[\d.,\s]+$/) + return lines.first.split(',').map { |v| { "value" => v.strip.to_f } } + end + # Plain lines + lines.map { |l| { "value" => l } } + end + # Generate sample data for testing def generate_sample_data(size = 30) (1..size).map do |i| diff --git a/lib/shared_tools/tools/dns_tool.rb b/lib/shared_tools/tools/dns_tool.rb new file mode 100644 index 0000000..04f6b35 --- /dev/null +++ b/lib/shared_tools/tools/dns_tool.rb @@ -0,0 +1,422 @@ +# frozen_string_literal: true + +require 'resolv' +require 'socket' +require 'net/http' +require 'uri' +require_relative '../../shared_tools' + +module SharedTools + module Tools + # DNS lookup tool supporting A, AAAA, MX, TXT, NS, CNAME, reverse, all-records, + # external IP detection, and WHOIS database queries. + # + # @example + # tool = SharedTools::Tools::DnsTool.new + # tool.execute(action: 'a', host: 'ruby-lang.org') + # tool.execute(action: 'mx', host: 'gmail.com') + # tool.execute(action: 'reverse', host: '8.8.8.8') + # tool.execute(action: 'external_ip') + # tool.execute(action: 'whois', host: 'github.com') + # tool.execute(action: 'whois', host: '8.8.8.8') + class DnsTool < ::RubyLLM::Tool + def self.name = 'dns_tool' + + description <<~DESC + Perform DNS lookups, reverse lookups, record queries, external IP detection, + and WHOIS database queries for any hostname, domain name, or IP address. + + Actions: + - 'a' — IPv4 address records (A) + - 'aaaa' — IPv6 address records (AAAA) + - 'mx' — Mail exchange records, sorted by priority + - 'txt' — TXT records (SPF, DKIM, verification tokens, etc.) + - 'ns' — Authoritative nameserver records + - 'cname' — Canonical name (alias) records + - 'reverse' — Reverse PTR lookup for an IP address + - 'all' — A, MX, TXT, NS, and CNAME records combined + - 'external_ip' — Detect the current machine's public-facing IP address + - 'ip_location' — Geolocate an IP address (city, region, country, lat/lon, timezone, ISP) + - 'whois' — Query the WHOIS database for a domain name or IP address + DESC + + params do + string :action, description: <<~DESC.strip + The DNS or network operation to perform. One of: + - 'a' — Look up IPv4 A records for a hostname. Returns the IPv4 addresses + that the hostname resolves to. Useful for verifying DNS propagation, + checking load balancer IPs, or confirming a domain points to the + expected server. + - 'aaaa' — Look up IPv6 AAAA records for a hostname. Returns IPv6 addresses + that the hostname resolves to. Important for dual-stack network + verification and IPv6 connectivity testing. + - 'mx' — Look up Mail Exchange (MX) records for a domain, sorted by + priority (lowest number = highest priority). Essential for diagnosing + email delivery issues, verifying mail server configuration, and + confirming that a domain is using the expected mail provider. + - 'txt' — Look up TXT records for a domain. TXT records carry human-readable + text and machine-readable data including SPF policies (which servers + may send mail for the domain), DKIM public keys (for email signing), + DMARC policies, domain ownership verification tokens (Google, GitHub, + etc.), and BIMI brand indicators. + - 'ns' — Look up the authoritative Name Server (NS) records for a domain. + Returns the hostnames of the DNS servers that are authoritative for + the domain. Useful for verifying registrar settings, diagnosing DNS + delegation problems, and confirming a domain is using a specific + DNS provider. + - 'cname' — Look up Canonical Name (CNAME) alias records for a hostname. + Returns the target hostname that this alias points to. Common for + CDN configurations, third-party service integrations (e.g. Shopify, + Heroku), and subdomain aliases. + - 'reverse' — Perform a reverse PTR (pointer) lookup for an IP address. Returns + the hostname associated with the IP, if one is configured. Important + for mail server deliverability (forward-confirmed reverse DNS), + identifying unknown IP addresses, and network forensics. + - 'all' — Retrieve A, MX, TXT, NS, and CNAME records for a domain in a + single call. Provides a comprehensive snapshot of a domain's DNS + configuration. Useful for domain audits, migration planning, and + quick overviews. + - 'external_ip' — Detect the current machine's public-facing (external) IP address + as seen by the internet. Does not require a host parameter. Useful + for firewall rule generation, VPN verification, geolocation context, + abuse report submissions, and confirming that traffic is routing + through the expected network path (e.g. a VPN or proxy). + - 'ip_location' — Geolocate an IP address using a free geolocation API. Returns the + city, region, country, country code, latitude, longitude, timezone, + ISP name, and organisation. Accepts any public IPv4 address in the + host parameter; omit host (or pass the result of an 'external_ip' + call) to geolocate your own public IP. Useful for determining a + user's approximate location from their IP address, cross-referencing + IP ownership with physical geography, building location-aware + workflows (e.g. routing to the nearest server), and providing + contextual information such as local time and weather. + - 'whois' — Query the WHOIS database for a domain name or IP address. For + domain names, returns registrar information, registration and + expiry dates, name servers, registrant organization (when not + privacy-protected), and domain status flags. For IP addresses, + returns the network owner, ASN (Autonomous System Number), CIDR + netblock, country of allocation, and abuse contact information. + Useful for identifying who owns an IP attacking your server, + checking domain expiry dates, verifying registrar lock status, + finding abuse contacts, and threat intelligence workflows. + DESC + + string :host, description: <<~DESC.strip, required: false + The hostname, domain name, or IP address to query. Required for all actions + except 'external_ip'. Examples: + - Hostname: 'ruby-lang.org', 'mail.google.com' + - Domain: 'github.com', 'cloudflare.com' + - IP address: '8.8.8.8', '2001:4860:4860::8888' + For the 'reverse' action, provide an IP address. + For the 'whois' action, provide either a domain name or an IP address. + For the 'external_ip' action, this parameter is ignored. + For the 'ip_location' action, provide a public IPv4 address, or omit to geolocate your own external IP. + DESC + end + + WHOIS_PORT = 43 + WHOIS_TIMEOUT = 10 + IANA_WHOIS = 'whois.iana.org' + ARIN_WHOIS = 'whois.arin.net' + IP_SERVICES = %w[ + https://api.ipify.org + https://ifconfig.me/ip + https://icanhazip.com + ].freeze + + # @param logger [Logger] optional logger + def initialize(logger: nil) + @logger = logger || RubyLLM.logger + end + + # @param action [String] lookup type + # @param host [String] hostname or IP (not required for external_ip) + # @return [Hash] results + def execute(action:, host: nil) + @logger.info("DnsTool#execute action=#{action} host=#{host}") + + case action.to_s.downcase + when 'a' then lookup_a(host) + when 'aaaa' then lookup_aaaa(host) + when 'mx' then lookup_mx(host) + when 'txt' then lookup_txt(host) + when 'ns' then lookup_ns(host) + when 'cname' then lookup_cname(host) + when 'reverse' then lookup_reverse(host) + when 'all' then lookup_all(host) + when 'external_ip' then lookup_external_ip + when 'ip_location' then lookup_ip_location(host) + when 'whois' then lookup_whois(host) + else + { success: false, error: "Unknown action '#{action}'. Use: a, aaaa, mx, txt, ns, cname, reverse, all, external_ip, ip_location, whois" } + end + rescue => e + @logger.error("DnsTool error for #{host}: #{e.message}") + { success: false, host: host, error: e.message } + end + + private + + def lookup_a(host) + records = Resolv.getaddresses(host).select { |a| a.match?(/\A\d+\.\d+\.\d+\.\d+\z/) } + { success: true, host: host, type: 'A', records: records } + end + + def lookup_aaaa(host) + records = Resolv.getaddresses(host).reject { |a| a.match?(/\A\d+\.\d+\.\d+\.\d+\z/) } + { success: true, host: host, type: 'AAAA', records: records } + end + + def lookup_mx(host) + records = [] + Resolv::DNS.open do |dns| + dns.getresources(host, Resolv::DNS::Resource::IN::MX).each do |r| + records << { priority: r.preference, exchange: r.exchange.to_s } + end + end + records.sort_by! { |r| r[:priority] } + { success: true, host: host, type: 'MX', records: records } + end + + def lookup_txt(host) + records = [] + Resolv::DNS.open do |dns| + dns.getresources(host, Resolv::DNS::Resource::IN::TXT).each do |r| + records << r.strings.join(' ') + end + end + { success: true, host: host, type: 'TXT', records: records } + end + + def lookup_ns(host) + records = [] + Resolv::DNS.open do |dns| + dns.getresources(host, Resolv::DNS::Resource::IN::NS).each do |r| + records << r.name.to_s + end + end + { success: true, host: host, type: 'NS', records: records.sort } + end + + def lookup_cname(host) + records = [] + Resolv::DNS.open do |dns| + dns.getresources(host, Resolv::DNS::Resource::IN::CNAME).each do |r| + records << r.name.to_s + end + end + { success: true, host: host, type: 'CNAME', records: records } + end + + def lookup_reverse(ip) + hostname = Resolv.getname(ip) + { success: true, ip: ip, type: 'PTR', hostname: hostname } + rescue Resolv::ResolvError => e + { success: false, ip: ip, type: 'PTR', error: "No reverse DNS entry found: #{e.message}" } + end + + def lookup_all(host) + { + success: true, + host: host, + a: lookup_a(host)[:records], + mx: lookup_mx(host)[:records], + txt: lookup_txt(host)[:records], + ns: lookup_ns(host)[:records], + cname: lookup_cname(host)[:records] + } + end + + # Detect external IP by querying well-known public IP echo services. + # Tries each service in order and returns the first successful response. + def lookup_external_ip + IP_SERVICES.each do |url| + ip = http_get(url)&.strip + next unless ip&.match?(/\A[\d.:a-fA-F]+\z/) + + @logger.info("External IP resolved via #{url}: #{ip}") + return { + success: true, + type: 'external_ip', + ip: ip, + source: url, + note: 'This is your public-facing IP address as seen by the internet.' + } + rescue => e + @logger.warn("IP service #{url} failed: #{e.message}") + next + end + + { success: false, type: 'external_ip', error: 'All external IP services unreachable' } + end + + # Geolocate an IP address using the ip-api.com free JSON endpoint. + # If no IP is supplied, geolocates the caller's own external IP. + def lookup_ip_location(ip = nil) + target = ip.to_s.strip.empty? ? '' : "/#{ip.strip}" + url = "http://ip-api.com/json#{target}?fields=status,message,country,countryCode,region,regionName,city,zip,lat,lon,timezone,isp,org,as,query" + + @logger.info("IP geolocation query: #{url}") + raw = http_get(url) + data = JSON.parse(raw) + + if data['status'] == 'fail' + return { success: false, type: 'ip_location', error: data['message'], ip: ip } + end + + { + success: true, + type: 'ip_location', + ip: data['query'], + city: data['city'], + region: data['regionName'], + region_code: data['region'], + country: data['country'], + country_code: data['countryCode'], + zip: data['zip'], + latitude: data['lat'], + longitude: data['lon'], + timezone: data['timezone'], + isp: data['isp'], + organization: data['org'], + asn: data['as'], + note: 'Geolocation is approximate. Accuracy varies by ISP and region.' + } + rescue => e + @logger.error("IP geolocation failed for #{ip}: #{e.message}") + { success: false, type: 'ip_location', error: e.message, ip: ip } + end + + # Query the WHOIS database for a domain name or IP address. + # For domains, queries IANA first to find the authoritative WHOIS server, + # then queries that server for full registration details. + # For IPs, queries ARIN (which redirects to the appropriate RIR). + def lookup_whois(host) + return { success: false, error: "host is required for whois lookup" } if host.nil? || host.strip.empty? + + host = host.strip.downcase + + if ip_address?(host) + whois_server = ARIN_WHOIS + raw = whois_query(whois_server, host) + parsed = parse_whois_ip(raw) + else + # Step 1: ask IANA which server is authoritative for this TLD + iana_response = whois_query(IANA_WHOIS, host) + whois_server = extract_whois_server(iana_response) || IANA_WHOIS + + # Step 2: query the authoritative server + raw = whois_query(whois_server, host) + parsed = parse_whois_domain(raw) + end + + { + success: true, + host: host, + type: ip_address?(host) ? 'whois_ip' : 'whois_domain', + whois_server: whois_server, + parsed: parsed, + raw: raw + } + rescue => e + @logger.error("WHOIS lookup failed for #{host}: #{e.message}") + { success: false, host: host, type: 'whois', error: e.message } + end + + # Open a TCP connection to a WHOIS server on port 43, send the query, + # and return the full plain-text response. + def whois_query(server, query) + @logger.debug("WHOIS query: #{server} <- #{query}") + response = String.new(encoding: 'binary') + + Socket.tcp(server, WHOIS_PORT, connect_timeout: WHOIS_TIMEOUT) do |sock| + sock.write("#{query}\r\n") + response << sock.read + end + + response.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?') + end + + # Extract the 'whois:' referral server from an IANA response. + def extract_whois_server(iana_response) + iana_response.each_line do |line| + return $1.strip if line.match?(/^whois:/i) && line =~ /^whois:\s+(\S+)/i + end + nil + end + + # Parse key fields from a domain WHOIS response into a structured hash. + def parse_whois_domain(raw) + fields = { + registrar: extract_field(raw, /registrar:\s+(.+)/i), + registrar_url: extract_field(raw, /registrar url:\s+(.+)/i), + created: extract_field(raw, /creation date:\s+(.+)/i) || + extract_field(raw, /registered:\s+(.+)/i), + updated: extract_field(raw, /updated date:\s+(.+)/i) || + extract_field(raw, /last[\s-]updated?:\s+(.+)/i), + expires: extract_field(raw, /registry expiry date:\s+(.+)/i) || + extract_field(raw, /expir(?:y|ation) date:\s+(.+)/i) || + extract_field(raw, /paid[\s-]till:\s+(.+)/i), + status: extract_all_fields(raw, /domain status:\s+(.+)/i), + name_servers: extract_all_fields(raw, /name server:\s+(.+)/i), + registrant_org: extract_field(raw, /registrant\s+organization:\s+(.+)/i) || + extract_field(raw, /registrant:\s+(.+)/i), + registrant_country: extract_field(raw, /registrant\s+country:\s+(.+)/i), + dnssec: extract_field(raw, /dnssec:\s+(.+)/i) + }.compact + + fields[:name_servers] = fields[:name_servers]&.map(&:downcase)&.sort&.uniq + fields + end + + # Parse key fields from an IP/network WHOIS response into a structured hash. + def parse_whois_ip(raw) + { + organization: extract_field(raw, /orgname:\s+(.+)/i) || + extract_field(raw, /org-name:\s+(.+)/i) || + extract_field(raw, /netname:\s+(.+)/i), + network: extract_field(raw, /(?:inetnum|netrange|cidr):\s+(.+)/i), + cidr: extract_field(raw, /cidr:\s+(.+)/i), + country: extract_field(raw, /country:\s+(.+)/i), + asn: extract_field(raw, /originas:\s+(.+)/i) || + extract_field(raw, /aut-num:\s+(.+)/i), + abuse_email: extract_field(raw, /orgabuseemail:\s+(.+)/i) || + extract_field(raw, /abuse-mailbox:\s+(.+)/i), + abuse_phone: extract_field(raw, /orgabusephone:\s+(.+)/i), + updated: extract_field(raw, /updated:\s+(.+)/i) || + extract_field(raw, /last[\s-]modified:\s+(.+)/i) + }.compact + end + + def extract_field(text, pattern) + text.each_line do |line| + m = line.match(pattern) + return m[1].strip if m + end + nil + end + + def extract_all_fields(text, pattern) + results = [] + text.each_line do |line| + m = line.match(pattern) + results << m[1].strip if m + end + results.empty? ? nil : results + end + + def ip_address?(str) + str.match?(/\A\d{1,3}(\.\d{1,3}){3}\z/) || + str.match?(/\A[0-9a-fA-F:]+\z/) && str.include?(':') + end + + def http_get(url) + uri = URI(url) + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', + open_timeout: 5, read_timeout: 5) do |http| + http.get(uri.path.empty? ? '/' : uri.path).body + end + end + end + end +end diff --git a/lib/shared_tools/tools/doc.rb b/lib/shared_tools/tools/doc.rb index cfbfb84..e4ea0ed 100644 --- a/lib/shared_tools/tools/doc.rb +++ b/lib/shared_tools/tools/doc.rb @@ -6,3 +6,6 @@ require 'shared_tools' require_relative 'doc/pdf_reader_tool' +require_relative 'doc/text_reader_tool' +require_relative 'doc/docx_reader_tool' +require_relative 'doc/spreadsheet_reader_tool' diff --git a/lib/shared_tools/tools/doc/docx_reader_tool.rb b/lib/shared_tools/tools/doc/docx_reader_tool.rb new file mode 100644 index 0000000..7b6fed5 --- /dev/null +++ b/lib/shared_tools/tools/doc/docx_reader_tool.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +begin + require "docx" +rescue LoadError + # docx is optional - will raise error when tool is used without it +end + +module SharedTools + module Tools + module Doc + # Read text content from Microsoft Word (.docx) documents. + # + # @example + # tool = SharedTools::Tools::Doc::DocxReaderTool.new + # tool.execute(doc_path: "./report.docx") + # tool.execute(doc_path: "./report.docx", paragraph_range: "1-10") + class DocxReaderTool < ::RubyLLM::Tool + def self.name = 'doc_docx_read' + + description "Read the text content of a Microsoft Word (.docx) document." + + params do + string :doc_path, description: "Path to the .docx file." + + string :paragraph_range, description: <<~DESC.strip, required: false + Optional range of paragraphs to extract, 1-based. + Accepts the same notation as pdf_read page numbers: + - Single paragraph: "5" + - Multiple paragraphs: "1, 3, 5" + - Range: "1-20" + - Mixed: "1, 5-10, 15" + Omit to return the full document. + DESC + end + + # @param logger [Logger] optional logger + def initialize(logger: nil) + @logger = logger || RubyLLM.logger + end + + # @param doc_path [String] path to .docx file + # @param paragraph_range [String, nil] optional paragraph range + # @return [Hash] extraction result + def execute(doc_path:, paragraph_range: nil) + raise LoadError, "DocxReaderTool requires the 'docx' gem. Install it with: gem install docx" unless defined?(Docx) + + @logger.info("DocxReaderTool#execute doc_path=#{doc_path} paragraph_range=#{paragraph_range}") + + unless File.exist?(doc_path) + return { error: "File not found: #{doc_path}" } + end + + unless File.extname(doc_path).downcase == '.docx' + return { error: "Expected a .docx file, got: #{File.extname(doc_path)}" } + end + + doc = Docx::Document.open(doc_path) + paragraphs = doc.paragraphs.map(&:to_s).reject { |p| p.strip.empty? } + total = paragraphs.length + + @logger.debug("Loaded #{total} non-empty paragraphs from #{doc_path}") + + selected_indices = if paragraph_range + parse_range(paragraph_range, total) + else + (1..total).to_a + end + + invalid = selected_indices.select { |n| n < 1 || n > total } + valid = selected_indices.select { |n| n >= 1 && n <= total } + + extracted = valid.map { |n| { paragraph: n, text: paragraphs[n - 1] } } + + @logger.info("Extracted #{extracted.size} paragraphs from #{doc_path}") + + { + doc_path: doc_path, + total_paragraphs: total, + requested_range: paragraph_range || "all", + invalid_paragraphs: invalid, + paragraphs: extracted, + full_text: extracted.map { |p| p[:text] }.join("\n\n") + } + rescue => e + @logger.error("Failed to read DOCX '#{doc_path}': #{e.message}") + { error: e.message } + end + + private + + # Parse a range string like "1, 3-5, 10" into a sorted array of integers. + def parse_range(range_str, max) + range_str.split(',').flat_map do |part| + part.strip! + if part.include?('-') + lo, hi = part.split('-').map { |n| n.strip.to_i } + (lo..hi).to_a + else + [part.to_i] + end + end.uniq.sort + end + end + end + end +end diff --git a/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb b/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb new file mode 100644 index 0000000..42d6a57 --- /dev/null +++ b/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +begin + require "roo" +rescue LoadError + # roo is optional - will raise an error when the tool is used without it +end + +module SharedTools + module Tools + module Doc + # Read spreadsheet data from CSV, XLSX, ODS, and other formats supported by the roo gem. + # + # @example Read all rows from a CSV + # tool = SharedTools::Tools::Doc::SpreadsheetReaderTool.new + # tool.execute(doc_path: "./data.csv") + # + # @example Read a specific sheet from an Excel workbook + # tool.execute(doc_path: "./report.xlsx", sheet: "Q1 Sales") + # + # @example Read a row range from a worksheet + # tool.execute(doc_path: "./report.xlsx", sheet: "Summary", row_range: "2-50") + class SpreadsheetReaderTool < ::RubyLLM::Tool + def self.name = 'doc_spreadsheet_read' + + description "Read tabular data from spreadsheet files (CSV, XLSX, ODS, and other formats)." + + SUPPORTED_FORMATS = %w[.csv .xlsx .ods .xlsm].freeze + + params do + string :doc_path, description: <<~DESC.strip + Path to the spreadsheet file. Supported formats: + - .csv — Comma-separated values (plain text, no gem beyond roo required) + - .xlsx — Microsoft Excel Open XML Workbook (Excel 2007+) + - .xlsm — Microsoft Excel Macro-Enabled Workbook + - .ods — OpenDocument Spreadsheet (LibreOffice / OpenOffice) + Note: Legacy .xls (Excel 97-2003) requires the additional 'roo-xls' gem. + DESC + + string :sheet, description: <<~DESC.strip, required: false + Name or 1-based index of the worksheet to read. For multi-sheet workbooks + (XLSX, ODS), specify the exact sheet name (e.g. "Q1 Sales") or a number + (e.g. "2" for the second sheet). Defaults to the first sheet. + CSV files always have a single implicit sheet called "default". + DESC + + string :row_range, description: <<~DESC.strip, required: false + Row range to extract, 1-based (row 1 is the header row in most spreadsheets). + Accepts the same notation as other doc tool parameters: + - Single row: "3" + - Multiple rows: "1, 3, 5" + - Range: "1-100" + - Mixed: "1, 5-20, 30" + Omit to return all rows. + DESC + + boolean :headers, description: <<~DESC.strip, required: false + When true (default), treats the first row as column headers and returns + each subsequent row as a hash keyed by header name. When false, returns + each row as a plain array of values. Set to false when the spreadsheet + has no header row or when you want raw positional data. + DESC + end + + # @param logger [Logger] optional logger + def initialize(logger: nil) + @logger = logger || RubyLLM.logger + end + + # @param doc_path [String] path to spreadsheet file + # @param sheet [String, nil] sheet name or 1-based index + # @param row_range [String, nil] row range to extract + # @param headers [Boolean] whether first row is headers + # @return [Hash] extraction result + def execute(doc_path:, sheet: nil, row_range: nil, headers: true) + raise LoadError, "SpreadsheetReaderTool requires the 'roo' gem. Install it with: gem install roo" unless defined?(Roo) + + @logger.info("SpreadsheetReaderTool#execute doc_path=#{doc_path} sheet=#{sheet} row_range=#{row_range}") + + return { error: "File not found: #{doc_path}" } unless File.exist?(doc_path) + + ext = File.extname(doc_path).downcase + unless SUPPORTED_FORMATS.include?(ext) + return { error: "Unsupported format '#{ext}'. Supported: #{SUPPORTED_FORMATS.join(', ')}" } + end + + ss = Roo::Spreadsheet.open(doc_path) + + # Select sheet + active_sheet = resolve_sheet(ss, sheet) + return active_sheet if active_sheet.is_a?(Hash) && active_sheet[:error] + + ss.default_sheet = active_sheet + + total_rows = ss.last_row.to_i + first_row = ss.first_row.to_i + header_row = headers ? ss.row(first_row).map { |h| h.to_s.strip } : nil + + # Determine data rows (skip header if using headers) + data_start = headers ? first_row + 1 : first_row + all_indices = (data_start..total_rows).to_a + + selected = row_range ? filter_rows(all_indices, row_range) : all_indices + invalid = selected.reject { |n| n >= first_row && n <= total_rows } + valid = selected.select { |n| n >= first_row && n <= total_rows } + + rows = valid.map do |n| + raw = ss.row(n) + if headers && header_row + header_row.zip(raw).to_h + else + raw + end + end + + @logger.info("SpreadsheetReaderTool: read #{rows.size} rows from '#{active_sheet}'") + + { + doc_path: doc_path, + format: ext, + available_sheets: ss.sheets, + active_sheet: active_sheet, + total_rows: total_rows, + header_row: header_row, + requested_range: row_range || "all", + invalid_rows: invalid, + row_count: rows.size, + rows: rows + } + rescue => e + @logger.error("SpreadsheetReaderTool failed for '#{doc_path}': #{e.message}") + { error: e.message } + end + + private + + # Resolve sheet name or 1-based index to the actual sheet name. + def resolve_sheet(ss, sheet_param) + return ss.sheets.first if sheet_param.nil? + + # Numeric string → treat as 1-based index + if sheet_param.match?(/\A\d+\z/) + idx = sheet_param.to_i - 1 + return { error: "Sheet index #{sheet_param} out of range (workbook has #{ss.sheets.size} sheet(s))" } if idx < 0 || idx >= ss.sheets.size + return ss.sheets[idx] + end + + # Named sheet + return sheet_param if ss.sheets.include?(sheet_param) + + { error: "Sheet '#{sheet_param}' not found. Available sheets: #{ss.sheets.join(', ')}" } + end + + # Parse a range string like "1, 3-5, 10" into a sorted array of row indices. + def filter_rows(all_indices, range_str) + requested = range_str.split(',').flat_map do |part| + part.strip! + if part.include?('-') + lo, hi = part.split('-').map { |n| n.strip.to_i } + (lo..hi).to_a + else + [part.to_i] + end + end.uniq.sort + + all_indices & requested + end + end + end + end +end diff --git a/lib/shared_tools/tools/doc/text_reader_tool.rb b/lib/shared_tools/tools/doc/text_reader_tool.rb new file mode 100644 index 0000000..f59116a --- /dev/null +++ b/lib/shared_tools/tools/doc/text_reader_tool.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module SharedTools + module Tools + module Doc + # Read and return the full contents of a plain text file. + # + # @example + # tool = SharedTools::Tools::Doc::TextReaderTool.new + # tool.execute(doc_path: "./guide.txt") + class TextReaderTool < ::RubyLLM::Tool + def self.name = 'doc_text_read' + + description "Read the full contents of a plain text file." + + params do + string :doc_path, description: "Path to the text file to read." + end + + # @param logger [Logger] optional logger + def initialize(logger: nil) + @logger = logger || RubyLLM.logger + end + + # @param doc_path [String] path to the text file + # @return [Hash] file content and metadata + def execute(doc_path:) + @logger.info("TextReaderTool#execute doc_path=#{doc_path.inspect}") + + raise ArgumentError, "doc_path is required" if doc_path.nil? || doc_path.strip.empty? + raise ArgumentError, "File not found: #{doc_path}" unless File.exist?(doc_path) + raise ArgumentError, "Not a file: #{doc_path}" unless File.file?(doc_path) + + content = File.read(doc_path, encoding: 'utf-8') + line_count = content.lines.size + char_count = content.length + word_count = content.split.size + + @logger.info("TextReaderTool read #{char_count} chars, #{line_count} lines from #{doc_path}") + + { + doc_path: doc_path, + content: content, + line_count: line_count, + word_count: word_count, + char_count: char_count + } + rescue ArgumentError + raise + rescue => e + @logger.error("TextReaderTool failed to read #{doc_path}: #{e.message}") + { error: e.message, doc_path: doc_path } + end + end + end + end +end diff --git a/lib/shared_tools/tools/doc_tool.rb b/lib/shared_tools/tools/doc_tool.rb index d83e7b5..7af4d89 100644 --- a/lib/shared_tools/tools/doc_tool.rb +++ b/lib/shared_tools/tools/doc_tool.rb @@ -9,19 +9,28 @@ class DocTool < ::RubyLLM::Tool def self.name = 'doc_tool' module Action - PDF_READ = "pdf_read" + PDF_READ = "pdf_read" + TEXT_READ = "text_read" + DOCX_READ = "docx_read" + SPREADSHEET_READ = "spreadsheet_read" end ACTIONS = [ Action::PDF_READ, + Action::TEXT_READ, + Action::DOCX_READ, + Action::SPREADSHEET_READ, ].freeze description <<~TEXT - Read and process various document formats. + Read and process document files. ## Actions: - 1. `#{Action::PDF_READ}` - Read specific pages from a PDF document + 1. `#{Action::TEXT_READ}` - Read the full contents of a plain text file (.txt, .md, etc.) + Required: "action": "text_read", "doc_path": "[path to text file]" + + 2. `#{Action::PDF_READ}` - Read specific pages from a PDF document Required: "action": "pdf_read", "doc_path": "[path to PDF]", "page_numbers": "[comma-separated page numbers]" The page_numbers parameter accepts: @@ -29,8 +38,18 @@ module Action - Multiple pages: "1, 3, 5" - Range notation: "1-10" or "1, 3-5, 10" + 3. `#{Action::DOCX_READ}` - Read text content from a Microsoft Word (.docx) document + Required: "action": "docx_read", "doc_path": "[path to .docx file]" + Optional: "paragraph_range": "[comma-separated paragraph numbers or ranges]" + + The paragraph_range parameter accepts the same notation as page_numbers. + Omit paragraph_range to return the full document. + ## Examples: + Read a text file + {"action": "#{Action::TEXT_READ}", "doc_path": "./notes.txt"} + Read single page from PDF {"action": "#{Action::PDF_READ}", "doc_path": "./document.pdf", "page_numbers": "1"} @@ -42,17 +61,41 @@ module Action Read specific pages with range {"action": "#{Action::PDF_READ}", "doc_path": "./manual.pdf", "page_numbers": "1, 5-8, 15, 20-25"} + + Read a full Word document + {"action": "#{Action::DOCX_READ}", "doc_path": "./report.docx"} + + Read first 20 paragraphs of a Word document + {"action": "#{Action::DOCX_READ}", "doc_path": "./report.docx", "paragraph_range": "1-20"} + + 4. `#{Action::SPREADSHEET_READ}` - Read tabular data from a spreadsheet file + Supported formats: CSV, XLSX, ODS, XLSM + Required: "action": "spreadsheet_read", "doc_path": "[path to spreadsheet]" + Optional: "sheet": "[sheet name or 1-based index]" + "row_range": "[row range, e.g. '2-100']" + "headers": true/false (default true — first row treated as headers) + + Read a full CSV file + {"action": "#{Action::SPREADSHEET_READ}", "doc_path": "./data.csv"} + + Read a specific sheet from an Excel workbook + {"action": "#{Action::SPREADSHEET_READ}", "doc_path": "./report.xlsx", "sheet": "Q1 Sales"} + + Read rows 2-50 from a worksheet without header treatment + {"action": "#{Action::SPREADSHEET_READ}", "doc_path": "./report.xlsx", "row_range": "2-50", "headers": false} TEXT params do string :action, description: <<~TEXT.strip The document action to perform. Options: + * `#{Action::TEXT_READ}`: Read a plain text file * `#{Action::PDF_READ}`: Read pages from a PDF document + * `#{Action::DOCX_READ}`: Read paragraphs from a Microsoft Word (.docx) document + * `#{Action::SPREADSHEET_READ}`: Read tabular data from a spreadsheet (CSV, XLSX, ODS) TEXT string :doc_path, description: <<~TEXT.strip, required: false - Path to the document file. Required for the following actions: - * `#{Action::PDF_READ}` + Path to the document file. Required for all actions. TEXT string :page_numbers, description: <<~TEXT.strip, required: false @@ -61,6 +104,34 @@ module Action Required for the following actions: * `#{Action::PDF_READ}` TEXT + + string :paragraph_range, description: <<~TEXT.strip, required: false + Comma-separated paragraph numbers or ranges to read from a Word document (first paragraph is 1). + Examples: "1", "1, 3, 5", "1-20", "1, 5-8, 15" + Optional for the following actions (omit to return the full document): + * `#{Action::DOCX_READ}` + TEXT + + string :sheet, description: <<~TEXT.strip, required: false + Sheet name or 1-based sheet index to read from a multi-sheet spreadsheet. + Examples: "Q1 Sales", "2" + Optional for the following actions (defaults to the first sheet): + * `#{Action::SPREADSHEET_READ}` + TEXT + + string :row_range, description: <<~TEXT.strip, required: false + Comma-separated row numbers or ranges to read from a spreadsheet (first row is 1). + Examples: "1", "2-100", "1, 5-20, 30" + Optional for the following actions (omit to return all rows): + * `#{Action::SPREADSHEET_READ}` + TEXT + + boolean :headers, description: <<~TEXT.strip, required: false + When true (default), treats the first row as column headers and returns each + subsequent row as a hash. When false, returns rows as plain arrays. + Optional for the following actions: + * `#{Action::SPREADSHEET_READ}` + TEXT end # @param logger [Logger] optional logger @@ -73,14 +144,23 @@ def initialize(logger: nil) # @param page_numbers [String, nil] page numbers to read # # @return [Hash] execution result - def execute(action:, doc_path: nil, page_numbers: nil) + def execute(action:, doc_path: nil, page_numbers: nil, paragraph_range: nil, sheet: nil, row_range: nil, headers: true) @logger.info("DocTool#execute action=#{action}") case action.to_s.downcase + when Action::TEXT_READ + require_param!(:doc_path, doc_path) + text_reader_tool.execute(doc_path: doc_path) when Action::PDF_READ require_param!(:doc_path, doc_path) require_param!(:page_numbers, page_numbers) pdf_reader_tool.execute(doc_path: doc_path, page_numbers: page_numbers) + when Action::DOCX_READ + require_param!(:doc_path, doc_path) + docx_reader_tool.execute(doc_path: doc_path, paragraph_range: paragraph_range) + when Action::SPREADSHEET_READ + require_param!(:doc_path, doc_path) + spreadsheet_reader_tool.execute(doc_path: doc_path, sheet: sheet, row_range: row_range, headers: headers) else { error: "Unsupported action: #{action}. Supported actions are: #{ACTIONS.join(', ')}" } end @@ -104,6 +184,21 @@ def require_param!(name, value) def pdf_reader_tool @pdf_reader_tool ||= Doc::PdfReaderTool.new(logger: @logger) end + + # @return [Doc::TextReaderTool] + def text_reader_tool + @text_reader_tool ||= Doc::TextReaderTool.new(logger: @logger) + end + + # @return [Doc::DocxReaderTool] + def docx_reader_tool + @docx_reader_tool ||= Doc::DocxReaderTool.new(logger: @logger) + end + + # @return [Doc::SpreadsheetReaderTool] + def spreadsheet_reader_tool + @spreadsheet_reader_tool ||= Doc::SpreadsheetReaderTool.new(logger: @logger) + end end end end diff --git a/lib/shared_tools/tools/error_handling_tool.rb b/lib/shared_tools/tools/error_handling_tool.rb index 1f5264a..7c7628c 100644 --- a/lib/shared_tools/tools/error_handling_tool.rb +++ b/lib/shared_tools/tools/error_handling_tool.rb @@ -104,9 +104,11 @@ def initialize(logger: nil) # @param max_retries [Integer] Maximum retry attempts # # @return [Hash] Operation result with success status - def execute(operation:, simulate_error: nil, max_retries: 3, **data) + def execute(operation:, simulate_error: nil, max_retries: 3, data: {}, **_rest) @operation_start_time = Time.now @logger.info("ErrorHandlingTool#execute operation=#{operation} simulate_error=#{simulate_error}") + # Normalise data keys to symbols (JSON tool calls use string keys) + data = (data || {}).transform_keys(&:to_sym) begin # Validate inputs diff --git a/lib/shared_tools/tools/system_info_tool.rb b/lib/shared_tools/tools/system_info_tool.rb new file mode 100644 index 0000000..e5adc78 --- /dev/null +++ b/lib/shared_tools/tools/system_info_tool.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require_relative '../../shared_tools' + +module SharedTools + module Tools + # Returns OS, CPU, memory, disk, network, and Ruby runtime information. + # + # @example + # tool = SharedTools::Tools::SystemInfoTool.new + # tool.execute # all categories + # tool.execute(category: 'cpu') # CPU only + class SystemInfoTool < ::RubyLLM::Tool + def self.name = 'system_info_tool' + + description <<~DESC + Retrieve system information from the local machine. + + Categories: + - 'os' — Operating system name, version, hostname + - 'cpu' — CPU model, core count, load averages + - 'memory' — Total and available RAM in GB + - 'disk' — Mounted filesystems with used/available space + - 'network' — Active network interfaces and their IP addresses + - 'ruby' — Ruby version, platform, engine, RubyGems version + - 'all' (default) — All of the above combined + DESC + + params do + string :category, required: false, description: <<~DESC.strip + Info category. Options: 'os', 'cpu', 'memory', 'disk', 'network', 'ruby', 'all' (default). + DESC + end + + # @param logger [Logger] optional logger + def initialize(logger: nil) + @logger = logger || RubyLLM.logger + end + + # @param category [String] which subsystem to query + # @return [Hash] system information + def execute(category: 'all') + @logger.info("SystemInfoTool#execute category=#{category}") + + case category.to_s.downcase + when 'os' then { success: true }.merge(os_info) + when 'cpu' then { success: true }.merge(cpu_info) + when 'memory' then { success: true }.merge(memory_info) + when 'disk' then { success: true }.merge(disk_info) + when 'network' then { success: true }.merge(network_info) + when 'ruby' then { success: true }.merge(ruby_info) + else + { success: true } + .merge(os_info) + .merge(cpu_info) + .merge(memory_info) + .merge(disk_info) + .merge(network_info) + .merge(ruby_info) + end + rescue => e + @logger.error("SystemInfoTool error: #{e.message}") + { success: false, error: e.message } + end + + private + + def os_info + { + os_platform: RUBY_PLATFORM, + os_name: detect_os_name, + os_version: detect_os_version, + hostname: `hostname`.strip + } + end + + def cpu_info + if RUBY_PLATFORM.include?('darwin') + model = `sysctl -n machdep.cpu.brand_string 2>/dev/null`.strip + cores = `sysctl -n hw.ncpu 2>/dev/null`.strip.to_i + load = `sysctl -n vm.loadavg 2>/dev/null`.strip + .gsub(/[{}]/, '').split.first(3).map(&:to_f) + else + model = File.read('/proc/cpuinfo') + .match(/model name\s*:\s*(.+)/)&.captures&.first&.strip rescue 'Unknown' + cores = `nproc 2>/dev/null`.strip.to_i + load = File.read('/proc/loadavg').split.first(3).map(&:to_f) rescue [0.0, 0.0, 0.0] + end + + { + cpu_model: model.empty? ? 'Unknown' : model, + cpu_cores: cores, + load_avg_1m: load[0], + load_avg_5m: load[1], + load_avg_15m: load[2] + } + end + + def memory_info + if RUBY_PLATFORM.include?('darwin') + total = `sysctl -n hw.memsize 2>/dev/null`.strip.to_i + vm_stat = `vm_stat 2>/dev/null` + pg_size = vm_stat.match(/page size of (\d+) bytes/)&.captures&.first&.to_i || 4096 + free_pg = vm_stat.match(/Pages free:\s+(\d+)/)&.captures&.first&.to_i || 0 + inact_pg = vm_stat.match(/Pages inactive:\s+(\d+)/)&.captures&.first&.to_i || 0 + available = (free_pg + inact_pg) * pg_size + else + mem = File.read('/proc/meminfo') rescue '' + total = (mem.match(/MemTotal:\s+(\d+) kB/)&.captures&.first&.to_i || 0) * 1024 + available = (mem.match(/MemAvailable:\s+(\d+) kB/)&.captures&.first&.to_i || 0) * 1024 + end + + gb = 1024.0**3 + { + memory_total_gb: (total / gb).round(2), + memory_available_gb: (available / gb).round(2), + memory_used_gb: ((total - available) / gb).round(2) + } + end + + def disk_info + lines = `df -k 2>/dev/null`.lines.drop(1) + mounts = lines.filter_map do |line| + parts = line.split + next unless parts.size >= 6 + + kb = 1024.0**2 + { + filesystem: parts[0], + mount_point: parts[5], + size_gb: (parts[1].to_i / kb).round(2), + used_gb: (parts[2].to_i / kb).round(2), + available_gb: (parts[3].to_i / kb).round(2), + use_percent: parts[4] + } + end + { disks: mounts } + end + + def network_info + interfaces = {} + + if RUBY_PLATFORM.include?('darwin') + current = nil + `ifconfig 2>/dev/null`.lines.each do |line| + if (m = line.match(/^(\w[\w:]+\d+):/)) + current = m.captures.first + interfaces[current] = [] + elsif current && (m = line.match(/\s+inet6?\s+(\S+)/)) + addr = m.captures.first.split('%').first + interfaces[current] << addr + end + end + else + current = nil + `ip addr 2>/dev/null`.lines.each do |line| + if (m = line.match(/^\d+: (\w+):/)) + current = m.captures.first + interfaces[current] = [] + elsif current && (m = line.match(/\s+inet6?\s+(\S+)/)) + interfaces[current] << m.captures.first.split('/').first + end + end + end + + { network_interfaces: interfaces.reject { |_, ips| ips.empty? } } + end + + def ruby_info + { + ruby_version: RUBY_VERSION, + ruby_platform: RUBY_PLATFORM, + ruby_engine: RUBY_ENGINE, + ruby_description: RUBY_DESCRIPTION, + rubygems_version: Gem::VERSION + } + end + + def detect_os_name + if RUBY_PLATFORM.include?('darwin') + `sw_vers -productName 2>/dev/null`.strip + elsif File.exist?('/etc/os-release') + File.read('/etc/os-release').match(/^NAME="?([^"\n]+)"?/)&.captures&.first || 'Linux' + else + 'Unknown' + end + rescue + 'Unknown' + end + + def detect_os_version + if RUBY_PLATFORM.include?('darwin') + `sw_vers -productVersion 2>/dev/null`.strip + elsif File.exist?('/etc/os-release') + File.read('/etc/os-release').match(/^VERSION="?([^"\n]+)"?/)&.captures&.first || 'Unknown' + else + 'Unknown' + end + rescue + 'Unknown' + end + end + end +end diff --git a/lib/shared_tools/tools/workflow_manager_tool.rb b/lib/shared_tools/tools/workflow_manager_tool.rb index cc785d1..e402405 100644 --- a/lib/shared_tools/tools/workflow_manager_tool.rb +++ b/lib/shared_tools/tools/workflow_manager_tool.rb @@ -34,6 +34,7 @@ def self.name = 'workflow_manager' - 'step': Execute the next step in an existing workflow using provided step data - 'status': Check the current status and progress of an existing workflow - 'complete': Mark a workflow as finished and clean up associated resources + - 'list': List all existing workflows with their current status and summary information Each action requires different combinations of the other parameters. DESC @@ -84,6 +85,8 @@ def execute(action:, workflow_id: nil, **step_data) when "complete" return {success: false, error: "workflow_id required for 'complete' action"} unless workflow_id complete_workflow(workflow_id) + when "list" + list_workflows else {success: false, error: "Unknown action: #{action}"} end @@ -279,6 +282,35 @@ def process_step_logic(step_data, workflow_state) } end + # List all workflows in the storage directory + def list_workflows + pattern = File.join(@storage_dir, "workflow_*.json") + files = Dir.glob(pattern) + + workflows = files.filter_map do |file| + state = JSON.parse(File.read(file), symbolize_names: true) + { + workflow_id: state[:id], + status: state[:status], + step_count: state[:steps]&.length || 0, + created_at: state[:created_at], + updated_at: state[:updated_at], + completed_at: state[:completed_at] + } + rescue => e + @logger.warn("Skipping unreadable workflow file #{file}: #{e.message}") + nil + end + + workflows.sort_by! { |w| w[:created_at] || "" } + + { + success: true, + total: workflows.size, + workflows: workflows + } + end + # Save workflow state to disk def save_workflow_state(workflow_id, state) file_path = workflow_file_path(workflow_id) diff --git a/shared_tools.gemspec b/shared_tools.gemspec index a585435..c268ec5 100644 --- a/shared_tools.gemspec +++ b/shared_tools.gemspec @@ -44,9 +44,10 @@ Gem::Specification.new do |spec| # Optional tool dependencies - install as needed spec.add_development_dependency "pdf-reader" # For Doc/PDF tools + spec.add_development_dependency "docx" # For Doc/DOCX tools + spec.add_development_dependency "roo" # For Doc/spreadsheet tools (xlsx, xls, ods, csv) spec.add_development_dependency "sqlite3" # For Database examples - spec.add_development_dependency "watir" # For Browser tools - spec.add_development_dependency "webdrivers" # For Browser tools (driver management) + spec.add_development_dependency "ferrum" # For Browser tools (Chrome DevTools Protocol, no chromedriver needed) spec.add_development_dependency "macos" # For Computer tools (macOS only) # For SharedTools development From 6dec39cf057f574af15387f5a68f4cb22891c0a3 Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Wed, 25 Mar 2026 23:43:11 -0500 Subject: [PATCH 03/17] feat: Expand the capabilities of SharedTools gem This commit expands the features and capabilities of the SharedTools Ruby gem by adding the following improvements: - Increased the number of available tools from 13+ to 20+, including new tools for document processing, DNS/WHOIS lookups, IP geolocation, data science, and more. - Improved the description of the gem to highlight the expanded set of features and use cases. - Updated the installation instructions to include additional gem dependencies for the new tool capabilities. - Adjusted the Quick Start example to showcase the broader set of tools that can be used with the RubyLLM agent. These changes significantly enhance the functionality and versatility of the SharedTools gem, making it a more comprehensive solution for a wide range of automation, processing, and integration tasks. --- README.md | 438 ++++------ docs/development/changelog.md | 15 + docs/examples/index.md | 376 +++------ docs/getting-started/installation.md | 54 +- docs/index.md | 52 +- docs/tools/data_science_kit.md | 189 +++++ docs/tools/dns_tool.md | 211 +++++ docs/tools/doc.md | 542 ++++-------- docs/tools/index.md | 496 ++++------- docs/tools/weather.md | 414 ++-------- examples/README.md | 774 +++++------------- lib/shared_tools.rb | 1 - lib/shared_tools/tools/devops_toolkit.rb | 420 ---------- .../shared_tools/tools/devops_toolkit_test.rb | 472 ----------- 14 files changed, 1349 insertions(+), 3105 deletions(-) create mode 100644 docs/tools/data_science_kit.md create mode 100644 docs/tools/dns_tool.md delete mode 100644 lib/shared_tools/tools/devops_toolkit.rb delete mode 100644 test/shared_tools/tools/devops_toolkit_test.rb diff --git a/README.md b/README.md index de11967..68e9cbd 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

SharedTools

Two Robots sharing the same set of tools -

A Ruby gem providing LLM-callable tools for browser automation, file operations, code evaluation, and more

+

A Ruby gem providing LLM-callable tools for browser automation, file operations, code evaluation, document processing, network queries, data science, workflow management, and more

[![Gem Version](https://badge.fury.io/rb/shared_tools.svg)](https://badge.fury.io/rb/shared_tools) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt) @@ -16,12 +16,12 @@ SharedTools is a comprehensive collection of production-ready tools designed for ### Key Features -- 🔧 **13+ Production Tools** - Browser automation, file operations, database queries, code evaluation, PDF processing, system control, mathematical calculations, weather data, workflow management, data analysis, Docker integration, and more -- 🔒 **Human-in-the-Loop Authorization** - Built-in safety system for sensitive operations -- 🎯 **Facade Pattern** - Simplified interfaces with complex capabilities under the hood -- 🔌 **Pluggable Drivers** - Swap implementations for testing or different backends -- 📚 **Comprehensive Documentation** - Detailed guides, examples, and API reference -- ✅ **Well Tested** - 85%+ test coverage with Minitest +- 🔧 **20+ Production Tools** — Browser automation, file operations, database queries, code evaluation, document processing, DNS and WHOIS lookups, IP geolocation, data science, weather data, workflow management, system utilities, and more +- 🔒 **Human-in-the-Loop Authorization** — Built-in safety system for sensitive operations +- 🎯 **Facade Pattern** — Simplified interfaces with complex capabilities under the hood +- 🔌 **Pluggable Drivers** — Swap implementations for testing or different backends +- 📚 **Comprehensive Documentation** — Detailed guides, examples, and API reference +- ✅ **Well Tested** — 85%+ test coverage with Minitest ## Installation @@ -45,19 +45,20 @@ Depending on which tools you use, you may need additional gems: ```ruby # For BrowserTool gem 'watir' -gem 'webdrivers' # For DatabaseTool and DatabaseQueryTool gem 'sqlite3' # or pg, mysql2, etc. # For DocTool -gem 'pdf-reader' +gem 'pdf-reader' # PDF support +gem 'docx' # Microsoft Word (.docx) support +gem 'roo' # Spreadsheet support: CSV, XLSX, ODS, XLSM # Core dependencies (automatically installed) -gem 'dentaku' # For CalculatorTool -gem 'openweathermap' # For WeatherTool -gem 'sequel' # For DatabaseQueryTool -gem 'nokogiri' # For various tools +gem 'dentaku' # For CalculatorTool +gem 'openweathermap' # For WeatherTool +gem 'sequel' # For DatabaseQueryTool +gem 'nokogiri' # For various tools ``` ## Quick Start @@ -67,24 +68,21 @@ require 'shared_tools' require 'ruby_llm' # Initialize an LLM agent with SharedTools -agent = RubyLLM::Agent.new( - tools: [ - SharedTools::Tools::BrowserTool.new, - SharedTools::Tools::DiskTool.new, - SharedTools::Tools::DatabaseTool.new, - SharedTools::Tools::CalculatorTool.new, - SharedTools::Tools::WeatherTool.new, - SharedTools::Tools::WorkflowManagerTool.new - ] +chat = RubyLLM.chat.with_tools( + SharedTools::Tools::BrowserTool.new, + SharedTools::Tools::DiskTool.new, + SharedTools::Tools::DnsTool.new, + SharedTools::Tools::WeatherTool.new, + SharedTools::Tools::WorkflowManagerTool.new ) # Use with human-in-the-loop authorization (default) -agent.process("Visit example.com and save the page title to title.txt") +chat.ask("Visit example.com and save the page title to title.txt") # User will be prompted: "Allow BrowserTool to visit https://example.com? (y/n)" # Or enable auto-execution for automated workflows SharedTools.auto_execute(true) -agent.process("Calculate the square root of 144 and tell me the weather in London") +chat.ask("Calculate the square root of 144 and tell me the weather in London") ``` ## Tool Collections @@ -93,19 +91,10 @@ agent.process("Calculate the square root of 144 and tell me the weather in Londo Web automation and scraping capabilities. -**Actions:** -- `visit` - Navigate to URLs -- `page_inspect` - Get page HTML content -- `ui_inspect` - Find elements by text -- `selector_inspect` - Find elements by CSS selector -- `click` - Click elements -- `text_field_set` - Fill in forms -- `screenshot` - Capture page screenshots +**Actions:** `visit`, `page_inspect`, `ui_inspect`, `selector_inspect`, `click`, `text_field_set`, `screenshot` -**Example:** ```ruby browser = SharedTools::Tools::BrowserTool.new - browser.execute(action: "visit", url: "https://example.com") browser.execute(action: "page_inspect", full_html: false) ``` @@ -118,22 +107,10 @@ browser.execute(action: "page_inspect", full_html: false) Secure file system operations with path traversal protection. -**Actions:** -- `file_create` - Create new files -- `file_read` - Read file contents -- `file_write` - Write to files -- `file_delete` - Delete files -- `file_move` - Move/rename files -- `file_replace` - Find and replace text in files -- `directory_create` - Create directories -- `directory_list` - List directory contents -- `directory_move` - Move directories -- `directory_delete` - Delete directories - -**Example:** +**Actions:** `file_create`, `file_read`, `file_write`, `file_delete`, `file_move`, `file_replace`, `directory_create`, `directory_list`, `directory_move`, `directory_delete` + ```ruby disk = SharedTools::Tools::DiskTool.new - disk.execute(action: "file_create", path: "./report.txt") disk.execute(action: "file_write", path: "./report.txt", text: "Hello, World!") content = disk.execute(action: "file_read", path: "./report.txt") @@ -147,13 +124,8 @@ content = disk.execute(action: "file_read", path: "./report.txt") Execute SQL operations on databases. -**Actions:** -- Execute SQL statements (CREATE, INSERT, SELECT, UPDATE, DELETE) -- Batch statement execution -- Transaction-like error handling (stops on first error) -- Support for SQLite, PostgreSQL, MySQL via drivers +**Features:** SELECT, INSERT, UPDATE, DELETE; read-only query mode; automatic LIMIT enforcement; pluggable drivers (SQLite, PostgreSQL, MySQL) -**Example:** ```ruby require 'sqlite3' @@ -178,19 +150,11 @@ results = database.execute( Safe code evaluation for Ruby, Python, and shell commands. -**Languages:** -- `ruby` - Execute Ruby code -- `python` - Execute Python code (with sandboxing) -- `shell` - Execute shell commands +**Languages:** `ruby`, `python`, `shell` -**Example:** ```ruby eval_tool = SharedTools::Tools::EvalTool.new - -# Ruby evaluation result = eval_tool.execute(language: "ruby", code: "puts 2 + 2") - -# Shell command execution (requires authorization by default) output = eval_tool.execute(language: "shell", code: "ls -la") ``` @@ -200,52 +164,74 @@ output = eval_tool.execute(language: "shell", code: "ls -la") ### 📄 Doc Tools -PDF document processing and text extraction. +Read and reason over documents in any format. -**Actions:** -- `read_pdf` - Read PDF content from specific pages or entire documents -- Extract text, statistics, and metadata -- Process multi-page documents +**Actions:** `text_read`, `pdf_read`, `docx_read`, `spreadsheet_read` -**Example:** ```ruby doc = SharedTools::Tools::DocTool.new -# Read first page -content = doc.execute(action: "read_pdf", path: "./document.pdf", page: 1) +# Plain text +doc.execute(action: "text_read", doc_path: "./notes.txt") -# Read entire document -full_content = doc.execute(action: "read_pdf", path: "./document.pdf") +# PDF — specific pages or ranges +doc.execute(action: "pdf_read", doc_path: "./report.pdf", page_numbers: "1, 5-10") + +# Microsoft Word +doc.execute(action: "docx_read", doc_path: "./meeting.docx") + +# Spreadsheets (CSV, XLSX, ODS, XLSM) +doc.execute(action: "spreadsheet_read", doc_path: "./data.xlsx", sheet: "Q1 Sales") ``` [📖 Full Doc Documentation](https://madbomber.github.io/shared_tools/tools/doc/) --- -### 🖱️ Computer Tools +### 🌐 DNS Tool -System-level automation for mouse, keyboard, and screen control. +DNS resolution, WHOIS queries, IP geolocation, and external IP detection. No API key required. + +**Actions:** `a`, `aaaa`, `mx`, `ns`, `txt`, `cname`, `reverse`, `all`, `external_ip`, `ip_location`, `whois` -**Actions:** -- `mouse_click` - Click at coordinates -- `mouse_move` - Move mouse cursor -- `mouse_position` - Get current mouse position -- `type` - Type text -- `key` - Press keyboard keys and shortcuts -- `hold_key` - Hold keys for duration -- `scroll` - Scroll windows -- `wait` - Wait for specified duration - -**Example:** ```ruby -computer = SharedTools::Tools::ComputerTool.new +dns = SharedTools::Tools::DnsTool.new -computer.execute(action: "mouse_click", coordinate: {x: 100, y: 200}) -computer.execute(action: "type", text: "Hello, World!") -computer.execute(action: "key", text: "Return") +dns.execute(action: "mx", host: "gmail.com") +dns.execute(action: "whois", host: "ruby-lang.org") +dns.execute(action: "external_ip") +dns.execute(action: "ip_location") # geolocate your own IP +dns.execute(action: "ip_location", host: "8.8.8.8") # geolocate any IP ``` -[📖 Full Computer Documentation](https://madbomber.github.io/shared_tools/tools/computer/) +[📖 Full DNS Documentation](https://madbomber.github.io/shared_tools/tools/dns_tool/) + +--- + +### 🌤️ Weather Tool + +Real-time weather data from OpenWeatherMap API. Combine with DnsTool for automatic local forecasts. + +**Features:** Current conditions, 3-day forecast, metric/imperial/kelvin units, global coverage + +```ruby +weather = SharedTools::Tools::WeatherTool.new +weather.execute(city: "London,UK", units: "metric", include_forecast: true) +``` + +**Local forecast with automatic location detection:** + +```ruby +chat = RubyLLM.chat.with_tools( + SharedTools::Tools::DnsTool.new, + SharedTools::Tools::WeatherTool.new, + SharedTools::Tools::CurrentDateTimeTool.new +) + +chat.ask("Get my external IP, find my city, then give me the current weather and 3-day forecast.") +``` + +[📖 Full Weather Documentation](https://madbomber.github.io/shared_tools/tools/weather/) --- @@ -253,77 +239,102 @@ computer.execute(action: "key", text: "Return") Safe mathematical calculations without code execution risks. -**Features:** -- Safe expression evaluation using Dentaku parser -- Basic arithmetic: +, -, *, /, %, ^ -- Mathematical functions: sqrt, round, abs -- Trigonometric functions: sin, cos, tan -- Configurable precision (0-10 decimal places) -- Comprehensive error handling +**Features:** Arithmetic, math functions (sqrt, round, abs), trigonometry (sin, cos, tan), configurable precision -**Example:** ```ruby calculator = SharedTools::Tools::CalculatorTool.new +calculator.execute(expression: "sqrt(16) * 2", precision: 4) +# => {success: true, result: 8.0} +``` -calculator.execute(expression: "2 + 2") -# => {success: true, result: 4.0, precision: 2} +--- -calculator.execute(expression: "sqrt(16) * 2", precision: 4) -# => {success: true, result: 8.0, precision: 4} +### 📊 Data Science Kit + +Real statistical analysis on actual data — file-based or inline. + +**Analysis types:** `statistical_summary`, `correlation_analysis`, `time_series`, `clustering`, `prediction` + +**Inline data formats:** pipe-delimited tables, CSV strings, JSON arrays, comma-separated numbers + +```ruby +kit = SharedTools::Tools::DataScienceKit.new + +# From a file +kit.execute(analysis_type: "statistical_summary", data_source: "./sales.csv") + +# Inline pipe-delimited table +kit.execute( + analysis_type: "correlation_analysis", + data: "| month | revenue | cost |\n| Jan | 12400 | 8200 |\n| Feb | 11800 | 7900 |" +) ``` +[📖 Full Data Science Documentation](https://madbomber.github.io/shared_tools/tools/data_science_kit/) + --- -### 🌤️ Weather Tool +### 🖱️ Computer Tools -Real-time weather data from OpenWeatherMap API. +System-level automation for mouse, keyboard, and screen control. -**Features:** -- Current weather conditions for any city worldwide -- Multiple temperature units (metric, imperial, kelvin) -- Optional 3-day forecast data -- Comprehensive atmospheric data (humidity, pressure, wind) -- Requires OPENWEATHER_API_KEY environment variable +**Actions:** `mouse_click`, `mouse_move`, `mouse_position`, `type`, `key`, `hold_key`, `scroll`, `wait` -**Example:** ```ruby -weather = SharedTools::Tools::WeatherTool.new +computer = SharedTools::Tools::ComputerTool.new +computer.execute(action: "mouse_click", coordinate: {x: 100, y: 200}) +computer.execute(action: "type", text: "Hello, World!") +``` + +[📖 Full Computer Documentation](https://madbomber.github.io/shared_tools/tools/computer/) + +--- -weather.execute(city: "London,UK", units: "metric") -# => {success: true, current: {temperature: 15.5, ...}} +### 🕐 Date/Time, System Info & Clipboard -weather.execute(city: "New York,US", units: "imperial", include_forecast: true) -# => Includes current weather and 3-day forecast +Utility tools for context and system access: + +```ruby +# Current date and day of week (prevents LLM hallucination) +dt = SharedTools::Tools::CurrentDateTimeTool.new +dt.execute(format: "date") +# => { date: "2026-03-25", day_of_week: "Wednesday", ... } + +# System hardware and OS info +info = SharedTools::Tools::SystemInfoTool.new +info.execute + +# Clipboard +clipboard = SharedTools::Tools::ClipboardTool.new +clipboard.execute(action: "read") +clipboard.execute(action: "write", text: "Hello!") ``` --- ### 🔄 Workflow Manager Tool -Manage complex multi-step workflows with persistent state. +Persistent multi-step workflow orchestration with JSON file storage. -**Features:** -- Create and track stateful workflows -- Step-by-step execution with state persistence -- Status monitoring and progress tracking -- Workflow completion and cleanup -- Survives process restarts +**Actions:** `start`, `step`, `status`, `complete`, `list` -**Example:** ```ruby workflow = SharedTools::Tools::WorkflowManagerTool.new # Start a workflow -result = workflow.execute(action: "start", step_data: {project: "demo"}) +result = workflow.execute(action: "start", step_data: {project: "release-v2.0"}) workflow_id = result[:workflow_id] # Execute steps -workflow.execute(action: "step", workflow_id: workflow_id, step_data: {task: "compile"}) -workflow.execute(action: "step", workflow_id: workflow_id, step_data: {task: "test"}) +workflow.execute(action: "step", workflow_id: workflow_id, step_data: {task: "run_tests"}) +workflow.execute(action: "step", workflow_id: workflow_id, step_data: {task: "deploy"}) # Check status workflow.execute(action: "status", workflow_id: workflow_id) +# List all workflows +workflow.execute(action: "list") + # Complete workflow.execute(action: "complete", workflow_id: workflow_id) ``` @@ -334,51 +345,13 @@ workflow.execute(action: "complete", workflow_id: workflow_id) Multi-stage data analysis orchestration. -**Features:** -- Automatic data source detection (files or URLs) -- Data structure analysis -- Statistical insights generation -- Visualization suggestions -- Correlation analysis -- Supports CSV, JSON, and text formats - -**Example:** ```ruby analyzer = SharedTools::Tools::CompositeAnalysisTool.new - analyzer.execute( data_source: "./sales_data.csv", analysis_type: "comprehensive", options: {include_correlations: true, visualization_limit: 5} ) -# => Complete analysis with structure, insights, and visualizations -``` - ---- - -### 🗄️ Database Query Tool - -Safe, read-only database query execution. - -**Features:** -- SELECT-only queries for security -- Automatic LIMIT clause enforcement -- Query timeout protection -- Prepared statement support -- Connection pooling -- Supports PostgreSQL, MySQL, SQLite, and more - -**Example:** -```ruby -db_query = SharedTools::Tools::DatabaseQueryTool.new - -db_query.execute( - query: "SELECT * FROM users WHERE active = ?", - params: [true], - limit: 50, - timeout: 10 -) -# => {success: true, row_count: 50, data: [...]} ``` --- @@ -387,21 +360,9 @@ db_query.execute( Execute Docker Compose commands safely. -**Features:** -- Run commands in Docker containers -- Service specification -- Automatic container cleanup -- Build and run in one step - -**Example:** ```ruby docker = SharedTools::Tools::Docker::ComposeRunTool.new - -docker.execute( - service: "app", - command: "rspec", - args: ["spec/main_spec.rb"] -) +docker.execute(service: "app", command: "rspec", args: ["spec/main_spec.rb"]) ``` --- @@ -410,24 +371,13 @@ docker.execute( Reference implementation for robust error handling patterns. -**Features:** -- Multiple error type handling -- Retry mechanisms with exponential backoff -- Input/output validation -- Resource cleanup patterns -- Detailed error categorization -- Support reference IDs for debugging - -**Example:** ```ruby error_tool = SharedTools::Tools::ErrorHandlingTool.new - error_tool.execute( operation: "process", data: {name: "test", value: 42}, max_retries: 3 ) -# => Demonstrates comprehensive error handling patterns ``` --- @@ -458,30 +408,46 @@ Comprehensive documentation is available at **[madbomber.github.io/shared_tools] ### Documentation Sections -- **[Getting Started](https://madbomber.github.io/shared_tools/getting-started/installation/)** - Installation, quick start, basic usage -- **[Tool Collections](https://madbomber.github.io/shared_tools/tools/)** - Detailed documentation for each tool -- **[Guides](https://madbomber.github.io/shared_tools/guides/)** - Authorization, drivers, error handling, testing -- **[Examples](https://madbomber.github.io/shared_tools/examples/)** - Working code examples and workflows -- **[API Reference](https://madbomber.github.io/shared_tools/api/)** - Tool base class, facade pattern, driver interface -- **[Development](https://madbomber.github.io/shared_tools/development/)** - Architecture, contributing, changelog +- **[Getting Started](https://madbomber.github.io/shared_tools/getting-started/installation/)** — Installation, quick start, basic usage +- **[Tool Collections](https://madbomber.github.io/shared_tools/tools/)** — Detailed documentation for each tool +- **[Guides](https://madbomber.github.io/shared_tools/guides/)** — Authorization, drivers, error handling, testing +- **[Examples](https://madbomber.github.io/shared_tools/examples/)** — Working code examples and workflows +- **[API Reference](https://madbomber.github.io/shared_tools/api/)** — Tool base class, facade pattern, driver interface +- **[Development](https://madbomber.github.io/shared_tools/development/)** — Architecture, contributing, changelog ## Examples -The `/examples` directory contains working demonstrations: - -- `browser_tool_example.rb` - Web automation -- `disk_tool_example.rb` - File operations -- `database_tool_example.rb` - SQL operations -- `eval_tool_example.rb` - Code evaluation -- `doc_tool_example.rb` - PDF processing -- `comprehensive_workflow_example.rb` - Multi-tool workflow +The `/examples` directory contains runnable demonstrations using a shared `common.rb` helper: -Run examples: ```bash -bundle install -ruby examples/browser_tool_example.rb +bundle exec ruby -I examples examples/weather_tool_demo.rb +bundle exec ruby -I examples examples/dns_tool_demo.rb +bundle exec ruby -I examples examples/doc_tool_demo.rb ``` +| Demo | What it shows | +|------|--------------| +| `browser_tool_demo.rb` | Web automation | +| `calculator_tool_demo.rb` | Math expressions | +| `clipboard_tool_demo.rb` | Clipboard read/write | +| `composite_analysis_tool_demo.rb` | Multi-stage analysis | +| `computer_tool_demo.rb` | Mouse and keyboard | +| `cron_tool_demo.rb` | Cron scheduling | +| `current_date_time_tool_demo.rb` | Real date and time | +| `data_science_kit_demo.rb` | Statistical analysis with inline data | +| `database_tool_demo.rb` | SQL operations | +| `database_query_tool_demo.rb` | Read-only SQL queries | +| `disk_tool_demo.rb` | File operations | +| `dns_tool_demo.rb` | DNS, WHOIS, geolocation | +| `doc_tool_demo.rb` | Text, PDF, Word, spreadsheets | +| `error_handling_tool_demo.rb` | Error handling patterns | +| `eval_tool_demo.rb` | Code evaluation | +| `mcp_client_demo.rb` | MCP client integration | +| `system_info_tool_demo.rb` | System info | +| `weather_tool_demo.rb` | Weather + local forecast | +| `workflow_manager_tool_demo.rb` | Workflow orchestration | +| `comprehensive_workflow_demo.rb` | Multi-tool pipeline | + [📖 View All Examples](https://madbomber.github.io/shared_tools/examples/) ## Development @@ -510,20 +476,13 @@ COVERAGE=true bundle exec rake test ### Building Documentation ```bash -# Install MkDocs and dependencies pip install mkdocs-material - -# Serve documentation locally -mkdocs serve - -# Build static site -mkdocs build +mkdocs serve # Serve locally +mkdocs build # Build static site ``` ### Code Quality -The project uses standard Ruby tooling: - - **Testing**: Minitest (85%+ coverage) - **Code Loading**: Zeitwerk for autoloading - **Documentation**: MkDocs with Material theme @@ -533,59 +492,14 @@ The project uses standard Ruby tooling: Contributions are welcome! Here's how you can help: -### Reporting Issues - -Found a bug or have a feature request? Please [open an issue](https://github.com/madbomber/shared_tools/issues/new) with: - -- Clear description of the problem -- Steps to reproduce (for bugs) -- Expected vs actual behavior -- Ruby version and gem version -- Code examples if applicable - -### Submitting Pull Requests - 1. Fork the repository 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 3. Make your changes with tests 4. Ensure tests pass (`bundle exec rake test`) -5. Commit your changes (`git commit -m 'Add amazing feature'`) -6. Push to your branch (`git push origin feature/amazing-feature`) -7. Open a Pull Request - -### Contribution Guidelines - -- Add tests for new features -- Update documentation as needed -- Follow existing code style -- Keep commits focused and atomic -- Write clear commit messages +5. Open a Pull Request [📖 Contributing Guide](https://madbomber.github.io/shared_tools/development/contributing/) -## Roadmap - -See the [Changelog](https://madbomber.github.io/shared_tools/development/changelog/) for version history and upcoming features. - -### Recent Additions (v0.12+) - -- ✅ Calculator Tool - Safe mathematical calculations with Dentaku -- ✅ Weather Tool - Real-time weather data via OpenWeatherMap API -- ✅ Workflow Manager Tool - Stateful multi-step workflow orchestration -- ✅ Composite Analysis Tool - Multi-stage data analysis -- ✅ Database Query Tool - Safe read-only database queries -- ✅ Docker Compose Tool - Container command execution -- ✅ Error Handling Tool - Reference implementation for robust patterns - -### Future Enhancements - -- Additional browser drivers (Selenium, Playwright) -- More database adapters and query builders -- Enhanced PDF processing capabilities -- Additional document formats (Word, Excel) -- Video and image processing tools -- Extended data science and analytics capabilities - ## Requirements - Ruby 3.0 or higher diff --git a/docs/development/changelog.md b/docs/development/changelog.md index 92a1598..cb08cdf 100644 --- a/docs/development/changelog.md +++ b/docs/development/changelog.md @@ -7,8 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `DnsTool` — DNS resolution (A, AAAA, MX, NS, TXT, CNAME, reverse, all), WHOIS queries for domains and IPs, external IP detection via public services, and IP geolocation via ip-api.com. No API key required. +- `CurrentDateTimeTool` — Returns the real current date, time, and day of week from the system clock, preventing LLMs from hallucinating temporal information. +- `DataScienceKit` — Statistical summary, correlation analysis, time series, clustering, and prediction. Accepts both file paths (`data_source`) and inline data (`data`) in pipe-delimited, CSV, JSON, or comma-separated number formats. All computation is real, not simulated. +- `SystemInfoTool` — Hardware and OS information (CPU, memory, disk, platform). +- `ClipboardTool` — Read and write the system clipboard. +- `CronTool` — Cron expression parsing and next-run time calculation. +- `WorkflowManagerTool` `list` action — enumerate all existing workflows with status, step count, and timestamps. +- `DocTool` `docx_read` action — read text content from Microsoft Word (.docx) files, with optional paragraph range. Requires the `docx` gem. +- `DocTool` `spreadsheet_read` action — read tabular data from CSV, XLSX, ODS, and XLSM files with sheet selection, row range, and header control. Requires the `roo` gem. +- `DocTool` `text_read` action — read plain text files without any external gem dependency. +- `examples/common.rb` — shared helper providing `title`, `ask`, `new_chat`, and `@chat` for all demo scripts. +- All example scripts renamed from `*_example.rb` to `*_demo.rb` for consistency. + ### Changed - Changing direction. Beginning with 0.3.0 will only support RubyLLM framework +- `WeatherTool` demo updated to include a local forecast section combining `DnsTool` (external IP → geolocation) and `CurrentDateTimeTool` (real day of week) with `WeatherTool`. ### Deprecated - Support for OmniAI framework (use RubyLLM instead) diff --git a/docs/examples/index.md b/docs/examples/index.md index 8bbacb3..94dbbea 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -1,338 +1,190 @@ # Examples -This section contains practical examples demonstrating how to use SharedTools in your LLM applications. +Practical demonstrations showing how to use SharedTools with LLM agents. All examples are in the `/examples` directory and use a shared `common.rb` helper that sets up the LLM chat session. -## Available Examples +## Running the Examples -### Basic Tool Examples +```bash +bundle install -Each tool has a dedicated example file in the `/examples` directory showing basic usage: +# Run any demo +bundle exec ruby -I examples examples/weather_tool_demo.rb +bundle exec ruby -I examples examples/dns_tool_demo.rb +bundle exec ruby -I examples examples/doc_tool_demo.rb +``` -#### [Browser Tool Example](https://github.com/madbomber/shared_tools/blob/main/examples/browser_tool_example.rb) -Demonstrates web automation capabilities including: +Some demos require environment variables: -- Navigating to web pages -- Inspecting page content and HTML -- Finding elements by text or CSS selectors -- Clicking elements -- Filling in forms -- Taking screenshots +```bash +# WeatherTool demos +export OPENWEATHER_API_KEY="your_key_here" +``` -```ruby -require 'shared_tools' +--- -# Initialize browser tool with Watir driver -browser = SharedTools::Tools::BrowserTool.new +## Tool Demos -# Navigate to a website -browser.execute( - action: SharedTools::Tools::BrowserTool::Action::VISIT, - url: "https://example.com" -) +### [browser_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/browser_tool_demo.rb) -# Get page summary -summary = browser.execute( - action: SharedTools::Tools::BrowserTool::Action::PAGE_INSPECT -) -``` +Web automation: navigate pages, inspect content, click elements, fill forms, take screenshots. -#### [Disk Tool Example](https://github.com/madbomber/shared_tools/blob/main/examples/disk_tool_example.rb) -Shows file system operations including: +--- -- Creating and deleting directories -- Reading and writing files -- Moving files and directories -- Listing directory contents -- Replacing text in files +### [calculator_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/calculator_tool_demo.rb) -```ruby -require 'shared_tools' - -# Initialize disk tool -disk = SharedTools::Tools::DiskTool.new - -# Create a file -disk.execute( - action: SharedTools::Tools::DiskTool::Action::FILE_CREATE, - path: "./demo.txt" -) - -# Write content -disk.execute( - action: SharedTools::Tools::DiskTool::Action::FILE_WRITE, - path: "./demo.txt", - text: "Hello, World!" -) - -# Read content -content = disk.execute( - action: SharedTools::Tools::DiskTool::Action::FILE_READ, - path: "./demo.txt" -) -``` +Safe mathematical expression evaluation: arithmetic, functions, trigonometry, configurable precision. -#### [Database Tool Example](https://github.com/madbomber/shared_tools/blob/main/examples/database_tool_example.rb) -Illustrates database operations including: +--- -- Creating tables -- Inserting data -- Querying with SELECT statements -- Updating and deleting records -- Transaction handling +### [clipboard_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/clipboard_tool_demo.rb) -```ruby -require 'shared_tools' -require 'sqlite3' - -# Setup database connection -db = SQLite3::Database.new(':memory:') -driver = SharedTools::Tools::Database::SqliteDriver.new(db: db) -database = SharedTools::Tools::DatabaseTool.new(driver: driver) - -# Execute SQL statements -results = database.execute( - statements: [ - "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)", - "INSERT INTO users (name) VALUES ('Alice')", - "SELECT * FROM users" - ] -) -``` +Read and write the system clipboard. -#### [Computer Tool Example](https://github.com/madbomber/shared_tools/blob/main/examples/computer_tool_example.rb) -Demonstrates system-level operations: +--- -- Taking screenshots -- Getting screen information -- Performing mouse actions -- Keyboard input simulation -- Accessing system clipboard +### [composite_analysis_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/composite_analysis_tool_demo.rb) -```ruby -require 'shared_tools' +Multi-stage data analysis orchestration: structure analysis, statistical insights, visualisation suggestions. -# Initialize computer tool (Mac-specific in this example) -computer = SharedTools::Tools::ComputerTool.new +--- -# Take a screenshot -screenshot = computer.execute( - action: SharedTools::Tools::ComputerTool::Action::SCREENSHOT -) +### [computer_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/computer_tool_demo.rb) -# Get screen dimensions -info = computer.execute( - action: SharedTools::Tools::ComputerTool::Action::SCREEN_INFO -) -``` +System-level automation: mouse clicks and movement, keyboard typing and shortcuts, screenshots, scrolling. -#### [Eval Tool Example](https://github.com/madbomber/shared_tools/blob/main/examples/eval_tool_example.rb) -Shows code evaluation capabilities: +--- -- Ruby code evaluation -- Python code evaluation (with safe sandboxing) -- Shell command execution -- Error handling and output capture +### [cron_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/cron_tool_demo.rb) -```ruby -require 'shared_tools' - -# Initialize eval tool -eval_tool = SharedTools::Tools::EvalTool.new - -# Evaluate Ruby code -result = eval_tool.execute( - language: 'ruby', - code: 'puts [1, 2, 3].sum' -) - -# Execute shell commands -output = eval_tool.execute( - language: 'shell', - code: 'ls -la' -) -``` +Cron expression parsing, scheduling utilities, and next-run time calculations. -#### [Doc Tool Example](https://github.com/madbomber/shared_tools/blob/main/examples/doc_tool_example.rb) -Demonstrates document processing: +--- -- Reading PDF files -- Extracting text from specific pages -- Processing multi-page documents -- Handling PDF metadata +### [current_date_time_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/current_date_time_tool_demo.rb) -```ruby -require 'shared_tools' +Fetch the real current date, time, and day of week — prevents LLMs from hallucinating temporal information. -# Initialize doc tool -doc = SharedTools::Tools::DocTool.new +--- -# Read PDF content -content = doc.execute( - action: 'read_pdf', - path: './document.pdf', - page: 1 -) -``` +### [data_science_kit_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/data_science_kit_demo.rb) -### Advanced Workflow Example +Statistical summary, correlation analysis, time series, clustering, and prediction — using both file-based and inline pipe-delimited data. -#### [Comprehensive Workflow Example](https://github.com/madbomber/shared_tools/blob/main/examples/comprehensive_workflow_example.rb) -A complete end-to-end example showing how multiple tools work together in a realistic workflow: +--- -**Scenario**: Web Scraping to Database with Report Generation +### [database_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/database_tool_demo.rb) -This example demonstrates: +Full SQL operations (CREATE, INSERT, SELECT, UPDATE, DELETE) with the pluggable driver architecture. -1. **Web Scraping Phase** - - Using BrowserTool to navigate to a product catalog - - Extracting structured data from HTML - - Parsing and transforming the data +--- -2. **Database Storage Phase** - - Creating database tables with DatabaseTool - - Inserting scraped data - - Performing queries and aggregations - - Generating statistics +### [database_query_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/database_query_tool_demo.rb) -3. **Report Generation Phase** - - Using DiskTool to create report directories - - Generating Markdown reports - - Exporting data to JSON and CSV formats - - Organizing output files +Safe read-only SQL queries with automatic LIMIT enforcement and timeout protection. -```ruby -# Phase 1: Scrape data -browser = SharedTools::Tools::BrowserTool.new(driver: browser_driver) -browser.execute(action: "visit", url: "https://example.com/products") -html = browser.execute(action: "page_inspect", full_html: true) - -# Phase 2: Store in database -database = SharedTools::Tools::DatabaseTool.new(driver: db_driver) -database.execute(statements: [ - "CREATE TABLE products (...)", - "INSERT INTO products VALUES (...)" -]) - -# Phase 3: Generate reports -disk = SharedTools::Tools::DiskTool.new -disk.execute(action: "file_write", path: "./report.md", text: report_content) -``` +--- -See the [Workflows Guide](./workflows.md) for a detailed breakdown of this example. +### [disk_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/disk_tool_demo.rb) -## Running the Examples +Secure file system operations: create, read, write, delete, move files and directories. -All examples are located in the `/examples` directory and can be run directly: +--- -```bash -# Install dependencies first -bundle install +### [dns_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/dns_tool_demo.rb) -# Run a specific example -ruby examples/browser_tool_example.rb -ruby examples/comprehensive_workflow_example.rb -``` +DNS lookups (A, AAAA, MX, NS, TXT, CNAME, reverse), WHOIS queries for domains and IPs, external IP detection, and IP geolocation. Demonstrates combining multiple actions in a single LLM workflow. -## Mock Drivers for Testing +--- -Many examples include mock driver implementations that demonstrate the driver interface pattern: +### [doc_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/doc_tool_demo.rb) -- **MockBrowserDriver**: Simulates browser behavior without requiring a real browser -- **SimpleSqliteDriver**: Minimal database driver implementation -- **LocalDriver**: File system operations within a specified root directory +Document reading across all supported formats: +- Plain text files +- PDF documents (specific pages and page ranges) +- Microsoft Word (.docx) documents built from scratch +- CSV expense reports +- Multi-sheet Excel (.xlsx) workbooks -These mock drivers are useful for: +--- -- Understanding the driver interface requirements -- Testing without external dependencies -- Creating your own custom drivers +### [error_handling_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/error_handling_tool_demo.rb) -## Next Steps +Reference implementation for robust error handling: retries with exponential backoff, input validation, resource cleanup, and error categorisation. -- Learn about [Multi-Tool Workflows](./workflows.md) -- Explore the [Tool API Reference](../api/index.md) -- Read about [Testing Your Tools](../guides/testing.md) -- Understand [Error Handling Patterns](../guides/error-handling.md) +--- -## Example Categories +### [eval_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/eval_tool_demo.rb) -### By Tool Type +Code evaluation in Ruby, Python, and shell — with authorization controls. -- **Browser Automation**: [browser_tool_example.rb](https://github.com/madbomber/shared_tools/blob/main/examples/browser_tool_example.rb) -- **File System**: [disk_tool_example.rb](https://github.com/madbomber/shared_tools/blob/main/examples/disk_tool_example.rb) -- **Database**: [database_tool_example.rb](https://github.com/madbomber/shared_tools/blob/main/examples/database_tool_example.rb) -- **System Control**: [computer_tool_example.rb](https://github.com/madbomber/shared_tools/blob/main/examples/computer_tool_example.rb) -- **Code Execution**: [eval_tool_example.rb](https://github.com/madbomber/shared_tools/blob/main/examples/eval_tool_example.rb) -- **Document Processing**: [doc_tool_example.rb](https://github.com/madbomber/shared_tools/blob/main/examples/doc_tool_example.rb) +--- -### By Complexity +### [mcp_client_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/mcp_client_demo.rb) -- **Beginner**: Single-tool examples showing basic operations -- **Intermediate**: Examples with custom drivers and error handling -- **Advanced**: [comprehensive_workflow_example.rb](https://github.com/madbomber/shared_tools/blob/main/examples/comprehensive_workflow_example.rb) showing multi-tool integration +MCP (Model Context Protocol) client integration example. -### By Use Case +--- -- **Data Collection**: Web scraping and extraction -- **Data Processing**: Database operations and transformations -- **Report Generation**: Creating output files in multiple formats -- **System Automation**: Orchestrating multiple tools together +### [system_info_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/system_info_tool_demo.rb) -## Common Patterns +System hardware and OS information: CPU, memory, disk, platform details. -### Human-in-the-Loop Authorization +--- -All tools respect the SharedTools authorization system: +### [weather_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/weather_tool_demo.rb) -```ruby -# Require user confirmation (default) -SharedTools.auto_execute(false) +Real-time weather data for multiple cities, travel recommendations, packing advice, and — most notably — a local forecast that auto-detects your location via DnsTool and uses CurrentDateTimeTool to get the correct day of week. -# The AI will ask permission before executing -disk.execute(action: "file_delete", path: "./important.txt") +Requires `OPENWEATHER_API_KEY`. -# Auto-execute for automated workflows -SharedTools.auto_execute(true) -``` +--- -### Error Handling +### [workflow_manager_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/workflow_manager_tool_demo.rb) -Examples demonstrate proper error handling: +Multi-step workflow orchestration: start a workflow, list all existing workflows, execute steps, check status, and complete. Demonstrates a full software release pipeline tracked from creation to completion. -```ruby -begin - result = database.execute(statements: ["SELECT * FROM missing_table"]) - if result.first[:status] == :error - puts "Database error: #{result.first[:result]}" - end -rescue => e - puts "Unexpected error: #{e.message}" -end -``` +--- + +### [comprehensive_workflow_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/comprehensive_workflow_demo.rb) + +End-to-end multi-tool workflow combining web scraping, database storage, and report generation. + +--- -### Cleanup and Resource Management +## Shared Helper: common.rb -Examples show proper resource cleanup: +All demos require `common.rb`, which provides: + +- `title(label, char: '=')` — prints a formatted section header +- `ask(prompt)` — sends a prompt to the shared `@chat` session and prints the response +- `new_chat` — creates a fresh chat session (used to reset context between demo sections) +- `@chat` — the default chat session with `ENV['RUBY_LLM_DEBUG'] = 'true'` enabled ```ruby -browser = SharedTools::Tools::BrowserTool.new -begin - # Use the tool - browser.execute(action: "visit", url: "https://example.com") -ensure - # Always cleanup - browser.cleanup! -end +# Run any demo with the examples directory in the load path +bundle exec ruby -I examples examples/some_tool_demo.rb ``` -## Contributing Examples +--- + +## Demo Categories -Have a great example to share? See our [Contributing Guide](../development/contributing.md) for information on submitting examples. +### By Capability -Good examples: +| Category | Demos | +|----------|-------| +| Web & Network | browser_tool_demo, dns_tool_demo | +| Files & Documents | disk_tool_demo, doc_tool_demo | +| Data & Analysis | data_science_kit_demo, composite_analysis_tool_demo, database_tool_demo, database_query_tool_demo | +| System & Utilities | computer_tool_demo, system_info_tool_demo, clipboard_tool_demo, current_date_time_tool_demo, cron_tool_demo | +| External APIs | weather_tool_demo | +| Workflow | workflow_manager_tool_demo, comprehensive_workflow_demo | +| Code Execution | eval_tool_demo, calculator_tool_demo | + +### By Complexity -- Solve a real-world problem -- Are well-commented and explain the "why" -- Include error handling -- Clean up resources properly -- Can run independently with minimal setup +| Level | Demos | +|-------|-------| +| Beginner | calculator_tool_demo, current_date_time_tool_demo, disk_tool_demo | +| Intermediate | dns_tool_demo, doc_tool_demo, data_science_kit_demo, weather_tool_demo | +| Advanced | comprehensive_workflow_demo, workflow_manager_tool_demo | diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index f3d0f3f..8f1cce6 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -11,6 +11,7 @@ Add SharedTools to your Gemfile: ```ruby gem 'shared_tools' +gem 'ruby_llm' # Required LLM framework ``` Then install: @@ -32,6 +33,9 @@ SharedTools has the following core dependencies that are automatically installed - `ruby_llm` - Ruby LLM framework for tool integration - `zeitwerk` - Code autoloading - `nokogiri` - HTML/XML parsing +- `dentaku` - Mathematical expression evaluation (CalculatorTool) +- `openweathermap` - Weather API client (WeatherTool) +- `sequel` - SQL toolkit (DatabaseQueryTool) ## Optional Dependencies @@ -45,7 +49,7 @@ For the `BrowserTool`: gem 'watir' ``` -Watir also requires a browser driver (Chrome, Firefox, etc.). Install ChromeDriver: +Watir also requires a browser driver. Install ChromeDriver: ```bash # macOS @@ -60,11 +64,8 @@ apt-get install chromium-chromedriver For the `DatabaseTool`: ```ruby -# SQLite -gem 'sqlite3' - -# PostgreSQL -gem 'pg' +gem 'sqlite3' # SQLite +gem 'pg' # PostgreSQL ``` ### Document Processing @@ -72,7 +73,15 @@ gem 'pg' For the `DocTool`: ```ruby -gem 'pdf-reader', '~> 2.0' +gem 'pdf-reader', '~> 2.0' # PDF support +gem 'docx' # Microsoft Word (.docx) support +gem 'roo' # Spreadsheet support: CSV, XLSX, ODS, XLSM +``` + +Install all three to support all document formats: + +```bash +gem install pdf-reader docx roo ``` ### Code Evaluation @@ -100,10 +109,19 @@ For a full-featured installation with all optional dependencies: ```ruby # Gemfile gem 'shared_tools' -gem 'watir' # Browser automation -gem 'sqlite3' # SQLite database -gem 'pg' # PostgreSQL database -gem 'pdf-reader' # PDF processing +gem 'ruby_llm' + +# Browser automation +gem 'watir' + +# Database +gem 'sqlite3' +gem 'pg' + +# Document processing +gem 'pdf-reader' +gem 'docx' +gem 'roo' ``` Then run: @@ -137,7 +155,7 @@ ruby test_install.rb Expected output: ``` -SharedTools version: 0.5.1 +SharedTools version: 0.x.x RubyLLM detected: Yes DiskTool initialized successfully! ``` @@ -162,13 +180,23 @@ If BrowserTool fails with "browser not found": ### SQLite3 Compilation Issues -On macOS, if `sqlite3` gem fails to install: +On macOS, if the `sqlite3` gem fails to install: ```bash gem install sqlite3 -- --with-sqlite3-include=/usr/local/opt/sqlite/include \ --with-sqlite3-lib=/usr/local/opt/sqlite/lib ``` +### DocTool — Missing Gem for Format + +If DocTool raises a `LoadError` when reading a specific format, install the corresponding gem: + +```bash +gem install pdf-reader # for PDF +gem install docx # for Word documents +gem install roo # for CSV, XLSX, ODS +``` + ## Next Steps - [Quickstart Guide](quickstart.md) - Get started in 5 minutes diff --git a/docs/index.md b/docs/index.md index 07c39a3..d1969cc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,19 +2,23 @@ ![SharedTools](assets/images/shared_tools.png) -A comprehensive Ruby gem providing LLM-callable tools for browser automation, file operations, code evaluation, database operations, and document processing. +A comprehensive Ruby gem providing LLM-callable tools for browser automation, file operations, code evaluation, database operations, document processing, network queries, data science, workflow management, and more. ## Overview -SharedTools provides a collection of reusable tools designed to work seamlessly with the [RubyLLM](https://github.com/madbomber/ruby_llm) framework. Each tool extends `RubyLLM::Tool` and provides a clean, consistent interface for common operations that LLM agents need to perform. +SharedTools provides a collection of reusable tools designed to work seamlessly with the [RubyLLM](https://github.com/mariochavez/ruby_llm) framework. Each tool extends `RubyLLM::Tool` and provides a clean, consistent interface for common operations that LLM agents need to perform. ### Key Features - **Browser Automation**: Control web browsers with Watir for visiting pages, clicking elements, filling forms, and taking screenshots - **File System Operations**: Secure file and directory operations with path traversal protection - **Code Evaluation**: Execute Ruby, Python, and shell commands with authorization controls -- **Database Operations**: Execute SQL queries against SQLite and PostgreSQL databases -- **Document Processing**: Read and extract content from PDF documents +- **Database Operations**: Execute SQL queries against SQLite, PostgreSQL, and other databases +- **Document Processing**: Read plain text, PDF, Word (.docx), and spreadsheet files (CSV, XLSX, ODS) +- **Network & DNS**: DNS lookups, WHOIS queries, IP geolocation, and external IP detection +- **Data Science**: Statistical analysis, correlation, time series, clustering, and prediction +- **Workflow Management**: Persistent multi-step workflow orchestration with state tracking +- **System Utilities**: Date/time, system info, clipboard, cron scheduling, and more - **Authorization System**: Human-in-the-loop confirmation for potentially dangerous operations - **Driver Architecture**: Pluggable driver system for extensibility @@ -65,15 +69,8 @@ browser.execute(action: "visit", url: "https://example.com/products") html = browser.execute(action: "page_inspect", full_html: true) # Save to file -disk.execute( - action: "file_create", - path: "./scraped_data.html" -) -disk.execute( - action: "file_write", - path: "./scraped_data.html", - text: html -) +disk.execute(action: "file_create", path: "./scraped_data.html") +disk.execute(action: "file_write", path: "./scraped_data.html", text: html) # Clean up browser.cleanup! @@ -87,11 +84,32 @@ browser.cleanup! ## Available Tools +### Core Tools - **[BrowserTool](tools/browser.md)** - Web browser automation with Watir - **[DiskTool](tools/disk.md)** - File and directory operations - **[EvalTool](tools/eval.md)** - Code execution (Ruby, Python, Shell) -- **[DocTool](tools/doc.md)** - PDF document processing -- **[DatabaseTool](tools/database.md)** - SQL database operations +- **[DocTool](tools/doc.md)** - Document reading: plain text, PDF, Word, and spreadsheets +- **[DatabaseTool](tools/database.md)** - SQL database operations (read/write) +- **[DatabaseQueryTool](tools/database.md)** - Safe read-only SQL queries +- **[ComputerTool](tools/computer.md)** - Mouse, keyboard, and screen automation + +### Data & Analysis +- **[CalculatorTool](tools/calculator.md)** - Safe mathematical expression evaluation +- **[DataScienceKit](tools/data_science_kit.md)** - Statistical analysis, correlation, clustering, prediction +- **[CompositeAnalysisTool](tools/index.md)** - Multi-stage data analysis orchestration + +### Network & System +- **[DnsTool](tools/dns_tool.md)** - DNS lookups, WHOIS, IP geolocation, external IP detection +- **[WeatherTool](tools/weather.md)** - Real-time weather data via OpenWeatherMap +- **[SystemInfoTool](tools/index.md)** - System hardware and OS information +- **[CurrentDateTimeTool](tools/index.md)** - Current date, time, and day of week +- **[ClipboardTool](tools/index.md)** - Read and write system clipboard +- **[CronTool](tools/index.md)** - Cron expression scheduling utilities + +### Workflow & DevOps +- **[WorkflowManagerTool](tools/index.md)** - Persistent multi-step workflow orchestration +- **[Docker ComposeRunTool](tools/index.md)** - Docker container command execution +- **[ErrorHandlingTool](tools/index.md)** - Reference implementation for error handling patterns ## Guides @@ -102,7 +120,7 @@ browser.cleanup! - Ruby >= 3.3.0 - RubyLLM gem -- Optional: watir (for browser automation), sqlite3 or pg (for databases), pdf-reader (for PDFs) +- Optional: watir (browser), sqlite3/pg (databases), pdf-reader (PDFs), docx (Word), roo (spreadsheets) ## License @@ -110,4 +128,4 @@ MIT License - see [LICENSE](https://github.com/madbomber/shared_tools/blob/main/ ## Credits -Originally inspired by Kevin Sylvestre's [omniai-tools](https://github.com/ksylvest/omniai-tools) gem. SharedTools has evolved to focus exclusively on RubyLLM support with enhanced features and an extended tool collection. +Originally inspired by Kevin Sylvestre's [omniai-tools](https://github.com/ksylvest/omniai-tools) gem. SharedTools has evolved to focus exclusively on RubyLLM support with an extended tool collection. diff --git a/docs/tools/data_science_kit.md b/docs/tools/data_science_kit.md new file mode 100644 index 0000000..1d18fe9 --- /dev/null +++ b/docs/tools/data_science_kit.md @@ -0,0 +1,189 @@ +# DataScienceKit + +Statistical analysis, correlation, time series analysis, clustering, and prediction — performed by the tool itself using real math, not simulated results. + +## Basic Usage + +```ruby +require 'shared_tools' +require 'shared_tools/data_science_kit' + +kit = SharedTools::Tools::DataScienceKit.new + +# Analyse a CSV file +result = kit.execute( + analysis_type: "statistical_summary", + data_source: "./sales_data.csv" +) + +# Pass data inline as a pipe-delimited table +result = kit.execute( + analysis_type: "correlation_analysis", + data: <<~TABLE + | month | revenue | expenses | + | Jan | 12400 | 8200 | + | Feb | 11800 | 7900 | + | Mar | 13200 | 8500 | + TABLE +) +``` + +## Analysis Types + +### statistical_summary + +Descriptive statistics: count, mean, median, standard deviation, min, max, and quartiles for every numeric column. + +```ruby +kit.execute(analysis_type: "statistical_summary", data_source: "./data.csv") +``` + +--- + +### correlation_analysis + +Pearson correlation coefficients between all pairs of numeric columns. Identifies which variables move together. + +```ruby +kit.execute(analysis_type: "correlation_analysis", data_source: "./data.csv") +``` + +--- + +### time_series + +Trend detection, moving averages, and basic seasonality analysis. Requires a date/time column and a numeric value column. + +```ruby +kit.execute( + analysis_type: "time_series", + data_source: "./monthly_revenue.csv", + date_column: "month", + value_column: "revenue" +) +``` + +--- + +### clustering + +K-means clustering to group similar records. Returns cluster assignments and centroids. + +```ruby +kit.execute( + analysis_type: "clustering", + data_source: "./customers.csv", + n_clusters: 3 +) +``` + +--- + +### prediction + +Simple linear regression to predict a target column from one or more feature columns. + +```ruby +kit.execute( + analysis_type: "prediction", + data_source: "./sales.csv", + target_column: "revenue", + feature_columns: ["ad_spend", "headcount"] +) +``` + +## Data Sources + +### File-based (`data_source`) + +Pass a path to a CSV or JSON file: + +```ruby +kit.execute(analysis_type: "statistical_summary", data_source: "./data.csv") +kit.execute(analysis_type: "statistical_summary", data_source: "./records.json") +``` + +### Inline data (`data`) + +Pass raw data directly as a string. The tool parses these formats automatically: + +**Pipe-delimited table** (easiest for LLMs to generate): + +```ruby +kit.execute( + analysis_type: "statistical_summary", + data: <<~TABLE + | name | score | age | + | Alice | 88 | 29 | + | Bob | 72 | 34 | + | Carol | 95 | 27 | + TABLE +) +``` + +**CSV string:** + +```ruby +kit.execute( + analysis_type: "statistical_summary", + data: "name,score,age\nAlice,88,29\nBob,72,34" +) +``` + +**JSON array of objects:** + +```ruby +kit.execute( + analysis_type: "statistical_summary", + data: '[{"name":"Alice","score":88},{"name":"Bob","score":72}]' +) +``` + +**Comma-separated numbers** (for single-series analysis): + +```ruby +kit.execute( + analysis_type: "statistical_summary", + data: "12, 45, 67, 23, 89, 34" +) +``` + +## Integration with LLM Agents + +The inline `data` parameter makes DataScienceKit ideal for LLM agents — the model can embed the data directly in the tool call without needing to write a file first: + +```ruby +require 'ruby_llm' +require 'shared_tools/data_science_kit' + +chat = RubyLLM.chat.with_tool(SharedTools::Tools::DataScienceKit.new) + +chat.ask(<<~PROMPT) + Analyse the following sales data and provide a statistical summary. + Pass the data using the inline 'data' parameter as a pipe-delimited table. + + | quarter | revenue | units_sold | returns | + | Q1 | 124000 | 1240 | 38 | + | Q2 | 118000 | 1180 | 45 | + | Q3 | 132000 | 1320 | 29 | + | Q4 | 156000 | 1560 | 22 | +PROMPT +``` + +## Real Computation + +DataScienceKit performs genuine calculations on the data you provide: + +- **Statistical summary** — mean, median, standard deviation, quartiles computed from actual values +- **Correlation** — Pearson r computed between real column pairs +- **Time series** — moving averages and trend lines derived from actual data points +- **Clustering** — real k-means iteration over your data +- **Prediction** — actual linear regression coefficients fit to your data + +Results are not simulated or canned — they reflect the data you pass in. + +## See Also + +- [CompositeAnalysisTool](index.md) - Higher-level analysis orchestration +- [DocTool](doc.md) - Read spreadsheet data before analysing it +- [DatabaseQueryTool](database.md) - Query a database then analyse the results diff --git a/docs/tools/dns_tool.md b/docs/tools/dns_tool.md new file mode 100644 index 0000000..dd88b5f --- /dev/null +++ b/docs/tools/dns_tool.md @@ -0,0 +1,211 @@ +# DnsTool + +DNS resolution, WHOIS lookups, IP geolocation, and external IP detection — all via the standard library with no API key required. + +## Basic Usage + +```ruby +require 'shared_tools' +require 'shared_tools/tools/dns_tool' + +dns = SharedTools::Tools::DnsTool.new + +# A record lookup +result = dns.execute(action: "a", host: "example.com") + +# Get my external IP address +result = dns.execute(action: "external_ip") + +# Geolocate an IP +result = dns.execute(action: "ip_location", host: "8.8.8.8") + +# WHOIS for a domain +result = dns.execute(action: "whois", host: "ruby-lang.org") +``` + +## Actions + +### a + +Look up the IPv4 address (A record) for a hostname. + +```ruby +dns.execute(action: "a", host: "example.com") +# => { host: "example.com", addresses: ["93.184.216.34"], ttl: 3600 } +``` + +--- + +### aaaa + +Look up the IPv6 address (AAAA record) for a hostname. + +```ruby +dns.execute(action: "aaaa", host: "example.com") +``` + +--- + +### mx + +Look up mail exchange (MX) records for a domain. + +```ruby +dns.execute(action: "mx", host: "gmail.com") +# => { host: "gmail.com", records: [{ priority: 10, exchange: "alt1.gmail-smtp-in.l.google.com" }, ...] } +``` + +--- + +### ns + +Look up name server (NS) records for a domain. + +```ruby +dns.execute(action: "ns", host: "ruby-lang.org") +# => { host: "ruby-lang.org", nameservers: ["ns1.ruby-lang.org", ...] } +``` + +--- + +### txt + +Look up TXT records (SPF, DKIM, domain verification tokens, etc.) for a domain. + +```ruby +dns.execute(action: "txt", host: "github.com") +# => { host: "github.com", records: ["v=spf1 ip4:..."] } +``` + +--- + +### cname + +Look up the canonical name (CNAME) for an alias. + +```ruby +dns.execute(action: "cname", host: "www.example.com") +``` + +--- + +### reverse + +Perform a reverse DNS lookup (PTR record) for an IP address. + +```ruby +dns.execute(action: "reverse", host: "8.8.8.8") +# => { ip: "8.8.8.8", hostname: "dns.google" } +``` + +--- + +### all + +Run all standard lookups (A, AAAA, MX, NS, TXT, CNAME) for a host in one call. + +```ruby +dns.execute(action: "all", host: "example.com") +``` + +--- + +### external_ip + +Detect the machine's current public (external) IP address. No `host` parameter required. + +Uses a list of free public IP services and returns the first successful result. + +```ruby +dns.execute(action: "external_ip") +# => { ip: "203.0.113.42", source: "https://api.ipify.org" } +``` + +--- + +### ip_location + +Geolocate an IP address using the free [ip-api.com](http://ip-api.com) service. No API key required. + +Returns city, region, country, latitude, longitude, timezone, ISP, and ASN. + +**Parameters:** + +- `host` *(optional)*: IP address to geolocate. If omitted, geolocates the machine's own external IP. + +```ruby +# Geolocate a specific IP +dns.execute(action: "ip_location", host: "8.8.8.8") +# => { ip: "8.8.8.8", city: "Mountain View", region: "California", +# country: "United States", country_code: "US", lat: 37.4056, +# lon: -122.0775, timezone: "America/Los_Angeles", +# isp: "Google LLC", org: "Google Public DNS", asn: "AS15169" } + +# Geolocate your own machine's IP +dns.execute(action: "ip_location") +``` + +--- + +### whois + +Query the WHOIS database for a domain name or IP address using standard TCP on port 43. No API key required. + +For domains, automatically follows IANA referrals to the authoritative registrar. For IP addresses, queries ARIN. + +**Parameters:** + +- `host`: Domain name or IP address to look up + +```ruby +# WHOIS for a domain +dns.execute(action: "whois", host: "ruby-lang.org") +# => { query: "ruby-lang.org", registrar: "...", created: "...", +# expires: "...", updated: "...", status: [...], nameservers: [...], +# raw: "..." } + +# WHOIS for an IP address +dns.execute(action: "whois", host: "1.1.1.1") +# => { query: "1.1.1.1", organization: "APNIC and Cloudflare DNS Resolver project", +# country: "AU", raw: "..." } +``` + +## Integration with LLM Agents + +DnsTool is especially useful when combined with WeatherTool and CurrentDateTimeTool for location-aware workflows: + +```ruby +require 'ruby_llm' +require 'shared_tools/tools/dns_tool' +require 'shared_tools/tools/weather_tool' +require 'shared_tools/tools/current_date_time_tool' + +chat = RubyLLM.chat.with_tools( + SharedTools::Tools::DnsTool.new, + SharedTools::Tools::WeatherTool.new, + SharedTools::Tools::CurrentDateTimeTool.new +) + +chat.ask(<<~PROMPT) + 1. Use current_date_time_tool to get today's date and day of week. + 2. Use dns_tool (action: external_ip) to get my public IP. + 3. Use dns_tool (action: ip_location) to find my city and country. + 4. Use weather_tool to fetch current weather and a 3-day forecast for that city. + Report the current conditions and what to expect over the next three days, + using the real date from the tool (not your training data). +PROMPT +``` + +## No API Key Required + +All DnsTool actions use one of the following: + +- Ruby's built-in `resolv` library for DNS queries +- Free public IP services (ipify.org, ifconfig.me, etc.) for `external_ip` +- The free [ip-api.com](http://ip-api.com) JSON API for `ip_location` +- Standard WHOIS protocol (TCP port 43) for `whois` + +## See Also + +- [WeatherTool](weather.md) - Combine with ip_location for local forecasts +- [CurrentDateTimeTool](index.md) - Use alongside DnsTool in location-aware prompts diff --git a/docs/tools/doc.md b/docs/tools/doc.md index 16fa0cb..67588cf 100644 --- a/docs/tools/doc.md +++ b/docs/tools/doc.md @@ -1,50 +1,70 @@ # DocTool -Read and process document formats, currently supporting PDF files for text extraction. +Read and process document files including plain text, PDF, Microsoft Word (.docx), and spreadsheets (CSV, XLSX, ODS, XLSM). ## Installation -DocTool requires the pdf-reader gem: +Install optional gems depending on the formats you need: ```ruby -gem 'pdf-reader', '~> 2.0' +gem 'pdf-reader', '~> 2.0' # PDF support +gem 'docx' # Word (.docx) support +gem 'roo' # Spreadsheet support (CSV, XLSX, ODS, XLSM) ``` ## Basic Usage ```ruby require 'shared_tools' +require 'shared_tools/doc_tool' doc = SharedTools::Tools::DocTool.new -# Read first page -result = doc.execute( - action: "pdf_read", - doc_path: "./document.pdf", - page_numbers: "1" -) +# Read a plain text file +result = doc.execute(action: "text_read", doc_path: "./notes.txt") + +# Read a PDF +result = doc.execute(action: "pdf_read", doc_path: "./report.pdf", page_numbers: "1-5") + +# Read a Word document +result = doc.execute(action: "docx_read", doc_path: "./meeting.docx") -puts result[:pages] # Array of page content +# Read a spreadsheet +result = doc.execute(action: "spreadsheet_read", doc_path: "./data.csv") ``` ## Actions -### pdf_read +### text_read -Read specific pages from a PDF document. +Read the full contents of a plain text file (.txt, .md, etc.). **Parameters:** -- `action`: "pdf_read" -- `doc_path`: Path to PDF file -- `page_numbers`: Page numbers to read (comma-separated, supports ranges) +- `action`: `"text_read"` +- `doc_path`: Path to the text file + +**Example:** + +```ruby +result = doc.execute( + action: "text_read", + doc_path: "./README.md" +) +puts result[:content] +``` + +--- + +### pdf_read -**Page Number Format:** +Read specific pages from a PDF document. -- Single page: `"1"` -- Multiple pages: `"1, 3, 5"` -- Range: `"1-10"` -- Combined: `"1, 5-8, 15"` +**Parameters:** + +- `action`: `"pdf_read"` +- `doc_path`: Path to the PDF file +- `page_numbers`: Pages to read — single (`"5"`), list (`"1, 3, 5"`), range (`"1-10"`), or combined (`"1, 5-8, 15"`) **Examples:** @@ -52,456 +72,178 @@ Read specific pages from a PDF document. doc = SharedTools::Tools::DocTool.new # Read first page -result = doc.execute( - action: "pdf_read", - doc_path: "./report.pdf", - page_numbers: "1" -) +result = doc.execute(action: "pdf_read", doc_path: "./report.pdf", page_numbers: "1") -# Read specific pages -result = doc.execute( - action: "pdf_read", - doc_path: "./book.pdf", - page_numbers: "1, 10, 20" -) - -# Read page range -result = doc.execute( - action: "pdf_read", - doc_path: "./manual.pdf", - page_numbers: "5-15" -) +# Read a range +result = doc.execute(action: "pdf_read", doc_path: "./book.pdf", page_numbers: "10-15") # Read complex selection -result = doc.execute( - action: "pdf_read", - doc_path: "./thesis.pdf", - page_numbers: "1, 5-8, 15, 20-25" -) +result = doc.execute(action: "pdf_read", doc_path: "./manual.pdf", page_numbers: "1, 5-8, 15, 20-25") ``` -## Return Value - -The `pdf_read` action returns a hash with: +**Return value:** ```ruby { pages: [ { number: 1, content: "Page 1 text..." }, - { number: 2, content: "Page 2 text..." }, - # ... + { number: 2, content: "Page 2 text..." } ], total_pages: 100, requested: "1-5" } ``` -## Complete Examples +--- -### Example 1: Extract Table of Contents +### docx_read -```ruby -require 'shared_tools' - -doc = SharedTools::Tools::DocTool.new +Read text content from a Microsoft Word (.docx) document. -# Read first 5 pages (usually contains TOC) -result = doc.execute( - action: "pdf_read", - doc_path: "./book.pdf", - page_numbers: "1-5" -) - -# Process pages -toc_lines = [] -result[:pages].each do |page| - lines = page[:content].lines - lines.each do |line| - # Find lines that look like TOC entries - if line =~ /^(Chapter|Section)\s+\d+/ - toc_lines << line.strip - end - end -end - -puts "Table of Contents:" -puts toc_lines.join("\n") -``` - -### Example 2: Search for Keywords - -```ruby -require 'shared_tools' - -doc = SharedTools::Tools::DocTool.new -keyword = "machine learning" - -# Read all pages (adjust range as needed) -result = doc.execute( - action: "pdf_read", - doc_path: "./research_paper.pdf", - page_numbers: "1-50" -) - -# Search for keyword -matches = [] -result[:pages].each do |page| - if page[:content].downcase.include?(keyword.downcase) - matches << { - page: page[:number], - excerpt: page[:content][0..200] + "..." - } - end -end +**Parameters:** -puts "Found '#{keyword}' on #{matches.size} pages:" -matches.each do |match| - puts "\nPage #{match[:page]}:" - puts match[:excerpt] -end -``` +- `action`: `"docx_read"` +- `doc_path`: Path to the .docx file +- `paragraph_range` *(optional)*: Paragraphs to read, same notation as `page_numbers`. Omit to return the full document. -### Example 3: Extract and Save Text +**Examples:** ```ruby -require 'shared_tools' - -doc = SharedTools::Tools::DocTool.new -disk = SharedTools::Tools::DiskTool.new +# Read the full document +result = doc.execute(action: "docx_read", doc_path: "./report.docx") -# Read PDF pages +# Read only the first 20 paragraphs result = doc.execute( - action: "pdf_read", - doc_path: "./document.pdf", - page_numbers: "1-10" + action: "docx_read", + doc_path: "./report.docx", + paragraph_range: "1-20" ) - -# Combine all page content -full_text = result[:pages].map { |page| - "=== Page #{page[:number]} ===\n\n#{page[:content]}\n\n" -}.join - -# Save to text file -disk.execute(action: "file_create", path: "./extracted.txt") -disk.execute(action: "file_write", path: "./extracted.txt", text: full_text) - -puts "Extracted #{result[:pages].size} pages to extracted.txt" ``` -### Example 4: PDF Summary Generator +**Return value:** ```ruby -require 'shared_tools' - -doc = SharedTools::Tools::DocTool.new - -# Read document -result = doc.execute( - action: "pdf_read", - doc_path: "./report.pdf", - page_numbers: "1-#{result[:total_pages]}" # All pages -) - -# Generate summary -summary = { - title: "PDF Summary", - total_pages: result[:total_pages], - total_words: 0, - average_words_per_page: 0, - pages_with_content: 0 +{ + paragraphs: [ + { number: 1, text: "Introduction" }, + { number: 2, text: "This document covers..." } + ], + total_paragraphs: 42, + requested: "1-20" } - -result[:pages].each do |page| - word_count = page[:content].split.size - summary[:total_words] += word_count - summary[:pages_with_content] += 1 if word_count > 0 -end - -summary[:average_words_per_page] = - summary[:total_words] / summary[:pages_with_content] - -puts "Document Summary:" -puts " Total Pages: #{summary[:total_pages]}" -puts " Total Words: #{summary[:total_words]}" -puts " Avg Words/Page: #{summary[:average_words_per_page]}" -``` - -### Example 5: Multi-Document Processing - -```ruby -require 'shared_tools' - -doc = SharedTools::Tools::DocTool.new -pdf_files = Dir.glob("./documents/*.pdf") - -results = pdf_files.map do |pdf_path| - puts "Processing #{pdf_path}..." - - result = doc.execute( - action: "pdf_read", - doc_path: pdf_path, - page_numbers: "1" # Read first page only - ) - - { - file: File.basename(pdf_path), - pages: result[:total_pages], - first_page: result[:pages].first[:content][0..200] - } -end - -# Generate report -puts "\n" + "=" * 80 -puts "Document Processing Report" -puts "=" * 80 - -results.each do |r| - puts "\nFile: #{r[:file]}" - puts " Pages: #{r[:pages]}" - puts " Preview: #{r[:first_page]}..." -end ``` -## Working with Large PDFs - -### Strategy 1: Read in Chunks - -```ruby -doc = SharedTools::Tools::DocTool.new - -# For a 1000-page PDF, read in chunks -chunk_size = 50 -total_pages = 1000 +--- -(1..total_pages).step(chunk_size) do |start_page| - end_page = [start_page + chunk_size - 1, total_pages].min +### spreadsheet_read - result = doc.execute( - action: "pdf_read", - doc_path: "./large_document.pdf", - page_numbers: "#{start_page}-#{end_page}" - ) +Read tabular data from a spreadsheet file. Supports CSV, XLSX, ODS, and XLSM formats. - # Process this chunk - process_pages(result[:pages]) +**Parameters:** - puts "Processed pages #{start_page}-#{end_page}" -end -``` +- `action`: `"spreadsheet_read"` +- `doc_path`: Path to the spreadsheet file +- `sheet` *(optional)*: Sheet name or 1-based index for multi-sheet workbooks (defaults to first sheet) +- `row_range` *(optional)*: Rows to read, e.g. `"2-100"` (defaults to all rows) +- `headers` *(optional)*: When `true` (default), treats the first row as column headers and returns each row as a hash -### Strategy 2: Read Specific Sections +**Examples:** ```ruby -# Only read pages you need -important_pages = "1, 5, 10-15, 50, 100-110" +# Read a CSV file +result = doc.execute(action: "spreadsheet_read", doc_path: "./data.csv") +# Read a specific sheet from an Excel workbook result = doc.execute( - action: "pdf_read", - doc_path: "./large_document.pdf", - page_numbers: important_pages + action: "spreadsheet_read", + doc_path: "./report.xlsx", + sheet: "Q1 Sales" ) -``` -## Error Handling - -```ruby -doc = SharedTools::Tools::DocTool.new - -# Handle file not found -begin - doc.execute( - action: "pdf_read", - doc_path: "./missing.pdf", - page_numbers: "1" - ) -rescue Errno::ENOENT => e - puts "PDF not found: #{e.message}" -end - -# Handle invalid page numbers -begin - doc.execute( - action: "pdf_read", - doc_path: "./document.pdf", - page_numbers: "999" # Beyond last page - ) -rescue StandardError => e - puts "Invalid page number: #{e.message}" -end - -# Handle corrupted PDF -begin - doc.execute( - action: "pdf_read", - doc_path: "./corrupted.pdf", - page_numbers: "1" - ) -rescue PDF::Reader::MalformedPDFError => e - puts "Corrupted PDF: #{e.message}" -end -``` - -## Text Extraction Limitations - -DocTool extracts raw text from PDFs, which has some limitations: - -### What Works Well: - -- Plain text documents -- Simple formatting -- Basic tables (as text) -- Standard fonts - -### Limitations: - -- **Images**: Not extracted (OCR not supported) -- **Complex layouts**: May not preserve exact formatting -- **Tables**: Extracted as text, not structured data -- **Fonts**: Embedded fonts work, but special characters may vary -- **Encryption**: Password-protected PDFs not supported - -### Handling These Limitations: - -```ruby -# Check if page has content -result[:pages].each do |page| - if page[:content].strip.empty? - puts "Page #{page[:number]} appears to be empty (possibly image-only)" - else - puts "Page #{page[:number]} has text content" - end -end -``` - -## Best Practices - -### 1. Check File Exists First - -```ruby -pdf_path = "./document.pdf" - -if File.exist?(pdf_path) - result = doc.execute( - action: "pdf_read", - doc_path: pdf_path, - page_numbers: "1" - ) -else - puts "PDF not found: #{pdf_path}" -end -``` - -### 2. Validate Page Numbers - -```ruby -# Get total pages first +# Read by sheet index result = doc.execute( - action: "pdf_read", - doc_path: "./document.pdf", - page_numbers: "1" + action: "spreadsheet_read", + doc_path: "./report.xlsx", + sheet: "2" ) -total_pages = result[:total_pages] - -# Now read valid range -desired_page = 100 -if desired_page <= total_pages - result = doc.execute( - action: "pdf_read", - doc_path: "./document.pdf", - page_numbers: desired_page.to_s - ) -else - puts "Page #{desired_page} exceeds total pages (#{total_pages})" -end -``` - -### 3. Handle Large Documents Efficiently - -```ruby -# Don't read all pages at once for large PDFs -# Use ranges to read what you need +# Read rows 2-50, raw arrays (no header treatment) result = doc.execute( - action: "pdf_read", - doc_path: "./large.pdf", - page_numbers: "1-10" # Start with first 10 pages + action: "spreadsheet_read", + doc_path: "./data.xlsx", + row_range: "2-50", + headers: false ) ``` -### 4. Clean Extracted Text +**Return value (with headers):** ```ruby -result[:pages].each do |page| - # Remove extra whitespace - cleaned = page[:content].gsub(/\s+/, ' ').strip - - # Remove page numbers if present - cleaned = cleaned.gsub(/^Page \d+\s*/, '') - - puts cleaned -end +{ + rows: [ + { "Month" => "January", "Amount" => "1240.50", "Approved" => "true" }, + { "Month" => "February", "Amount" => "875.00", "Approved" => "true" } + ], + row_count: 2, + sheet: "Sheet1" +} ``` -## Troubleshooting +## Integration with LLM Agents -### PDF-Reader Gem Not Found +The DocTool is designed for LLM agents that need to read and reason over documents: -``` -Error: cannot load such file -- pdf-reader -``` +```ruby +require 'ruby_llm' +require 'shared_tools/doc_tool' -**Solution:** Install the gem: +chat = RubyLLM.chat.with_tool(SharedTools::Tools::DocTool.new) -```bash -gem install pdf-reader +# The LLM can read and summarise any supported document type +chat.ask("Read the file './meeting_notes.docx' and summarise the action items.") +chat.ask("Read the 'Summary' sheet from './quarterly_sales.xlsx'. Which product grew the most?") +chat.ask("From './style_guide.txt', what are the naming conventions for constants?") ``` -### Corrupted PDF Error +## Supported Formats -``` -PDF::Reader::MalformedPDFError -``` +| Format | Action | Gem required | +|--------|--------|-------------| +| Plain text (.txt, .md, etc.) | `text_read` | None | +| PDF | `pdf_read` | `pdf-reader` | +| Microsoft Word (.docx) | `docx_read` | `docx` | +| CSV | `spreadsheet_read` | `roo` | +| Excel (.xlsx) | `spreadsheet_read` | `roo` | +| OpenDocument (.ods) | `spreadsheet_read` | `roo` | +| Excel macro-enabled (.xlsm) | `spreadsheet_read` | `roo` | -**Solution:** Try repairing the PDF with external tools: +## Error Handling -```bash -# Using Ghostscript -gs -o repaired.pdf -sDEVICE=pdfwrite -dPDFSETTINGS=/prepress original.pdf +```ruby +result = doc.execute(action: "pdf_read", doc_path: "./missing.pdf", page_numbers: "1") +if result[:error] + puts "DocTool error: #{result[:error]}" +end ``` -### Empty Text Extraction +Common errors and solutions: -If text is empty but you know the PDF has content: +- **`pdf-reader` not found** — run `gem install pdf-reader` +- **`docx` not found** — run `gem install docx` +- **`roo` not found** — run `gem install roo` +- **File not found** — check the path and working directory +- **Empty text extraction from PDF** — the PDF may be image-based (scanned); OCR is not supported -1. PDF might be image-based (scanned document) - requires OCR -2. PDF might use non-standard encoding -3. PDF might be encrypted - -### Memory Issues with Large PDFs - -``` -Error: memory allocation failed -``` - -**Solution:** Read in smaller chunks: +## Best Practices -```ruby -# Instead of reading all pages -# Read in chunks of 50 pages -(1..total_pages).step(50) do |start_page| - end_page = [start_page + 49, total_pages].min - result = doc.execute( - action: "pdf_read", - doc_path: "./large.pdf", - page_numbers: "#{start_page}-#{end_page}" - ) - # Process and discard before next chunk -end -``` +- Use `text_read` for `.txt` and `.md` files — it is lightweight and requires no extra gems. +- For large PDFs, request only the pages you need with `page_numbers`. +- For multi-sheet workbooks, always specify `sheet` to avoid ambiguity. +- Pair with **DiskTool** to save extracted content to a new file. +- Pair with **DnsTool** or **WeatherTool** for workflows that mix documents with live data. ## See Also -- [Basic Usage](../getting-started/basic-usage.md) - Common patterns -- [DiskTool](disk.md) - Save extracted text to files +- [DiskTool](disk.md) - Save extracted content to files - [EvalTool](eval.md) - Process extracted text with code -- [Examples](https://github.com/madbomber/shared_tools/tree/main/examples/doc_tool_example.rb) +- [DatabaseTool](database.md) - Store extracted data in a database diff --git a/docs/tools/index.md b/docs/tools/index.md index d6c0ccf..e156da0 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -2,22 +2,13 @@ SharedTools provides a collection of LLM-callable tools for common operations. Each tool follows a consistent facade pattern with an action-based interface. -## Available Tools +## Core Tools ### [BrowserTool](browser.md) Web browser automation using Watir for visiting pages, inspecting content, clicking elements, filling forms, and capturing screenshots. -**Key Features:** - -- Navigate to URLs -- Inspect page content and DOM -- Find elements by text or CSS selectors -- Click buttons and links -- Fill input fields -- Take screenshots - -**Example:** +**Actions:** `visit`, `page_inspect`, `ui_inspect`, `selector_inspect`, `click`, `text_field_set`, `screenshot` ```ruby browser = SharedTools::Tools::BrowserTool.new @@ -31,16 +22,9 @@ browser.execute(action: "click", selector: "button.login") ### [DiskTool](disk.md) -Secure file system operations with path traversal protection for managing files and directories. - -**Key Features:** - -- Create, read, write, delete files -- Create, list, move, delete directories -- Find and replace text in files -- Path security (sandboxing) +Secure file system operations with path traversal protection. -**Example:** +**Actions:** `file_create`, `file_read`, `file_write`, `file_delete`, `file_move`, `file_replace`, `directory_create`, `directory_list`, `directory_move`, `directory_delete` ```ruby disk = SharedTools::Tools::DiskTool.new @@ -54,21 +38,13 @@ content = disk.execute(action: "file_read", path: "./data.txt") ### [EvalTool](eval.md) -Execute code in multiple languages (Ruby, Python, Shell) with authorization controls. +Execute code in multiple languages with authorization controls. -**Key Features:** - -- Execute Ruby code -- Execute Python scripts -- Run shell commands -- Authorization system for safety - -**Example:** +**Languages:** `ruby`, `python`, `shell` ```ruby eval_tool = SharedTools::Tools::EvalTool.new -result = eval_tool.execute(action: "ruby", code: "[1,2,3].sum") -# => 6 +result = eval_tool.execute(language: "ruby", code: "[1,2,3].sum") ``` [View EvalTool Documentation →](eval.md) @@ -77,23 +53,17 @@ result = eval_tool.execute(action: "ruby", code: "[1,2,3].sum") ### [DocTool](doc.md) -Read and process document formats, currently supporting PDF files. - -**Key Features:** - -- Read specific PDF pages -- Support for page ranges -- Extract text content +Read and process documents: plain text, PDF, Word (.docx), and spreadsheets (CSV, XLSX, ODS, XLSM). -**Example:** +**Actions:** `text_read`, `pdf_read`, `docx_read`, `spreadsheet_read` ```ruby doc = SharedTools::Tools::DocTool.new -result = doc.execute( - action: "pdf_read", - doc_path: "./report.pdf", - page_numbers: "1-5" -) + +doc.execute(action: "text_read", doc_path: "./notes.txt") +doc.execute(action: "pdf_read", doc_path: "./report.pdf", page_numbers: "1-5") +doc.execute(action: "docx_read", doc_path: "./meeting.docx") +doc.execute(action: "spreadsheet_read", doc_path: "./data.xlsx", sheet: "Q1") ``` [View DocTool Documentation →](doc.md) @@ -102,16 +72,7 @@ result = doc.execute( ### [DatabaseTool](database.md) -Execute SQL statements against SQLite or PostgreSQL databases. - -**Key Features:** - -- Execute SELECT, INSERT, UPDATE, DELETE -- Transaction-like execution (stops on error) -- Support for multiple databases -- Pluggable driver architecture - -**Example:** +Execute SQL statements (CREATE, INSERT, SELECT, UPDATE, DELETE) with pluggable drivers. ```ruby driver = SharedTools::Tools::Database::SqliteDriver.new(db: db) @@ -123,18 +84,24 @@ database.execute(statements: ["SELECT * FROM users"]) --- -### [ComputerTool](computer.md) +### [DatabaseQueryTool](database.md) -System-level automation for mouse, keyboard, and screen control. +Safe, read-only SQL queries with automatic LIMIT enforcement and timeout protection. -**Key Features:** +```ruby +db_query = SharedTools::Tools::DatabaseQueryTool.new +db_query.execute(query: "SELECT * FROM users WHERE active = ?", params: [true], limit: 50) +``` + +[View DatabaseQueryTool Documentation →](database.md) -- Mouse click, move, and position tracking -- Keyboard typing and key press simulation -- Scroll control -- Wait functionality for timing +--- + +### [ComputerTool](computer.md) -**Example:** +System-level automation for mouse, keyboard, and screen control. + +**Actions:** `mouse_click`, `mouse_move`, `mouse_position`, `type`, `key`, `hold_key`, `scroll`, `wait` ```ruby computer = SharedTools::Tools::ComputerTool.new @@ -146,87 +113,54 @@ computer.execute(action: "type", text: "Hello, World!") --- -### CalculatorTool - -Safe mathematical calculations without code execution risks. +## Data & Analysis Tools -**Key Features:** +### [CalculatorTool](calculator.md) -- Safe expression evaluation using Dentaku -- Basic arithmetic and mathematical functions -- Trigonometric operations -- Configurable precision (0-10 decimal places) -- Comprehensive error handling +Safe mathematical expression evaluation using the Dentaku parser. -**Example:** +**Features:** Basic arithmetic, math functions (sqrt, round, abs), trigonometry (sin, cos, tan), configurable precision ```ruby calculator = SharedTools::Tools::CalculatorTool.new calculator.execute(expression: "sqrt(16) * 2", precision: 4) -# => {success: true, result: 8.0, precision: 4} +# => {success: true, result: 8.0} ``` ---- +[View CalculatorTool Documentation →](calculator.md) -### WeatherTool +--- -Real-time weather data from OpenWeatherMap API. +### [DataScienceKit](data_science_kit.md) -**Key Features:** +Real statistical analysis performed on actual data — not simulated results. -- Current weather conditions worldwide -- Multiple temperature units (metric, imperial, kelvin) -- Optional 3-day forecast -- Atmospheric data (humidity, pressure, wind) -- Requires OPENWEATHER_API_KEY environment variable +**Analysis types:** `statistical_summary`, `correlation_analysis`, `time_series`, `clustering`, `prediction` -**Example:** +**Data sources:** File path (`data_source`) or inline string (`data`) — supports pipe-delimited tables, CSV, JSON, and comma-separated numbers. ```ruby -weather = SharedTools::Tools::WeatherTool.new -weather.execute(city: "London,UK", units: "metric", include_forecast: true) -# => {success: true, current: {...}, forecast: [...]} -``` +kit = SharedTools::Tools::DataScienceKit.new ---- - -### WorkflowManagerTool - -Manage complex multi-step workflows with persistent state tracking. +# From a file +kit.execute(analysis_type: "statistical_summary", data_source: "./sales.csv") -**Key Features:** - -- Create and track stateful workflows -- Step-by-step execution with persistence -- Status monitoring and progress tracking -- Workflow completion and cleanup -- Survives process restarts - -**Example:** - -```ruby -workflow = SharedTools::Tools::WorkflowManagerTool.new -result = workflow.execute(action: "start", step_data: {project: "demo"}) -workflow_id = result[:workflow_id] -workflow.execute(action: "step", workflow_id: workflow_id, step_data: {task: "compile"}) +# Inline pipe-delimited table +kit.execute( + analysis_type: "correlation_analysis", + data: "| month | revenue | cost |\n| Jan | 12400 | 8200 |\n| Feb | 11800 | 7900 |" +) ``` +[View DataScienceKit Documentation →](data_science_kit.md) + --- ### CompositeAnalysisTool Multi-stage data analysis orchestration for comprehensive insights. -**Key Features:** - -- Automatic data source detection (files or URLs) -- Data structure analysis -- Statistical insights generation -- Visualization suggestions -- Correlation analysis -- Supports CSV, JSON, and text formats - -**Example:** +**Features:** Automatic data source detection, structure analysis, statistical insights, visualisation suggestions, correlation analysis, CSV/JSON/text support ```ruby analyzer = SharedTools::Tools::CompositeAnalysisTool.new @@ -239,286 +173,180 @@ analyzer.execute( --- -### DatabaseQueryTool - -Safe, read-only database query execution with security controls. +## Network & System Tools -**Key Features:** +### [DnsTool](dns_tool.md) -- SELECT-only queries for security -- Automatic LIMIT clause enforcement -- Query timeout protection -- Prepared statement support -- Connection pooling -- Supports PostgreSQL, MySQL, SQLite, and more +DNS resolution, WHOIS queries, IP geolocation, and external IP detection. No API key required. -**Example:** +**Actions:** `a`, `aaaa`, `mx`, `ns`, `txt`, `cname`, `reverse`, `all`, `external_ip`, `ip_location`, `whois` ```ruby -db_query = SharedTools::Tools::DatabaseQueryTool.new -db_query.execute( - query: "SELECT * FROM users WHERE active = ?", - params: [true], - limit: 50 -) -``` +dns = SharedTools::Tools::DnsTool.new ---- +dns.execute(action: "a", host: "example.com") +dns.execute(action: "external_ip") +dns.execute(action: "ip_location", host: "8.8.8.8") +dns.execute(action: "whois", host: "ruby-lang.org") +``` -### Docker ComposeRunTool +[View DnsTool Documentation →](dns_tool.md) -Execute Docker Compose commands safely within containers. +--- -**Key Features:** +### [WeatherTool](weather.md) -- Run commands in Docker containers -- Service specification support -- Automatic container cleanup -- Build and run in one step -- Working directory support +Real-time weather data from OpenWeatherMap. Combine with DnsTool for automatic local forecasts. -**Example:** +**Features:** Current conditions, 3-day forecast, metric/imperial/kelvin units, global city coverage ```ruby -docker = SharedTools::Tools::Docker::ComposeRunTool.new -docker.execute( - service: "app", - command: "rspec", - args: ["spec/main_spec.rb"] -) +weather = SharedTools::Tools::WeatherTool.new +weather.execute(city: "London,UK", units: "metric", include_forecast: true) ``` ---- +[View WeatherTool Documentation →](weather.md) -### ErrorHandlingTool - -Reference implementation demonstrating robust error handling patterns. +--- -**Key Features:** +### CurrentDateTimeTool -- Multiple error type handling -- Retry mechanisms with exponential backoff -- Input/output validation -- Resource cleanup patterns -- Detailed error categorization -- Support reference IDs for debugging +Returns the current date, time, and day of week from the system clock — preventing LLMs from hallucinating temporal information. -**Example:** +**Formats:** `"date"`, `"time"`, `"datetime"`, `"day_of_week"`, `"iso8601"` ```ruby -error_tool = SharedTools::Tools::ErrorHandlingTool.new -error_tool.execute( - operation: "process", - data: {name: "test", value: 42}, - max_retries: 3 -) +dt = SharedTools::Tools::CurrentDateTimeTool.new +dt.execute(format: "date") +# => { date: "2026-03-25", day_of_week: "Wednesday", ... } ``` --- -## Tool Architecture +### SystemInfoTool -All tools share common architectural patterns: - -### Facade Pattern - -Each tool acts as a facade, providing a unified interface to complex operations: +System hardware and OS information: CPU, memory, disk, platform details. ```ruby -# Single tool, multiple related actions -tool.execute(action: "action_one", params...) -tool.execute(action: "action_two", params...) +info = SharedTools::Tools::SystemInfoTool.new +info.execute ``` -### Action-Based Interface - -Tools use an action parameter to specify the operation: - -```ruby -tool.execute( - action: "specific_action", - param1: "value1", - param2: "value2" -) -``` +--- -### Driver Architecture +### ClipboardTool -Tools delegate to driver implementations for flexibility: +Read and write the system clipboard. ```ruby -# Use built-in driver -tool = SharedTools::Tools::SomeTool.new - -# Or provide custom driver -custom_driver = MyCustomDriver.new -tool = SharedTools::Tools::SomeTool.new(driver: custom_driver) +clipboard = SharedTools::Tools::ClipboardTool.new +clipboard.execute(action: "read") +clipboard.execute(action: "write", text: "Hello from the LLM!") ``` -### Authorization Integration - -Tools respect the global authorization setting: - -```ruby -# Default: requires confirmation -SharedTools.execute?(tool: 'tool_name', stuff: 'operation details') +--- -# Disable for automation -SharedTools.auto_execute(true) -``` +### CronTool -## Tool Comparison - -| Tool | Primary Use | Authorization | Requires External Gem | -|------|-------------|---------------|----------------------| -| BrowserTool | Web automation | No | Yes (watir) | -| DiskTool | File operations | No | No | -| EvalTool | Code execution | Yes | No (Python for python action) | -| DocTool | Document processing | No | Yes (pdf-reader) | -| DatabaseTool | SQL operations | No | Yes (sqlite3 or pg) | -| ComputerTool | System automation | No | Yes (platform-specific) | -| CalculatorTool | Math calculations | No | Yes (dentaku - included) | -| WeatherTool | Weather data | No | Yes (openweathermap - included) | -| WorkflowManagerTool | Workflow orchestration | No | No | -| CompositeAnalysisTool | Data analysis | No | No | -| DatabaseQueryTool | Read-only SQL queries | No | Yes (sequel - included) | -| Docker ComposeRunTool | Container commands | No | No (requires Docker) | -| ErrorHandlingTool | Reference/example | No | No | - -## Common Usage Patterns - -### Pattern 1: Resource Management +Cron expression parsing and next-run time calculation. ```ruby -tool = SharedTools::Tools::BrowserTool.new -begin - tool.execute(action: "visit", url: "https://example.com") - # Do work... -ensure - tool.cleanup! # Always clean up -end +cron = SharedTools::Tools::CronTool.new +cron.execute(expression: "0 9 * * MON-FRI") ``` -### Pattern 2: Error Handling +--- -```ruby -begin - result = tool.execute(action: "some_action", param: "value") -rescue ArgumentError => e - puts "Invalid parameters: #{e.message}" -rescue StandardError => e - puts "Operation failed: #{e.message}" -end -``` +## Workflow & DevOps Tools -### Pattern 3: Multi-Tool Workflows +### WorkflowManagerTool -```ruby -# Combine tools for complex workflows -browser = SharedTools::Tools::BrowserTool.new -disk = SharedTools::Tools::DiskTool.new -database = SharedTools::Tools::DatabaseTool.new(driver: driver) +Manage persistent multi-step workflows with JSON file storage. -# 1. Scrape data -html = browser.execute(action: "page_inspect", full_html: true) +**Actions:** `start`, `step`, `status`, `complete`, `list` -# 2. Save raw data -disk.execute(action: "file_write", path: "./raw.html", text: html) +```ruby +workflow = SharedTools::Tools::WorkflowManagerTool.new -# 3. Store in database -database.execute(statements: ["INSERT INTO pages (content) VALUES ('#{html}')"]) +result = workflow.execute(action: "start", step_data: {project: "release-v2.0"}) +workflow_id = result[:workflow_id] -browser.cleanup! +workflow.execute(action: "step", workflow_id: workflow_id, step_data: {task: "run_tests"}) +workflow.execute(action: "status", workflow_id: workflow_id) +workflow.execute(action: "list") +workflow.execute(action: "complete", workflow_id: workflow_id) ``` -## Tool Selection Guide - -### Choose BrowserTool when you need to: - -- Interact with web pages -- Fill forms and click buttons -- Scrape dynamic content -- Take screenshots of web pages - -### Choose DiskTool when you need to: - -- Read or write files -- Manage directory structures -- Search and replace in files -- Work with the file system safely - -### Choose EvalTool when you need to: - -- Execute Ruby code dynamically -- Run Python scripts -- Execute shell commands -- Process data with code - -### Choose DocTool when you need to: - -- Extract text from PDFs -- Read specific document pages -- Process document content - -### Choose DatabaseTool when you need to: - -- Execute SQL queries -- Manage database records -- Perform CRUD operations -- Work with relational data - -### Choose ComputerTool when you need to: - -- Automate mouse and keyboard actions -- Control system-level operations -- Simulate user interactions -- Automate GUI applications +--- -### Choose CalculatorTool when you need to: +### Docker ComposeRunTool -- Perform safe mathematical calculations -- Evaluate mathematical expressions -- Avoid code execution risks -- Get precise numeric results +Execute Docker Compose commands safely within containers. -### Choose WeatherTool when you need to: +```ruby +docker = SharedTools::Tools::Docker::ComposeRunTool.new +docker.execute(service: "app", command: "rspec", args: ["spec/main_spec.rb"]) +``` -- Get real-time weather data -- Access weather forecasts -- Retrieve atmospheric conditions -- Work with weather APIs +--- -### Choose WorkflowManagerTool when you need to: +### ErrorHandlingTool -- Manage multi-step processes -- Track workflow state across sessions -- Coordinate complex operations -- Persist workflow progress +Reference implementation demonstrating robust error handling patterns: retries with exponential backoff, input validation, resource cleanup. -### Choose CompositeAnalysisTool when you need to: +```ruby +error_tool = SharedTools::Tools::ErrorHandlingTool.new +error_tool.execute(operation: "process", data: {name: "test", value: 42}, max_retries: 3) +``` -- Analyze data from multiple sources -- Generate statistical insights -- Get visualization suggestions -- Perform correlation analysis +--- -### Choose DatabaseQueryTool when you need to: +## Tool Comparison -- Execute read-only database queries -- Ensure query security with SELECT-only access -- Manage query timeouts -- Use parameterized queries safely +| Tool | Primary Use | Requires Gem | Requires API Key | +|------|-------------|-------------|-----------------| +| BrowserTool | Web automation | watir | No | +| DiskTool | File operations | None | No | +| EvalTool | Code execution | None (Python optional) | No | +| DocTool | Document reading | pdf-reader, docx, roo | No | +| DatabaseTool | SQL read/write | sqlite3 or pg | No | +| DatabaseQueryTool | Read-only SQL | sequel | No | +| ComputerTool | System automation | Platform-specific | No | +| CalculatorTool | Math expressions | dentaku (included) | No | +| DataScienceKit | Statistical analysis | None | No | +| CompositeAnalysisTool | Data analysis | None | No | +| DnsTool | DNS / WHOIS / geolocation | None | No | +| WeatherTool | Weather data | openweathermap (included) | Yes (free) | +| CurrentDateTimeTool | Date and time | None | No | +| SystemInfoTool | System info | None | No | +| ClipboardTool | Clipboard | None | No | +| CronTool | Cron scheduling | None | No | +| WorkflowManagerTool | Workflow orchestration | None | No | +| Docker ComposeRunTool | Container commands | Docker installed | No | +| ErrorHandlingTool | Reference patterns | None | No | -### Choose Docker ComposeRunTool when you need to: +## Tool Selection Guide -- Run commands in containers -- Execute tests in isolated environments -- Work with Docker Compose services -- Automate containerized workflows +| I need to... | Use | +|--------------|-----| +| Browse a website and extract content | BrowserTool | +| Read, write, or organise files | DiskTool | +| Execute code dynamically | EvalTool | +| Read a PDF, Word doc, or spreadsheet | DocTool | +| Run SQL queries | DatabaseTool / DatabaseQueryTool | +| Automate mouse and keyboard | ComputerTool | +| Evaluate a math expression | CalculatorTool | +| Analyse data statistically | DataScienceKit | +| Look up DNS records or WHOIS | DnsTool | +| Get current weather | WeatherTool | +| Auto-detect my location from IP | DnsTool (ip_location) | +| Get the current date and day | CurrentDateTimeTool | +| Orchestrate a multi-step process | WorkflowManagerTool | +| Run a command in a Docker container | Docker ComposeRunTool | ## Next Steps -- View detailed documentation for each tool -- [Basic Usage Guide](../getting-started/basic-usage.md) - Learn common patterns -- [Authorization System](../guides/authorization.md) - Control operation approval -- [Working with Drivers](../guides/drivers.md) - Create custom drivers +- [Basic Usage Guide](../getting-started/basic-usage.md) — Learn common patterns +- [Authorization System](../guides/authorization.md) — Control operation approval +- [Working with Drivers](../guides/drivers.md) — Create custom drivers +- [Examples](../examples/index.md) — Runnable demo scripts for every tool diff --git a/docs/tools/weather.md b/docs/tools/weather.md index 3ed5a6b..e59289c 100644 --- a/docs/tools/weather.md +++ b/docs/tools/weather.md @@ -1,10 +1,10 @@ # WeatherTool -Real-time weather data retrieval from the OpenWeatherMap API. +Real-time weather data retrieval from the OpenWeatherMap API, including current conditions, forecasts, and location-aware lookups. ## Overview -The WeatherTool provides access to current weather conditions and forecasts for any city worldwide through the OpenWeatherMap API. It supports multiple unit systems and can optionally include extended forecast data. +WeatherTool provides access to current weather conditions and forecasts for any city worldwide through the OpenWeatherMap API. It supports multiple unit systems and optional extended forecast data. Pair it with **DnsTool** and **CurrentDateTimeTool** for fully automatic local forecasts that detect your location from your IP address. ## Features @@ -13,7 +13,7 @@ The WeatherTool provides access to current weather conditions and forecasts for - **Multiple Units**: Metric (Celsius), Imperial (Fahrenheit), or Kelvin - **Comprehensive Data**: Temperature, feels-like, humidity, pressure, wind, cloudiness, visibility - **Global Coverage**: Weather data for cities worldwide -- **Forecast Analysis**: Aggregated daily min/max/average values +- **Local Forecast**: Combine with DnsTool to auto-detect your location ## Installation @@ -35,12 +35,6 @@ You need a free API key from [OpenWeatherMap](https://openweathermap.org/api): export OPENWEATHER_API_KEY="your_api_key_here" ``` -Or in Ruby: - -```ruby -ENV['OPENWEATHER_API_KEY'] = 'your_api_key_here' -``` - ## Basic Usage ### Current Weather @@ -50,88 +44,36 @@ require 'shared_tools' weather = SharedTools::Tools::WeatherTool.new -# Get current weather for a city -result = weather.execute(city: "London") +result = weather.execute(city: "London,UK", units: "metric") puts "Temperature: #{result[:current][:temperature]}°C" puts "Conditions: #{result[:current][:description]}" ``` -### With Country Code - -For cities with common names, include the country code: - -```ruby -# London, UK -result = weather.execute(city: "London,UK", units: "metric") - -# Paris, France -result = weather.execute(city: "Paris,FR", units: "metric") - -# Portland, Oregon, USA -result = weather.execute(city: "Portland,US", units: "imperial") -``` - -## Unit Systems - -### Metric (Default) - -```ruby -result = weather.execute(city: "Tokyo,JP", units: "metric") -# Temperature in Celsius -# Wind speed in m/s -# Pressure in hPa -``` - -### Imperial - -```ruby -result = weather.execute(city: "New York,US", units: "imperial") -# Temperature in Fahrenheit -# Wind speed in mph -# Pressure in hPa -``` - -### Kelvin - -```ruby -result = weather.execute(city: "Moscow,RU", units: "kelvin") -# Temperature in Kelvin (scientific standard) -# Wind speed in m/s -# Pressure in hPa -``` - -## Forecast Data - -### Including Forecasts +### With Forecast ```ruby result = weather.execute( - city: "London,UK", + city: "Tokyo,JP", units: "metric", include_forecast: true ) -# Access forecast data result[:forecast].each do |day| puts "#{day[:date]}: #{day[:temp_min]}°C - #{day[:temp_max]}°C" - puts "Conditions: #{day[:conditions]}" end ``` -### Forecast Data Structure +## Unit Systems -Each forecast day includes: -- `date`: Date string (YYYY-MM-DD) -- `temp_min`: Minimum temperature for the day -- `temp_max`: Maximum temperature for the day -- `temp_avg`: Average temperature -- `conditions`: Most common weather condition -- `avg_humidity`: Average humidity percentage -- `avg_wind_speed`: Average wind speed +| Unit | Temperature | Wind Speed | +|------|-------------|------------| +| `"metric"` | Celsius | m/s | +| `"imperial"` | Fahrenheit | mph | +| `"kelvin"` | Kelvin | m/s | ## Response Format -### Successful Response (Current Weather Only) +### Current Weather ```ruby { @@ -149,7 +91,7 @@ Each forecast day includes: visibility: 10000 }, units: "metric", - timestamp: "2025-01-15T10:30:00Z" + timestamp: "2026-03-25T10:30:00Z" } ``` @@ -162,127 +104,50 @@ Each forecast day includes: current: { ... }, forecast: [ { - date: "2025-01-15", + date: "2026-03-25", temp_min: 12.5, temp_max: 16.8, temp_avg: 14.6, conditions: "partly cloudy", avg_humidity: 70, avg_wind_speed: 4.8 - }, + } # ... 2 more days ], units: "metric", - timestamp: "2025-01-15T10:30:00Z" + timestamp: "2026-03-25T10:30:00Z" } ``` -### Error Response +## Local Forecast (Auto-Detect Location) -```ruby -{ - success: false, - error: "City not found or API error message", - city: "InvalidCity", - suggestion: "Verify city name and API key configuration" -} -``` - -## Current Weather Fields - -### Temperature Data - -| Field | Description | Units | -|-------|-------------|-------| -| `temperature` | Current temperature | °C / °F / K | -| `feels_like` | Perceived temperature | °C / °F / K | - -### Atmospheric Data - -| Field | Description | Units | -|-------|-------------|-------| -| `humidity` | Relative humidity | % | -| `pressure` | Atmospheric pressure | hPa | -| `visibility` | Visibility distance | meters | -| `cloudiness` | Cloud coverage | % | - -### Wind Data - -| Field | Description | Units | -|-------|-------------|-------| -| `wind_speed` | Wind speed | m/s or mph | -| `wind_direction` | Wind direction | degrees | - -### Weather Description - -| Field | Description | -|-------|-------------| -| `description` | Human-readable weather description (e.g., "partly cloudy", "light rain") | - -## Advanced Examples - -### Multiple Cities - -```ruby -cities = ["London,UK", "Paris,FR", "Berlin,DE", "Rome,IT"] - -cities.each do |city| - result = weather.execute(city: city, units: "metric") - - if result[:success] - temp = result[:current][:temperature] - desc = result[:current][:description] - puts "#{city}: #{temp}°C - #{desc}" - else - puts "#{city}: Error - #{result[:error]}" - end -end -``` - -### Weather Comparison +Combine **WeatherTool**, **DnsTool**, and **CurrentDateTimeTool** to automatically detect your location from your IP address and fetch an accurate local forecast — including the correct day of week from the clock, not the LLM's training data. ```ruby -def compare_weather(city1, city2) - weather = SharedTools::Tools::WeatherTool.new - - result1 = weather.execute(city: city1, units: "metric") - result2 = weather.execute(city: city2, units: "metric") - - if result1[:success] && result2[:success] - temp1 = result1[:current][:temperature] - temp2 = result2[:current][:temperature] +require 'ruby_llm' +require 'shared_tools/tools/dns_tool' +require 'shared_tools/tools/weather_tool' +require 'shared_tools/tools/current_date_time_tool' + +chat = RubyLLM.chat.with_tools( + SharedTools::Tools::DnsTool.new, + SharedTools::Tools::WeatherTool.new, + SharedTools::Tools::CurrentDateTimeTool.new +) - diff = (temp1 - temp2).round(1) - warmer = diff > 0 ? city1 : city2 +chat.ask(<<~PROMPT) + I want to know the weather where I currently am. - puts "#{warmer} is #{diff.abs}°C warmer" - end -end + Use these tools in order: + 1. current_date_time_tool (format: 'date') — get today's actual date and day of week + 2. dns_tool (action: 'external_ip') — get my public IP address + 3. dns_tool (action: 'ip_location') — geolocate that IP to find my city and country + 4. weather_tool — fetch current weather and a 3-day forecast for that city (imperial units) -compare_weather("Miami,US", "Seattle,US") -``` - -### Detailed Forecast Analysis - -```ruby -result = weather.execute( - city: "San Francisco,US", - units: "imperial", - include_forecast: true -) - -if result[:success] && result[:forecast] - puts "3-Day Forecast for #{result[:city]}:" - puts "-" * 50 - - result[:forecast].each do |day| - puts "\n#{day[:date]}:" - puts " Temperature: #{day[:temp_min]}°F - #{day[:temp_max]}°F" - puts " Conditions: #{day[:conditions]}" - puts " Humidity: #{day[:avg_humidity]}%" - puts " Wind: #{day[:avg_wind_speed]} mph" - end -end + Use the real date and day of week from the tool when labelling today and + the following days. Tell me: where am I, what are the current conditions, + and what should I expect over the next three days? +PROMPT ``` ## Integration with LLM Agents @@ -291,201 +156,50 @@ end require 'ruby_llm' agent = RubyLLM::Agent.new( - tools: [ - SharedTools::Tools::WeatherTool.new - ] + tools: [SharedTools::Tools::WeatherTool.new] ) -# Let the LLM fetch weather data -response = agent.process("What's the weather like in Tokyo right now?") -response = agent.process("Give me a 3-day forecast for London") -response = agent.process("Is it warmer in Miami or Seattle today?") -``` - -## Configuration - -### Custom Logger - -```ruby -require 'logger' - -custom_logger = Logger.new($stdout) -custom_logger.level = Logger::DEBUG - -weather = SharedTools::Tools::WeatherTool.new(logger: custom_logger) +agent.process("What's the weather like in Tokyo right now?") +agent.process("Give me a 3-day forecast for London in metric units.") +agent.process("I'm planning a trip. Fetch weather for Paris, Barcelona, and Amsterdam and recommend the best destination for outdoor sightseeing.") ``` ## Error Handling -### API Key Not Configured - ```ruby -result = weather.execute(city: "London") -# Without OPENWEATHER_API_KEY set: -# { -# success: false, -# error: "OpenWeather API key not configured", -# ... -# } -``` +result = weather.execute(city: "InvalidCity12345", units: "metric") -### City Not Found - -```ruby -result = weather.execute(city: "InvalidCityName12345") -# { -# success: false, -# error: "City not found", -# city: "InvalidCityName12345", -# suggestion: "Verify city name and API key configuration" -# } +unless result[:success] + puts "Error: #{result[:error]}" + puts "Suggestion: #{result[:suggestion]}" +end ``` -### API Request Failure +Common errors: -```ruby -# Network error, rate limit, or API issue -result = weather.execute(city: "London") -# { -# success: false, -# error: "API request failed: [error details]", -# ... -# } -``` +| Error | Cause | Fix | +|-------|-------|-----| +| `"API key not configured"` | `OPENWEATHER_API_KEY` not set | Export the env var | +| `"City not found"` | Typo or ambiguous city name | Add country code: `"Portland,US"` | +| `"API request failed"` | Network or rate limit | Check connectivity; wait and retry | ## Rate Limits -OpenWeatherMap free tier has rate limits: - - **Free Plan**: 60 calls/minute, 1,000,000 calls/month -- **Startup Plan**: Higher limits available - -Monitor your usage at https://openweathermap.org/price - -## Performance Considerations - -- **API Latency**: Network request time varies (typically 100-500ms) -- **Forecast Data**: Including forecasts requires an additional API call -- **Caching**: Consider caching results for frequently requested cities -- **Batch Requests**: Space out multiple requests to respect rate limits +- Monitor usage at https://openweathermap.org/price ## Best Practices -### Use Country Codes - -```ruby -# Good - specific -weather.execute(city: "Portland,US") # Oregon, USA -weather.execute(city: "Portland,AU") # Victoria, Australia - -# Less reliable -weather.execute(city: "Portland") # Which Portland? -``` - -### Handle Errors Gracefully - -```ruby -result = weather.execute(city: user_input) - -if result[:success] - # Use weather data - process_weather(result) -else - # Show user-friendly error - puts "Unable to fetch weather: #{result[:error]}" - puts "Suggestion: #{result[:suggestion]}" -end -``` - -### Cache Results - -```ruby -class WeatherCache - def initialize - @weather = SharedTools::Tools::WeatherTool.new - @cache = {} - @cache_duration = 10 * 60 # 10 minutes - end - - def get_weather(city, units: "metric") - cache_key = "#{city}:#{units}" - cached = @cache[cache_key] - - if cached && (Time.now - cached[:time]) < @cache_duration - return cached[:data] - end - - result = @weather.execute(city: city, units: units) - @cache[cache_key] = {data: result, time: Time.now} - result - end -end -``` - -## Security Considerations - -- ✅ API key stored in environment variable (not in code) -- ✅ No arbitrary code execution -- ✅ Input validation on city parameter -- ✅ Safe HTTP requests via openweathermap gem -- ⚠️ Protect API key (don't commit to version control) -- ⚠️ Monitor API usage to prevent unexpected charges - -## Limitations - -- **Free Tier**: Limited to 60 calls/minute -- **Forecast Period**: Only 3-day forecast provided -- **Historical Data**: Not available (current and future only) -- **City Resolution**: Some obscure locations may not be found -- **Update Frequency**: Weather data updates vary by location - -## Troubleshooting - -### API Key Issues - -**Problem**: "API key not configured" error - -**Solution**: -```bash -# Set environment variable -export OPENWEATHER_API_KEY="your_key_here" - -# Or in Ruby before using the tool -ENV['OPENWEATHER_API_KEY'] = 'your_key_here' -``` - -### City Not Found - -**Problem**: Valid city returns "not found" error - -**Solution**: Try adding country code or checking spelling -```ruby -# Instead of -weather.execute(city: "Muenchen") - -# Try -weather.execute(city: "Munich,DE") -``` - -### Rate Limit Errors - -**Problem**: "Rate limit exceeded" error - -**Solution**: Space out requests or upgrade API plan -```ruby -cities.each do |city| - result = weather.execute(city: city) - sleep(1) # Wait between requests -end -``` - -## Related Tools - -- [CompositeAnalysisTool](index.md) - Can incorporate weather data in analysis -- [WorkflowManagerTool](index.md) - For multi-step workflows involving weather data +- Always include a country code: `"London,UK"` not just `"London"` +- Including forecast data requires a second API call — omit `include_forecast` when not needed +- For the most accurate day-of-week labelling, pair with **CurrentDateTimeTool** rather than relying on the LLM's training data ## References - [OpenWeatherMap API Documentation](https://openweathermap.org/api) -- [OpenWeatherMap Ruby Gem](https://github.com/lucaswinningham/openweathermap) - [API Pricing](https://openweathermap.org/price) + +## Related Tools + +- [DnsTool](dns_tool.md) - IP geolocation for automatic location detection +- [CurrentDateTimeTool](index.md) - Real date and day of week for accurate forecast labels diff --git a/examples/README.md b/examples/README.md index 3997ce7..e49bf75 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,792 +1,418 @@ # SharedTools Examples -This directory contains example programs demonstrating how to use SharedTools with the RubyLLM framework. Each example showcases a different tool collection and demonstrates practical use cases. +Runnable demo scripts showing how to use SharedTools with the RubyLLM framework. Each demo drives a real LLM conversation and exercises a specific tool or set of tools. ## Prerequisites -**All examples require:** +**All demos require:** -1. **RubyLLM gem** - The Ruby LLM integration framework +1. **SharedTools gem and dependencies** ```bash - gem install ruby_llm + bundle install ``` -2. **LLM Provider** - A running LLM service (one of): +2. **LLM Provider** — a running LLM service (one of): - **Ollama** (recommended for local development) ```bash # Install Ollama from https://ollama.ai - # Pull a model: ollama pull llama3.2 - # Or use the model referenced in examples: - ollama pull gpt-oss:20b ``` - - **OpenAI API** - Set `OPENAI_API_KEY` environment variable - - **Anthropic API** - Set `ANTHROPIC_API_KEY` environment variable + - **OpenAI API** — set `OPENAI_API_KEY` + - **Anthropic API** — set `ANTHROPIC_API_KEY` -3. **Shared Configuration** - The `ruby_llm_config.rb` file in this directory provides helper methods (`title()`, `test_with_prompt()`, `ollama_chat()`) used by all examples. +3. **Shared helper** — `common.rb` in this directory provides `title()`, `ask()`, `new_chat`, and a pre-configured `@chat` session. It is automatically loaded by all demos via `require_relative 'common'`. -## Running Examples +## Running Demos -All examples are executable Ruby scripts: +All demos are run from the project root with the examples directory on the load path: ```bash -cd examples -./browser_tool_example.rb -# or -ruby browser_tool_example.rb +bundle exec ruby -I examples examples/weather_tool_demo.rb ``` -**Debug mode** - See detailed LLM tool calls: +Debug mode shows every LLM tool call: + ```bash -RUBY_LLM_DEBUG=true ./eval_tool_example.rb +RUBY_LLM_DEBUG=true bundle exec ruby -I examples examples/eval_tool_demo.rb ``` -**Expected behavior:** -- Examples will make LLM API calls and may take 10-30 seconds to complete -- With `RUBY_LLM_DEBUG=true`, you'll see tool invocations in the format: `Tool eval_ruby called with params={...}` -- Most examples will timeout (this is expected) as they demonstrate multiple interactions with the LLM +Demos make real LLM API calls and may take 10–60 seconds depending on the number of sections and the model in use. -## Available Examples +--- -### 1. Browser Tool Example (`browser_tool_example.rb`) +## Available Demos -Demonstrates web browser automation capabilities including: -- Visiting web pages -- Inspecting page content (HTML/text) -- Finding UI elements by text or CSS selectors -- Clicking buttons and links -- Filling in form fields -- Taking screenshots -- Complete login workflow automation +### `browser_tool_demo.rb` -**Requirements:** -```bash -gem install watir webdrivers -``` +Web browser automation: navigate pages, inspect HTML, find elements by text or selector, click buttons, fill forms, take screenshots, and complete login workflows. -**Additional Requirements:** -- Chrome, Firefox, or Safari browser installed -- Compatible webdriver (automatically managed by `webdrivers` gem) +**Requires:** `watir` gem + Chrome or Firefox installed -**Run it:** ```bash -./browser_tool_example.rb +bundle exec ruby -I examples examples/browser_tool_demo.rb ``` -**Key Features:** -- Uses mock driver for demonstration (no browser required) -- Shows all available browser actions -- Includes a complete form-filling workflow - --- -### 2. Disk Tool Example (`disk_tool_example.rb`) +### `calculator_tool_demo.rb` + +Safe mathematical expression evaluation: basic arithmetic, complex expressions, square root, exponentiation, percentages, precision control, and multi-step conversational calculations. -Demonstrates file system operations including: -- Creating directories (single and nested) -- Creating, reading, writing files -- Moving and deleting files/directories -- Replacing text within files -- Listing directory contents -- Security: path traversal protection -- Complete project structure generation +**Requires:** `dentaku` gem (included in gem dependencies) -**Requirements:** -- None (uses standard Ruby libraries) +> **Note:** Modern frontier LLMs perform arithmetic accurately without a calculator tool. This demo is most valuable for learning the tool integration pattern, for smaller or older models, or for compliance/audit workflows where calculations must go through a deterministic evaluator. -**Run it:** ```bash -./disk_tool_example.rb +bundle exec ruby -I examples examples/calculator_tool_demo.rb ``` -**Key Features:** -- Works with temporary directories (automatically cleaned up) -- Demonstrates sandboxed file operations -- Shows security features preventing path traversal attacks -- Includes a complete Ruby project scaffolding example - --- -### 3. Database Tool Example (`database_tool_example.rb`) +### `clipboard_tool_demo.rb` -Demonstrates SQL database operations using SQLite: -- Creating tables -- Inserting data -- Querying with SELECT, WHERE, JOIN -- Updating and deleting records -- Aggregate functions (COUNT, AVG) -- Transaction-like sequential execution (stops on error) +Read and write the system clipboard. -**Requirements:** ```bash -gem install sqlite3 +bundle exec ruby -I examples examples/clipboard_tool_demo.rb ``` -**Additional Requirements:** -- None (uses in-memory database) - -**Run it:** -```bash -./database_tool_example.rb -``` - -**Key Features:** -- Uses in-memory SQLite database -- Demonstrates foreign key relationships -- Shows error handling and transaction behavior -- Includes aggregate queries and statistics - --- -### 4. Computer Tool Example (`computer_tool_example.rb`) - -Demonstrates system automation capabilities including: -- Mouse movements and positioning -- Mouse clicks (single, double, triple) -- Right-click context menus -- Drag and drop operations -- Keyboard input (typing text) -- Keyboard shortcuts (Cmd+C, Cmd+V, etc.) -- Holding keys for duration -- Scrolling -- Automated form filling -- Text selection workflows - -**Requirements:** -```bash -gem install macos # macOS only -``` +### `composite_analysis_tool_demo.rb` -**Additional Requirements:** -- macOS operating system -- Accessibility permissions granted: - - System Preferences → Security & Privacy → Privacy → Accessibility - - Add Terminal or your IDE to the allowed applications +Multi-stage data analysis orchestration: automatic data source detection, structure analysis, statistical insights, visualisation suggestions, and correlation analysis. Supports CSV, JSON, and text formats. -**Run it:** ```bash -./computer_tool_example.rb +bundle exec ruby -I examples examples/composite_analysis_tool_demo.rb ``` -**Key Features:** -- Uses mock driver for demonstration -- Shows all mouse and keyboard actions -- Includes complete automation workflows -- Note: For real system automation on macOS, requires `macos` gem and accessibility permissions - --- -### 5. Eval Tool Example (`eval_tool_example.rb`) - -Demonstrates code evaluation capabilities including: -- Evaluating Ruby code with results and console output -- Evaluating Python code (if python3 is available) -- Executing shell commands with output capture -- Handling errors in code execution -- Using authorization system for safe execution -- Practical calculator example +### `computer_tool_demo.rb` -**Requirements:** -- None (uses standard Ruby libraries) +System-level automation: mouse movements and clicks (single, double, right-click), drag and drop, keyboard typing, shortcuts (Cmd+C, Cmd+V, etc.), holding keys, scrolling, and automated form-filling. -**Additional Requirements:** -- Python installed (for Python evaluation examples) -- Shell access (for shell command examples) +**Requires:** macOS + accessibility permissions granted in System Settings → Privacy → Accessibility -**Run it:** ```bash -./eval_tool_example.rb +bundle exec ruby -I examples examples/computer_tool_demo.rb ``` -**Key Features:** -- Supports Ruby, Python, and Shell code execution -- Built-in authorization system (bypassed for demo with auto_execute) -- Captures both output and result values -- Error handling with detailed messages -- Individual tools can be used directly for more control - -**Security Note:** -- Auto-execution is enabled for this demo only -- In production, always use `SharedTools.auto_execute(false)` to require user confirmation - --- -### 6. Doc Tool Example (`doc_tool_example.rb`) - -Demonstrates PDF document processing including: -- Reading single pages from PDF documents -- Reading multiple specific pages -- Handling invalid page numbers gracefully -- Extracting text for search and analysis -- Document statistics (word count, character count) -- Finding section headers -- Word frequency analysis - -**Requirements:** -```bash -gem install pdf-reader -``` +### `cron_tool_demo.rb` -**Additional Requirements:** -- A sample PDF file (the example looks for `test/fixtures/test.pdf`) +Cron expression parsing and next-run time calculation. -**Run it:** ```bash -./doc_tool_example.rb +bundle exec ruby -I examples examples/cron_tool_demo.rb ``` -**Key Features:** -- Uses the test fixture PDF (automatically available) -- Demonstrates single and multi-page extraction -- Shows practical text analysis examples -- Error handling for missing files and invalid pages -- Individual PdfReaderTool can be used directly - --- -### 7. Comprehensive Workflow Example (`comprehensive_workflow_example.rb`) - -Demonstrates using multiple tools together in a realistic scenario: - -**Workflow:** Web Scraping → Database Storage → Report Generation +### `current_date_time_tool_demo.rb` -1. **Phase 1: Web Scraping** - - Uses BrowserTool to scrape product data from HTML - - Parses HTML with Nokogiri - - Extracts structured product information +Fetch the real current date, time, and day of week from the system clock. Demonstrates how this prevents LLMs from hallucinating temporal information such as the wrong day of the week. -2. **Phase 2: Database Storage** - - Uses DatabaseTool to create tables - - Stores scraped products in SQLite - - Generates statistics and analytics - -3. **Phase 3: Report Generation** - - Uses DiskTool to create report directory - - Generates reports in multiple formats: - - Markdown summary report - - JSON data export - - CSV data export - -**Requirements:** ```bash -gem install sqlite3 +bundle exec ruby -I examples examples/current_date_time_tool_demo.rb ``` -**Additional Requirements:** -- None - -**Run it:** -```bash -./comprehensive_workflow_example.rb -``` - -**Key Features:** -- Shows real-world integration of multiple tools -- Demonstrates data pipeline: scrape → store → report -- Generates reports in multiple formats -- Preserves output directory for inspection - --- -### 8. Calculator Tool Example (`calculator_tool_example.rb`) +### `data_science_kit_demo.rb` -Demonstrates safe mathematical calculations including: -- Basic arithmetic operations (+, -, *, /) -- Complex expressions with parentheses -- Square root and exponentiation -- Percentage calculations -- Precision control for decimal results -- Rounding operations -- Multi-step calculations with conversational context +Real statistical analysis (not simulated) on actual data: -**Requirements:** -```bash -gem install dentaku -``` +- Statistical summary (mean, median, std dev, quartiles) +- Correlation analysis (Pearson r between columns) +- Time series trend detection and moving averages +- K-means clustering +- Linear regression prediction -**Additional Requirements:** -- None +All prompts instruct the LLM to pass data **inline** using the `data` parameter as a pipe-delimited table, so no files are required. -**Run it:** ```bash -./calculator_tool_example.rb +bundle exec ruby -I examples examples/data_science_kit_demo.rb ``` -**Key Features:** -- Uses Dentaku for safe math evaluation (no code injection) -- Supports mathematical functions (sqrt, round, pow, etc.) -- Configurable decimal precision -- Natural language to mathematical expressions -- Maintains conversational context across calculations - -**⚠️ Note on Modern LLM Capabilities:** -Modern LLMs (GPT-4, Claude 3+, Gemini, and even advanced open-source models like those from Meta and Mistral) have excellent built-in arithmetic capabilities and can accurately perform most mathematical calculations without requiring a calculator tool. This example demonstrates the tool architecture and safety patterns (using Dentaku to prevent code injection), but in practice, **this tool may be largely unnecessary with today's frontier and advanced open-source models**. - -The tool might still be useful for: -- **Legacy or smaller models** with weaker math capabilities -- **Extremely high-precision calculations** where you need exact decimal control -- **Compliance/audit requirements** where calculations must be verifiably performed by a specific evaluator -- **Learning purposes** to understand tool integration patterns - --- -### 9. Weather Tool Example (`weather_tool_example.rb`) +### `database_tool_demo.rb` -Demonstrates weather data retrieval including: -- Current weather conditions for cities worldwide -- Temperature, humidity, wind, and atmospheric data -- Multiple unit systems (metric, imperial, kelvin) -- 3-day weather forecasts -- Multi-city comparisons -- Real-time API integration +Full SQL operations on an in-memory SQLite database: CREATE TABLE, INSERT, SELECT with WHERE and JOIN, UPDATE, DELETE, aggregate functions (COUNT, AVG), and transaction-like sequential execution. -**Requirements:** -```bash -gem install openweathermap -``` +**Requires:** `sqlite3` gem -**Additional Requirements:** -- OpenWeatherMap API key (free at https://openweathermap.org/api) -- Set `OPENWEATHER_API_KEY` environment variable -- Active internet connection - -**Run it:** ```bash -export OPENWEATHER_API_KEY="your-key-here" -./weather_tool_example.rb +bundle exec ruby -I examples examples/database_tool_demo.rb ``` -**Key Features:** -- Real-time weather data from OpenWeatherMap API -- Supports metric, imperial, and kelvin units -- Includes current conditions and forecasts -- Natural language weather queries -- Conversational context maintenance - --- -### 10. Workflow Manager Tool Example (`workflow_manager_tool_example.rb`) +### `database_query_tool_demo.rb` -Demonstrates stateful workflow management including: -- Creating and initializing workflows -- Adding sequential workflow steps -- Checking workflow status and progress -- Managing workflow metadata -- Completing workflows with summaries -- Persistent state across process restarts -- Multiple concurrent workflows +Safe read-only SQL queries: SELECT-only access, parameterised queries for SQL injection prevention, automatic LIMIT enforcement, query timeout protection, joins, and aggregations. -**Requirements:** -- None (uses standard Ruby libraries) +**Requires:** `sequel` and `sqlite3` gems (included in gem dependencies) -**Additional Requirements:** -- None (uses temporary storage) - -**Run it:** ```bash -./workflow_manager_tool_example.rb +bundle exec ruby -I examples examples/database_query_tool_demo.rb ``` -**Key Features:** -- Stateful workflow management with persistence -- Unique workflow IDs for tracking -- Step-by-step execution with metadata -- Status monitoring and progress tracking -- Automatic state persistence to disk -- Workflow completion summaries -- Perfect for coordinating complex multi-step processes - --- -### 11. Composite Analysis Tool Example (`composite_analysis_tool_example.rb`) - -Demonstrates comprehensive data analysis including: -- Multi-stage data analysis orchestration -- Data structure analysis -- Statistical insights generation -- Visualization recommendations -- Correlation analysis -- Support for CSV, JSON, and web data sources -- Three analysis levels (quick, standard, comprehensive) - -**Requirements:** -- None (uses standard Ruby libraries with simulated data) +### `disk_tool_demo.rb` -**Additional Requirements:** -- None (examples use simulated data) +Secure file system operations: create, read, write, delete, and move files and directories; find and replace text within files; list directory contents; path traversal protection; complete project structure generation. -**Run it:** ```bash -./composite_analysis_tool_example.rb +bundle exec ruby -I examples examples/disk_tool_demo.rb ``` -**Key Features:** -- Automatically detects data format (CSV, JSON, text) -- Three analysis modes: quick, standard, comprehensive -- Generates structure, insights, and visualization suggestions -- Supports both file paths and web URLs -- Comprehensive correlation analysis -- Perfect for exploratory data analysis - --- -### 12. Data Science Kit Example (`data_science_kit_example.rb`) - -Demonstrates advanced data science operations including: -- Statistical summary with distributions and outliers -- Correlation analysis (Pearson and Spearman) -- Time series analysis with forecasting -- K-means and hierarchical clustering -- Predictive modeling and regression -- Custom parameters for fine-tuned analysis +### `dns_tool_demo.rb` -**Requirements:** -- None (uses standard Ruby libraries with simulated data) +DNS resolution, WHOIS queries, IP geolocation, and external IP detection — all with no API key required: -**Additional Requirements:** -- None (examples use simulated data) +- A, AAAA, MX, NS, TXT, CNAME record lookups +- Reverse DNS +- Full record dump (`all`) +- External IP detection via public services +- IP geolocation via ip-api.com (city, region, country, timezone, ISP) +- WHOIS for domain names and IP addresses +- Combined workflow: detect IP → geolocate → WHOIS the ISP domain -**Run it:** ```bash -./data_science_kit_example.rb +bundle exec ruby -I examples examples/dns_tool_demo.rb ``` -**Key Features:** -- Five analysis types: statistical_summary, correlation_analysis, time_series, clustering, prediction -- Supports multiple ML algorithms and methods -- Automatic data loading and preprocessing -- Detailed results with visualization recommendations -- Custom parameter support for advanced users -- Conversational context across analysis steps - --- -### 13. Database Query Tool Example (`database_query_tool_example.rb`) +### `doc_tool_demo.rb` -Demonstrates safe, read-only database queries including: -- SELECT-only queries (no data modification) -- Parameterized queries for SQL injection prevention -- Automatic LIMIT enforcement -- Query timeout protection -- Join operations and aggregations -- Connection pooling with automatic cleanup +Document reading across all supported formats. Creates sample documents from scratch to keep the demo self-contained: -**Requirements:** -```bash -gem install sequel sqlite3 -``` +- **Plain text** — reads a Ruby style guide `.txt` file +- **PDF** — reads specific pages and page ranges (requires a PDF file) +- **Microsoft Word** — builds a minimal `.docx` meeting notes file from scratch using `rubyzip` and reads it with the `docx` gem +- **CSV** — reads an expense report CSV with aggregation and filtering questions +- **Multi-sheet XLSX** — builds a quarterly sales workbook from scratch and reads individual sheets -**Additional Requirements:** -- None (uses in-memory database for examples) +**Requires:** `docx` and `roo` gems for Word and spreadsheet support -**Run it:** ```bash -./database_query_tool_example.rb +bundle exec ruby -I examples examples/doc_tool_demo.rb ``` -**Key Features:** -- Read-only security (SELECT statements only) -- Parameterized query support prevents SQL injection -- Automatic connection management -- Query timeout protection -- Works with any Sequel-supported database -- Perfect for AI-assisted data analysis and reporting - --- -### 14. DevOps Toolkit Example (`devops_toolkit_example.rb`) +### `error_handling_tool_demo.rb` -Demonstrates DevOps operations including: -- Application deployments across environments -- Rollback capabilities for failed deployments -- Health checks and system monitoring -- Log analysis and error detection -- Metrics collection and reporting -- Production safety mechanisms with confirmations +Reference implementation for robust error handling: input validation with helpful suggestions, network retry with exponential backoff, authorisation checks, resource cleanup, error categorisation, operation metadata tracking, and configurable retry mechanisms. -**Requirements:** -- None (uses standard Ruby libraries) - -**Additional Requirements:** -- None (examples simulate DevOps operations) - -**Run it:** ```bash -./devops_toolkit_example.rb +bundle exec ruby -I examples examples/error_handling_tool_demo.rb ``` -**Key Features:** -- Environment-specific restrictions (dev, staging, production) -- Production operations require explicit confirmation -- All operations logged with unique operation IDs -- Supports deployments, rollbacks, health checks, logs, and metrics -- Built-in safety mechanisms prevent accidents -- Audit trail for compliance requirements - --- -### 15. Error Handling Tool Example (`error_handling_tool_example.rb`) +### `eval_tool_demo.rb` -Demonstrates comprehensive error handling patterns including: -- Input validation with helpful suggestions -- Network retry with exponential backoff -- Authorization checks and errors -- Resource cleanup in ensure blocks -- Detailed error categorization -- Operation metadata tracking -- Configurable retry mechanisms +Code evaluation in multiple languages: -**Requirements:** -- None (uses standard Ruby libraries) +- Ruby code with result and stdout capture +- Python code (requires `python3` in PATH) +- Shell command execution -**Additional Requirements:** -- None +Authorization system is bypassed for this demo. In production, keep `SharedTools.auto_execute(false)`. + +**Requires:** Python 3 installed for Python examples -**Run it:** ```bash -./error_handling_tool_example.rb +bundle exec ruby -I examples examples/eval_tool_demo.rb ``` -**Key Features:** -- Reference implementation for robust tool development -- Demonstrates all major error types and handling patterns -- Retry mechanism with exponential backoff -- Proper resource cleanup -- Unique reference IDs for error tracking -- Detailed error messages with suggestions -- Perfect reference for building production-ready tools - --- -### 16. MCP Client Example (`mcp_client_example.rb`) +### `mcp_client_demo.rb` -Demonstrates Model Context Protocol (MCP) client integration including: -- Connecting to MCP servers (Tavily, GitHub, iMCP) -- Using MCP tools in LLM conversations -- Multi-client orchestration -- Accessing MCP resources and prompts -- Real-world integration patterns -- Environment configuration and setup +Model Context Protocol (MCP) client integration: connecting to MCP servers (Tavily, GitHub, iMCP), using MCP tools in LLM conversations, multi-client orchestration, and accessing MCP resources. + +**Requires:** +- `ruby_llm-mcp` gem +- `TAVILY_API_KEY` for web search +- `GITHUB_PERSONAL_ACCESS_TOKEN` for GitHub operations +- `brew install github-mcp-server` and `brew install --cask loopwork/tap/iMCP` (macOS) -**Requirements:** ```bash -gem install ruby_llm-mcp +export TAVILY_API_KEY="your-key" +export GITHUB_PERSONAL_ACCESS_TOKEN="your-token" +bundle exec ruby -I examples examples/mcp_client_demo.rb ``` -**Additional Requirements:** -- **For Tavily (web search):** - - Set `TAVILY_API_KEY` environment variable - - Get your API key at: https://tavily.com +--- -- **For GitHub operations:** - - Set `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable - - Install: `brew install github-mcp-server` +### `system_info_tool_demo.rb` -- **For iMCP (macOS only):** - - Install: `brew install --cask loopwork/tap/iMCP` +System hardware and OS information: CPU model and core count, memory, disk usage, Ruby version, platform details. -**Run it:** ```bash -export TAVILY_API_KEY="your-api-key" -export GITHUB_PERSONAL_ACCESS_TOKEN="your-token" -./mcp_client_example.rb +bundle exec ruby -I examples examples/system_info_tool_demo.rb ``` -**Key Features:** -- Supports ruby_llm-mcp v0.7.0+ (requires RubyLLM 1.9+) -- Automatically detects available MCP servers -- Shows integration with multiple MCP clients simultaneously -- Demonstrates tools, resources, and prompts -- Production-ready configuration patterns -- Comprehensive error handling examples - -**About MCP:** -Model Context Protocol (MCP) is an open protocol that standardizes how AI applications interact with external data sources and tools. MCP provides: -- **Standardized Integration**: Connect to any MCP server with consistent APIs -- **Tool Discovery**: Automatically discover available tools from servers -- **Resource Access**: Read files, databases, and other resources -- **Prompt Templates**: Reusable prompts with parameters - -**Available MCP Clients:** -- **Tavily**: AI-powered web search and research -- **GitHub**: Repository management and operations -- **iMCP**: macOS system integration (Notes, Calendar, Contacts) - -**Documentation:** -- Official MCP docs: https://www.rubyllm-mcp.com -- GitHub repository: https://github.com/patvice/ruby_llm-mcp -- SharedTools MCP README: ../lib/shared_tools/mcp/README.md - --- -## General Usage Patterns +### `weather_tool_demo.rb` -### Tool Initialization +Real-time weather data from OpenWeatherMap: -All tools follow a similar initialization pattern: +- Current conditions for individual cities +- Metric and imperial unit lookups +- Current weather + 3-day forecast +- Multi-city travel recommendation (Paris vs Barcelona vs Amsterdam) +- Packing advice with forecast data +- Temperature comparison across extreme climates +- **Local forecast** — combines `DnsTool` (external IP → city geolocation) and `CurrentDateTimeTool` (real day of week) with `WeatherTool` to automatically detect your location and give an accurate forecast without hallucinating the day of the week -```ruby -# With default driver -tool = SharedTools::Tools::BrowserTool.new - -# With custom driver -driver = CustomDriver.new -tool = SharedTools::Tools::BrowserTool.new(driver: driver) +**Requires:** `OPENWEATHER_API_KEY` environment variable (free at https://openweathermap.org/api) -# With custom logger -logger = Logger.new(STDOUT) -tool = SharedTools::Tools::DiskTool.new(logger: logger) +```bash +export OPENWEATHER_API_KEY="your-key-here" +bundle exec ruby -I examples examples/weather_tool_demo.rb ``` -### Executing Actions - -Tools use a consistent `execute` method with named parameters: - -```ruby -result = tool.execute( - action: ActionConstant, - param1: value1, - param2: value2 -) -``` +--- -### Action Constants +### `workflow_manager_tool_demo.rb` -Each tool defines action constants in its module: +Multi-step workflow orchestration with persistent JSON state: -```ruby -# Browser actions -SharedTools::Tools::BrowserTool::Action::VISIT -SharedTools::Tools::BrowserTool::Action::CLICK +- **List all workflows** — before starting, shows any workflows from prior runs +- **Start a workflow** — initialises a v2.0.0 release pipeline +- **Execute steps** — LLM drives each phase: tests, security scan, staging deploy, QA, production deploy, stakeholder notification, completion summary +- **Status checks** — inspect progress mid-workflow +- **Complete** — finalise and summarise the workflow -# Disk actions -SharedTools::Tools::DiskTool::Action::FILE_CREATE -SharedTools::Tools::DiskTool::Action::DIRECTORY_LIST +Workflow state persists in `.workflows/` and survives process restarts. -# Computer actions -SharedTools::Tools::ComputerTool::Action::MOUSE_CLICK -SharedTools::Tools::ComputerTool::Action::TYPE +```bash +bundle exec ruby -I examples examples/workflow_manager_tool_demo.rb +``` -# Eval actions -SharedTools::Tools::EvalTool::Action::RUBY -SharedTools::Tools::EvalTool::Action::PYTHON -SharedTools::Tools::EvalTool::Action::SHELL +--- -# Doc actions -SharedTools::Tools::DocTool::Action::PDF_READ +### `comprehensive_workflow_demo.rb` -# Database - uses SQL statements directly -database.execute(statements: ["SELECT * FROM users"]) -``` +End-to-end multi-tool pipeline demonstrating how tools compose together in a realistic scenario: -## Running All Examples +1. **Web scraping phase** — BrowserTool navigates a product catalogue and extracts structured data +2. **Database storage phase** — DatabaseTool creates tables, inserts products, and generates statistics +3. **Report generation phase** — DiskTool creates a report directory and writes Markdown, JSON, and CSV output files -To run all examples sequentially: +**Requires:** `sqlite3` gem ```bash -for example in examples/*.rb; do - echo "Running $example..." - ruby "$example" - echo "" -done +bundle exec ruby -I examples examples/comprehensive_workflow_demo.rb ``` -## Production Usage +--- -These examples use mock drivers for demonstration. In production: +## common.rb — Shared Helper -1. **BrowserTool**: Install `watir` gem and use real browser drivers - ```bash - gem install watir webdrivers - ``` +All demos `require_relative 'common'`, which provides: -2. **ComputerTool**: On macOS, the tool works with system automation - (requires accessibility permissions) +| Helper | Description | +|--------|-------------| +| `title(label, char: '=')` | Prints a formatted section header | +| `ask(prompt)` | Sends a prompt to `@chat` and prints the response | +| `new_chat` | Creates a fresh chat session (resets conversation context) | +| `@chat` | Default chat session shared across demo sections | -3. **DatabaseTool**: Works with any database that has a compatible driver - (SQLite, PostgreSQL, MySQL, etc.) +Debug logging (`RUBY_LLM_DEBUG=true`) is set in each demo's header so tool calls are visible in the output. -4. **DiskTool**: Uses `LocalDriver` with sandboxed root directory for security +--- ## Environment Variables -For LLM provider configuration: - -```bash -# For OpenAI -export OPENAI_API_KEY="your-api-key-here" +| Variable | Required by | +|----------|------------| +| `OPENAI_API_KEY` | All demos (if using OpenAI) | +| `ANTHROPIC_API_KEY` | All demos (if using Anthropic) | +| `OLLAMA_HOST` | All demos (if using Ollama; default: `http://localhost:11434`) | +| `OPENWEATHER_API_KEY` | `weather_tool_demo.rb` | +| `TAVILY_API_KEY` | `mcp_client_demo.rb` | +| `GITHUB_PERSONAL_ACCESS_TOKEN` | `mcp_client_demo.rb` | -# For Anthropic -export ANTHROPIC_API_KEY="your-api-key-here" - -# For Ollama (default: http://localhost:11434) -export OLLAMA_HOST="http://localhost:11434" -``` +--- ## Troubleshooting -### "LoadError: cannot load such file - ruby_llm" - -The RubyLLM framework is not installed. Install it: +### `LoadError: cannot load such file — ruby_llm` ```bash -gem install ruby_llm +bundle install ``` ### LLM connection errors -**Ollama connection refused:** -1. Make sure Ollama is running: `ollama serve` -2. Verify the model is pulled: `ollama list` -3. Check Ollama is listening on default port (11434) - -**OpenAI/Anthropic API errors:** -1. Verify your API key is set in environment variables -2. Check your API key has sufficient credits/permissions -3. Verify network connectivity to the API endpoint +- **Ollama refused**: run `ollama serve`, check `ollama list` for the model +- **API errors**: verify the key is exported and has credits -### "LoadError: cannot load such file" +### `LoadError` for a specific gem -This means a required gem is not installed. Read the error message carefully - it will tell you which gem is missing. Install it using: +Each demo's header lists what it requires. Install the missing gem: ```bash -gem install +gem install pdf-reader # DocTool — PDF +gem install docx # DocTool — Word +gem install roo # DocTool — spreadsheets +gem install sqlite3 # DatabaseTool +gem install watir # BrowserTool ``` ### Browser automation fails -1. Make sure Chrome (or your preferred browser) is installed -2. Check that `webdrivers` gem is installed: `gem install webdrivers` -3. Try running with a visible browser (non-headless) for debugging +1. Ensure Chrome or Firefox is installed +2. Install `webdrivers`: `gem install webdrivers` ### Computer automation fails on macOS -1. Grant accessibility permissions: - - System Preferences → Security & Privacy → Privacy → Accessibility - - Add your Terminal app or IDE - - Restart the application after granting permissions +Grant accessibility permissions: **System Settings → Privacy & Security → Accessibility** → add your Terminal or IDE. -### PDF reading fails +### PDF reading returns empty text -1. Make sure `pdf-reader` gem is installed: `gem install pdf-reader` -2. Verify the PDF file exists at the specified path -3. Check that the PDF is not corrupted or password-protected +The PDF is likely image-based (scanned). OCR is not supported. Try a text-based PDF. + +--- + +## Action Constants Reference + +Tools define their action names as constants for use in direct (non-LLM) code: + +```ruby +SharedTools::Tools::BrowserTool::Action::VISIT +SharedTools::Tools::DiskTool::Action::FILE_CREATE +SharedTools::Tools::ComputerTool::Action::MOUSE_CLICK +SharedTools::Tools::EvalTool::Action::RUBY +SharedTools::Tools::DocTool::Action::PDF_READ +SharedTools::Tools::DocTool::Action::DOCX_READ +SharedTools::Tools::DocTool::Action::SPREADSHEET_READ +SharedTools::Tools::DocTool::Action::TEXT_READ +``` + +String literals work equally well in LLM tool calls and are shown throughout these demos. + +--- ## Further Reading - [SharedTools Documentation](../README.md) +- [Full Tool Reference](../docs/tools/index.md) - [RubyLLM Framework](https://github.com/mariochavez/ruby_llm) - [Tool Source Code](../lib/shared_tools/tools/) -## Contributing - -Have an interesting use case? Consider contributing an example! - -1. Create a new example file -2. Follow the existing pattern (descriptive comments, clear sections) -3. Make it runnable with minimal dependencies -4. Add it to this README - ## License All examples are released under the same MIT license as SharedTools. diff --git a/lib/shared_tools.rb b/lib/shared_tools.rb index 8335e0d..74a0692 100644 --- a/lib/shared_tools.rb +++ b/lib/shared_tools.rb @@ -23,7 +23,6 @@ # Ignore per-tool shim files (require-path shortcuts: require 'shared_tools/') Dir.glob("#{__dir__}/shared_tools/*_tool.rb").each { |f| SharedToolsLoader.ignore(f) } SharedToolsLoader.ignore("#{__dir__}/shared_tools/data_science_kit.rb") -SharedToolsLoader.ignore("#{__dir__}/shared_tools/devops_toolkit.rb") SharedToolsLoader.ignore("#{__dir__}/shared_tools/database.rb") SharedToolsLoader.setup diff --git a/lib/shared_tools/tools/devops_toolkit.rb b/lib/shared_tools/tools/devops_toolkit.rb deleted file mode 100644 index f53bc3b..0000000 --- a/lib/shared_tools/tools/devops_toolkit.rb +++ /dev/null @@ -1,420 +0,0 @@ -# devops_toolkit.rb - System administration and deployment tools -require 'ruby_llm/tool' -require 'securerandom' - -module SharedTools - module Tools - class DevopsToolkit < RubyLLM::Tool - def self.name = "devops_toolkit" - - description <<~'DESCRIPTION' - Comprehensive DevOps and system administration toolkit for managing application deployments, - monitoring system health, and performing operational tasks across different environments. - This tool provides secure, audited access to common DevOps operations including deployments, - rollbacks, health checks, log analysis, and metrics collection. It includes built-in safety - mechanisms for production environments, comprehensive logging for compliance, and support - for multiple deployment environments. All operations are logged and require appropriate - permissions and confirmations for sensitive environments. - - Safety features: - - Production operations require explicit confirmation - - All operations are logged with unique operation IDs - - Environment-specific restrictions and validations - - Rollback capabilities for failed deployments - - Health check integration before critical operations - - Supported environments: development, staging, production - DESCRIPTION - - params do - string :operation, description: <<~DESC.strip - Specific DevOps operation to perform: - - 'deploy': Deploy application code to the specified environment - - 'rollback': Revert to the previous stable deployment version - - 'health_check': Perform comprehensive health and status checks - - 'log_analysis': Analyze application and system logs for issues - - 'metric_collection': Gather and report system and application metrics - Each operation has specific requirements and safety checks. - DESC - - string :environment, description: <<~DESC.strip, required: false - Target environment for the DevOps operation: - - 'development': Local or shared development environment (minimal restrictions) - - 'staging': Pre-production environment for testing (moderate restrictions) - - 'production': Live production environment (maximum restrictions and confirmations) - Production operations require explicit confirmation via the 'production_confirmed' option. - Default: staging - DESC - - object :options, description: <<~DESC.strip, required: false do - Operation-specific options and parameters. Different operations use different option combinations. - Production operations always require 'production_confirmed: true' for safety. - DESC - # Production safety - boolean :production_confirmed, description: "Explicit confirmation for production operations. Must be true for production environment. Default: false", required: false - - # Deploy operation options - string :version, description: "Version identifier to deploy. Default: 'latest'", required: false - string :branch, description: "Git branch to deploy from. Default: 'main'", required: false - boolean :rollback_on_failure, description: "Automatically rollback if deployment fails. Default: true", required: false - array :notification_channels, of: :string, description: "Array of notification channels for deployment status. Default: []", required: false - - # Rollback operation options - string :target_version, description: "Specific version to rollback to. Default: 'previous'", required: false - boolean :rollback_confirmed, description: "Extra confirmation for production rollback. Default: false", required: false - - # Health check options - array :services_to_check, of: :string, description: "Array of service names to check. Default: ['web', 'api', 'database', 'cache']", required: false - integer :timeout_seconds, description: "Timeout for health check operations. Default: 30", required: false - - # Log analysis options - string :time_range, description: "Time range for log analysis: 'last_hour', 'last_day', 'last_week'. Default: 'last_hour'", required: false - string :log_level, description: "Minimum log level: 'debug', 'info', 'warning', 'error'. Default: 'error'", required: false - array :search_patterns, of: :string, description: "Array of regex patterns to search for in logs. Default: []", required: false - - # Metric collection options - array :metric_types, of: :string, description: "Array of metric types: 'cpu', 'memory', 'disk', 'network'. Default: all", required: false - string :time_window, description: "Time window for metrics: 'last_5_minutes', 'last_hour', 'last_day'. Default: 'last_5_minutes'", required: false - string :output_format, description: "Format for metric output: 'summary', 'detailed', 'json'. Default: 'summary'", required: false - end - end - - def initialize(logger: nil) - @logger = logger || RubyLLM.logger - @operation_log = [] - end - - def execute(operation:, environment: "staging", **options) - operation_id = SecureRandom.uuid - @logger.info("DevOpsToolkit#execute operation=#{operation} environment=#{environment} operation_id=#{operation_id}") - - # Validate environment - unless valid_environment?(environment) - return { - success: false, - error: "Invalid environment: #{environment}", - valid_environments: ["development", "staging", "production"] - } - end - - # Security: Require explicit production confirmation - if environment == "production" && !options[:production_confirmed] - @logger.warn("Production operation attempted without confirmation") - return { - success: false, - error: "Production operations require explicit confirmation", - required_option: "production_confirmed: true", - environment: environment - } - end - - # Log operation - log_operation(operation_id, operation, environment, options) - - # Execute operation - result = case operation - when "deploy" - perform_deployment(environment, options, operation_id) - when "rollback" - perform_rollback(environment, options, operation_id) - when "health_check" - perform_health_check(environment, options, operation_id) - when "log_analysis" - analyze_logs(environment, options, operation_id) - when "metric_collection" - collect_metrics(environment, options, operation_id) - else - { - success: false, - error: "Unknown operation: #{operation}", - valid_operations: ["deploy", "rollback", "health_check", "log_analysis", "metric_collection"] - } - end - - # Add operation_id to result - result[:operation_id] = operation_id - result - rescue => e - @logger.error("DevOps operation failed: #{e.message}") - { - success: false, - error: "DevOps operation failed: #{e.message}", - error_type: e.class.name, - operation_id: operation_id - } - end - - private - - # Validate environment - def valid_environment?(environment) - ["development", "staging", "production"].include?(environment) - end - - # Log operation for audit trail - def log_operation(operation_id, operation, environment, options) - log_entry = { - operation_id: operation_id, - operation: operation, - environment: environment, - options_summary: options.keys, - timestamp: Time.now.iso8601, - user: "system" # In production: actual user from auth context - } - - @operation_log << log_entry - @logger.info("DevOps operation logged: #{operation_id}") - end - - # Perform deployment - def perform_deployment(environment, options, operation_id) - @logger.info("Starting deployment to #{environment}") - - version = options[:version] || "latest" - branch = options[:branch] || "main" - # rollback_on_failure = options[:rollback_on_failure].nil? ? true : options[:rollback_on_failure] - - # Simulate deployment steps - deployment_steps = [ - {step: "pre_deployment_checks", status: "completed", duration: 0.5}, - {step: "backup_current_version", status: "completed", duration: 1.0}, - {step: "deploy_new_version", status: "completed", duration: 2.5}, - {step: "run_migrations", status: "completed", duration: 1.5}, - {step: "post_deployment_checks", status: "completed", duration: 1.0} - ] - - @logger.info("Deployment completed successfully") - - { - success: true, - operation: "deploy", - environment: environment, - deployment_id: SecureRandom.uuid, - version: version, - branch: branch, - deployed_at: Time.now.iso8601, - deployment_steps: deployment_steps, - rollback_available: true, - total_duration_seconds: deployment_steps.sum { |s| s[:duration] }, - details: "Deployment completed successfully to #{environment}" - } - end - - # Perform rollback - def perform_rollback(environment, options, operation_id) - @logger.info("Starting rollback in #{environment}") - - target_version = options[:target_version] || "previous" - - # Production rollbacks need extra confirmation - if environment == "production" && !options[:rollback_confirmed] - @logger.warn("Production rollback requires confirmation") - return { - success: false, - error: "Production rollback requires explicit confirmation", - required_option: "rollback_confirmed: true", - environment: environment - } - end - - rollback_steps = [ - {step: "validate_target_version", status: "completed", duration: 0.5}, - {step: "stop_current_services", status: "completed", duration: 1.0}, - {step: "restore_previous_version", status: "completed", duration: 2.0}, - {step: "restart_services", status: "completed", duration: 1.5}, - {step: "verify_rollback", status: "completed", duration: 1.0} - ] - - @logger.info("Rollback completed successfully") - - { - success: true, - operation: "rollback", - environment: environment, - rollback_id: SecureRandom.uuid, - target_version: target_version, - rolled_back_at: Time.now.iso8601, - rollback_steps: rollback_steps, - total_duration_seconds: rollback_steps.sum { |s| s[:duration] }, - details: "Successfully rolled back to #{target_version}" - } - end - - # Perform health check - def perform_health_check(environment, options, operation_id) - @logger.info("Performing health check for #{environment}") - - services_to_check = options[:services_to_check] || ["web", "api", "database", "cache"] - timeout_seconds = options[:timeout_seconds] || 30 - - # Simulate health checks - health_results = services_to_check.map do |service| - { - service: service, - status: "healthy", - response_time_ms: rand(50..200), - last_check: Time.now.iso8601, - details: "Service operational" - } - end - - all_healthy = health_results.all? { |r| r[:status] == "healthy" } - - @logger.info("Health check completed: #{all_healthy ? 'All services healthy' : 'Issues detected'}") - - { - success: true, - operation: "health_check", - environment: environment, - overall_status: all_healthy ? "healthy" : "degraded", - services_checked: services_to_check.length, - healthy_services: health_results.count { |r| r[:status] == "healthy" }, - health_results: health_results, - checked_at: Time.now.iso8601, - check_duration_seconds: timeout_seconds - } - end - - # Analyze logs - def analyze_logs(environment, options, operation_id) - @logger.info("Analyzing logs for #{environment}") - - time_range = options[:time_range] || "last_hour" - log_level = options[:log_level] || "error" - # search_patterns = options[:search_patterns] || [] - - # Simulate log analysis - log_entries_analyzed = 5000 - errors_found = rand(0..20) - warnings_found = rand(5..50) - - findings = [] - - if errors_found > 0 - findings << { - severity: "error", - count: errors_found, - pattern: "Exception in /api/users", - first_occurrence: (Time.now - 3600).iso8601, - last_occurrence: Time.now.iso8601 - } - end - - if warnings_found > 10 - findings << { - severity: "warning", - count: warnings_found, - pattern: "Slow query detected", - first_occurrence: (Time.now - 1800).iso8601, - last_occurrence: Time.now.iso8601 - } - end - - @logger.info("Log analysis completed: #{findings.length} issues found") - - { - success: true, - operation: "log_analysis", - environment: environment, - time_range: time_range, - log_level: log_level, - entries_analyzed: log_entries_analyzed, - errors_found: errors_found, - warnings_found: warnings_found, - findings: findings, - analyzed_at: Time.now.iso8601, - recommendations: generate_log_recommendations(findings) - } - end - - # Collect metrics - def collect_metrics(environment, options, operation_id) - @logger.info("Collecting metrics for #{environment}") - - metric_types = options[:metric_types] || ["cpu", "memory", "disk", "network"] - time_window = options[:time_window] || "last_5_minutes" - output_format = options[:output_format] || "summary" - - # Simulate metric collection - metrics = metric_types.map do |metric_type| - case metric_type - when "cpu" - { - type: "cpu", - current_usage_percent: rand(10..90), - average_usage_percent: rand(20..70), - peak_usage_percent: rand(50..100), - unit: "percent" - } - when "memory" - { - type: "memory", - current_usage_gb: rand(1.0..8.0).round(2), - total_gb: 16.0, - usage_percent: rand(20..80), - unit: "gigabytes" - } - when "disk" - { - type: "disk", - current_usage_gb: rand(10.0..100.0).round(2), - total_gb: 500.0, - usage_percent: rand(10..60), - unit: "gigabytes" - } - when "network" - { - type: "network", - ingress_mbps: rand(1.0..50.0).round(2), - egress_mbps: rand(1.0..50.0).round(2), - unit: "megabits_per_second" - } - else - { - type: metric_type, - status: "unknown", - message: "Metric type not implemented" - } - end - end - - @logger.info("Metrics collection completed: #{metrics.length} metrics") - - { - success: true, - operation: "metric_collection", - environment: environment, - time_window: time_window, - metrics_collected: metrics.length, - metrics: metrics, - collected_at: Time.now.iso8601, - output_format: output_format - } - end - - # Generate recommendations based on log findings - def generate_log_recommendations(findings) - recommendations = [] - - findings.each do |finding| - case finding[:severity] - when "error" - recommendations << "Investigate #{finding[:pattern]} - #{finding[:count]} occurrences" - when "warning" - if finding[:count] > 20 - recommendations << "High frequency of #{finding[:pattern]} - consider optimization" - end - end - end - - recommendations << "No critical issues found" if recommendations.empty? - recommendations - end - - # Accessor for operation log (for testing) - def operation_log - @operation_log - end - end - end -end diff --git a/test/shared_tools/tools/devops_toolkit_test.rb b/test/shared_tools/tools/devops_toolkit_test.rb deleted file mode 100644 index 1b5c335..0000000 --- a/test/shared_tools/tools/devops_toolkit_test.rb +++ /dev/null @@ -1,472 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class DevopsToolkitTest < Minitest::Test - def setup - @tool = SharedTools::Tools::DevopsToolkit.new - end - - def test_tool_name - assert_equal 'devops_toolkit', SharedTools::Tools::DevopsToolkit.name - end - - def test_inherits_from_ruby_llm_tool - assert_kind_of ::RubyLLM::Tool, @tool - end - - # Deploy operation tests - def test_deploy_to_development - result = @tool.execute( - operation: "deploy", - environment: "development", - version: "1.0.0" - ) - - assert result[:success] - assert_equal "deploy", result[:operation] - assert_equal "development", result[:environment] - assert result[:deployment_id] - assert result[:deployed_at] - assert result[:deployment_steps] - assert result[:rollback_available] - end - - def test_deploy_to_staging - result = @tool.execute( - operation: "deploy", - environment: "staging", - version: "2.0.0", branch: "main" - ) - - assert result[:success] - assert_equal "staging", result[:environment] - assert_equal "2.0.0", result[:version] - assert_equal "main", result[:branch] - end - - def test_deploy_to_production_requires_confirmation - result = @tool.execute( - operation: "deploy", - environment: "production", - version: "1.0.0" - ) - - refute result[:success] - assert_includes result[:error], "explicit confirmation" - assert_equal "production_confirmed: true", result[:required_option] - end - - def test_deploy_to_production_with_confirmation - result = @tool.execute( - operation: "deploy", - environment: "production", - version: "1.0.0", production_confirmed: true - ) - - assert result[:success] - assert_equal "production", result[:environment] - end - - def test_deploy_includes_deployment_steps - result = @tool.execute( - operation: "deploy", - environment: "development" - ) - - assert result[:deployment_steps] - assert result[:deployment_steps].is_a?(Array) - assert result[:deployment_steps].length > 0 - - step = result[:deployment_steps].first - assert step[:step] - assert step[:status] - assert step[:duration] - end - - def test_deploy_default_version_is_latest - result = @tool.execute( - operation: "deploy", - environment: "development" - ) - - assert result[:success] - assert_equal "latest", result[:version] - end - - # Rollback operation tests - def test_rollback_to_development - result = @tool.execute( - operation: "rollback", - environment: "development", - target_version: "1.0.0" - ) - - assert result[:success] - assert_equal "rollback", result[:operation] - assert_equal "development", result[:environment] - assert result[:rollback_id] - assert result[:rolled_back_at] - assert result[:rollback_steps] - end - - def test_rollback_to_staging - result = @tool.execute( - operation: "rollback", - environment: "staging" - ) - - assert result[:success] - assert_equal "staging", result[:environment] - assert_equal "previous", result[:target_version] - end - - def test_rollback_to_production_requires_confirmation - result = @tool.execute( - operation: "rollback", - environment: "production", - production_confirmed: true - ) - - refute result[:success] - assert_includes result[:error], "rollback requires" - assert_equal "rollback_confirmed: true", result[:required_option] - end - - def test_rollback_to_production_with_both_confirmations - result = @tool.execute( - operation: "rollback", - environment: "production", - production_confirmed: true, rollback_confirmed: true - ) - - assert result[:success] - assert_equal "production", result[:environment] - end - - # Health check operation tests - def test_health_check_development - result = @tool.execute( - operation: "health_check", - environment: "development" - ) - - assert result[:success] - assert_equal "health_check", result[:operation] - assert result[:overall_status] - assert result[:services_checked] - assert result[:healthy_services] - assert result[:health_results] - assert result[:checked_at] - end - - def test_health_check_with_custom_services - result = @tool.execute( - operation: "health_check", - environment: "staging", - services_to_check: ["web", "database"] - ) - - assert result[:success] - assert_equal 2, result[:services_checked] - assert_equal 2, result[:health_results].length - end - - def test_health_check_default_services - result = @tool.execute( - operation: "health_check", - environment: "development" - ) - - assert result[:success] - assert_equal 4, result[:services_checked] # web, api, database, cache - end - - def test_health_check_includes_service_details - result = @tool.execute( - operation: "health_check", - environment: "development" - ) - - assert result[:success] - service = result[:health_results].first - - assert service[:service] - assert service[:status] - assert service[:response_time_ms] - assert service[:last_check] - end - - # Log analysis operation tests - def test_log_analysis_development - result = @tool.execute( - operation: "log_analysis", - environment: "development" - ) - - assert result[:success] - assert_equal "log_analysis", result[:operation] - assert result[:entries_analyzed] - assert result[:errors_found] - assert result[:warnings_found] - assert result[:findings] - assert result[:analyzed_at] - assert result[:recommendations] - end - - def test_log_analysis_with_custom_options - result = @tool.execute( - operation: "log_analysis", - environment: "staging", - time_range: "last_24_hours", - log_level: "warning", - search_patterns: ["error", "exception"] - ) - - assert result[:success] - assert_equal "last_24_hours", result[:time_range] - assert_equal "warning", result[:log_level] - end - - def test_log_analysis_includes_recommendations - result = @tool.execute( - operation: "log_analysis", - environment: "development" - ) - - assert result[:success] - assert result[:recommendations] - assert result[:recommendations].is_a?(Array) - assert result[:recommendations].length > 0 - end - - # Metric collection operation tests - def test_metric_collection_development - result = @tool.execute( - operation: "metric_collection", - environment: "development" - ) - - assert result[:success] - assert_equal "metric_collection", result[:operation] - assert result[:metrics_collected] - assert result[:metrics] - assert result[:collected_at] - end - - def test_metric_collection_with_custom_metrics - result = @tool.execute( - operation: "metric_collection", - environment: "staging", - metric_types: ["cpu", "memory"] - ) - - assert result[:success] - assert_equal 2, result[:metrics_collected] - assert_equal 2, result[:metrics].length - end - - def test_metric_collection_default_metrics - result = @tool.execute( - operation: "metric_collection", - environment: "development" - ) - - assert result[:success] - assert_equal 4, result[:metrics_collected] # cpu, memory, disk, network - end - - def test_metric_collection_includes_metric_details - result = @tool.execute( - operation: "metric_collection", - environment: "development" - ) - - assert result[:success] - cpu_metric = result[:metrics].find { |m| m[:type] == "cpu" } - - assert cpu_metric - assert cpu_metric[:current_usage_percent] - assert cpu_metric[:average_usage_percent] - assert cpu_metric[:unit] - end - - # Environment validation tests - def test_invalid_environment - result = @tool.execute( - operation: "deploy", - environment: "invalid_env" - ) - - refute result[:success] - assert_includes result[:error], "Invalid environment" - assert result[:valid_environments] - end - - def test_valid_environments - ["development", "staging", "production"].each do |env| - params = { - operation: "health_check", - environment: env - } - params[:production_confirmed] = true if env == "production" - - result = @tool.execute(**params) - - assert result[:success], "#{env} should be valid" - end - end - - # Operation validation tests - def test_invalid_operation - result = @tool.execute( - operation: "invalid_operation", - environment: "development" - ) - - refute result[:success] - assert_includes result[:error], "Unknown operation" - assert result[:valid_operations] - end - - def test_all_valid_operations - operations = ["deploy", "rollback", "health_check", "log_analysis", "metric_collection"] - - operations.each do |op| - result = @tool.execute( - operation: op, - environment: "development" - ) - - assert result[:success], "#{op} should be a valid operation" - end - end - - # Operation logging tests - def test_operation_is_logged - result = @tool.execute( - operation: "health_check", - environment: "development" - ) - - assert result[:success] - assert result[:operation_id] - - # Verify operation appears in log - operation_log = @tool.send(:operation_log) - log_entry = operation_log.find { |entry| entry[:operation_id] == result[:operation_id] } - - assert log_entry - assert_equal "health_check", log_entry[:operation] - assert_equal "development", log_entry[:environment] - end - - def test_each_operation_has_unique_id - result1 = @tool.execute(operation: "health_check", environment: "development") - result2 = @tool.execute(operation: "health_check", environment: "development") - - assert result1[:operation_id] - assert result2[:operation_id] - refute_equal result1[:operation_id], result2[:operation_id] - end - - # Default values tests - def test_default_environment_is_staging - result = @tool.execute(operation: "health_check") - - assert result[:success] - assert_equal "staging", result[:environment] - end - - def test_operations_accept_empty_options - result = @tool.execute( - operation: "health_check", - environment: "development", - options: {} - ) - - assert result[:success] - end - - # Duration and timing tests - def test_deployment_includes_total_duration - result = @tool.execute( - operation: "deploy", - environment: "development" - ) - - assert result[:success] - assert result[:total_duration_seconds] - assert result[:total_duration_seconds] > 0 - end - - def test_rollback_includes_total_duration - result = @tool.execute( - operation: "rollback", - environment: "development" - ) - - assert result[:success] - assert result[:total_duration_seconds] - assert result[:total_duration_seconds] > 0 - end - - # Timestamp tests - def test_operations_include_timestamps - result = @tool.execute( - operation: "health_check", - environment: "development" - ) - - assert result[:success] - assert result[:checked_at] - assert_match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/, result[:checked_at]) - end - - # Production safety tests - def test_production_operations_blocked_without_confirmation - operations = ["deploy", "health_check", "log_analysis", "metric_collection"] - - operations.each do |op| - result = @tool.execute( - operation: op, - environment: "production" - ) - - refute result[:success], "#{op} should require confirmation in production" - assert_includes result[:error], "confirmation" - end - end - - def test_production_operations_succeed_with_confirmation - operations = ["deploy", "health_check", "log_analysis", "metric_collection"] - - operations.each do |op| - result = @tool.execute( - operation: op, - environment: "production", - production_confirmed: true - ) - - # Rollback needs additional confirmation - if op == "rollback" - refute result[:success] - else - assert result[:success], "#{op} should succeed with confirmation" - end - end - end - - # Error handling tests - def test_handles_errors_gracefully - # This would test error handling, but since our tool doesn't raise errors - # in normal operation, we verify it returns success/failure properly - result = @tool.execute( - operation: "invalid", - environment: "development" - ) - - refute result[:success] - assert result[:error] - end -end From d7647ed058728acb4cecfff4b5087175c8e0a008 Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Thu, 26 Mar 2026 00:09:27 -0500 Subject: [PATCH 04/17] feat(screenshot): add ability to capture web page screenshots This commit introduces a new tool called `PageScreenshotTool` that allows capturing screenshots of web pages. The tool uses a `MockDriver` class to simulate the screenshot functionality, which is then returned as a base64-encoded string. Additionally, this commit makes the following changes: - Adds the `minitest-mock` gem as a development dependency to provide stubbing and mocking support for Minitest 6, as it was removed from the core library. - Removes the `DevopsToolkit` module, as it was providing simulated functionality without any real system interaction. - Adds a `load_all_tools` method to the `SharedTools` module to eagerly load all tool classes into `ObjectSpace`. This is necessary for the `AIA::GemActivator` to properly detect and load the available tools. - Adds a `tools` method to the `SharedTools` module to return all loaded `RubyLLM::Tool` subclasses provided by the gem. BREAKING CHANGE: The `DevopsToolkit` module has been removed entirely, and any references or usage of it should be updated accordingly. --- Gemfile | 3 +- docs/development/changelog.md | 3 + lib/shared_tools.rb | 15 +++ shared_tools.gemspec | 1 + .../browser/page_screenshot_tool_test.rb | 39 +++--- test/shared_tools/tools/browser_tool_test.rb | 7 +- .../shared_tools/tools/clipboard_tool_test.rb | 54 ++++++++ test/shared_tools/tools/cron_tool_test.rb | 117 ++++++++++++++++++ .../tools/current_date_time_tool_test.rb | 113 +++++++++++++++++ test/shared_tools/tools/dns_tool_test.rb | 98 +++++++++++++++ .../tools/error_handling_tool_test.rb | 40 +++--- .../tools/system_info_tool_test.rb | 117 ++++++++++++++++++ .../tools/workflow_manager_tool_test.rb | 63 ++++++++++ test/shared_tools_test.rb | 35 ++++++ test/test_helper.rb | 1 + 15 files changed, 661 insertions(+), 45 deletions(-) create mode 100644 test/shared_tools/tools/clipboard_tool_test.rb create mode 100644 test/shared_tools/tools/cron_tool_test.rb create mode 100644 test/shared_tools/tools/current_date_time_tool_test.rb create mode 100644 test/shared_tools/tools/dns_tool_test.rb create mode 100644 test/shared_tools/tools/system_info_tool_test.rb diff --git a/Gemfile b/Gemfile index 069910e..7a643b4 100644 --- a/Gemfile +++ b/Gemfile @@ -6,4 +6,5 @@ source "https://rubygems.org" gemspec # Development dependencies -gem "simplecov", require: false +gem "simplecov", require: false +gem "minitest-mock", require: false # stub/mock support for Minitest 6 (removed from core) diff --git a/docs/development/changelog.md b/docs/development/changelog.md index cb08cdf..e8e3312 100644 --- a/docs/development/changelog.md +++ b/docs/development/changelog.md @@ -25,6 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Changing direction. Beginning with 0.3.0 will only support RubyLLM framework - `WeatherTool` demo updated to include a local forecast section combining `DnsTool` (external IP → geolocation) and `CurrentDateTimeTool` (real day of week) with `WeatherTool`. +### Removed +- `DevopsToolkit` — removed entirely. All operations were simulated and provided no real system interaction. The tool, its loader shim, its test file, and all demo/documentation references have been deleted. + ### Deprecated - Support for OmniAI framework (use RubyLLM instead) - Support for llm.rb framework (use RubyLLM instead) diff --git a/lib/shared_tools.rb b/lib/shared_tools.rb index 74a0692..fbd97b1 100644 --- a/lib/shared_tools.rb +++ b/lib/shared_tools.rb @@ -48,5 +48,20 @@ def execute?(tool: 'unknown', stuff: '') print "\nIs it okay to proceed? (y/N" STDIN.getch == "y" end + + # Force-load all tool classes into ObjectSpace. + # Called by AIA's GemActivator.trigger_tool_loading when shared_tools is + # passed to --require. Without this, Zeitwerk lazy-loads classes on first + # reference, so no RubyLLM::Tool subclasses appear in ObjectSpace at scan time. + def load_all_tools + SharedToolsLoader.eager_load + end + + # Return all loaded RubyLLM::Tool subclasses provided by this gem. + # Also triggers eager loading so the list is complete. + def tools + load_all_tools + ObjectSpace.each_object(Class).select { |k| k < ::RubyLLM::Tool }.to_a + end end end diff --git a/shared_tools.gemspec b/shared_tools.gemspec index c268ec5..4a92ba0 100644 --- a/shared_tools.gemspec +++ b/shared_tools.gemspec @@ -54,5 +54,6 @@ Gem::Specification.new do |spec| spec.add_development_dependency "bundler" spec.add_development_dependency "debug_me" spec.add_development_dependency "minitest" + spec.add_development_dependency "minitest-mock" # stub/mock support (removed from minitest 6 core) spec.add_development_dependency "rake" end diff --git a/test/shared_tools/tools/browser/page_screenshot_tool_test.rb b/test/shared_tools/tools/browser/page_screenshot_tool_test.rb index b7703c3..139348a 100644 --- a/test/shared_tools/tools/browser/page_screenshot_tool_test.rb +++ b/test/shared_tools/tools/browser/page_screenshot_tool_test.rb @@ -2,18 +2,9 @@ require "test_helper" require "tempfile" -require "base64" +require "tmpdir" class PageScreenshotToolTest < Minitest::Test - def nokogiri_available? - begin - require 'nokogiri' - true - rescue LoadError - false - end - end - class MockDriver def screenshot # Create a minimal PNG file (1x1 transparent pixel) @@ -41,22 +32,26 @@ def test_inherits_from_ruby_llm_tool assert_kind_of ::RubyLLM::Tool, @tool end - def test_returns_base64_encoded_screenshot - skip "Nokogiri gem not installed" unless nokogiri_available? - + def test_returns_saved_path_hash result = @tool.execute - assert_kind_of String, result - assert_match /^data:image\/png;base64,/, result + assert_kind_of Hash, result + assert_equal :ok, result[:status] + assert result[:saved_to] + assert_match(/\.png$/, result[:saved_to]) end - def test_screenshot_is_valid_base64 - skip "Nokogiri gem not installed" unless nokogiri_available? - + def test_screenshot_file_exists_after_capture result = @tool.execute - base64_data = result.sub(/^data:image\/png;base64,/, '') + assert File.exist?(result[:saved_to]) + ensure + File.delete(result[:saved_to]) if result && result[:saved_to] && File.exist?(result[:saved_to]) + end - # Should successfully decode without raising an error - decoded = Base64.strict_decode64(base64_data) - assert decoded.length > 0 + def test_custom_path_is_used + custom_path = File.join(Dir.tmpdir, "custom_test_#{Time.now.to_i}.png") + result = @tool.execute(path: custom_path) + assert_equal File.expand_path(custom_path), result[:saved_to] + ensure + File.delete(custom_path) if File.exist?(custom_path) end end diff --git a/test/shared_tools/tools/browser_tool_test.rb b/test/shared_tools/tools/browser_tool_test.rb index 1da5d36..b565a7d 100644 --- a/test/shared_tools/tools/browser_tool_test.rb +++ b/test/shared_tools/tools/browser_tool_test.rb @@ -133,8 +133,11 @@ def test_text_field_set_action def test_screenshot_action result = @tool.execute(action: SharedTools::Tools::BrowserTool::Action::SCREENSHOT) - assert_kind_of String, result - assert_match /^data:image\/png;base64,/, result + assert_kind_of Hash, result + assert_equal :ok, result[:status] + assert result[:saved_to] + ensure + File.delete(result[:saved_to]) if result.is_a?(Hash) && result[:saved_to] && File.exist?(result[:saved_to]) end def test_cleanup_closes_driver diff --git a/test/shared_tools/tools/clipboard_tool_test.rb b/test/shared_tools/tools/clipboard_tool_test.rb new file mode 100644 index 0000000..ea7bc93 --- /dev/null +++ b/test/shared_tools/tools/clipboard_tool_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "test_helper" + +class ClipboardToolTest < Minitest::Test + def setup + @tool = SharedTools::Tools::ClipboardTool.new + end + + def test_tool_name + assert_equal 'clipboard_tool', SharedTools::Tools::ClipboardTool.name + end + + def test_inherits_from_ruby_llm_tool + assert_kind_of ::RubyLLM::Tool, @tool + end + + def test_write_then_read_roundtrip + text = "clipboard_test_#{Time.now.to_i}" + write_result = @tool.execute(action: 'write', text: text) + assert write_result[:success], "write failed: #{write_result.inspect}" + + read_result = @tool.execute(action: 'read') + assert read_result[:success] + assert_equal text, read_result[:content] + rescue => e + skip "Clipboard not available in this environment: #{e.message}" + end + + def test_clear_empties_clipboard + @tool.execute(action: 'write', text: 'something') + clear_result = @tool.execute(action: 'clear') + assert clear_result[:success] + + read_result = @tool.execute(action: 'read') + assert_equal '', read_result[:content].to_s.strip + rescue => e + skip "Clipboard not available in this environment: #{e.message}" + end + + def test_unknown_action_returns_error + result = @tool.execute(action: 'explode') + refute result[:success] + assert result[:error] + assert_includes result[:error], 'Unknown action' + end + + def test_write_without_text_returns_error + result = @tool.execute(action: 'write', text: nil) + refute result[:success] + rescue => e + skip "Clipboard not available in this environment: #{e.message}" + end +end diff --git a/test/shared_tools/tools/cron_tool_test.rb b/test/shared_tools/tools/cron_tool_test.rb new file mode 100644 index 0000000..1570a08 --- /dev/null +++ b/test/shared_tools/tools/cron_tool_test.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "test_helper" + +class CronToolTest < Minitest::Test + def setup + @tool = SharedTools::Tools::CronTool.new + end + + def test_tool_name + assert_equal 'cron_tool', SharedTools::Tools::CronTool.name + end + + def test_inherits_from_ruby_llm_tool + assert_kind_of ::RubyLLM::Tool, @tool + end + + # parse action + def test_parse_valid_expression + result = @tool.execute(action: 'parse', expression: '0 9 * * 1-5') + assert result[:success] + assert result[:expression] + assert result[:explanation] + end + + def test_parse_every_minute + result = @tool.execute(action: 'parse', expression: '* * * * *') + assert result[:success] + assert_includes result[:explanation].downcase, 'minute' + end + + def test_parse_missing_expression_returns_error + result = @tool.execute(action: 'parse', expression: nil) + refute result[:success] + assert result[:error] + end + + def test_parse_invalid_expression_returns_error + result = @tool.execute(action: 'parse', expression: 'not a cron') + refute result[:success] + assert result[:error] + end + + # validate action + def test_validate_valid_expression + result = @tool.execute(action: 'validate', expression: '*/15 * * * *') + assert result[:success] + assert result[:valid] + assert_equal '*/15 * * * *', result[:expression] + end + + def test_validate_invalid_expression + result = @tool.execute(action: 'validate', expression: '99 99 99 99 99') + assert result[:success] # validate always returns success:true; valid: is the flag + refute result[:valid] + assert result[:error] # singular :error, not :errors + end + + def test_validate_missing_expression_returns_error + # nil expression: require_expr! raises, rescued inside validate_expression + result = @tool.execute(action: 'validate', expression: nil) + # validate rescues to {success: true, valid: false} when expression is missing + refute result[:valid] + end + + # next_times action + def test_next_times_returns_list + result = @tool.execute(action: 'next_times', expression: '0 * * * *', count: 3) + assert result[:success] + assert result[:next_times] + assert_equal 3, result[:next_times].length + end + + def test_next_times_default_count_is_5 + result = @tool.execute(action: 'next_times', expression: '0 * * * *') + assert result[:success] + assert_equal 5, result[:next_times].length + end + + def test_next_times_are_in_future + result = @tool.execute(action: 'next_times', expression: '0 * * * *', count: 2) + assert result[:success] + result[:next_times].each do |t| + assert Time.parse(t) > Time.now, "Expected #{t} to be in the future" + end + end + + def test_next_times_are_sorted_ascending + result = @tool.execute(action: 'next_times', expression: '*/30 * * * *', count: 4) + assert result[:success] + times = result[:next_times].map { |t| Time.parse(t) } + assert_equal times.sort, times + end + + # generate action + def test_generate_returns_expression + result = @tool.execute(action: 'generate', description: 'every day at 9am') + assert result[:success] + assert result[:expression] + # Should look like a valid cron expression (5 fields) + assert_match(/\A\S+ \S+ \S+ \S+ \S+\z/, result[:expression]) + end + + def test_generate_missing_description_returns_error + result = @tool.execute(action: 'generate', description: nil) + refute result[:success] + assert result[:error] + end + + # unknown action + def test_unknown_action_returns_error + result = @tool.execute(action: 'explode', expression: '* * * * *') + refute result[:success] + assert result[:error] + assert_includes result[:error], 'Unknown action' + end +end diff --git a/test/shared_tools/tools/current_date_time_tool_test.rb b/test/shared_tools/tools/current_date_time_tool_test.rb new file mode 100644 index 0000000..8e78bb1 --- /dev/null +++ b/test/shared_tools/tools/current_date_time_tool_test.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "test_helper" + +class CurrentDateTimeToolTest < Minitest::Test + def setup + @tool = SharedTools::Tools::CurrentDateTimeTool.new + end + + def test_tool_name + assert_equal 'current_date_time_tool', SharedTools::Tools::CurrentDateTimeTool.name + end + + def test_inherits_from_ruby_llm_tool + assert_kind_of ::RubyLLM::Tool, @tool + end + + def test_full_format_includes_all_sections + result = @tool.execute(format: 'full') + + assert result[:success] + # date fields + assert result[:year] + assert result[:month] + assert result[:day] + assert result[:day_of_week] + # time fields + assert result.key?(:hour) + assert result.key?(:minute) + assert result.key?(:second) + # iso fields + assert result[:iso8601] + assert result[:unix_timestamp] + end + + def test_date_format_returns_date_fields + result = @tool.execute(format: 'date') + + assert result[:success] + assert result[:year] + assert result[:month] + assert result[:day] + assert result[:day_of_week] + assert result[:quarter] + assert result[:ordinal_day] + refute result.key?(:hour) + end + + def test_time_format_returns_time_fields + result = @tool.execute(format: 'time') + + assert result[:success] + assert result.key?(:hour) + assert result.key?(:minute) + assert result.key?(:second) + assert result[:timezone] + refute result.key?(:year) + end + + def test_iso8601_format_returns_iso_fields + result = @tool.execute(format: 'iso8601') + + assert result[:success] + assert result[:iso8601] + assert result[:iso8601_utc] + assert result[:unix_timestamp] + refute result.key?(:year) + end + + def test_default_format_is_full + result = @tool.execute + + assert result[:success] + assert result[:year] + assert result.key?(:hour) + assert result[:iso8601] + end + + def test_unknown_format_falls_back_to_full + result = @tool.execute(format: 'bogus') + + assert result[:success] + assert result[:year] + assert result[:iso8601] + end + + def test_year_is_current_year + result = @tool.execute(format: 'date') + assert_equal Time.now.year, result[:year] + end + + def test_day_of_week_is_a_string + result = @tool.execute(format: 'date') + assert_kind_of String, result[:day_of_week] + assert_includes %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday], result[:day_of_week] + end + + def test_iso8601_format_is_valid + result = @tool.execute(format: 'iso8601') + assert_match(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/, result[:iso8601]) + end + + def test_unix_timestamp_is_positive_integer + result = @tool.execute(format: 'iso8601') + assert_kind_of Integer, result[:unix_timestamp] + assert result[:unix_timestamp] > 0 + end + + def test_quarter_is_between_1_and_4 + result = @tool.execute(format: 'date') + assert_includes 1..4, result[:quarter] + end +end diff --git a/test/shared_tools/tools/dns_tool_test.rb b/test/shared_tools/tools/dns_tool_test.rb new file mode 100644 index 0000000..44794a1 --- /dev/null +++ b/test/shared_tools/tools/dns_tool_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "test_helper" + +class DnsToolTest < Minitest::Test + def setup + @tool = SharedTools::Tools::DnsTool.new + end + + def test_tool_name + assert_equal 'dns_tool', SharedTools::Tools::DnsTool.name + end + + def test_inherits_from_ruby_llm_tool + assert_kind_of ::RubyLLM::Tool, @tool + end + + # A record lookup — use localhost which always resolves + def test_a_record_lookup_localhost + result = @tool.execute(action: 'a', host: 'localhost') + assert result[:success] + assert result[:records] + assert_kind_of Array, result[:records] + # localhost always resolves to 127.0.0.1 + assert result[:records].include?('127.0.0.1') + end + + def test_a_record_returns_host_and_type + result = @tool.execute(action: 'a', host: 'localhost') + assert result[:success] + assert_equal 'localhost', result[:host] + assert_equal 'A', result[:type] + end + + # Reverse lookup — 127.0.0.1 may not have a PTR record in all environments + def test_reverse_lookup_loopback + result = @tool.execute(action: 'reverse', host: '127.0.0.1') + # Result is always a Hash with a :success key, whether PTR exists or not + assert result.key?(:success) + assert_equal 'PTR', result[:type] + if result[:success] + assert result[:hostname] + else + assert result[:error] + end + end + + # external_ip action + def test_external_ip_returns_success_or_graceful_error + result = @tool.execute(action: 'external_ip') + # May fail if no internet — just check it returns a well-formed response + assert result.key?(:success) + if result[:success] + assert result[:ip] + assert_match(/\A\d+\.\d+\.\d+\.\d+\z/, result[:ip]) + else + assert result[:error] + end + end + + # ip_location action + def test_ip_location_returns_success_or_graceful_error + result = @tool.execute(action: 'ip_location', host: '8.8.8.8') + assert result.key?(:success) + if result[:success] + assert result[:ip] + assert result[:country] + else + assert result[:error] + end + end + + # Missing host returns error for host-required actions + def test_a_record_missing_host_returns_error + result = @tool.execute(action: 'a', host: nil) + refute result[:success] + assert result[:error] + end + + def test_mx_missing_host_returns_error + result = @tool.execute(action: 'mx', host: nil) + refute result[:success] + assert result[:error] + end + + # Unknown action + def test_unknown_action_returns_error + result = @tool.execute(action: 'bogus', host: 'localhost') + refute result[:success] + assert result[:error] + end + + # Response shape + def test_result_always_has_success_key + result = @tool.execute(action: 'a', host: 'localhost') + assert result.key?(:success) + end +end diff --git a/test/shared_tools/tools/error_handling_tool_test.rb b/test/shared_tools/tools/error_handling_tool_test.rb index 3d6dc9c..f8beac7 100644 --- a/test/shared_tools/tools/error_handling_tool_test.rb +++ b/test/shared_tools/tools/error_handling_tool_test.rb @@ -19,7 +19,7 @@ def test_inherits_from_ruby_llm_tool def test_validate_operation_success result = @tool.execute( operation: "validate", - name: "test", value: 42 + data: {name: "test", value: 42} ) assert result[:success] @@ -32,7 +32,7 @@ def test_validate_operation_success def test_process_operation_success result = @tool.execute( operation: "process", - name: "test", value: 10 + data: {name: "test", value: 10} ) assert result[:success] @@ -82,7 +82,7 @@ def test_validate_missing_required_fields def test_validate_invalid_value_type result = @tool.execute( operation: "validate", - name: "test", value: "not_a_number" + data: {name: "test", value: "not_a_number"} ) refute result[:success] @@ -103,7 +103,7 @@ def test_process_requires_data def test_max_retries_validation result = @tool.execute( operation: "validate", - name: "test", value: 1, + data: {name: "test", value: 1}, max_retries: 15 ) @@ -115,7 +115,7 @@ def test_max_retries_validation def test_negative_max_retries result = @tool.execute( operation: "validate", - name: "test", value: 1, + data: {name: "test", value: 1}, max_retries: -1 ) @@ -127,7 +127,7 @@ def test_negative_max_retries def test_simulated_validation_error result = @tool.execute( operation: "validate", - name: "test", value: 1, + data: {name: "test", value: 1}, simulate_error: "validation" ) @@ -140,7 +140,7 @@ def test_simulated_validation_error def test_simulated_network_error result = @tool.execute( operation: "process", - name: "test", value: 1, + data: {name: "test", value: 1}, simulate_error: "network" ) @@ -166,7 +166,7 @@ def test_simulated_authorization_error def test_simulated_resource_not_found_error result = @tool.execute( operation: "validate", - name: "test", value: 1, + data: {name: "test", value: 1}, simulate_error: "resource_not_found" ) @@ -179,7 +179,7 @@ def test_simulated_resource_not_found_error def test_retryable_error_with_success_after_retries result = @tool.execute( operation: "process", - name: "test", value: 1, + data: {name: "test", value: 1}, simulate_error: "retryable", max_retries: 3 ) @@ -192,7 +192,7 @@ def test_retryable_error_with_success_after_retries def test_retryable_error_max_retries_exceeded result = @tool.execute( operation: "process", - name: "test", value: 1, + data: {name: "test", value: 1}, simulate_error: "retryable", max_retries: 1 ) @@ -206,7 +206,7 @@ def test_retryable_error_max_retries_exceeded def test_zero_retries_disabled result = @tool.execute( operation: "process", - name: "test", value: 1, + data: {name: "test", value: 1}, simulate_error: "retryable", max_retries: 0 ) @@ -219,7 +219,7 @@ def test_zero_retries_disabled def test_metadata_included_in_success result = @tool.execute( operation: "validate", - name: "test", value: 1 + data: {name: "test", value: 1} ) assert result[:success] @@ -233,7 +233,7 @@ def test_metadata_included_in_success def test_execution_time_tracked result = @tool.execute( operation: "process", - name: "test", value: 1 + data: {name: "test", value: 1} ) assert result[:success] @@ -243,7 +243,7 @@ def test_execution_time_tracked def test_resources_allocated_tracked result = @tool.execute( operation: "validate", - name: "test", value: 1 + data: {name: "test", value: 1} ) assert result[:success] @@ -265,7 +265,7 @@ def test_support_reference_in_validation_error def test_support_reference_in_network_error result = @tool.execute( operation: "process", - name: "test", value: 1, + data: {name: "test", value: 1}, simulate_error: "network" ) @@ -288,7 +288,7 @@ def test_support_reference_in_authorization_error def test_validate_with_warnings result = @tool.execute( operation: "validate", - name: "x", value: -5 + data: {name: "x", value: -5} ) assert result[:success] @@ -300,7 +300,7 @@ def test_validate_with_warnings def test_validate_minimal_valid_data result = @tool.execute( operation: "validate", - name: "ab", value: 0 + data: {name: "ab", value: 0} ) assert result[:success] @@ -311,7 +311,7 @@ def test_validate_minimal_valid_data def test_empty_simulate_error_works_normally result = @tool.execute( operation: "validate", - name: "test", value: 1, + data: {name: "test", value: 1}, simulate_error: "" ) @@ -321,7 +321,7 @@ def test_empty_simulate_error_works_normally def test_nil_simulate_error_works_normally result = @tool.execute( operation: "validate", - name: "test", value: 1, + data: {name: "test", value: 1}, simulate_error: nil ) @@ -331,7 +331,7 @@ def test_nil_simulate_error_works_normally def test_process_with_decimal_value result = @tool.execute( operation: "process", - name: "test", value: 10.5 + data: {name: "test", value: 10.5} ) assert result[:success] diff --git a/test/shared_tools/tools/system_info_tool_test.rb b/test/shared_tools/tools/system_info_tool_test.rb new file mode 100644 index 0000000..70e0575 --- /dev/null +++ b/test/shared_tools/tools/system_info_tool_test.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "test_helper" + +class SystemInfoToolTest < Minitest::Test + def setup + @tool = SharedTools::Tools::SystemInfoTool.new + end + + def test_tool_name + assert_equal 'system_info_tool', SharedTools::Tools::SystemInfoTool.name + end + + def test_inherits_from_ruby_llm_tool + assert_kind_of ::RubyLLM::Tool, @tool + end + + # 'all' category (default) + def test_default_returns_all_categories + result = @tool.execute + assert result[:success] + # Each sub-info merges flat keys; verify at least one from each group + assert result.key?(:os_platform) + assert result.key?(:cpu_model) + assert result.key?(:memory_total_gb) + assert result.key?(:ruby_version) + end + + def test_category_all_explicit + result = @tool.execute(category: 'all') + assert result[:success] + assert result.key?(:os_platform) + assert result.key?(:cpu_model) + end + + # Individual categories + def test_category_os + result = @tool.execute(category: 'os') + assert result[:success] + assert result.key?(:os_platform) + assert result.key?(:hostname) + refute result.key?(:cpu_model) + end + + def test_category_cpu + result = @tool.execute(category: 'cpu') + assert result[:success] + assert result.key?(:cpu_model) + assert result.key?(:cpu_cores) + refute result.key?(:os_platform) + end + + def test_category_memory + result = @tool.execute(category: 'memory') + assert result[:success] + assert result.key?(:memory_total_gb) + assert result.key?(:memory_available_gb) + refute result.key?(:os_platform) + end + + def test_category_disk + result = @tool.execute(category: 'disk') + assert result[:success] + assert result.key?(:disks) + assert_kind_of Array, result[:disks] + refute result.key?(:os_platform) + end + + def test_category_ruby + result = @tool.execute(category: 'ruby') + assert result[:success] + assert result.key?(:ruby_version) + assert result.key?(:ruby_engine) + assert result.key?(:rubygems_version) + refute result.key?(:os_platform) + end + + def test_unknown_category_falls_back_to_all + result = @tool.execute(category: 'bogus') + assert result[:success] + assert result.key?(:os_platform) + assert result.key?(:cpu_model) + end + + def test_ruby_version_matches_current + result = @tool.execute(category: 'ruby') + assert result[:success] + assert_equal RUBY_VERSION, result[:ruby_version] + end + + def test_os_platform_is_a_string + result = @tool.execute(category: 'os') + assert result[:success] + assert_kind_of String, result[:os_platform] + refute result[:os_platform].empty? + end + + def test_hostname_is_present + result = @tool.execute(category: 'os') + assert result[:success] + assert result[:hostname] + assert_kind_of String, result[:hostname] + end + + def test_cpu_cores_is_positive_integer + result = @tool.execute(category: 'cpu') + assert result[:success] + assert_kind_of Integer, result[:cpu_cores] + assert result[:cpu_cores] > 0 + end + + def test_memory_total_is_positive + result = @tool.execute(category: 'memory') + assert result[:success] + assert result[:memory_total_gb] > 0 + end +end diff --git a/test/shared_tools/tools/workflow_manager_tool_test.rb b/test/shared_tools/tools/workflow_manager_tool_test.rb index 75fc2c5..bac8dfc 100644 --- a/test/shared_tools/tools/workflow_manager_tool_test.rb +++ b/test/shared_tools/tools/workflow_manager_tool_test.rb @@ -318,6 +318,69 @@ def test_no_next_actions_for_completed_workflow assert_empty status_result[:next_actions] end + # List workflows tests + def test_list_returns_empty_when_no_workflows + result = @tool.execute(action: "list") + + assert result[:success] + assert_equal 0, result[:total] + assert_equal [], result[:workflows] + end + + def test_list_returns_all_workflows + # Create 3 workflows + 3.times { |i| @tool.execute(action: "start", step_data: {label: "wf#{i}"}) } + + result = @tool.execute(action: "list") + + assert result[:success] + assert_equal 3, result[:total] + assert_equal 3, result[:workflows].length + end + + def test_list_includes_workflow_summary_fields + @tool.execute(action: "start", step_data: {}) + + result = @tool.execute(action: "list") + wf = result[:workflows].first + + assert wf[:workflow_id] + assert wf[:status] + assert_kind_of Integer, wf[:step_count] + assert wf[:created_at] + end + + def test_list_reflects_correct_step_count + start_result = @tool.execute(action: "start", step_data: {}) + wf_id = start_result[:workflow_id] + 2.times { @tool.execute(action: "step", workflow_id: wf_id, step_data: {}) } + + result = @tool.execute(action: "list") + wf = result[:workflows].find { |w| w[:workflow_id] == wf_id } + + assert_equal 2, wf[:step_count] + end + + def test_list_reflects_correct_status + start_result = @tool.execute(action: "start", step_data: {}) + wf_id = start_result[:workflow_id] + @tool.execute(action: "complete", workflow_id: wf_id) + + result = @tool.execute(action: "list") + wf = result[:workflows].find { |w| w[:workflow_id] == wf_id } + + assert_equal "completed", wf[:status] + end + + def test_list_is_sorted_by_created_at + 3.times { |i| @tool.execute(action: "start", step_data: {i: i}) } + + result = @tool.execute(action: "list") + timestamps = result[:workflows].map { |w| w[:created_at] } + + assert_equal timestamps.sort, timestamps + end + # Invalid action tests def test_invalid_action result = @tool.execute(action: "invalid_action") diff --git a/test/shared_tools_test.rb b/test/shared_tools_test.rb index 5fbea9d..a812a1c 100644 --- a/test/shared_tools_test.rb +++ b/test/shared_tools_test.rb @@ -85,4 +85,39 @@ def test_treats_nil_auto_execute_as_requiring_user_interaction assert_equal true, result end end + + # .load_all_tools tests + def test_load_all_tools_triggers_eager_loading + # Should not raise + SharedTools.load_all_tools + assert true + end + + def test_load_all_tools_makes_tool_classes_available + SharedTools.load_all_tools + # At least one known tool class should be defined after eager loading + assert defined?(SharedTools::Tools::DiskTool) + assert defined?(SharedTools::Tools::EvalTool) + assert defined?(SharedTools::Tools::WorkflowManagerTool) + end + + # .tools tests + def test_tools_returns_array + result = SharedTools.tools + assert_kind_of Array, result + end + + def test_tools_contains_ruby_llm_tool_subclasses + result = SharedTools.tools + assert result.all? { |k| k < ::RubyLLM::Tool }, + "Expected all entries to be RubyLLM::Tool subclasses" + end + + def test_tools_includes_known_tools + result = SharedTools.tools + class_names = result.map(&:name) + assert_includes class_names, 'disk_tool' + assert_includes class_names, 'workflow_manager' + assert_includes class_names, 'dns_tool' + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 2f90a7b..8ed7772 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -12,6 +12,7 @@ end require "minitest/autorun" +require "minitest/mock" require "minitest/pride" require "ruby_llm" require "shared_tools" From a7fa940db240c3ef0a425ff42c230cda64809804 Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Thu, 26 Mar 2026 09:48:12 -0500 Subject: [PATCH 05/17] feat(docker): add support for array arguments in compose_run_tool The commit adds support for passing an array of arguments to the `compose_run_tool` in the `shared_tools` gem. Previously, the `args` parameter was defined as a string, but this change updates it to be an array of strings, allowing more flexibility in passing multiple arguments to the command being run. --- lib/shared_tools/tools/docker/compose_run_tool.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/shared_tools/tools/docker/compose_run_tool.rb b/lib/shared_tools/tools/docker/compose_run_tool.rb index 1965602..f9b4d1b 100644 --- a/lib/shared_tools/tools/docker/compose_run_tool.rb +++ b/lib/shared_tools/tools/docker/compose_run_tool.rb @@ -16,7 +16,7 @@ def self.name = 'docker_compose_run' params do string :service, description: "The service to run the command on (e.g. `app`).", required: false string :command, description: "The command to run (e.g. `rspec`)." - array :args, description: "The arguments for the command.", required: false + array :args, of: :string, description: "The arguments for the command.", required: false end # @example From fef43d4c25af32400a9549328270fd1df9c50c01 Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Thu, 26 Mar 2026 10:23:26 -0500 Subject: [PATCH 06/17] feat(notification): add demo for NotificationTool This commit adds a new demo script, `notification_tool_demo.rb`, that showcases the functionality of the `NotificationTool` in the `SharedTools` library. The demo includes examples of using the tool to display desktop notifications, speak text aloud, and show modal alert dialogs. The new demo script is located in the `examples` directory and can be run using the provided instructions. This addition helps demonstrate the capabilities of the `NotificationTool` and provides a useful reference for developers working with the `SharedTools` library. --- examples/notification_tool_demo.rb | 73 ++++++++ lib/shared_tools.rb | 1 + lib/shared_tools/tools/notification.rb | 12 ++ .../tools/notification/base_driver.rb | 51 ++++++ .../tools/notification/linux_driver.rb | 115 ++++++++++++ .../tools/notification/mac_driver.rb | 66 +++++++ .../tools/notification/null_driver.rb | 29 +++ lib/shared_tools/tools/notification_tool.rb | 99 ++++++++++ .../tools/notification_tool_test.rb | 169 ++++++++++++++++++ 9 files changed, 615 insertions(+) create mode 100755 examples/notification_tool_demo.rb create mode 100644 lib/shared_tools/tools/notification.rb create mode 100644 lib/shared_tools/tools/notification/base_driver.rb create mode 100644 lib/shared_tools/tools/notification/linux_driver.rb create mode 100644 lib/shared_tools/tools/notification/mac_driver.rb create mode 100644 lib/shared_tools/tools/notification/null_driver.rb create mode 100644 lib/shared_tools/tools/notification_tool.rb create mode 100644 test/shared_tools/tools/notification_tool_test.rb diff --git a/examples/notification_tool_demo.rb b/examples/notification_tool_demo.rb new file mode 100755 index 0000000..ea85b46 --- /dev/null +++ b/examples/notification_tool_demo.rb @@ -0,0 +1,73 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: NotificationTool +# +# Cross-platform desktop notifications, modal alert dialogs, and text-to-speech. +# Supports macOS (osascript, say) and Linux (notify-send, zenity/terminal, espeak-ng/espeak). +# +# NOTE: This demo triggers real OS-level interactions: +# - notify → desktop banner notifications appear on screen +# - alert → a modal dialog pops up and BLOCKS until you click a button +# - speak → your system will speak text aloud +# +# Run: +# bundle exec ruby -I examples examples/notification_tool_demo.rb + +require_relative 'common' +require 'shared_tools/tools/notification' + +title "NotificationTool Demo — Desktop notifications, modal alerts, and text-to-speech" + +@chat = new_chat.with_tool(SharedTools::Tools::NotificationTool.new) + +# --------------------------------------------------------------------------- +title "Notify — Non-blocking desktop banner", char: '-' +# --------------------------------------------------------------------------- + +ask "Send a desktop notification with the title 'SharedTools Demo' and the message 'NotificationTool is working!'" + +ask "Send a desktop notification with title 'Build Status', subtitle 'CI Pipeline', and message 'All tests passed — 600 runs, 0 failures.'" + +ask "Send a notification with the title 'Reminder' and message 'Time to take a break.' Use the 'Glass' sound." + +# --------------------------------------------------------------------------- +title "Speak — Text-to-speech", char: '-' +# --------------------------------------------------------------------------- + +@chat = new_chat.with_tool(SharedTools::Tools::NotificationTool.new) + +ask "Please speak the following message aloud: 'Hello! The SharedTools notification tool is working correctly on this system.'" + +ask "Speak this message at a rate of 150 words per minute: 'Shared tools makes it easy for AI agents to interact with your operating system.'" + +# --------------------------------------------------------------------------- +title "Alert — Modal dialog (will block for your input)", char: '-' +# --------------------------------------------------------------------------- + +@chat = new_chat.with_tool(SharedTools::Tools::NotificationTool.new) + +ask "Show an alert dialog with the title 'Demo Checkpoint' and the message 'The notification demo is running. Click OK to continue.' Use a single OK button." + +ask <<~PROMPT + Show an alert dialog asking: 'Do you want to hear another spoken message?' + Give it the title 'Continue?' and provide two buttons: 'Yes' and 'No'. + Report back which button was clicked. +PROMPT + +# --------------------------------------------------------------------------- +title "Combined workflow", char: '-' +# --------------------------------------------------------------------------- + +@chat = new_chat.with_tool(SharedTools::Tools::NotificationTool.new) + +ask <<~PROMPT + Run this three-step notification sequence: + 1. Send a desktop notification with title 'Workflow Starting' and message 'Step 1 of 3 complete.' + 2. Speak the message: 'Workflow demo is running. Please stand by.' + 3. Show an alert dialog titled 'Workflow Complete' with the message 'All three notification types demonstrated successfully.' and an OK button. + Tell me the result of each step. +PROMPT + +title "Done", char: '-' +puts "NotificationTool demonstrated desktop banners, text-to-speech, and modal alert dialogs." diff --git a/lib/shared_tools.rb b/lib/shared_tools.rb index fbd97b1..78432de 100644 --- a/lib/shared_tools.rb +++ b/lib/shared_tools.rb @@ -17,6 +17,7 @@ SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/doc.rb") SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/docker.rb") SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/eval.rb") +SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/notification.rb") SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/version.rb") # Defines VERSION constant, not Version class SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/incomplete") # Empty/incomplete tools directory diff --git a/lib/shared_tools/tools/notification.rb b/lib/shared_tools/tools/notification.rb new file mode 100644 index 0000000..5f957ea --- /dev/null +++ b/lib/shared_tools/tools/notification.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Collection loader for notification tools +# Usage: require 'shared_tools/tools/notification' + +require 'shared_tools' + +require_relative 'notification/base_driver' +require_relative 'notification/mac_driver' +require_relative 'notification/linux_driver' +require_relative 'notification/null_driver' +require_relative 'notification_tool' diff --git a/lib/shared_tools/tools/notification/base_driver.rb b/lib/shared_tools/tools/notification/base_driver.rb new file mode 100644 index 0000000..4907592 --- /dev/null +++ b/lib/shared_tools/tools/notification/base_driver.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module SharedTools + module Tools + module Notification + # Abstract base class for platform-specific notification drivers. + # Subclasses must implement notify, alert, and speak. + class BaseDriver + # Show a non-blocking desktop banner notification. + # + # @param message [String] + # @param title [String, nil] + # @param subtitle [String, nil] + # @param sound [String, nil] + # @return [Hash] + def notify(message:, title: nil, subtitle: nil, sound: nil) + raise NotImplementedError, "#{self.class}##{__method__} undefined" + end + + # Show a modal dialog and wait for the user to click a button. + # + # @param message [String] + # @param title [String, nil] + # @param buttons [Array] + # @param default_button [String, nil] + # @return [Hash] includes :button with the label of the clicked button + def alert(message:, title: nil, buttons: ['OK'], default_button: nil) + raise NotImplementedError, "#{self.class}##{__method__} undefined" + end + + # Speak text aloud using text-to-speech. + # + # @param text [String] + # @param voice [String, nil] + # @param rate [Integer, nil] words per minute + # @return [Hash] + def speak(text:, voice: nil, rate: nil) + raise NotImplementedError, "#{self.class}##{__method__} undefined" + end + + protected + + # @param cmd [String] + # @return [Boolean] + def command_available?(cmd) + system("which #{cmd} > /dev/null 2>&1") + end + end + end + end +end diff --git a/lib/shared_tools/tools/notification/linux_driver.rb b/lib/shared_tools/tools/notification/linux_driver.rb new file mode 100644 index 0000000..37c9ae4 --- /dev/null +++ b/lib/shared_tools/tools/notification/linux_driver.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'open3' + +module SharedTools + module Tools + module Notification + # Linux notification driver. + # + # notify — uses notify-send (libnotify); logs a warning if no display is available. + # alert — uses zenity when a display is present; falls back to a terminal prompt. + # speak — tries espeak-ng first, then espeak. + class LinuxDriver < BaseDriver + # @param message [String] + # @param title [String, nil] + # @param subtitle [String, nil] appended to the message body + # @param sound [String, nil] ignored on Linux + # @return [Hash] + def notify(message:, title: nil, subtitle: nil, sound: nil) + unless display_available? + RubyLLM.logger.warn('NotificationTool: No display server available, cannot show notification') + return { success: false, error: 'No display server available' } + end + + unless command_available?('notify-send') + return { success: false, error: 'notify-send not found. Install libnotify-bin (Debian/Ubuntu) or libnotify (Fedora/Arch).' } + end + + body = [message, subtitle].compact.join("\n") + cmd = ['notify-send', (title || 'Notification'), body] + _, stderr, status = Open3.capture3(*cmd) + status.success? ? { success: true, action: 'notify' } : { success: false, error: stderr.strip } + end + + # @param message [String] + # @param title [String, nil] + # @param buttons [Array] + # @param default_button [String, nil] + # @return [Hash] includes :button with the label of the clicked button + def alert(message:, title: nil, buttons: ['OK'], default_button: nil) + if display_available? && command_available?('zenity') + alert_zenity(message:, title:, buttons:, default_button:) + else + alert_terminal(message:, buttons:, default_button:) + end + end + + # @param text [String] + # @param voice [String, nil] espeak voice name (e.g. 'en', 'en-us') + # @param rate [Integer, nil] words per minute (espeak -s flag) + # @return [Hash] + def speak(text:, voice: nil, rate: nil) + binary = espeak_binary + unless binary + return { success: false, error: 'espeak-ng or espeak not found. Install espeak-ng (recommended) or espeak.' } + end + + cmd = [binary, text] + cmd += ['-v', voice] if voice + cmd += ['-s', rate.to_s] if rate + _, stderr, status = Open3.capture3(*cmd) + status.success? ? { success: true, action: 'speak' } : { success: false, error: stderr.strip } + end + + private + + def display_available? + ENV['DISPLAY'] || ENV['WAYLAND_DISPLAY'] + end + + def espeak_binary + return 'espeak-ng' if command_available?('espeak-ng') + return 'espeak' if command_available?('espeak') + nil + end + + def alert_zenity(message:, title:, buttons:, default_button:) + if buttons.length == 1 + cmd = ['zenity', '--info', '--text', message] + cmd += ['--title', title] if title + _, stderr, status = Open3.capture3(*cmd) + status.success? ? { success: true, button: buttons.first } : { success: false, error: stderr.strip } + else + ok_label = buttons[0] + cancel_label = buttons[1] + cmd = ['zenity', '--question', '--text', message, + '--ok-label', ok_label, '--cancel-label', cancel_label] + cmd += ['--title', title] if title + # zenity supports --extra-button for additional buttons beyond two + buttons[2..].each { |b| cmd += ['--extra-button', b] } if buttons.length > 2 + + stdout, stderr, status = Open3.capture3(*cmd) + case status.exitstatus + when 0 then { success: true, button: ok_label } + when 1 then { success: true, button: cancel_label } + else + btn = stdout.strip + btn.empty? ? { success: false, error: stderr.strip } : { success: true, button: btn } + end + end + end + + def alert_terminal(message:, buttons:, default_button:) + $stdout.puts "\n[ALERT] #{message}" + $stdout.puts "Options: #{buttons.each_with_index.map { |b, i| "#{i + 1}) #{b}" }.join(' ')}" + $stdout.print "Enter choice (1-#{buttons.length}): " + $stdout.flush + input = $stdin.gets&.strip.to_i + button = buttons[input - 1] || default_button || buttons.first + { success: true, button: button } + end + end + end + end +end diff --git a/lib/shared_tools/tools/notification/mac_driver.rb b/lib/shared_tools/tools/notification/mac_driver.rb new file mode 100644 index 0000000..44d1509 --- /dev/null +++ b/lib/shared_tools/tools/notification/mac_driver.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'open3' + +module SharedTools + module Tools + module Notification + # macOS notification driver using osascript and the say command. + class MacDriver < BaseDriver + # @param message [String] + # @param title [String, nil] + # @param subtitle [String, nil] + # @param sound [String, nil] e.g. 'Glass', 'Ping' + # @return [Hash] + def notify(message:, title: nil, subtitle: nil, sound: nil) + parts = ["display notification #{message.inspect}"] + parts << "with title #{title.inspect}" if title + parts << "subtitle #{subtitle.inspect}" if subtitle + parts << "sound name #{sound.inspect}" if sound + run_osascript(parts.join(' ')) + .then { |r| r[:success] ? r.merge(action: 'notify') : r } + end + + # @param message [String] + # @param title [String, nil] + # @param buttons [Array] + # @param default_button [String, nil] + # @return [Hash] includes :button with label of clicked button + def alert(message:, title: nil, buttons: ['OK'], default_button: nil) + btn_list = buttons.map(&:inspect).join(', ') + script = "display dialog #{message.inspect}" + script += " with title #{title.inspect}" if title + script += " buttons {#{btn_list}}" + script += " default button #{default_button.inspect}" if default_button + + stdout, stderr, status = Open3.capture3('osascript', '-e', script) + if status.success? + button = stdout.match(/button returned:(.+)/i)&.captures&.first&.strip + { success: true, button: button } + else + { success: false, error: stderr.strip } + end + end + + # @param text [String] + # @param voice [String, nil] e.g. 'Samantha' + # @param rate [Integer, nil] words per minute + # @return [Hash] + def speak(text:, voice: nil, rate: nil) + cmd = ['say', text] + cmd += ['-v', voice] if voice + cmd += ['-r', rate.to_s] if rate + _, stderr, status = Open3.capture3(*cmd) + status.success? ? { success: true, action: 'speak' } : { success: false, error: stderr.strip } + end + + private + + def run_osascript(script) + _, stderr, status = Open3.capture3('osascript', '-e', script) + status.success? ? { success: true } : { success: false, error: stderr.strip } + end + end + end + end +end diff --git a/lib/shared_tools/tools/notification/null_driver.rb b/lib/shared_tools/tools/notification/null_driver.rb new file mode 100644 index 0000000..84ec5e8 --- /dev/null +++ b/lib/shared_tools/tools/notification/null_driver.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module SharedTools + module Tools + module Notification + # Fallback driver for unsupported platforms. + # All actions return a failure response with a clear error message. + class NullDriver < BaseDriver + def notify(message:, title: nil, subtitle: nil, sound: nil) + unsupported + end + + def alert(message:, title: nil, buttons: ['OK'], default_button: nil) + unsupported + end + + def speak(text:, voice: nil, rate: nil) + unsupported + end + + private + + def unsupported + { success: false, error: "NotificationTool is not supported on platform: #{RUBY_PLATFORM}" } + end + end + end + end +end diff --git a/lib/shared_tools/tools/notification_tool.rb b/lib/shared_tools/tools/notification_tool.rb new file mode 100644 index 0000000..7862fd3 --- /dev/null +++ b/lib/shared_tools/tools/notification_tool.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module SharedTools + module Tools + # Cross-platform notification tool for desktop banners, modal dialogs, and TTS. + # + # Supports macOS (osascript, say) and Linux (notify-send, zenity/terminal, espeak-ng/espeak). + # On unsupported platforms all actions return {success: false}. + # + # @example Desktop notification + # tool = SharedTools::Tools::NotificationTool.new + # tool.execute(action: 'notify', message: 'Build complete', title: 'CI') + # + # @example Modal alert + # result = tool.execute(action: 'alert', message: 'Deploy to production?', buttons: ['Yes', 'No']) + # result[:button] # => 'Yes' or 'No' + # + # @example Text-to-speech + # tool.execute(action: 'speak', message: 'Task finished') + class NotificationTool < ::RubyLLM::Tool + def self.name = 'notification_tool' + + description <<~DESC.strip + Send desktop notifications, modal alerts, or text-to-speech messages. + Supports macOS and Linux. On macOS uses osascript and say. + On Linux uses notify-send, zenity (or terminal fallback), and espeak-ng/espeak. + DESC + + params do + string :action, description: <<~TEXT.strip + The notification action to perform: + * `notify` — Non-blocking desktop banner notification. + * `alert` — Modal dialog; waits for the user to click a button. Returns the button label. + * `speak` — Speak text aloud using text-to-speech. + TEXT + + string :message, description: "The message to display or speak. Required for all actions." + + string :title, description: "Title for the notification or alert dialog. Optional.", required: false + + string :subtitle, description: "Subtitle line (notify action, macOS and Linux). Optional.", required: false + + string :sound, description: "Sound name to play with a notification (macOS only, e.g. 'Glass', 'Ping'). Optional.", required: false + + array :buttons, of: :string, description: <<~TEXT.strip, required: false + Button labels for the alert dialog (e.g. ['Yes', 'No']). Defaults to ['OK']. + TEXT + + string :default_button, description: "Default focused button label for the alert dialog. Optional.", required: false + + string :voice, description: "TTS voice name for the speak action (e.g. 'Samantha' on macOS, 'en' on Linux). Optional.", required: false + + integer :rate, description: "Speech rate in words per minute for the speak action. Optional.", required: false + end + + # @param driver [Notification::BaseDriver] optional; auto-detected from platform if omitted + def initialize(driver: nil) + @driver = driver || default_driver + end + + def execute(action:, message: nil, title: nil, subtitle: nil, sound: nil, + buttons: nil, default_button: nil, voice: nil, rate: nil) + buttons ||= ['OK'] + + case action + when 'notify' + return missing_param('message', 'notify') if blank?(message) + @driver.notify(message:, title:, subtitle:, sound:) + when 'alert' + return missing_param('message', 'alert') if blank?(message) + @driver.alert(message:, title:, buttons:, default_button:) + when 'speak' + return missing_param('message', 'speak') if blank?(message) + @driver.speak(text: message, voice:, rate:) + else + { success: false, error: "Unknown action: #{action.inspect}. Must be notify, alert, or speak." } + end + end + + private + + def default_driver + case RUBY_PLATFORM + when /darwin/ then Notification::MacDriver.new + when /linux/ then Notification::LinuxDriver.new + else Notification::NullDriver.new + end + end + + def blank?(value) + value.nil? || value.to_s.strip.empty? + end + + def missing_param(param, action) + { success: false, error: "'#{param}' is required for the #{action} action" } + end + end + end +end diff --git a/test/shared_tools/tools/notification_tool_test.rb b/test/shared_tools/tools/notification_tool_test.rb new file mode 100644 index 0000000..b37d4cd --- /dev/null +++ b/test/shared_tools/tools/notification_tool_test.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require "test_helper" + +class NotificationToolTest < Minitest::Test + # Minimal in-process driver for testing — no shell commands executed + class StubDriver + attr_reader :last_call + + def notify(message:, title: nil, subtitle: nil, sound: nil) + @last_call = { action: :notify, message:, title:, subtitle:, sound: } + { success: true, action: 'notify' } + end + + def alert(message:, title: nil, buttons: ['OK'], default_button: nil) + @last_call = { action: :alert, message:, title:, buttons:, default_button: } + { success: true, button: buttons.first } + end + + def speak(text:, voice: nil, rate: nil) + @last_call = { action: :speak, text:, voice:, rate: } + { success: true, action: 'speak' } + end + end + + class FailDriver + def notify(**) = { success: false, error: 'test failure' } + def alert(**) = { success: false, error: 'test failure' } + def speak(**) = { success: false, error: 'test failure' } + end + + def setup + @driver = StubDriver.new + @tool = SharedTools::Tools::NotificationTool.new(driver: @driver) + end + + # Tool metadata + def test_tool_name + assert_equal 'notification_tool', SharedTools::Tools::NotificationTool.name + end + + def test_inherits_from_ruby_llm_tool + assert_kind_of ::RubyLLM::Tool, @tool + end + + # notify action + def test_notify_returns_success + result = @tool.execute(action: 'notify', message: 'Hello') + assert result[:success] + end + + def test_notify_passes_message_to_driver + @tool.execute(action: 'notify', message: 'Build done', title: 'CI') + assert_equal 'Build done', @driver.last_call[:message] + assert_equal 'CI', @driver.last_call[:title] + end + + def test_notify_passes_subtitle_and_sound + @tool.execute(action: 'notify', message: 'msg', subtitle: 'sub', sound: 'Glass') + assert_equal 'sub', @driver.last_call[:subtitle] + assert_equal 'Glass', @driver.last_call[:sound] + end + + def test_notify_missing_message_returns_error + result = @tool.execute(action: 'notify', message: nil) + refute result[:success] + assert result[:error] + end + + def test_notify_blank_message_returns_error + result = @tool.execute(action: 'notify', message: ' ') + refute result[:success] + assert result[:error] + end + + # alert action + def test_alert_returns_success_with_button + result = @tool.execute(action: 'alert', message: 'Continue?') + assert result[:success] + assert result[:button] + end + + def test_alert_returns_first_button_by_default + result = @tool.execute(action: 'alert', message: 'Sure?', buttons: ['Yes', 'No']) + assert_equal 'Yes', result[:button] + end + + def test_alert_defaults_buttons_to_ok + @tool.execute(action: 'alert', message: 'OK?') + assert_equal ['OK'], @driver.last_call[:buttons] + end + + def test_alert_passes_title_and_default_button + @tool.execute(action: 'alert', message: 'msg', title: 'Confirm', default_button: 'Yes') + assert_equal 'Confirm', @driver.last_call[:title] + assert_equal 'Yes', @driver.last_call[:default_button] + end + + def test_alert_missing_message_returns_error + result = @tool.execute(action: 'alert', message: nil) + refute result[:success] + assert result[:error] + end + + # speak action + def test_speak_returns_success + result = @tool.execute(action: 'speak', message: 'Hello world') + assert result[:success] + end + + def test_speak_passes_text_to_driver + @tool.execute(action: 'speak', message: 'Task complete') + assert_equal 'Task complete', @driver.last_call[:text] + end + + def test_speak_passes_voice_and_rate + @tool.execute(action: 'speak', message: 'Hi', voice: 'Samantha', rate: 180) + assert_equal 'Samantha', @driver.last_call[:voice] + assert_equal 180, @driver.last_call[:rate] + end + + def test_speak_missing_message_returns_error + result = @tool.execute(action: 'speak', message: nil) + refute result[:success] + assert result[:error] + end + + # unknown action + def test_unknown_action_returns_error + result = @tool.execute(action: 'explode', message: 'boom') + refute result[:success] + assert result[:error] + assert_includes result[:error], 'Unknown action' + end + + # response shape + def test_result_always_has_success_key + result = @tool.execute(action: 'notify', message: 'hi') + assert result.key?(:success) + end + + # driver failure propagates + def test_driver_failure_propagates + tool = SharedTools::Tools::NotificationTool.new(driver: FailDriver.new) + result = tool.execute(action: 'notify', message: 'hi') + refute result[:success] + assert result[:error] + end + + # NullDriver + def test_null_driver_returns_failure_for_notify + null = SharedTools::Tools::Notification::NullDriver.new + result = null.notify(message: 'hi') + refute result[:success] + assert_includes result[:error], 'not supported' + end + + def test_null_driver_returns_failure_for_alert + null = SharedTools::Tools::Notification::NullDriver.new + result = null.alert(message: 'hi') + refute result[:success] + end + + def test_null_driver_returns_failure_for_speak + null = SharedTools::Tools::Notification::NullDriver.new + result = null.speak(text: 'hi') + refute result[:success] + end +end From 56c02f978f2f5f38300375415a088421e58a5fd9 Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Thu, 26 Mar 2026 12:16:25 -0500 Subject: [PATCH 07/17] feat(notification): add new desktop notification, alert, and text-to-speech capabilities This commit adds a new Notification Tool to the SharedTools library, providing cross-platform support for desktop notifications, modal alert dialogs, and text-to-speech functionality. The key features include: - Simplified API with `notify`, `alert`, and `speak` actions - Supports macOS and Linux natively with no gem dependencies - Notification banners, blocking alert dialogs, and configurable text-to-speech - Integrated into the existing examples and documentation This new tool enhances the capabilities of the SharedTools library, making it easier for users to incorporate notification features into their applications. --- README.md | 31 +- docs/examples/index.md | 19 +- docs/tools/index.md | 25 ++ docs/tools/notification_tool.md | 177 +++++++++ ..._1a891023-61af-4406-9af5-4eb551ef2624.json | 357 ++++++++++++++++++ ..._37bcdad8-e55e-4729-b449-76babd8111d6.json | 355 +++++++++++++++++ examples/README.md | 22 ++ examples/mcp_client_demo.rb | 196 +++++++--- lib/shared_tools/mcp.rb | 34 +- lib/shared_tools/mcp/.keep | 0 lib/shared_tools/mcp/README.md | 93 +++-- .../mcp/brave_search_mcp_server.rb | 33 ++ lib/shared_tools/mcp/chart_mcp_server.rb | 32 ++ lib/shared_tools/mcp/github_mcp_server.rb | 58 --- lib/shared_tools/mcp/imcp.rb | 28 -- lib/shared_tools/mcp/memory_mcp_server.rb | 33 ++ .../mcp/sequential_thinking_mcp_server.rb | 33 ++ lib/shared_tools/mcp/tavily_mcp_server.rb | 47 +-- lib/shared_tools/mcp/temp.md | 84 ----- 19 files changed, 1363 insertions(+), 294 deletions(-) create mode 100644 docs/tools/notification_tool.md create mode 100644 examples/.workflows/workflow_1a891023-61af-4406-9af5-4eb551ef2624.json create mode 100644 examples/.workflows/workflow_37bcdad8-e55e-4729-b449-76babd8111d6.json delete mode 100644 lib/shared_tools/mcp/.keep create mode 100644 lib/shared_tools/mcp/brave_search_mcp_server.rb create mode 100644 lib/shared_tools/mcp/chart_mcp_server.rb delete mode 100644 lib/shared_tools/mcp/github_mcp_server.rb delete mode 100644 lib/shared_tools/mcp/imcp.rb create mode 100644 lib/shared_tools/mcp/memory_mcp_server.rb create mode 100644 lib/shared_tools/mcp/sequential_thinking_mcp_server.rb delete mode 100644 lib/shared_tools/mcp/temp.md diff --git a/README.md b/README.md index 68e9cbd..678ab5a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ SharedTools is a comprehensive collection of production-ready tools designed for ### Key Features -- 🔧 **20+ Production Tools** — Browser automation, file operations, database queries, code evaluation, document processing, DNS and WHOIS lookups, IP geolocation, data science, weather data, workflow management, system utilities, and more +- 🔧 **21+ Production Tools** — Browser automation, file operations, database queries, code evaluation, document processing, DNS and WHOIS lookups, IP geolocation, data science, weather data, workflow management, system utilities, notifications, and more - 🔒 **Human-in-the-Loop Authorization** — Built-in safety system for sensitive operations - 🎯 **Facade Pattern** — Simplified interfaces with complex capabilities under the hood - 🔌 **Pluggable Drivers** — Swap implementations for testing or different backends @@ -312,6 +312,34 @@ clipboard.execute(action: "write", text: "Hello!") --- +### 🔔 Notification Tool + +Cross-platform desktop notifications, modal alert dialogs, and text-to-speech. Supports macOS and Linux with no gem dependencies. + +**Actions:** `notify`, `alert`, `speak` + +```ruby +tool = SharedTools::Tools::NotificationTool.new + +# Non-blocking desktop banner +tool.execute(action: "notify", message: "Build complete", title: "CI", sound: "Glass") + +# Modal dialog — blocks until user clicks; returns clicked button label +result = tool.execute(action: "alert", message: "Deploy to production?", buttons: ["Yes", "No"]) +result[:button] # => "Yes" or "No" + +# Text-to-speech +tool.execute(action: "speak", message: "Task finished", voice: "Samantha", rate: 160) +``` + +| Action | macOS | Linux | +|--------|-------|-------| +| `notify` | osascript | notify-send | +| `alert` | osascript dialog | zenity or terminal fallback | +| `speak` | say | espeak-ng / espeak | + +--- + ### 🔄 Workflow Manager Tool Persistent multi-step workflow orchestration with JSON file storage. @@ -443,6 +471,7 @@ bundle exec ruby -I examples examples/doc_tool_demo.rb | `error_handling_tool_demo.rb` | Error handling patterns | | `eval_tool_demo.rb` | Code evaluation | | `mcp_client_demo.rb` | MCP client integration | +| `notification_tool_demo.rb` | Desktop notifications, alerts, TTS | | `system_info_tool_demo.rb` | System info | | `weather_tool_demo.rb` | Weather + local forecast | | `workflow_manager_tool_demo.rb` | Workflow orchestration | diff --git a/docs/examples/index.md b/docs/examples/index.md index 94dbbea..bd67f55 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -119,6 +119,23 @@ Code evaluation in Ruby, Python, and shell — with authorization controls. --- +### [notification_tool_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/notification_tool_demo.rb) + +Cross-platform desktop notifications, modal dialogs, and text-to-speech across five sections: + +- **Notify** — banner notifications with title, subtitle, and sound +- **Speak** — TTS with and without a rate override +- **Alert** — single-button checkpoint and a Yes/No dialog that reports which button was clicked +- **Combined workflow** — chains all three actions in one LLM prompt + +> **Note:** This demo triggers real OS interactions. The `alert` action **blocks** until you click a button; `speak` will use your system TTS engine. + +```bash +bundle exec ruby -I examples examples/notification_tool_demo.rb +``` + +--- + ### [mcp_client_demo.rb](https://github.com/madbomber/shared_tools/blob/main/examples/mcp_client_demo.rb) MCP (Model Context Protocol) client integration example. @@ -176,7 +193,7 @@ bundle exec ruby -I examples examples/some_tool_demo.rb | Web & Network | browser_tool_demo, dns_tool_demo | | Files & Documents | disk_tool_demo, doc_tool_demo | | Data & Analysis | data_science_kit_demo, composite_analysis_tool_demo, database_tool_demo, database_query_tool_demo | -| System & Utilities | computer_tool_demo, system_info_tool_demo, clipboard_tool_demo, current_date_time_tool_demo, cron_tool_demo | +| System & Utilities | computer_tool_demo, system_info_tool_demo, clipboard_tool_demo, current_date_time_tool_demo, cron_tool_demo, notification_tool_demo | | External APIs | weather_tool_demo | | Workflow | workflow_manager_tool_demo, comprehensive_workflow_demo | | Code Execution | eval_tool_demo, calculator_tool_demo | diff --git a/docs/tools/index.md b/docs/tools/index.md index e156da0..558776e 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -209,6 +209,27 @@ weather.execute(city: "London,UK", units: "metric", include_forecast: true) --- +### [NotificationTool](notification_tool.md) + +Cross-platform desktop notifications, modal alert dialogs, and text-to-speech. Supports macOS and Linux with no gem dependencies. + +**Actions:** `notify`, `alert`, `speak` + +```ruby +tool = SharedTools::Tools::NotificationTool.new + +tool.execute(action: "notify", message: "Build complete", title: "CI") + +result = tool.execute(action: "alert", message: "Deploy to prod?", buttons: ["Yes", "No"]) +result[:button] # => "Yes" or "No" + +tool.execute(action: "speak", message: "Task finished", voice: "Samantha") +``` + +[View NotificationTool Documentation →](notification_tool.md) + +--- + ### CurrentDateTimeTool Returns the current date, time, and day of week from the system clock — preventing LLMs from hallucinating temporal information. @@ -321,6 +342,7 @@ error_tool.execute(operation: "process", data: {name: "test", value: 42}, max_re | SystemInfoTool | System info | None | No | | ClipboardTool | Clipboard | None | No | | CronTool | Cron scheduling | None | No | +| NotificationTool | Desktop notifications, alerts, TTS | None (OS commands) | No | | WorkflowManagerTool | Workflow orchestration | None | No | | Docker ComposeRunTool | Container commands | Docker installed | No | | ErrorHandlingTool | Reference patterns | None | No | @@ -341,6 +363,9 @@ error_tool.execute(operation: "process", data: {name: "test", value: 42}, max_re | Get current weather | WeatherTool | | Auto-detect my location from IP | DnsTool (ip_location) | | Get the current date and day | CurrentDateTimeTool | +| Show a desktop notification | NotificationTool (notify) | +| Speak text aloud | NotificationTool (speak) | +| Prompt user with a dialog | NotificationTool (alert) | | Orchestrate a multi-step process | WorkflowManagerTool | | Run a command in a Docker container | Docker ComposeRunTool | diff --git a/docs/tools/notification_tool.md b/docs/tools/notification_tool.md new file mode 100644 index 0000000..76794fd --- /dev/null +++ b/docs/tools/notification_tool.md @@ -0,0 +1,177 @@ +# NotificationTool + +Cross-platform desktop notifications, modal alert dialogs, and text-to-speech. Supports macOS and Linux with no gem dependencies — all actions use OS-native commands. + +## Platform Support + +| Feature | macOS | Linux | +|---------|-------|-------| +| `notify` | `osascript display notification` | `notify-send` (libnotify) | +| `alert` | `osascript display dialog` | `zenity` (GUI) or terminal prompt (headless) | +| `speak` | `say` | `espeak-ng` → `espeak` fallback | + +## Basic Usage + +```ruby +require 'shared_tools' +require 'shared_tools/tools/notification' + +tool = SharedTools::Tools::NotificationTool.new + +# Desktop banner +tool.execute(action: "notify", message: "Build complete", title: "CI") + +# Modal dialog — blocks until user clicks +result = tool.execute(action: "alert", message: "Deploy to production?", buttons: ["Yes", "No"]) +result[:button] # => "Yes" or "No" + +# Text-to-speech +tool.execute(action: "speak", message: "Task finished successfully") +``` + +## Actions + +### notify + +Send a non-blocking desktop banner notification. + +```ruby +tool.execute( + action: "notify", + message: "All tests passed", + title: "CI Pipeline", # optional + subtitle: "main branch", # optional + sound: "Glass" # optional, macOS only (e.g. "Glass", "Ping", "Basso") +) +# => { success: true, action: "notify" } +``` + +--- + +### alert + +Show a modal dialog. **Blocks** until the user clicks a button. Returns the label of the clicked button. + +```ruby +result = tool.execute( + action: "alert", + message: "This will delete all temp files. Continue?", + title: "Confirm", # optional + buttons: ["Delete", "Cancel"], # optional, default: ["OK"] + default_button: "Cancel" # optional +) +result[:button] # => "Delete" or "Cancel" +``` + +On Linux without a display (headless/SSH), falls back to a terminal prompt: + +``` +[ALERT] This will delete all temp files. Continue? +Options: 1) Delete 2) Cancel +Enter choice (1-2): +``` + +--- + +### speak + +Speak text aloud using the system TTS engine. Non-blocking. + +```ruby +tool.execute( + action: "speak", + message: "Deployment complete", + voice: "Samantha", # optional — macOS voice name or espeak voice code + rate: 160 # optional — words per minute +) +# => { success: true, action: "speak" } +``` + +**Voice examples:** + +| Platform | Voice examples | +|----------|---------------| +| macOS | `"Samantha"`, `"Alex"`, `"Victoria"`, `"Daniel"` | +| Linux espeak-ng | `"en"`, `"en-us"`, `"en-gb"`, `"fr"`, `"de"` | + +--- + +## Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `action` | String | Yes | `notify`, `alert`, or `speak` | +| `message` | String | Yes | The message to display or speak | +| `title` | String | No | Title for notify or alert | +| `subtitle` | String | No | Subtitle for notify | +| `sound` | String | No | Sound name for notify (macOS only) | +| `buttons` | Array | No | Button labels for alert (default: `["OK"]`) | +| `default_button` | String | No | Default focused button for alert | +| `voice` | String | No | TTS voice name for speak | +| `rate` | Integer | No | Words per minute for speak | + +## Response Shape + +All actions return a Hash with a `:success` key: + +```ruby +# notify / speak success +{ success: true, action: "notify" } + +# alert success +{ success: true, button: "OK" } + +# any failure +{ success: false, error: "notify-send not found. Install libnotify-bin." } +``` + +## Pluggable Drivers + +The tool uses a driver pattern for platform abstraction. You can inject a driver for testing or custom backends: + +```ruby +# Inject a stub for testing — no OS commands executed +class MyTestDriver < SharedTools::Tools::Notification::BaseDriver + def notify(message:, **) = { success: true, action: "notify" } + def alert(message:, buttons: ["OK"], **) = { success: true, button: buttons.first } + def speak(text:, **) = { success: true, action: "speak" } +end + +tool = SharedTools::Tools::NotificationTool.new(driver: MyTestDriver.new) +``` + +## Linux Prerequisites + +Install the appropriate packages for each action: + +```bash +# notify action +sudo apt install libnotify-bin # Debian/Ubuntu +sudo dnf install libnotify # Fedora +sudo pacman -S libnotify # Arch + +# alert action (GUI) +sudo apt install zenity + +# speak action +sudo apt install espeak-ng # recommended +sudo apt install espeak # fallback +``` + +`alert` does not require `zenity` — it falls back to a terminal prompt automatically if no display server is available. + +## Combining with Other Tools + +Use `alert` to create human-in-the-loop checkpoints inside a workflow: + +```ruby +chat = RubyLLM.chat.with_tools( + SharedTools::Tools::WorkflowManagerTool.new, + SharedTools::Tools::NotificationTool.new +) + +chat.ask(<<~PROMPT) + Start a deployment workflow. Before each major step, show an alert dialog + asking for confirmation. Speak a summary when the workflow is complete. +PROMPT +``` diff --git a/examples/.workflows/workflow_1a891023-61af-4406-9af5-4eb551ef2624.json b/examples/.workflows/workflow_1a891023-61af-4406-9af5-4eb551ef2624.json new file mode 100644 index 0000000..508330f --- /dev/null +++ b/examples/.workflows/workflow_1a891023-61af-4406-9af5-4eb551ef2624.json @@ -0,0 +1,357 @@ +{ + "id": "1a891023-61af-4406-9af5-4eb551ef2624", + "status": "completed", + "steps": [ + { + "step_number": 1, + "data": { + "step_data": { + "step_name": "run_tests", + "step_type": "testing", + "description": "Execute the full test suite", + "status": "completed", + "metadata": { + "completed_at": "2026-03-25T22:52:00-05:00", + "test_results": "all_passed" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 1, + "total_steps_so_far": 0 + }, + "timestamp": "2026-03-25T22:51:27-05:00" + }, + "processed_at": "2026-03-25T22:51:27-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 2, + "data": { + "step_data": { + "step_name": "security_scan", + "step_type": "security", + "description": "Run dependency vulnerability scan", + "status": "completed", + "metadata": { + "completed_at": "2026-03-25T22:53:00-05:00", + "vulnerabilities_found": 0, + "scan_result": "passed" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 2, + "total_steps_so_far": 1 + }, + "timestamp": "2026-03-25T22:51:32-05:00" + }, + "processed_at": "2026-03-25T22:51:32-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 3, + "data": { + "step_data": { + "step_name": "build_artifacts", + "step_type": "build", + "description": "Compile and package the release artifacts", + "status": "in-progress", + "metadata": { + "started_at": "2026-03-25T22:53:30-05:00", + "build_version": "v2.0.0" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 3, + "total_steps_so_far": 2 + }, + "timestamp": "2026-03-25T22:51:32-05:00" + }, + "processed_at": "2026-03-25T22:51:32-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 4, + "data": { + "step_data": { + "step_name": "build_artifacts", + "step_type": "build", + "description": "Compile and package the release artifacts", + "status": "completed", + "metadata": { + "completed_at": "2026-03-25T22:55:00-05:00", + "build_version": "v2.0.0", + "artifacts_size": "256MB", + "artifacts_count": 12 + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 4, + "total_steps_so_far": 3 + }, + "timestamp": "2026-03-25T22:51:42-05:00" + }, + "processed_at": "2026-03-25T22:51:42-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 5, + "data": { + "step_data": { + "step_name": "staging_deploy", + "step_type": "deployment", + "description": "Deploy to staging environment", + "status": "completed", + "metadata": { + "completed_at": "2026-03-25T22:56:30-05:00", + "environment": "staging", + "deployment_status": "successful", + "url": "https://staging.v2.0.0.example.com" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 5, + "total_steps_so_far": 4 + }, + "timestamp": "2026-03-25T22:51:42-05:00" + }, + "processed_at": "2026-03-25T22:51:42-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 6, + "data": { + "step_data": { + "step_name": "qa_sign_off", + "step_type": "approval", + "description": "QA team reviews and approves staging", + "status": "blocked", + "metadata": { + "blocked_at": "2026-03-25T22:56:45-05:00", + "blocked_reason": "Waiting for QA team availability", + "blocking_note": "Waiting for QA team availability" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 6, + "total_steps_so_far": 5 + }, + "timestamp": "2026-03-25T22:51:42-05:00" + }, + "processed_at": "2026-03-25T22:51:42-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 7, + "data": { + "step_data": { + "step_name": "qa_sign_off", + "step_type": "approval", + "description": "QA team reviews and approves staging", + "status": "completed", + "metadata": { + "completed_at": "2026-03-25T23:30:00-05:00", + "qa_approval": "approved", + "qa_team": "QA Team", + "notes": "All staging tests passed. Ready for production deployment" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 7, + "total_steps_so_far": 6 + }, + "timestamp": "2026-03-25T22:51:49-05:00" + }, + "processed_at": "2026-03-25T22:51:49-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 8, + "data": { + "step_data": { + "step_name": "production_deploy", + "step_type": "deployment", + "description": "Deploy to production", + "status": "in-progress", + "metadata": { + "started_at": "2026-03-25T23:31:00-05:00", + "environment": "production", + "version": "v2.0.0", + "deployment_status": "in-progress" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 8, + "total_steps_so_far": 7 + }, + "timestamp": "2026-03-25T22:51:49-05:00" + }, + "processed_at": "2026-03-25T22:51:49-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 9, + "data": { + "step_data": { + "step_name": "production_deploy", + "step_type": "deployment", + "description": "Deploy to production", + "status": "completed", + "metadata": { + "completed_at": "2026-03-25T23:45:00-05:00", + "environment": "production", + "version": "v2.0.0", + "deployment_status": "successful", + "url": "https://v2.0.0.example.com", + "health_check": "passed" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 9, + "total_steps_so_far": 8 + }, + "timestamp": "2026-03-25T22:51:55-05:00" + }, + "processed_at": "2026-03-25T22:51:55-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 10, + "data": { + "step_data": { + "step_name": "notify_stakeholders", + "step_type": "notification", + "description": "Send release announcement", + "status": "completed", + "metadata": { + "completed_at": "2026-03-25T23:46:00-05:00", + "notification_status": "sent", + "recipients": "all stakeholders", + "channels": [ + "email", + "slack", + "jira" + ], + "announcement": "v2.0.0 released successfully to production" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 10, + "total_steps_so_far": 9 + }, + "timestamp": "2026-03-25T22:51:55-05:00" + }, + "processed_at": "2026-03-25T22:51:55-05:00", + "execution_time_seconds": 0.001 + } + ], + "created_at": "2026-03-25T22:51:21-05:00", + "updated_at": "2026-03-25T22:51:55-05:00", + "data": { + "step_data": { + "workflow_name": "v2.0.0-release", + "description": "Release workflow for v2.0.0 with testing, security scanning, artifact building, staging deployment, QA sign-off, production deployment, and stakeholder notification", + "steps": [ + { + "step_name": "run_tests", + "step_type": "testing", + "description": "Execute the full test suite" + }, + { + "step_name": "security_scan", + "step_type": "security", + "description": "Run dependency vulnerability scan" + }, + { + "step_name": "build_artifacts", + "step_type": "build", + "description": "Compile and package the release artifacts" + }, + { + "step_name": "staging_deploy", + "step_type": "deployment", + "description": "Deploy to staging environment" + }, + { + "step_name": "qa_sign_off", + "step_type": "approval", + "description": "QA team reviews and approves staging" + }, + { + "step_name": "production_deploy", + "step_type": "deployment", + "description": "Deploy to production" + }, + { + "step_name": "notify_stakeholders", + "step_type": "notification", + "description": "Send release announcement" + } + ] + } + }, + "metadata": { + "step_count": 10, + "last_step_at": "2026-03-25T22:51:55-05:00" + }, + "completed_at": "2026-03-25T22:51:55-05:00" +} \ No newline at end of file diff --git a/examples/.workflows/workflow_37bcdad8-e55e-4729-b449-76babd8111d6.json b/examples/.workflows/workflow_37bcdad8-e55e-4729-b449-76babd8111d6.json new file mode 100644 index 0000000..4686963 --- /dev/null +++ b/examples/.workflows/workflow_37bcdad8-e55e-4729-b449-76babd8111d6.json @@ -0,0 +1,355 @@ +{ + "id": "37bcdad8-e55e-4729-b449-76babd8111d6", + "status": "completed", + "steps": [ + { + "step_number": 1, + "data": { + "step_data": { + "step_name": "run_tests", + "step_type": "testing", + "description": "Execute the full test suite", + "status": "completed", + "metadata": { + "completion_time": "2026-03-25T15:35:00-05:00", + "result": "passed" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 1, + "total_steps_so_far": 0 + }, + "timestamp": "2026-03-25T15:31:49-05:00" + }, + "processed_at": "2026-03-25T15:31:49-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 2, + "data": { + "step_data": { + "step_name": "security_scan", + "step_type": "security", + "description": "Run dependency vulnerability scan", + "status": "completed", + "metadata": { + "completion_time": "2026-03-25T15:40:00-05:00", + "vulnerabilities_found": 0, + "result": "passed" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 2, + "total_steps_so_far": 1 + }, + "timestamp": "2026-03-25T15:31:54-05:00" + }, + "processed_at": "2026-03-25T15:31:54-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 3, + "data": { + "step_data": { + "step_name": "build_artifacts", + "step_type": "build", + "description": "Compile and package the release artifacts", + "status": "in-progress", + "metadata": { + "start_time": "2026-03-25T15:40:30-05:00" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 3, + "total_steps_so_far": 2 + }, + "timestamp": "2026-03-25T15:31:54-05:00" + }, + "processed_at": "2026-03-25T15:31:54-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 4, + "data": { + "step_data": { + "step_name": "build_artifacts", + "step_type": "build", + "description": "Compile and package the release artifacts", + "status": "completed", + "metadata": { + "completion_time": "2026-03-25T15:45:30-05:00", + "artifacts_count": 8, + "build_size_mb": 542, + "result": "passed" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 4, + "total_steps_so_far": 3 + }, + "timestamp": "2026-03-25T15:32:04-05:00" + }, + "processed_at": "2026-03-25T15:32:04-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 5, + "data": { + "step_data": { + "step_name": "staging_deploy", + "step_type": "deployment", + "description": "Deploy to staging environment", + "status": "completed", + "metadata": { + "completion_time": "2026-03-25T15:50:15-05:00", + "environment": "staging", + "deployment_status": "successful", + "health_checks": "passed" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 5, + "total_steps_so_far": 4 + }, + "timestamp": "2026-03-25T15:32:04-05:00" + }, + "processed_at": "2026-03-25T15:32:04-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 6, + "data": { + "step_data": { + "step_name": "qa_sign_off", + "step_type": "approval", + "description": "QA team reviews and approves staging", + "status": "blocked", + "metadata": { + "blocked_at": "2026-03-25T15:50:30-05:00", + "blocked_reason": "Waiting for QA team availability", + "blocking_note": "Waiting for QA team availability" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 6, + "total_steps_so_far": 5 + }, + "timestamp": "2026-03-25T15:32:04-05:00" + }, + "processed_at": "2026-03-25T15:32:04-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 7, + "data": { + "step_data": { + "step_name": "qa_sign_off", + "step_type": "approval", + "description": "QA team reviews and approves staging", + "status": "completed", + "metadata": { + "completion_time": "2026-03-25T16:15:00-05:00", + "approved_by": "QA Team", + "approval_notes": "All test cases passed. Ready for production deployment.", + "result": "approved" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 7, + "total_steps_so_far": 6 + }, + "timestamp": "2026-03-25T15:32:13-05:00" + }, + "processed_at": "2026-03-25T15:32:13-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 8, + "data": { + "step_data": { + "step_name": "production_deploy", + "step_type": "deployment", + "description": "Deploy to production", + "status": "in-progress", + "metadata": { + "start_time": "2026-03-25T16:15:30-05:00", + "environment": "production", + "deployment_strategy": "blue-green" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 8, + "total_steps_so_far": 7 + }, + "timestamp": "2026-03-25T15:32:13-05:00" + }, + "processed_at": "2026-03-25T15:32:13-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 9, + "data": { + "step_data": { + "step_name": "production_deploy", + "step_type": "deployment", + "description": "Deploy to production", + "status": "completed", + "metadata": { + "completion_time": "2026-03-25T16:35:45-05:00", + "environment": "production", + "deployment_strategy": "blue-green", + "health_checks": "passed", + "rollback_available": true, + "result": "successful" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 9, + "total_steps_so_far": 8 + }, + "timestamp": "2026-03-25T15:32:21-05:00" + }, + "processed_at": "2026-03-25T15:32:21-05:00", + "execution_time_seconds": 0.001 + }, + { + "step_number": 10, + "data": { + "step_data": { + "step_name": "notify_stakeholders", + "step_type": "notification", + "description": "Send release announcement", + "status": "completed", + "metadata": { + "completion_time": "2026-03-25T16:40:00-05:00", + "notification_type": "email", + "recipients_notified": 45, + "channels": [ + "email", + "slack", + "github" + ], + "result": "delivered" + } + } + }, + "result": { + "processed": true, + "input_keys": [ + "step_data" + ], + "workflow_context": { + "current_step": 10, + "total_steps_so_far": 9 + }, + "timestamp": "2026-03-25T15:32:21-05:00" + }, + "processed_at": "2026-03-25T15:32:21-05:00", + "execution_time_seconds": 0.001 + } + ], + "created_at": "2026-03-25T15:31:42-05:00", + "updated_at": "2026-03-25T15:32:21-05:00", + "data": { + "step_data": { + "workflow_name": "v2.0.0-release", + "description": "Release workflow for version 2.0.0 including testing, security, building, and deployment", + "steps": [ + { + "step_name": "run_tests", + "step_type": "testing", + "description": "Execute the full test suite" + }, + { + "step_name": "security_scan", + "step_type": "security", + "description": "Run dependency vulnerability scan" + }, + { + "step_name": "build_artifacts", + "step_type": "build", + "description": "Compile and package the release artifacts" + }, + { + "step_name": "staging_deploy", + "step_type": "deployment", + "description": "Deploy to staging environment" + }, + { + "step_name": "qa_sign_off", + "step_type": "approval", + "description": "QA team reviews and approves staging" + }, + { + "step_name": "production_deploy", + "step_type": "deployment", + "description": "Deploy to production" + }, + { + "step_name": "notify_stakeholders", + "step_type": "notification", + "description": "Send release announcement" + } + ] + } + }, + "metadata": { + "step_count": 10, + "last_step_at": "2026-03-25T15:32:21-05:00" + }, + "completed_at": "2026-03-25T15:32:21-05:00" +} \ No newline at end of file diff --git a/examples/README.md b/examples/README.md index e49bf75..0880855 100644 --- a/examples/README.md +++ b/examples/README.md @@ -236,6 +236,28 @@ bundle exec ruby -I examples examples/eval_tool_demo.rb --- +### `notification_tool_demo.rb` + +Cross-platform desktop notifications, modal alert dialogs, and text-to-speech covering all three actions: + +- **notify** — banner notifications with title, subtitle, and sound options +- **speak** — TTS at default rate and with explicit words-per-minute override +- **alert** — single-button checkpoint dialog and a two-button Yes/No that reports which was clicked +- **Combined workflow** — all three actions chained in a single LLM prompt + +> **Note:** This demo triggers real OS interactions. `alert` **blocks** until you click a button. `speak` will use your system TTS engine out loud. + +**Linux prerequisites (optional):** +```bash +sudo apt install libnotify-bin zenity espeak-ng +``` + +```bash +bundle exec ruby -I examples examples/notification_tool_demo.rb +``` + +--- + ### `mcp_client_demo.rb` Model Context Protocol (MCP) client integration: connecting to MCP servers (Tavily, GitHub, iMCP), using MCP tools in LLM conversations, multi-client orchestration, and accessing MCP resources. diff --git a/examples/mcp_client_demo.rb b/examples/mcp_client_demo.rb index d16c9d4..75cb910 100755 --- a/examples/mcp_client_demo.rb +++ b/examples/mcp_client_demo.rb @@ -3,74 +3,174 @@ # # Demo: MCP Client Integration # -# Shows how to use MCP (Model Context Protocol) clients with RubyLLM. -# Demonstrates Tavily (web search), GitHub, and iMCP (macOS) clients. +# Demonstrates MCP clients that require NO pre-installed binaries: +# +# Remote HTTP (API key only — no Node.js, no local binary): +# Tavily web search +# +# npx auto-download (Node.js required — package downloads on first use): +# Memory, Sequential Thinking, Chart (no API key) +# Brave Search (BRAVE_API_KEY) # # Prerequisites: -# export TAVILY_API_KEY=your_key # for Tavily web search -# export GITHUB_PERSONAL_ACCESS_TOKEN=token # for GitHub MCP -# brew install github-mcp-server # for GitHub MCP -# brew install --cask loopwork/tap/iMCP # for iMCP (macOS only) +# Node.js / npx — for all npx-based clients (https://nodejs.org) +# +# export TAVILY_API_KEY=your_key # Tavily web search (https://tavily.com) +# export BRAVE_API_KEY=your_key # Brave web search (https://brave.com/search/api/) # # Run: # bundle exec ruby -I examples examples/mcp_client_demo.rb require_relative 'common' +def npx_available? + system("which npx > /dev/null 2>&1") +end -title "MCP Client Demo — Model Context Protocol Integration" +# Open every https:// URL found in a response string using the OS default browser. +# macOS uses `open`, Linux uses `xdg-open`. +def extract_urls(text) + text.scan(/https?:\/\/\S+/) + .map { |u| u.gsub(/[.,;:)\]"']+$/, '') } + .uniq +end -title "Example 1: Tavily Web Search MCP Client", char: '-' -if ENV['TAVILY_API_KEY'] - begin - require 'shared_tools/mcp/tavily_mcp_server' - client = RubyLLM::MCP.clients["tavily"] - puts "Tavily client loaded — #{client.tools.count} tools available." - rescue => e - puts "Error loading Tavily client: #{e.message}" +# Open chart URLs — checks the LLM response text first, then falls back to +# scanning tool result messages on @chat directly. The LLM often summarises +# charts in plain English without repeating the URL, so the fallback is needed. +def open_chart_urls(response) + urls = extract_urls(response.content.to_s) + + if urls.empty? && @chat.respond_to?(:messages) + tool_text = @chat.messages + .select { |m| m.role.to_s == 'tool' } + .map { |m| m.content.to_s } + .join("\n") + urls = extract_urls(tool_text) end -else - puts "Skipping — TAVILY_API_KEY not set. Export it to enable this example." -end -title "Example 2: GitHub MCP Server", char: '-' -if ENV['GITHUB_PERSONAL_ACCESS_TOKEN'] && File.exist?("/opt/homebrew/bin/github-mcp-server") - begin - require 'shared_tools/mcp/github_mcp_server' - client = RubyLLM::MCP.clients["github-mcp-server"] - puts "GitHub MCP client loaded — #{client.tools.count} tools available." - puts "Sample tools: #{client.tools.take(5).map(&:name).join(', ')}" - rescue => e - puts "Error loading GitHub client: #{e.message}" + if urls.empty? + puts " (no chart URLs found)" + return end -else - puts "Skipping — GITHUB_PERSONAL_ACCESS_TOKEN not set or github-mcp-server not installed." -end -title "Example 3: iMCP — macOS Integration", char: '-' -if RUBY_PLATFORM.include?('darwin') && File.exist?("/Applications/iMCP.app") - begin - require 'shared_tools/mcp/imcp' - client = RubyLLM::MCP.clients["imcp-server"] - puts "iMCP client loaded — #{client.tools.count} tools available (Notes, Calendar, Contacts, Reminders)." - rescue => e - puts "Error loading iMCP client: #{e.message}" + urls.each do |url| + puts " Opening: #{url}" + system('open', url) end -else - puts "Skipping — iMCP.app not installed or not running on macOS." end -title "Example 4: MCP Tools with LLM Chat", char: '-' -loaded = %w[tavily github-mcp-server imcp-server].filter_map do |name| - RubyLLM::MCP.clients[name] rescue nil +def load_client(require_path, client_name, &check) + return false unless check.nil? || check.call + require require_path + client = RubyLLM::MCP.clients[client_name] + puts " Loaded '#{client_name}' — #{client.tools.count} tools: #{client.tools.map(&:name).join(', ')}" + true +rescue => e + puts " Error loading '#{client_name}': #{e.message}" + false +end + +title "MCP Client Demo — Zero-install MCP Servers" + +puts <<~INFO + Two types of MCP client are shown: + Remote HTTP — connects to a hosted endpoint; only an API key required + npx — package auto-downloads on first use; requires Node.js + +INFO + +# --------------------------------------------------------------------------- +title "Remote HTTP Clients (API key only)", char: '-' +# --------------------------------------------------------------------------- + +puts "Tavily web search:" +tavily_loaded = load_client('shared_tools/mcp/tavily_mcp_server', 'tavily') { ENV['TAVILY_API_KEY'] && !ENV['TAVILY_API_KEY'].empty? } +puts " Skipped — set TAVILY_API_KEY to enable" unless tavily_loaded + +# --------------------------------------------------------------------------- +title "npx Auto-download Clients (no API key)", char: '-' +# --------------------------------------------------------------------------- + +unless npx_available? + puts "Node.js / npx not found in PATH — skipping all npx-based clients." + puts "Install Node.js from https://nodejs.org to enable these clients." +else + puts "Memory (persistent knowledge graph):" + memory_loaded = load_client('shared_tools/mcp/memory_mcp_server', 'memory') + + puts "\nSequential Thinking (chain-of-thought reasoning):" + thinking_loaded = load_client('shared_tools/mcp/sequential_thinking_mcp_server', 'sequential-thinking') + + puts "\nChart generation (AntV):" + chart_loaded = load_client('shared_tools/mcp/chart_mcp_server', 'chart') + + puts "\nBrave Search (web + news):" + brave_loaded = load_client('shared_tools/mcp/brave_search_mcp_server', 'brave-search') { ENV['BRAVE_API_KEY'] && !ENV['BRAVE_API_KEY'].empty? } + puts " Skipped — set BRAVE_API_KEY to enable" unless brave_loaded end -if loaded.any? - @chat = @chat.with_tools(*loaded.flat_map(&:tools)) - ask "What MCP tools are available and what can you do with them?" +# --------------------------------------------------------------------------- +title "LLM Chat with Loaded MCP Tools", char: '-' +# --------------------------------------------------------------------------- + +all_tools = %w[tavily memory sequential-thinking chart brave-search].filter_map do |name| + RubyLLM::MCP.clients[name]&.tools +rescue + nil +end.flatten + +if all_tools.empty? + puts "No MCP clients loaded. Set API keys and/or install Node.js, then re-run." else - puts "No MCP clients loaded — set API keys and install servers to run this example." + puts "Building chat with #{all_tools.size} MCP tools across #{all_tools.map(&:class).uniq.size} clients...\n\n" + @chat = new_chat.with_tools(*all_tools) + + ask "What MCP tools do you have available? List each one with a one-line description of what it does." + + # Run a demo task for whichever clients loaded + if tavily_loaded || brave_loaded + title "Web Search", char: '-' + @chat = new_chat.with_tools(*all_tools) + ask "Search for the most recent Ruby language release and summarise what's new." + end + + if memory_loaded + title "Memory — Store and Recall", char: '-' + @chat = new_chat.with_tools(*all_tools) + ask "Remember that this project is called 'shared_tools', it is a Ruby gem providing LLM-callable tools, and today's demo ran successfully. Then confirm what you stored." + ask "What do you remember about this project?" + end + + if thinking_loaded + title "Sequential Thinking", char: '-' + @chat = new_chat.with_tools(*all_tools) + ask <<~PROMPT + Use sequential thinking to work through this problem step by step: + A Ruby gem needs to support both macOS and Linux. It uses system commands + for notifications. What are the key concerns to address and in what order + should they be tackled? + PROMPT + end + + if chart_loaded + title "Chart Generation", char: '-' + @chat = new_chat.with_tools(*all_tools) + + response = ask <<~PROMPT + Generate a bar chart showing these monthly active users: + Jan: 1200, Feb: 1450, Mar: 1800, Apr: 2100, May: 1950, Jun: 2400 + Title it "Monthly Active Users — H1 2026". + PROMPT + open_chart_urls(response) + + response = ask <<~PROMPT + Generate a line chart showing the same data as a trend over time. + Title it "MAU Trend — H1 2026". + PROMPT + open_chart_urls(response) + end end title "Done", char: '-' -puts "MCP clients let the LLM reach external services via the Model Context Protocol." +puts "Remote HTTP and npx-based MCP clients demonstrated — no brew installs required." diff --git a/lib/shared_tools/mcp.rb b/lib/shared_tools/mcp.rb index 9449744..3a8f6b9 100644 --- a/lib/shared_tools/mcp.rb +++ b/lib/shared_tools/mcp.rb @@ -1,24 +1,26 @@ # lib/shared_tools/mcp.rb # -# MCP (Model Context Protocol) support for SharedTools using ruby_llm-mcp gem >= 0.7.0 +# MCP (Model Context Protocol) client support for SharedTools. +# Requires the ruby_llm-mcp gem >= 0.7.0 and RubyLLM >= 1.9.0. # -# This module provides integration with various MCP servers, allowing Ruby applications -# to connect to external services and tools through the Model Context Protocol. +# @see https://github.com/patvice/ruby_llm-mcp +# @see https://www.rubyllm-mcp.com # -# @see https://github.com/patvice/ruby_llm-mcp RubyLLM MCP documentation -# @see https://www.rubyllm-mcp.com Official documentation +# Two categories of client are provided, both requiring no pre-installed binaries: # -# Usage: -# require 'shared_tools/mcp/imcp' # Load iMCP client -# require 'shared_tools/mcp/github_mcp_server' # Load GitHub client -# require 'shared_tools/mcp/tavily_mcp_server' # Load Tavily client +# REMOTE HTTP (transport: :streamable) +# Connect to cloud-hosted MCP servers. Requires only an API key. # -# Requirements: -# - ruby_llm-mcp >= 0.7.0 -# - RubyLLM >= 1.9.0 +# require 'shared_tools/mcp/tavily_mcp_server' # Web search (TAVILY_API_KEY) # -# Version 0.7.0 Changes: -# - Complex parameter support is now enabled by default -# - Requires RubyLLM 1.9+ -# - support_complex_parameters! method is deprecated +# NPX AUTO-DOWNLOAD (transport: :stdio via npx -y) +# The npm package is downloaded on first use. Requires Node.js / npx. # +# require 'shared_tools/mcp/memory_mcp_server' # Persistent knowledge graph +# require 'shared_tools/mcp/sequential_thinking_mcp_server' # Chain-of-thought reasoning +# require 'shared_tools/mcp/chart_mcp_server' # Chart / visualisation generation +# require 'shared_tools/mcp/brave_search_mcp_server' # Web search (BRAVE_API_KEY) +# +# After requiring a client file, access it via: +# client = RubyLLM::MCP.clients["client-name"] +# chat = RubyLLM.chat.with_tools(*client.tools) diff --git a/lib/shared_tools/mcp/.keep b/lib/shared_tools/mcp/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/shared_tools/mcp/README.md b/lib/shared_tools/mcp/README.md index 3fc425b..8103394 100644 --- a/lib/shared_tools/mcp/README.md +++ b/lib/shared_tools/mcp/README.md @@ -1,46 +1,81 @@ # SharedTools MCP Clients -These MCP (Model Context Protocol) clients require the ruby_llm-mcp gem version >= 0.7.0 +MCP (Model Context Protocol) clients that require **no pre-installed binaries**. +Uses `ruby_llm-mcp` >= 0.7.0 and RubyLLM >= 1.9.0. -## About ruby_llm-mcp v0.7.0 +--- -Version 0.7.0 includes the following improvements: -- **Requires RubyLLM 1.9+**: This version mandates RubyLLM 1.9 or higher -- **Automatic Complex Parameters**: Complex parameter support (arrays, nested objects) is now enabled by default -- **Deprecated**: `support_complex_parameters!` method is deprecated and will be removed in v0.8.0 +## Two Approaches -## Available MCP Clients +### Remote HTTP — `:streamable` transport -### iMCP Server -**File**: `imcp.rb` -**Purpose**: Provides access to macOS Notes, Calendar, Contacts, etc. -**Installation**: `brew install --cask loopwork/tap/iMCP` -**Documentation**: https://github.com/loopwork/iMCP +Connects to a cloud-hosted MCP endpoint. The user needs only an API key — no Node.js, +no local binary, nothing to install. -### GitHub MCP Server -**File**: `github_mcp_server.rb` -**Purpose**: GitHub repository management and operations -**Installation**: `brew install github-mcp-server` -**Requires**: `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable +| File | Client name | Requires | Provides | +|------|-------------|----------|----------| +| `tavily_mcp_server.rb` | `"tavily"` | `TAVILY_API_KEY` | AI-optimized web search, news, research | -### Tavily MCP Server -**File**: `tavily_mcp_server.rb` -**Purpose**: Web search and research capabilities -**Requires**: `TAVILY_API_KEY` environment variable +### npx Auto-download — `:stdio` transport via `npx -y` + +The npm package is downloaded automatically on first use. The only prerequisite is +**Node.js / npx** being installed on the machine. + +| File | Client name | Requires | Provides | +|------|-------------|----------|----------| +| `memory_mcp_server.rb` | `"memory"` | Node.js | Persistent knowledge graph across conversations | +| `sequential_thinking_mcp_server.rb` | `"sequential-thinking"` | Node.js | Structured chain-of-thought reasoning | +| `chart_mcp_server.rb` | `"chart"` | Node.js | Chart and visualisation generation (AntV) | +| `brave_search_mcp_server.rb` | `"brave-search"` | Node.js + `BRAVE_API_KEY` | Web and news search | + +--- ## Usage -To use these MCP clients, simply require them in your Ruby application: +Require the client file to register it, then attach its tools to a chat: + +```ruby +require 'shared_tools/mcp/tavily_mcp_server' + +client = RubyLLM::MCP.clients["tavily"] +chat = RubyLLM.chat.with_tools(*client.tools) +chat.ask("Search for the latest Ruby 3.4 release notes") +``` + +Multiple clients can be combined: ```ruby -require 'shared_tools/mcp/imcp' # For iMCP server -require 'shared_tools/mcp/github_mcp_server' # For GitHub -require 'shared_tools/mcp/tavily_mcp_server' # For Tavily +require 'shared_tools/mcp/memory_mcp_server' +require 'shared_tools/mcp/sequential_thinking_mcp_server' -# The clients are automatically registered with RubyLLM::MCP -# Access their tools via RubyLLM::MCP.client("client-name") +tools = %w[memory sequential-thinking].flat_map { |n| RubyLLM::MCP.clients[n].tools } +chat = RubyLLM.chat.with_tools(*tools) ``` -## Configuration +--- + +## Environment Variables + +| Variable | Used by | Where to get it | +|----------|---------|-----------------| +| `TAVILY_API_KEY` | `tavily_mcp_server.rb` | https://tavily.com (free tier) | +| `BRAVE_API_KEY` | `brave_search_mcp_server.rb` | https://brave.com/search/api/ (free tier) | +| `MEMORY_FILE_PATH` | `memory_mcp_server.rb` | Optional — path to `.jsonl` persistence file | + +--- + +## Removed Clients + +The following clients were removed because they required manual binary installation: + +| Client | Reason removed | +|--------|---------------| +| `github_mcp_server.rb` | Required `brew install github-mcp-server` | +| `imcp.rb` | Required `brew install --cask loopwork/tap/iMCP` (macOS only) | + +--- + +## Reference Files -Each client file contains its own configuration using `RubyLLM::MCP.add_client()`. You can customize the configuration by modifying the respective files or creating your own client configurations. +- `mcp_server_chart.json` — Claude Code settings snippet for `@antv/mcp-server-chart` +- `playwright.md` — Notes on wiring up Playwright MCP in Claude Code settings diff --git a/lib/shared_tools/mcp/brave_search_mcp_server.rb b/lib/shared_tools/mcp/brave_search_mcp_server.rb new file mode 100644 index 0000000..aa2a7af --- /dev/null +++ b/lib/shared_tools/mcp/brave_search_mcp_server.rb @@ -0,0 +1,33 @@ +# shared_tools/mcp/brave_search_mcp_server.rb +# +# Brave Search MCP Server — npx auto-download (no pre-installation required) +# +# Provides web search and news search via the Brave Search API. +# +# Prerequisites: +# - Node.js and npx (https://nodejs.org) +# - A Brave Search API key (free tier available at https://brave.com/search/api/) +# The @modelcontextprotocol/server-brave-search package is downloaded +# automatically on first use via `npx -y`. +# +# Configuration: +# export BRAVE_API_KEY=your_api_key_here +# +# Usage: +# require 'shared_tools/mcp/brave_search_mcp_server' +# client = RubyLLM::MCP.clients["brave-search"] +# chat = RubyLLM.chat.with_tools(*client.tools) +# +# Compatible with ruby_llm-mcp >= 0.7.0 + +require "ruby_llm/mcp" + +RubyLLM::MCP.add_client( + name: "brave-search", + transport_type: :stdio, + config: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-brave-search"], + env: { "BRAVE_API_KEY" => ENV.fetch("BRAVE_API_KEY", "") }, + }, +) diff --git a/lib/shared_tools/mcp/chart_mcp_server.rb b/lib/shared_tools/mcp/chart_mcp_server.rb new file mode 100644 index 0000000..44fcfcd --- /dev/null +++ b/lib/shared_tools/mcp/chart_mcp_server.rb @@ -0,0 +1,32 @@ +# shared_tools/mcp/chart_mcp_server.rb +# +# AntV Chart MCP Server — npx auto-download (no pre-installation required) +# +# Generates charts and data visualisations (bar, line, pie, scatter, heatmap, etc.) +# from structured data. Returns chart URLs or base64-encoded images. +# +# Prerequisites: +# - Node.js and npx (https://nodejs.org) +# The @antv/mcp-server-chart package is downloaded automatically on first use +# via `npx -y`. +# +# No API key required. +# +# Usage: +# require 'shared_tools/mcp/chart_mcp_server' +# client = RubyLLM::MCP.clients["chart"] +# chat = RubyLLM.chat.with_tools(*client.tools) +# +# Compatible with ruby_llm-mcp >= 0.7.0 + +require "ruby_llm/mcp" + +RubyLLM::MCP.add_client( + name: "chart", + transport_type: :stdio, + config: { + command: "npx", + args: ["-y", "@antv/mcp-server-chart"], + env: {}, + }, +) diff --git a/lib/shared_tools/mcp/github_mcp_server.rb b/lib/shared_tools/mcp/github_mcp_server.rb deleted file mode 100644 index ea26835..0000000 --- a/lib/shared_tools/mcp/github_mcp_server.rb +++ /dev/null @@ -1,58 +0,0 @@ -# shared_tools/mcp/github_mcp_server.rb -# -# GitHub MCP Server Client Configuration for ruby_llm-mcp >= 0.7.0 -# -# Provides access to GitHub operations including: -# - Repository management -# - Issue and PR operations -# - Code search -# - Branch operations -# - And more GitHub API functionality -# -# Installation: -# brew install github-mcp-server -# -# Configuration: -# Set environment variable: GITHUB_PERSONAL_ACCESS_TOKEN -# export GITHUB_PERSONAL_ACCESS_TOKEN=your_token_here -# -# Compatible with ruby_llm-mcp v0.7.0+ - -require "ruby_llm/mcp" - -RubyLLM::MCP.add_client( - name: "github-mcp-server", - transport_type: :stdio, - config: { - command: "/opt/homebrew/bin/github-mcp-server", - args: %w[stdio], - env: { "GITHUB_PERSONAL_ACCESS_TOKEN" => ENV.fetch("GITHUB_PERSONAL_ACCESS_TOKEN") }, - }, -) - - -__END__ - - -A GitHub MCP server that handles various tools and resources. - -Usage: - server [command] - -Available Commands: - completion Generate the autocompletion script for the specified shell - help Help about any command - stdio Start stdio server - -Flags: - --dynamic-toolsets Enable dynamic toolsets - --enable-command-logging When enabled, the server will log all command requests and responses to the log file - --export-translations Save translations to a JSON file - --gh-host string Specify the GitHub hostname (for GitHub Enterprise etc.) - -h, --help help for server - --log-file string Path to log file - --read-only Restrict the server to read-only operations - --toolsets strings An optional comma separated list of groups of tools to allow, defaults to enabling all (default [all]) - -v, --version version for server - -Use "server [command] --help" for more information about a command. diff --git a/lib/shared_tools/mcp/imcp.rb b/lib/shared_tools/mcp/imcp.rb deleted file mode 100644 index 8cf6c67..0000000 --- a/lib/shared_tools/mcp/imcp.rb +++ /dev/null @@ -1,28 +0,0 @@ -# shared_tools/mcp/imcp.rb -# -# iMCP Client Configuration for ruby_llm-mcp >= 0.7.0 -# -# iMCP is a macOS application that provides MCP access to: -# - Notes -# - Calendar -# - Contacts -# - Reminders -# - And other macOS native applications -# -# Installation: -# brew install --cask loopwork/tap/iMCP -# -# Documentation: -# https://github.com/loopwork/iMCP -# -# Compatible with ruby_llm-mcp v0.7.0+ - -require 'ruby_llm/mcp' - -RubyLLM::MCP.add_client( - name: "imcp-server", - transport_type: :stdio, - config: { - command: "/Applications/iMCP.app/Contents/MacOS/imcp-server 2> /dev/null" - } -) diff --git a/lib/shared_tools/mcp/memory_mcp_server.rb b/lib/shared_tools/mcp/memory_mcp_server.rb new file mode 100644 index 0000000..2346771 --- /dev/null +++ b/lib/shared_tools/mcp/memory_mcp_server.rb @@ -0,0 +1,33 @@ +# shared_tools/mcp/memory_mcp_server.rb +# +# MCP Memory Server — npx auto-download (no pre-installation required) +# +# Provides a persistent knowledge graph that the LLM can read and write across +# conversations: create/update/delete entities, add relations, search memories. +# +# Prerequisites: +# - Node.js and npx (https://nodejs.org) +# The @modelcontextprotocol/server-memory package is downloaded automatically +# on first use via `npx -y`. +# +# Configuration (all optional): +# export MEMORY_FILE_PATH=/path/to/memory.jsonl # default: memory.jsonl in cwd +# +# Usage: +# require 'shared_tools/mcp/memory_mcp_server' +# client = RubyLLM::MCP.clients["memory"] +# chat = RubyLLM.chat.with_tools(*client.tools) +# +# Compatible with ruby_llm-mcp >= 0.7.0 + +require "ruby_llm/mcp" + +RubyLLM::MCP.add_client( + name: "memory", + transport_type: :stdio, + config: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-memory"], + env: ENV['MEMORY_FILE_PATH'] ? { "MEMORY_FILE_PATH" => ENV['MEMORY_FILE_PATH'] } : {}, + }, +) diff --git a/lib/shared_tools/mcp/sequential_thinking_mcp_server.rb b/lib/shared_tools/mcp/sequential_thinking_mcp_server.rb new file mode 100644 index 0000000..57bca8b --- /dev/null +++ b/lib/shared_tools/mcp/sequential_thinking_mcp_server.rb @@ -0,0 +1,33 @@ +# shared_tools/mcp/sequential_thinking_mcp_server.rb +# +# MCP Sequential Thinking Server — npx auto-download (no pre-installation required) +# +# Provides a structured chain-of-thought reasoning tool. The LLM can break complex +# problems into numbered steps, revise earlier steps, and branch reasoning paths +# before committing to a conclusion. +# +# Prerequisites: +# - Node.js and npx (https://nodejs.org) +# The @modelcontextprotocol/server-sequential-thinking package is downloaded +# automatically on first use via `npx -y`. +# +# No API key required. +# +# Usage: +# require 'shared_tools/mcp/sequential_thinking_mcp_server' +# client = RubyLLM::MCP.clients["sequential-thinking"] +# chat = RubyLLM.chat.with_tools(*client.tools) +# +# Compatible with ruby_llm-mcp >= 0.7.0 + +require "ruby_llm/mcp" + +RubyLLM::MCP.add_client( + name: "sequential-thinking", + transport_type: :stdio, + config: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-sequential-thinking"], + env: {}, + }, +) diff --git a/lib/shared_tools/mcp/tavily_mcp_server.rb b/lib/shared_tools/mcp/tavily_mcp_server.rb index 3d7623a..c342218 100644 --- a/lib/shared_tools/mcp/tavily_mcp_server.rb +++ b/lib/shared_tools/mcp/tavily_mcp_server.rb @@ -1,44 +1,33 @@ # shared_tools/mcp/tavily_mcp_server.rb # -# Tavily MCP Server Client Configuration for ruby_llm-mcp >= 0.7.0 +# Tavily MCP Server Client — Remote HTTP (no local installation required) # -# Provides AI-powered web search and research capabilities: -# - Web search with AI-optimized results -# - Research-grade content extraction -# - Real-time information gathering -# - News and current events search +# Connects directly to Tavily's hosted MCP endpoint via Streamable HTTP transport. +# No npx, no Node.js, no local binary required — only a Tavily API key. # -# Installation: -# Requires Node.js and npx (comes with Node.js) -# The mcp-remote package will be installed automatically via npx +# Provides: +# - AI-optimized web search +# - Research-grade content extraction +# - Real-time news and current events # # Configuration: -# Set environment variable: TAVILY_API_KEY # export TAVILY_API_KEY=your_api_key_here -# Get your API key at: https://tavily.com +# Get a free key at: https://tavily.com # -# Compatible with ruby_llm-mcp v0.7.0+ +# Usage: +# require 'shared_tools/mcp/tavily_mcp_server' +# client = RubyLLM::MCP.clients["tavily"] +# chat = RubyLLM.chat.with_tools(*client.tools) +# +# Compatible with ruby_llm-mcp >= 0.7.0 require "ruby_llm/mcp" RubyLLM::MCP.add_client( name: "tavily", - transport_type: :stdio, + transport_type: :streamable, config: { - command: "npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=#{ENV.fetch('TAVILY_API_KEY')}", - env: {} - } + url: "https://mcp.tavily.com/mcp/", + headers: { "Authorization" => "Bearer #{ENV.fetch('TAVILY_API_KEY', '')}" }, + }, ) - - -__END__ - - -# { -# "mcpServers": { -# "tavily-remote-mcp": { -# "command": "npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=$TAVILY_API_KEY", -# "env": {} -# } -# } -# } diff --git a/lib/shared_tools/mcp/temp.md b/lib/shared_tools/mcp/temp.md deleted file mode 100644 index 9e91056..0000000 --- a/lib/shared_tools/mcp/temp.md +++ /dev/null @@ -1,84 +0,0 @@ - -You: use your tools to browse the web looking for recent news stories about Bossier City LA 71111. Give me a summary of what you find. - -AI: - Here's a summary of recent news stories related to Bossier City, LA 71111: - - 1. **Local News and Events**: The Shreveport-Bossier Advocate covers various topics, including local news, sports, food, and entertainment. For more detailed updates, you can visit their website [here](https://www.shreveportbossieradvocate.com/). - - 2. **Bossier Farmers Market**: The Shreveport-Bossier Journal highlights community events like the Bossier Farmers Market and the 'Dog Days of Summer' event happening this Saturday. More updates can be found on their site [here](https://shreveportbossierjournal.com/). - - 3. **Fire Incident**: An incident was reported where a Sportran bus exploded on North Hearne in Shreveport. This story is covered on social media and can be viewed on Instagram [here](https://www.instagram.com/loveshreveportbossier/). - - 4. **Safety Ranking**: A report from Reolink has ranked Bossier City as the 10th most dangerous city in Louisiana, while Shreveport did not appear on the list. More details are available on KSLA News' Facebook page [here](https://www.facebook.com/KSLANews12/posts/bossier-city-ranked-10th-most-dangerous-city-in-louisiana-according-to-a-report-/1186567093511263/). - - 5. **Weather and Breaking News**: KSLA News 12 provides updates on severe weather and breaking news in the region, covering Bossier City and surrounding areas. You can check their updates [here](https://www.ksla.com/). - - These sources provide a mix of local events, safety concerns, and community news relevant to Bossier City. - -You: please be more detailed that just give me links to websites. go inside those websites and summarize the stories you find. - -AI: - Here’s a detailed summary of recent news stories related to Bossier City, LA 71111, from various local sources: - - ### 1. **Governor Jeff Landry's Bill Signing** - - **Source**: Shreveport-Bossier Advocate - - Governor Jeff Landry held a bill signing in Bossier City focusing on reforms to welfare and unemployment programs aimed at reducing government intervention. - - ### 2. **Robbery Incident Using Dating App** - - **Source**: Shreveport-Bossier Advocate - - Shreveport police are investigating two women accused of using a fake profile on a dating app to lure men into a robbery. - - ### 3. **Square Dance Convention** - - **Source**: Shreveport-Bossier Advocate - - The 74th National Square Dance Convention is currently taking place in Shreveport, attracting attendees from various regions. - - ### 4. **Accreditation for LSU Health** - - **Source**: Shreveport-Bossier Advocate - - LSU Health in Shreveport has received continued accreditation from the Accreditation Council for Graduate Medical Education (ACGME). - - ### 5. **Caddo Parish Sheriff’s Office Updates** - - **Source**: Shreveport-Bossier Journal - - The CPSO is actively seeking suspects involved in various battery and theft cases in the area. - - ### 6. **Boil Advisory Lifted** - - **Source**: Shreveport-Bossier Journal - - A boil advisory affecting residents on Chandler Road has been lifted, with clean water restored. - - ### 7. **Fatal Crash Involving Truck** - - **Source**: KSLA News - - A truck collided with an 18-wheeler in a fatal crash; the victim has been identified. - - ### 8. **Shreveport Job Corps Closure** - - **Source**: KSLA News - - The Shreveport Job Corps has been ordered to shut down following funding cuts, prompting responses from the city council. - - ### 9. **Shreveport Fire Safety Initiative** - - **Source**: KSLA News - - The Shreveport Fire Department is inviting the public to tour fire stations to learn about fire safety and promote their summer camp. - - ### 10. **Bossier City Residents Open House** - - **Source**: KSLA News - - Residents are invited to attend an open house hosted by Barksdale Air Force Base, aimed at fostering community relations. - - These articles highlight significant local events, safety concerns, and community initiatives in Bossier City and nearby areas. - -You: give me a longer summary of article #10 - -AI: - ### Bossier City Residents Open House Hosted by Barksdale Air Force Base - - Barksdale Air Force Base is inviting residents of Bossier City to attend an open house event designed to foster community relations and offer insights into the base's operations. This initiative aims to strengthen the bond between the military and local communities, providing an opportunity for residents to learn more about the base's role in national defense and its contributions to the local economy. - - During the open house, attendees will have the chance to tour various facilities, meet military personnel, and engage in discussions about the base's impact on the region. This event is part of broader efforts by Barksdale to maintain transparency and encourage collaboration with the surrounding community. - - In addition to the tours, there may be informational booths set up to provide resources and answer questions about military service, community programs, and local partnerships. The open house is not only an opportunity for education but also a chance for residents to express their support and appreciation for the service members stationed there. - - The event reflects an ongoing commitment by Barksdale Air Force Base to be a good neighbor and actively participate in the life of Bossier City, enhancing mutual understanding and respect between the military and civilian populations. Residents are encouraged to attend and take advantage of this unique opportunity to connect with those who serve in their community. - -You: that was an interesting summary but it did not included any logistics information like location, date time etc. can you get that data - -AI: - It appears that there is no specific information available regarding the date, time, or any logistical details for the upcoming open house at Barksdale Air Force Base. The search did not yield relevant results directly detailing the event, such as when it will occur or specific location instructions. - - For the most accurate and up-to-date information, you may want to check the official Barksdale Air Force Base website or their social media channels, as they often post details about community events and open houses. Additionally, local news outlets might provide announcements closer to the event date. If you have specific contacts at the base, reaching out directly could also yield the necessary logistics for the open house event. From 2b6f595d3844c37c8a9f8002e9a2c4331266394c Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Thu, 26 Mar 2026 12:50:57 -0500 Subject: [PATCH 08/17] feat(mcp_client_demo): update client file paths The commit message should be: feat(mcp_client_demo): update client file paths This commit updates the file paths used to load the various MCP clients in the `mcp_client_demo.rb` file. The changes ensure that the correct client files are loaded for each MCP service. --- examples/mcp_client_demo.rb | 10 +++---- lib/shared_tools/mcp.rb | 30 +++++++++++++++---- lib/shared_tools/mcp/README.md | 22 +++++++------- ...h_mcp_server.rb => brave_search_client.rb} | 6 ++-- .../{chart_mcp_server.rb => chart_client.rb} | 4 +-- ...{memory_mcp_server.rb => memory_client.rb} | 4 +-- ...erver.rb => sequential_thinking_client.rb} | 4 +-- ...{tavily_mcp_server.rb => tavily_client.rb} | 6 ++-- 8 files changed, 54 insertions(+), 32 deletions(-) rename lib/shared_tools/mcp/{brave_search_mcp_server.rb => brave_search_client.rb} (86%) rename lib/shared_tools/mcp/{chart_mcp_server.rb => chart_client.rb} (89%) rename lib/shared_tools/mcp/{memory_mcp_server.rb => memory_client.rb} (91%) rename lib/shared_tools/mcp/{sequential_thinking_mcp_server.rb => sequential_thinking_client.rb} (88%) rename lib/shared_tools/mcp/{tavily_mcp_server.rb => tavily_client.rb} (86%) diff --git a/examples/mcp_client_demo.rb b/examples/mcp_client_demo.rb index 75cb910..d762d87 100755 --- a/examples/mcp_client_demo.rb +++ b/examples/mcp_client_demo.rb @@ -85,7 +85,7 @@ def load_client(require_path, client_name, &check) # --------------------------------------------------------------------------- puts "Tavily web search:" -tavily_loaded = load_client('shared_tools/mcp/tavily_mcp_server', 'tavily') { ENV['TAVILY_API_KEY'] && !ENV['TAVILY_API_KEY'].empty? } +tavily_loaded = load_client('shared_tools/mcp/tavily_client', 'tavily') { ENV['TAVILY_API_KEY'] && !ENV['TAVILY_API_KEY'].empty? } puts " Skipped — set TAVILY_API_KEY to enable" unless tavily_loaded # --------------------------------------------------------------------------- @@ -97,16 +97,16 @@ def load_client(require_path, client_name, &check) puts "Install Node.js from https://nodejs.org to enable these clients." else puts "Memory (persistent knowledge graph):" - memory_loaded = load_client('shared_tools/mcp/memory_mcp_server', 'memory') + memory_loaded = load_client('shared_tools/mcp/memory_client', 'memory') puts "\nSequential Thinking (chain-of-thought reasoning):" - thinking_loaded = load_client('shared_tools/mcp/sequential_thinking_mcp_server', 'sequential-thinking') + thinking_loaded = load_client('shared_tools/mcp/sequential_thinking_client', 'sequential-thinking') puts "\nChart generation (AntV):" - chart_loaded = load_client('shared_tools/mcp/chart_mcp_server', 'chart') + chart_loaded = load_client('shared_tools/mcp/chart_client', 'chart') puts "\nBrave Search (web + news):" - brave_loaded = load_client('shared_tools/mcp/brave_search_mcp_server', 'brave-search') { ENV['BRAVE_API_KEY'] && !ENV['BRAVE_API_KEY'].empty? } + brave_loaded = load_client('shared_tools/mcp/brave_search_client', 'brave-search') { ENV['BRAVE_API_KEY'] && !ENV['BRAVE_API_KEY'].empty? } puts " Skipped — set BRAVE_API_KEY to enable" unless brave_loaded end diff --git a/lib/shared_tools/mcp.rb b/lib/shared_tools/mcp.rb index 3a8f6b9..b78d0cb 100644 --- a/lib/shared_tools/mcp.rb +++ b/lib/shared_tools/mcp.rb @@ -11,16 +11,34 @@ # REMOTE HTTP (transport: :streamable) # Connect to cloud-hosted MCP servers. Requires only an API key. # -# require 'shared_tools/mcp/tavily_mcp_server' # Web search (TAVILY_API_KEY) +# require 'shared_tools/mcp/tavily_client' # Web search (TAVILY_API_KEY) # # NPX AUTO-DOWNLOAD (transport: :stdio via npx -y) # The npm package is downloaded on first use. Requires Node.js / npx. # -# require 'shared_tools/mcp/memory_mcp_server' # Persistent knowledge graph -# require 'shared_tools/mcp/sequential_thinking_mcp_server' # Chain-of-thought reasoning -# require 'shared_tools/mcp/chart_mcp_server' # Chart / visualisation generation -# require 'shared_tools/mcp/brave_search_mcp_server' # Web search (BRAVE_API_KEY) +# require 'shared_tools/mcp/memory_client' # Persistent knowledge graph +# require 'shared_tools/mcp/sequential_thinking_client' # Chain-of-thought reasoning +# require 'shared_tools/mcp/chart_client' # Chart / visualisation generation +# require 'shared_tools/mcp/brave_search_client' # Web search (BRAVE_API_KEY) # -# After requiring a client file, access it via: +# Requiring this file loads ALL available clients concurrently using threads. +# Each client's transport connection is established in parallel, so total startup +# time equals the slowest single client rather than the sum of all clients. +# +# Clients whose API keys are missing are silently skipped. +# +# After loading, access clients via: # client = RubyLLM::MCP.clients["client-name"] # chat = RubyLLM.chat.with_tools(*client.tools) + +require "ruby_llm/mcp" + +threads = Dir[File.join(__dir__, "mcp", "*_client.rb")].map do |path| + Thread.new do + require path + rescue => e + warn "SharedTools::MCP — failed to load #{File.basename(path)}: #{e.message}" + end +end + +threads.each(&:join) diff --git a/lib/shared_tools/mcp/README.md b/lib/shared_tools/mcp/README.md index 8103394..b473fc8 100644 --- a/lib/shared_tools/mcp/README.md +++ b/lib/shared_tools/mcp/README.md @@ -14,7 +14,7 @@ no local binary, nothing to install. | File | Client name | Requires | Provides | |------|-------------|----------|----------| -| `tavily_mcp_server.rb` | `"tavily"` | `TAVILY_API_KEY` | AI-optimized web search, news, research | +| `tavily_client.rb` | `"tavily"` | `TAVILY_API_KEY` | AI-optimized web search, news, research | ### npx Auto-download — `:stdio` transport via `npx -y` @@ -23,10 +23,10 @@ The npm package is downloaded automatically on first use. The only prerequisite | File | Client name | Requires | Provides | |------|-------------|----------|----------| -| `memory_mcp_server.rb` | `"memory"` | Node.js | Persistent knowledge graph across conversations | -| `sequential_thinking_mcp_server.rb` | `"sequential-thinking"` | Node.js | Structured chain-of-thought reasoning | -| `chart_mcp_server.rb` | `"chart"` | Node.js | Chart and visualisation generation (AntV) | -| `brave_search_mcp_server.rb` | `"brave-search"` | Node.js + `BRAVE_API_KEY` | Web and news search | +| `memory_client.rb` | `"memory"` | Node.js | Persistent knowledge graph across conversations | +| `sequential_thinking_client.rb` | `"sequential-thinking"` | Node.js | Structured chain-of-thought reasoning | +| `chart_client.rb` | `"chart"` | Node.js | Chart and visualisation generation (AntV) | +| `brave_search_client.rb` | `"brave-search"` | Node.js + `BRAVE_API_KEY` | Web and news search | --- @@ -35,7 +35,7 @@ The npm package is downloaded automatically on first use. The only prerequisite Require the client file to register it, then attach its tools to a chat: ```ruby -require 'shared_tools/mcp/tavily_mcp_server' +require 'shared_tools/mcp/tavily_client' client = RubyLLM::MCP.clients["tavily"] chat = RubyLLM.chat.with_tools(*client.tools) @@ -45,8 +45,8 @@ chat.ask("Search for the latest Ruby 3.4 release notes") Multiple clients can be combined: ```ruby -require 'shared_tools/mcp/memory_mcp_server' -require 'shared_tools/mcp/sequential_thinking_mcp_server' +require 'shared_tools/mcp/memory_client' +require 'shared_tools/mcp/sequential_thinking_client' tools = %w[memory sequential-thinking].flat_map { |n| RubyLLM::MCP.clients[n].tools } chat = RubyLLM.chat.with_tools(*tools) @@ -58,9 +58,9 @@ chat = RubyLLM.chat.with_tools(*tools) | Variable | Used by | Where to get it | |----------|---------|-----------------| -| `TAVILY_API_KEY` | `tavily_mcp_server.rb` | https://tavily.com (free tier) | -| `BRAVE_API_KEY` | `brave_search_mcp_server.rb` | https://brave.com/search/api/ (free tier) | -| `MEMORY_FILE_PATH` | `memory_mcp_server.rb` | Optional — path to `.jsonl` persistence file | +| `TAVILY_API_KEY` | `tavily_client.rb` | https://tavily.com (free tier) | +| `BRAVE_API_KEY` | `brave_search_client.rb` | https://brave.com/search/api/ (free tier) | +| `MEMORY_FILE_PATH` | `memory_client.rb` | Optional — path to `.jsonl` persistence file | --- diff --git a/lib/shared_tools/mcp/brave_search_mcp_server.rb b/lib/shared_tools/mcp/brave_search_client.rb similarity index 86% rename from lib/shared_tools/mcp/brave_search_mcp_server.rb rename to lib/shared_tools/mcp/brave_search_client.rb index aa2a7af..87a911c 100644 --- a/lib/shared_tools/mcp/brave_search_mcp_server.rb +++ b/lib/shared_tools/mcp/brave_search_client.rb @@ -1,4 +1,4 @@ -# shared_tools/mcp/brave_search_mcp_server.rb +# shared_tools/mcp/brave_search_client.rb # # Brave Search MCP Server — npx auto-download (no pre-installation required) # @@ -14,7 +14,7 @@ # export BRAVE_API_KEY=your_api_key_here # # Usage: -# require 'shared_tools/mcp/brave_search_mcp_server' +# require 'shared_tools/mcp/brave_search_client' # client = RubyLLM::MCP.clients["brave-search"] # chat = RubyLLM.chat.with_tools(*client.tools) # @@ -22,6 +22,8 @@ require "ruby_llm/mcp" +return if ENV.fetch("BRAVE_API_KEY", "").empty? + RubyLLM::MCP.add_client( name: "brave-search", transport_type: :stdio, diff --git a/lib/shared_tools/mcp/chart_mcp_server.rb b/lib/shared_tools/mcp/chart_client.rb similarity index 89% rename from lib/shared_tools/mcp/chart_mcp_server.rb rename to lib/shared_tools/mcp/chart_client.rb index 44fcfcd..11eecef 100644 --- a/lib/shared_tools/mcp/chart_mcp_server.rb +++ b/lib/shared_tools/mcp/chart_client.rb @@ -1,4 +1,4 @@ -# shared_tools/mcp/chart_mcp_server.rb +# shared_tools/mcp/chart_client.rb # # AntV Chart MCP Server — npx auto-download (no pre-installation required) # @@ -13,7 +13,7 @@ # No API key required. # # Usage: -# require 'shared_tools/mcp/chart_mcp_server' +# require 'shared_tools/mcp/chart_client' # client = RubyLLM::MCP.clients["chart"] # chat = RubyLLM.chat.with_tools(*client.tools) # diff --git a/lib/shared_tools/mcp/memory_mcp_server.rb b/lib/shared_tools/mcp/memory_client.rb similarity index 91% rename from lib/shared_tools/mcp/memory_mcp_server.rb rename to lib/shared_tools/mcp/memory_client.rb index 2346771..732a6a1 100644 --- a/lib/shared_tools/mcp/memory_mcp_server.rb +++ b/lib/shared_tools/mcp/memory_client.rb @@ -1,4 +1,4 @@ -# shared_tools/mcp/memory_mcp_server.rb +# shared_tools/mcp/memory_client.rb # # MCP Memory Server — npx auto-download (no pre-installation required) # @@ -14,7 +14,7 @@ # export MEMORY_FILE_PATH=/path/to/memory.jsonl # default: memory.jsonl in cwd # # Usage: -# require 'shared_tools/mcp/memory_mcp_server' +# require 'shared_tools/mcp/memory_client' # client = RubyLLM::MCP.clients["memory"] # chat = RubyLLM.chat.with_tools(*client.tools) # diff --git a/lib/shared_tools/mcp/sequential_thinking_mcp_server.rb b/lib/shared_tools/mcp/sequential_thinking_client.rb similarity index 88% rename from lib/shared_tools/mcp/sequential_thinking_mcp_server.rb rename to lib/shared_tools/mcp/sequential_thinking_client.rb index 57bca8b..4b880dc 100644 --- a/lib/shared_tools/mcp/sequential_thinking_mcp_server.rb +++ b/lib/shared_tools/mcp/sequential_thinking_client.rb @@ -1,4 +1,4 @@ -# shared_tools/mcp/sequential_thinking_mcp_server.rb +# shared_tools/mcp/sequential_thinking_client.rb # # MCP Sequential Thinking Server — npx auto-download (no pre-installation required) # @@ -14,7 +14,7 @@ # No API key required. # # Usage: -# require 'shared_tools/mcp/sequential_thinking_mcp_server' +# require 'shared_tools/mcp/sequential_thinking_client' # client = RubyLLM::MCP.clients["sequential-thinking"] # chat = RubyLLM.chat.with_tools(*client.tools) # diff --git a/lib/shared_tools/mcp/tavily_mcp_server.rb b/lib/shared_tools/mcp/tavily_client.rb similarity index 86% rename from lib/shared_tools/mcp/tavily_mcp_server.rb rename to lib/shared_tools/mcp/tavily_client.rb index c342218..f502b24 100644 --- a/lib/shared_tools/mcp/tavily_mcp_server.rb +++ b/lib/shared_tools/mcp/tavily_client.rb @@ -1,4 +1,4 @@ -# shared_tools/mcp/tavily_mcp_server.rb +# shared_tools/mcp/tavily_client.rb # # Tavily MCP Server Client — Remote HTTP (no local installation required) # @@ -15,7 +15,7 @@ # Get a free key at: https://tavily.com # # Usage: -# require 'shared_tools/mcp/tavily_mcp_server' +# require 'shared_tools/mcp/tavily_client' # client = RubyLLM::MCP.clients["tavily"] # chat = RubyLLM.chat.with_tools(*client.tools) # @@ -23,6 +23,8 @@ require "ruby_llm/mcp" +return if ENV.fetch("TAVILY_API_KEY", "").empty? + RubyLLM::MCP.add_client( name: "tavily", transport_type: :streamable, From ae7f4d9ba105161c9c05e245985f8d85cc9380f8 Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Thu, 26 Mar 2026 13:59:54 -0500 Subject: [PATCH 09/17] feat(mcp): add GitHub client for MCP server The commit introduces a new GitHub client for the MCP (Machine Capability Provider) server. The client provides full access to the GitHub API, including repositories, issues, pull requests, code search, commits, branches, and releases. The client has the following key features: - Requires Homebrew (https://brew.sh) and a GitHub Personal Access Token for configuration. - Automatically installs the `github-mcp-server` binary via Homebrew if missing. - Integrates with the `ruby_llm-mcp` gem, allowing the client to be used with the RubyLLM framework. - Includes a `SharedTools.utilities` module with utility methods for verifying environment variables and installing packages. This commit adds new files `lib/shared_tools/mcp/github_client.rb` and `lib/shared_tools/utilities.rb`, and modifies `lib/shared_tools.rb` to load the new utilities module. --- lib/shared_tools.rb | 3 + lib/shared_tools/mcp/github_client.rb | 38 +++++++ lib/shared_tools/utilities.rb | 140 ++++++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 lib/shared_tools/mcp/github_client.rb create mode 100644 lib/shared_tools/utilities.rb diff --git a/lib/shared_tools.rb b/lib/shared_tools.rb index 78432de..eeee4a4 100644 --- a/lib/shared_tools.rb +++ b/lib/shared_tools.rb @@ -25,6 +25,7 @@ Dir.glob("#{__dir__}/shared_tools/*_tool.rb").each { |f| SharedToolsLoader.ignore(f) } SharedToolsLoader.ignore("#{__dir__}/shared_tools/data_science_kit.rb") SharedToolsLoader.ignore("#{__dir__}/shared_tools/database.rb") +SharedToolsLoader.ignore("#{__dir__}/shared_tools/utilities.rb") # Reopens SharedTools, not SharedTools::Utilities SharedToolsLoader.setup @@ -66,3 +67,5 @@ def tools end end end + +require_relative "shared_tools/utilities" diff --git a/lib/shared_tools/mcp/github_client.rb b/lib/shared_tools/mcp/github_client.rb new file mode 100644 index 0000000..ee90254 --- /dev/null +++ b/lib/shared_tools/mcp/github_client.rb @@ -0,0 +1,38 @@ +# shared_tools/mcp/github_client.rb +# +# GitHub MCP Server Client — requires brew installation +# +# Provides full GitHub API access: repositories, issues, pull requests, +# code search, commits, branches, releases, and more. +# +# Prerequisites: +# - Homebrew (https://brew.sh) +# - A GitHub Personal Access Token +# The github-mcp-server binary is installed automatically via brew if missing. +# +# Configuration: +# export GITHUB_PERSONAL_ACCESS_TOKEN=your_token_here +# +# Usage: +# require 'shared_tools/mcp/github_client' +# client = RubyLLM::MCP.clients["github"] +# chat = RubyLLM.chat.with_tools(*client.tools) +# +# Compatible with ruby_llm-mcp >= 0.7.0 + +require_relative "../utilities" + +return unless SharedTools.verify_envars("GITHUB_PERSONAL_ACCESS_TOKEN") +return unless SharedTools.package_install("github-mcp-server") + +require "ruby_llm/mcp" + +RubyLLM::MCP.add_client( + name: "github", + transport_type: :stdio, + config: { + command: "github-mcp-server", + args: ["stdio"], + env: { "GITHUB_PERSONAL_ACCESS_TOKEN" => ENV.fetch("GITHUB_PERSONAL_ACCESS_TOKEN", "") }, + }, +) diff --git a/lib/shared_tools/utilities.rb b/lib/shared_tools/utilities.rb new file mode 100644 index 0000000..8bdbe92 --- /dev/null +++ b/lib/shared_tools/utilities.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true +# +# lib/shared_tools/utilities.rb +# +# General-purpose utility methods for SharedTools MCP clients and tools. +# Loaded automatically by lib/shared_tools.rb. + +module SharedTools + class << self + + # Returns true if all named environment variables are set and non-empty. + # Warns for each missing variable and returns false if any are absent. + # + # SharedTools.verify_envars("GITHUB_PERSONAL_ACCESS_TOKEN") + # SharedTools.verify_envars("TAVILY_API_KEY", "ANOTHER_KEY") + def verify_envars(*names) + missing = names.select { |n| ENV.fetch(n, "").empty? } + missing.each { |n| warn "SharedTools — #{n} is not set" } + missing.empty? + end + + # High-level package installer. Detects the current platform and calls the + # appropriate *_install method. Returns true if all packages are available. + # + # SharedTools.package_install("github-mcp-server") + # SharedTools.package_install("curl", "jq") + def package_install(*packages) + case RUBY_PLATFORM + when /darwin/ + brew_install(*packages) + when /linux/ + if system("which apt-get > /dev/null 2>&1") + apt_install(*packages) + elsif system("which dnf > /dev/null 2>&1") + dnf_install(*packages) + elsif system("which brew > /dev/null 2>&1") + brew_install(*packages) + else + warn "SharedTools — no supported package manager found (apt-get, dnf, brew)" + false + end + else + warn "SharedTools — unsupported platform: #{RUBY_PLATFORM}" + false + end + end + + # Ensures each named binary is available in PATH, installing via brew if missing. + # Returns true if all binaries are present (or successfully installed). + # Returns false if brew itself is missing or any install fails. + # + # SharedTools.brew_install("github-mcp-server") + # SharedTools.brew_install("gh", "jq") + def brew_install(*packages) + unless system("which brew > /dev/null 2>&1") + warn "SharedTools — Homebrew is not installed (https://brew.sh)" + return false + end + + packages.all? do |pkg| + next true if !`brew list --versions #{pkg} 2>/dev/null`.strip.empty? + + warn "SharedTools — #{pkg} not found, installing via brew..." + system("brew install --quiet #{pkg} > /dev/null 2>&1") + end + end + + # Ensures each named binary is available in PATH, installing via apt-get if missing. + # Returns true if all binaries are present (or successfully installed). + # Returns false if apt-get itself is missing or any install fails. + # + # SharedTools.apt_install("curl") + # SharedTools.apt_install("curl", "jq") + def apt_install(*packages) + packages.all? do |pkg| + # SMELL: what if package is a library? + next true if system("which #{pkg} > /dev/null 2>&1") + + warn "SharedTools — #{pkg} not found, installing via apt-get..." + system("sudo apt-get install -y -q #{pkg} > /dev/null 2>&1") + end + end + + # Ensures each named binary is available in PATH, installing via dnf if missing. + # Returns true if all binaries are present (or successfully installed). + # Returns false if dnf itself is missing or any install fails. + # + # SharedTools.dnf_install("curl") + # SharedTools.dnf_install("curl", "jq") + def dnf_install(*packages) + packages.all? do |pkg| + # SMELL: What if package is a library? + next true if system("which #{pkg} > /dev/null 2>&1") + + warn "SharedTools — #{pkg} not found, installing via dnf..." + system("sudo dnf install -y -q #{pkg} > /dev/null 2>&1") + end + end + + # Ensures each named npm package binary is available in PATH, installing + # globally via npm if missing. Returns false if npm itself is not found. + # + # SharedTools.npm_install("typescript") + # SharedTools.npm_install("typescript", "ts-node") + def npm_install(*packages) + unless system("which npm > /dev/null 2>&1") + warn "SharedTools — npm is not installed (https://nodejs.org)" + return false + end + + packages.all? do |pkg| + # SMELL: What if package is a library? + next true if system("which #{pkg} > /dev/null 2>&1") + + warn "SharedTools — #{pkg} not found, installing via npm..." + system("npm install -g --silent #{pkg} > /dev/null 2>&1") + end + end + + # Ensures each named gem is available, installing via gem install if missing. + # Returns false if gem itself is not found (should never happen in a Ruby process). + # + # SharedTools.gem_install("nokogiri") + # SharedTools.gem_install("nokogiri", "oj") + def gem_install(*packages) + unless system("which gem > /dev/null 2>&1") + warn "SharedTools — gem is not available" + return false + end + + packages.all? do |pkg| + next true if system("gem list -i #{pkg} > /dev/null 2>&1") + + warn "SharedTools — #{pkg} not found, installing via gem..." + system("gem install --silent #{pkg} > /dev/null 2>&1") + end + end + + end +end From 70b14380332e61234c3ff57ef24d1604eaaa8a14 Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Thu, 26 Mar 2026 15:44:32 -0500 Subject: [PATCH 10/17] feat(mcp): add new MCP client demos The commit introduces two new MCP client demo applications: 1. `brave_search_demo.rb`: A demo showcasing the usage of the Brave Search MCP client to perform web and news searches. 2. `chart_demo.rb`: A demo demonstrating the usage of the Chart MCP client to generate various types of data visualizations (bar, line, and pie charts). The commit also includes a common setup file `common.rb` that provides shared functionality for the MCP demo applications. --- examples/mcp/brave_search_demo.rb | 37 ++++++++++ examples/mcp/chart_demo.rb | 56 +++++++++++++++ examples/mcp/common.rb | 64 +++++++++++++++++ examples/mcp/github_demo.rb | 47 +++++++++++++ examples/mcp/memory_demo.rb | 52 ++++++++++++++ examples/mcp/sequential_thinking_demo.rb | 56 +++++++++++++++ examples/mcp/tavily_demo.rb | 39 +++++++++++ examples/mcp_client_demo.rb | 1 + lib/shared_tools/mcp.rb | 2 +- lib/shared_tools/mcp/brave_search_client.rb | 6 +- lib/shared_tools/mcp/github_client.rb | 4 +- lib/shared_tools/mcp/streamable_http_patch.rb | 42 +++++++++++ lib/shared_tools/mcp/tavily_client.rb | 16 +++-- lib/shared_tools/utilities.rb | 70 ++++++++----------- 14 files changed, 441 insertions(+), 51 deletions(-) create mode 100755 examples/mcp/brave_search_demo.rb create mode 100755 examples/mcp/chart_demo.rb create mode 100644 examples/mcp/common.rb create mode 100755 examples/mcp/github_demo.rb create mode 100755 examples/mcp/memory_demo.rb create mode 100755 examples/mcp/sequential_thinking_demo.rb create mode 100755 examples/mcp/tavily_demo.rb create mode 100644 lib/shared_tools/mcp/streamable_http_patch.rb diff --git a/examples/mcp/brave_search_demo.rb b/examples/mcp/brave_search_demo.rb new file mode 100755 index 0000000..f76f021 --- /dev/null +++ b/examples/mcp/brave_search_demo.rb @@ -0,0 +1,37 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: Brave Search MCP Client +# +# Web and news search via the Brave Search API. +# Requires Node.js/npx (package auto-downloads on first use). +# +# Prerequisites: +# Node.js / npx — https://nodejs.org +# export BRAVE_API_KEY=your_key # https://brave.com/search/api/ (free tier) +# +# Run: +# bundle exec ruby -I lib -I examples examples/mcp/brave_search_demo.rb + +require_relative 'common' + +title "Brave Search MCP Client Demo" + +begin + require 'shared_tools/mcp/brave_search_client' +rescue LoadError => e + puts "unable to load the client: #{e.message}" + exit +end + +client = RubyLLM::MCP.clients['brave-search'] +@chat = new_chat.with_tools(*client.tools) + +title "Web Search", char: '-' +ask "Search the web for the latest Homebrew release and summarise what changed." + +title "News Search", char: '-' +ask "Search for recent news about Ruby on Rails and summarise the top stories." + +title "Done", char: '-' +puts "Brave Search npx MCP client demonstrated." diff --git a/examples/mcp/chart_demo.rb b/examples/mcp/chart_demo.rb new file mode 100755 index 0000000..0317e08 --- /dev/null +++ b/examples/mcp/chart_demo.rb @@ -0,0 +1,56 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: Chart MCP Client +# +# Chart and data visualisation generation via AntV. +# Returns chart URLs that are opened automatically in the default browser. +# Requires Node.js/npx (package auto-downloads on first use). +# +# Prerequisites: +# Node.js / npx — https://nodejs.org +# +# Run: +# bundle exec ruby -I lib -I examples examples/mcp/chart_demo.rb + +require_relative 'common' + +title "Chart MCP Client Demo" + +begin + require 'shared_tools/mcp/chart_client' +rescue LoadError => e + puts "unable to load the client: #{e.message}" + exit +end + +client = RubyLLM::MCP.clients['chart'] +@chat = new_chat.with_tools(*client.tools) + +title "Bar Chart", char: '-' +response = ask <<~PROMPT + Generate a bar chart showing these monthly active users: + Jan: 1200, Feb: 1450, Mar: 1800, Apr: 2100, May: 1950, Jun: 2400 + Title it "Monthly Active Users — H1 2026". +PROMPT +open_chart_urls(response) + +title "Line Chart", char: '-' +@chat = new_chat.with_tools(*client.tools) +response = ask <<~PROMPT + Generate a line chart showing the same monthly active user data as a trend over time. + Title it "MAU Trend — H1 2026". +PROMPT +open_chart_urls(response) + +title "Pie Chart", char: '-' +@chat = new_chat.with_tools(*client.tools) +response = ask <<~PROMPT + Generate a pie chart showing this breakdown of tool usage in the shared_tools gem: + ShellCommand: 35%, FileRead: 25%, FileEdit: 20%, WebSearch: 12%, Other: 8% + Title it "Tool Usage Distribution". +PROMPT +open_chart_urls(response) + +title "Done", char: '-' +puts "Chart npx MCP client demonstrated." diff --git a/examples/mcp/common.rb b/examples/mcp/common.rb new file mode 100644 index 0000000..6bcd237 --- /dev/null +++ b/examples/mcp/common.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true +# +# Common setup for MCP client demo applications. +# Extends the base examples/common.rb with MCP-specific helpers. +# +# Usage — each MCP demo needs only: +# require_relative 'common' +# +# Run: +# bundle exec ruby -I lib -I examples examples/mcp/_demo.rb + +require_relative '../common' + +# Returns true if npx is available in PATH. +def npx_available? + system("which npx > /dev/null 2>&1") +end + +# Extracts all https:// URLs from a string, stripping trailing punctuation. +def extract_urls(text) + text.scan(/https?:\/\/\S+/) + .map { |u| u.gsub(/[.,;:)\]"']+$/, '') } + .uniq +end + +# Opens chart URLs found in a response using the OS default browser. +# Checks the LLM response text first, then falls back to scanning tool +# result messages — the LLM often summarises charts without echoing the URL. +def open_chart_urls(response) + urls = extract_urls(response.content.to_s) + + if urls.empty? && @chat.respond_to?(:messages) + tool_text = @chat.messages + .select { |m| m.role.to_s == 'tool' } + .map { |m| m.content.to_s } + .join("\n") + urls = extract_urls(tool_text) + end + + if urls.empty? + puts " (no chart URLs found)" + return + end + + urls.each do |url| + puts " Opening: #{url}" + system('open', url) + end +end + +# Loads an MCP client by require path and reports its available tools. +# Accepts an optional block guard — skips loading if the block returns false. +# Returns true on success, false if skipped or failed. +def load_client(require_path, client_name, &check) + return false unless check.nil? || check.call + require require_path + client = RubyLLM::MCP.clients[client_name] + return false if client.nil? + puts " Loaded '#{client_name}' — #{client.tools.count} tools: #{client.tools.map(&:name).join(', ')}" + true +rescue => e + puts " Error loading '#{client_name}': #{e.message}" + false +end diff --git a/examples/mcp/github_demo.rb b/examples/mcp/github_demo.rb new file mode 100755 index 0000000..3506050 --- /dev/null +++ b/examples/mcp/github_demo.rb @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: GitHub MCP Client +# +# Full GitHub API access — repositories, issues, pull requests, code search, +# commits, branches, and releases. +# Requires Homebrew (installed automatically if missing). +# +# Prerequisites: +# Homebrew — https://brew.sh +# export GITHUB_PERSONAL_ACCESS_TOKEN=your_token +# +# Run: +# bundle exec ruby -I lib -I examples examples/mcp/github_demo.rb + +require_relative 'common' + +title "GitHub MCP Client Demo" + +begin + require 'shared_tools/mcp/github_client' +rescue LoadError => e + puts "unable to load the client: #{e.message}" + exit +end + +client = RubyLLM::MCP.clients['github'] +@chat = new_chat.with_tools(*client.tools) + +title "Repository Info", char: '-' +ask "What are the most recently updated public repositories for the 'ruby' GitHub organisation? List the top 5 with their descriptions." + +title "Issue Search", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask "Search for open issues labelled 'bug' in the rails/rails repository. Summarise the top 3." + +title "Pull Request Activity", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask "What pull requests were merged into rails/rails in the last 7 days? Summarise what changed." + +title "Code Search", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask "Search GitHub for Ruby gems that implement MCP clients. List the top results with their descriptions and star counts." + +title "Done", char: '-' +puts "GitHub brew-installed MCP client demonstrated." diff --git a/examples/mcp/memory_demo.rb b/examples/mcp/memory_demo.rb new file mode 100755 index 0000000..2e02bf7 --- /dev/null +++ b/examples/mcp/memory_demo.rb @@ -0,0 +1,52 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: Memory MCP Client +# +# Persistent knowledge graph — the LLM can store and recall entities, +# relations, and observations across conversations. +# Requires Node.js/npx (package auto-downloads on first use). +# +# Prerequisites: +# Node.js / npx — https://nodejs.org +# +# Optional: +# export MEMORY_FILE_PATH=/path/to/memory.jsonl # default: memory.jsonl in cwd +# +# Run: +# bundle exec ruby -I lib -I examples examples/mcp/memory_demo.rb + +require_relative 'common' + +title "Memory MCP Client Demo" + +begin + require 'shared_tools/mcp/memory_client' +rescue LoadError => e + puts "unable to load the client: #{e.message}" + exit +end + +client = RubyLLM::MCP.clients['memory'] +@chat = new_chat.with_tools(*client.tools) + +title "Store Facts", char: '-' +ask "Remember the following facts: " \ + "(1) This project is called 'shared_tools'. " \ + "(2) It is a Ruby gem that provides LLM-callable tools. " \ + "(3) It supports MCP clients for web search, memory, and chart generation. " \ + "Confirm what you stored." + +title "Recall Facts", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask "What do you know about the shared_tools project?" + +title "Add a Relation", char: '-' +ask "Remember that shared_tools is maintained by a developer who uses a MacStudio with an M2 Max chip. " \ + "Link this to the shared_tools entity you already know about." + +title "Query the Graph", char: '-' +ask "Summarise everything you know about shared_tools and its developer." + +title "Done", char: '-' +puts "Memory npx MCP client demonstrated." diff --git a/examples/mcp/sequential_thinking_demo.rb b/examples/mcp/sequential_thinking_demo.rb new file mode 100755 index 0000000..9546e9a --- /dev/null +++ b/examples/mcp/sequential_thinking_demo.rb @@ -0,0 +1,56 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: Sequential Thinking MCP Client +# +# Structured chain-of-thought reasoning — the LLM breaks complex problems +# into numbered steps, revises earlier steps, and branches reasoning paths +# before committing to a conclusion. +# Requires Node.js/npx (package auto-downloads on first use). +# +# Prerequisites: +# Node.js / npx — https://nodejs.org +# +# Run: +# bundle exec ruby -I lib -I examples examples/mcp/sequential_thinking_demo.rb + +require_relative 'common' + +title "Sequential Thinking MCP Client Demo" + +begin + require 'shared_tools/mcp/sequential_thinking_client' +rescue LoadError => e + puts "unable to load the client: #{e.message}" + exit +end + +client = RubyLLM::MCP.clients['sequential-thinking'] +@chat = new_chat.with_tools(*client.tools) + +title "Cross-platform Design Problem", char: '-' +ask <<~PROMPT + Use sequential thinking to work through this problem step by step: + A Ruby gem needs to support both macOS and Linux. It uses system commands + for notifications, package installation, and browser automation. + What are the key concerns to address and in what order should they be tackled? +PROMPT + +title "Architectural Decision", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask <<~PROMPT + Use sequential thinking to decide: should a Ruby gem that wraps external CLI + tools use Thor or OptionParser for its own CLI interface? Walk through the + trade-offs and reach a recommendation. +PROMPT + +title "Debugging Strategy", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask <<~PROMPT + Use sequential thinking to devise a debugging strategy for this situation: + A Ruby gem works perfectly in tests but fails silently when loaded inside + an AI agent framework. No exceptions are raised. How would you isolate the cause? +PROMPT + +title "Done", char: '-' +puts "Sequential Thinking npx MCP client demonstrated." diff --git a/examples/mcp/tavily_demo.rb b/examples/mcp/tavily_demo.rb new file mode 100755 index 0000000..cf3765d --- /dev/null +++ b/examples/mcp/tavily_demo.rb @@ -0,0 +1,39 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: Tavily MCP Client +# +# AI-optimized web search via Tavily's remote HTTP MCP endpoint. +# No Node.js required — only a Tavily API key. +# +# Prerequisites: +# export TAVILY_API_KEY=your_key # https://tavily.com (free tier) +# +# Run: +# bundle exec ruby -I lib -I examples examples/mcp/tavily_demo.rb + +require_relative 'common' + +title "Tavily MCP Client Demo" + +begin + require 'shared_tools/mcp/tavily_client' +rescue LoadError => e + puts "unable to load the client: #{e.message}" + exit +end + +client = RubyLLM::MCP.clients['tavily'] +@chat = new_chat.with_tools(*client.tools) + +title "Web Search", char: '-' +ask "Search for the most recent Ruby language release and summarise what's new." + +title "News Search", char: '-' +ask "Search for recent news about the Model Context Protocol (MCP) and summarise the top stories." + +title "Research", char: '-' +ask "Search for and summarise the key differences between Anthropic Claude and OpenAI GPT-4 for use in coding assistants." + +title "Done", char: '-' +puts "Tavily remote HTTP MCP client demonstrated — no local installation required." diff --git a/examples/mcp_client_demo.rb b/examples/mcp_client_demo.rb index d762d87..62e3cb9 100755 --- a/examples/mcp_client_demo.rb +++ b/examples/mcp_client_demo.rb @@ -64,6 +64,7 @@ def load_client(require_path, client_name, &check) return false unless check.nil? || check.call require require_path client = RubyLLM::MCP.clients[client_name] + return false if client.nil? puts " Loaded '#{client_name}' — #{client.tools.count} tools: #{client.tools.map(&:name).join(', ')}" true rescue => e diff --git a/lib/shared_tools/mcp.rb b/lib/shared_tools/mcp.rb index b78d0cb..4999952 100644 --- a/lib/shared_tools/mcp.rb +++ b/lib/shared_tools/mcp.rb @@ -31,7 +31,7 @@ # client = RubyLLM::MCP.clients["client-name"] # chat = RubyLLM.chat.with_tools(*client.tools) -require "ruby_llm/mcp" +require_relative "mcp/streamable_http_patch" threads = Dir[File.join(__dir__, "mcp", "*_client.rb")].map do |path| Thread.new do diff --git a/lib/shared_tools/mcp/brave_search_client.rb b/lib/shared_tools/mcp/brave_search_client.rb index 87a911c..bd3fcea 100644 --- a/lib/shared_tools/mcp/brave_search_client.rb +++ b/lib/shared_tools/mcp/brave_search_client.rb @@ -20,9 +20,11 @@ # # Compatible with ruby_llm-mcp >= 0.7.0 -require "ruby_llm/mcp" +require_relative "../utilities" + +SharedTools.verify_envars("BRAVE_API_KEY") -return if ENV.fetch("BRAVE_API_KEY", "").empty? +require "ruby_llm/mcp" RubyLLM::MCP.add_client( name: "brave-search", diff --git a/lib/shared_tools/mcp/github_client.rb b/lib/shared_tools/mcp/github_client.rb index ee90254..6c32d8c 100644 --- a/lib/shared_tools/mcp/github_client.rb +++ b/lib/shared_tools/mcp/github_client.rb @@ -22,8 +22,8 @@ require_relative "../utilities" -return unless SharedTools.verify_envars("GITHUB_PERSONAL_ACCESS_TOKEN") -return unless SharedTools.package_install("github-mcp-server") +SharedTools.verify_envars("GITHUB_PERSONAL_ACCESS_TOKEN") +SharedTools.package_install("github-mcp-server") require "ruby_llm/mcp" diff --git a/lib/shared_tools/mcp/streamable_http_patch.rb b/lib/shared_tools/mcp/streamable_http_patch.rb new file mode 100644 index 0000000..fe8c732 --- /dev/null +++ b/lib/shared_tools/mcp/streamable_http_patch.rb @@ -0,0 +1,42 @@ +# lib/shared_tools/mcp/streamable_http_patch.rb +# +# Patch: normalize mixed CRLF/LF line endings in SSE event buffers. +# +# Some MCP servers (e.g. Tavily) use \n to terminate the SSE data line and +# \r\n for the blank separator line, producing the sequence \n\r\n instead +# of the uniform \n\n or \r\n\r\n that the upstream parser expects. +# Without this patch, extract_sse_event never finds an event boundary for +# such responses and every tools/list request times out. +# +# This is a server-side standards violation (RFC 8895 requires consistent +# line endings within an event), but it is common enough in hosted MCP +# servers to warrant a defensive client-side fix. +# +# The patch normalises the accumulation buffer in-place before the separator +# check, which is safe because: +# - the buffer is a mutable +String (prefixed with +) +# - it is always written back via buffer.replace(rest) by the caller +# - repeated normalisation of already-normalised content is a no-op + +require "ruby_llm/mcp" + +module RubyLLM + module MCP + module Native + module Transports + class StreamableHTTP + private + + def extract_sse_event(buffer) + buffer.gsub!("\r\n", "\n") + buffer.gsub!("\r", "\n") + return nil unless buffer.include?("\n\n") + + raw, rest = buffer.split("\n\n", 2) + [parse_sse_event(raw), rest || ""] + end + end + end + end + end +end diff --git a/lib/shared_tools/mcp/tavily_client.rb b/lib/shared_tools/mcp/tavily_client.rb index f502b24..7959cbd 100644 --- a/lib/shared_tools/mcp/tavily_client.rb +++ b/lib/shared_tools/mcp/tavily_client.rb @@ -21,15 +21,21 @@ # # Compatible with ruby_llm-mcp >= 0.7.0 -require "ruby_llm/mcp" +require_relative "../utilities" +require_relative "streamable_http_patch" -return if ENV.fetch("TAVILY_API_KEY", "").empty? +SharedTools.verify_envars("TAVILY_API_KEY") RubyLLM::MCP.add_client( - name: "tavily", - transport_type: :streamable, + name: "tavily", + transport_type: :streamable, + request_timeout: 30_000, config: { url: "https://mcp.tavily.com/mcp/", - headers: { "Authorization" => "Bearer #{ENV.fetch('TAVILY_API_KEY', '')}" }, + version: :http1, + headers: { + "Authorization" => "Bearer #{ENV.fetch('TAVILY_API_KEY', '')}", + "Accept" => "application/json, text/event-stream", + }, }, ) diff --git a/lib/shared_tools/utilities.rb b/lib/shared_tools/utilities.rb index 8bdbe92..4a72588 100644 --- a/lib/shared_tools/utilities.rb +++ b/lib/shared_tools/utilities.rb @@ -16,11 +16,13 @@ class << self def verify_envars(*names) missing = names.select { |n| ENV.fetch(n, "").empty? } missing.each { |n| warn "SharedTools — #{n} is not set" } - missing.empty? + unless missing.empty? + raise LoadError, "Missing envars: #{missing.join(', ')}" + end end # High-level package installer. Detects the current platform and calls the - # appropriate *_install method. Returns true if all packages are available. + # appropriate *_install method. Raises LoadError if any package cannot be installed. # # SharedTools.package_install("github-mcp-server") # SharedTools.package_install("curl", "jq") @@ -36,103 +38,89 @@ def package_install(*packages) elsif system("which brew > /dev/null 2>&1") brew_install(*packages) else - warn "SharedTools — no supported package manager found (apt-get, dnf, brew)" - false + raise LoadError, "No supported package manager found (apt-get, dnf, brew)" end else - warn "SharedTools — unsupported platform: #{RUBY_PLATFORM}" - false + raise LoadError, "Unsupported platform: #{RUBY_PLATFORM}" end end # Ensures each named binary is available in PATH, installing via brew if missing. - # Returns true if all binaries are present (or successfully installed). - # Returns false if brew itself is missing or any install fails. + # Raises LoadError if brew is not installed or any package install fails. # # SharedTools.brew_install("github-mcp-server") # SharedTools.brew_install("gh", "jq") def brew_install(*packages) - unless system("which brew > /dev/null 2>&1") - warn "SharedTools — Homebrew is not installed (https://brew.sh)" - return false - end + raise LoadError, "Homebrew is not installed (https://brew.sh)" unless system("which brew > /dev/null 2>&1") - packages.all? do |pkg| - next true if !`brew list --versions #{pkg} 2>/dev/null`.strip.empty? + packages.each do |pkg| + next unless `brew list --versions #{pkg} 2>/dev/null`.strip.empty? warn "SharedTools — #{pkg} not found, installing via brew..." - system("brew install --quiet #{pkg} > /dev/null 2>&1") + raise LoadError, "#{pkg} could not be installed" unless system("brew install --quiet #{pkg} > /dev/null 2>&1") end end # Ensures each named binary is available in PATH, installing via apt-get if missing. - # Returns true if all binaries are present (or successfully installed). - # Returns false if apt-get itself is missing or any install fails. + # Raises LoadError if any package install fails. # # SharedTools.apt_install("curl") # SharedTools.apt_install("curl", "jq") def apt_install(*packages) - packages.all? do |pkg| + packages.each do |pkg| # SMELL: what if package is a library? - next true if system("which #{pkg} > /dev/null 2>&1") + next if system("which #{pkg} > /dev/null 2>&1") warn "SharedTools — #{pkg} not found, installing via apt-get..." - system("sudo apt-get install -y -q #{pkg} > /dev/null 2>&1") + raise LoadError, "#{pkg} could not be installed" unless system("sudo apt-get install -y -q #{pkg} > /dev/null 2>&1") end end # Ensures each named binary is available in PATH, installing via dnf if missing. - # Returns true if all binaries are present (or successfully installed). - # Returns false if dnf itself is missing or any install fails. + # Raises LoadError if any package install fails. # # SharedTools.dnf_install("curl") # SharedTools.dnf_install("curl", "jq") def dnf_install(*packages) - packages.all? do |pkg| + packages.each do |pkg| # SMELL: What if package is a library? - next true if system("which #{pkg} > /dev/null 2>&1") + next if system("which #{pkg} > /dev/null 2>&1") warn "SharedTools — #{pkg} not found, installing via dnf..." - system("sudo dnf install -y -q #{pkg} > /dev/null 2>&1") + raise LoadError, "#{pkg} could not be installed" unless system("sudo dnf install -y -q #{pkg} > /dev/null 2>&1") end end # Ensures each named npm package binary is available in PATH, installing - # globally via npm if missing. Returns false if npm itself is not found. + # globally via npm if missing. Raises LoadError if npm is not found or any install fails. # # SharedTools.npm_install("typescript") # SharedTools.npm_install("typescript", "ts-node") def npm_install(*packages) - unless system("which npm > /dev/null 2>&1") - warn "SharedTools — npm is not installed (https://nodejs.org)" - return false - end + raise LoadError, "npm is not installed (https://nodejs.org)" unless system("which npm > /dev/null 2>&1") - packages.all? do |pkg| + packages.each do |pkg| # SMELL: What if package is a library? - next true if system("which #{pkg} > /dev/null 2>&1") + next if system("which #{pkg} > /dev/null 2>&1") warn "SharedTools — #{pkg} not found, installing via npm..." - system("npm install -g --silent #{pkg} > /dev/null 2>&1") + raise LoadError, "#{pkg} could not be installed" unless system("npm install -g --silent #{pkg} > /dev/null 2>&1") end end # Ensures each named gem is available, installing via gem install if missing. - # Returns false if gem itself is not found (should never happen in a Ruby process). + # Raises LoadError if gem is not available or any install fails. # # SharedTools.gem_install("nokogiri") # SharedTools.gem_install("nokogiri", "oj") def gem_install(*packages) - unless system("which gem > /dev/null 2>&1") - warn "SharedTools — gem is not available" - return false - end + raise LoadError, "gem is not available" unless system("which gem > /dev/null 2>&1") - packages.all? do |pkg| - next true if system("gem list -i #{pkg} > /dev/null 2>&1") + packages.each do |pkg| + next if system("gem list -i #{pkg} > /dev/null 2>&1") warn "SharedTools — #{pkg} not found, installing via gem..." - system("gem install --silent #{pkg} > /dev/null 2>&1") + raise LoadError, "#{pkg} could not be installed" unless system("gem install --silent #{pkg} > /dev/null 2>&1") end end From 625a8b77fd3350ea389a710b1f1a353d03b7792b Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Thu, 26 Mar 2026 16:11:38 -0500 Subject: [PATCH 11/17] feat(mcp): add Hugging Face client This commit adds a new Hugging Face MCP client to the SharedTools library. The client allows users to browse the Hugging Face Hub, search for models and datasets, read model cards, and explore trending repositories and AI tools. The client requires the `HF_TOKEN` environment variable to be set, which can be obtained by creating a token at https://huggingface.co/settings/tokens. The commit includes a new demo script `examples/mcp/hugging_face_demo.rb` that showcases the capabilities of the Hugging Face client, such as: 1. Listing the most popular and trending text-generation models on Hugging Face. 2. Searching for models or datasets related to Ruby programming, Rails, or software engineering code generation. 3. Finding small language models (under 8B parameters) that are highly capable. This client addition enhances the MCP (Model Context Protocol) capabilities of the SharedTools library, allowing users to seamlessly integrate Hugging Face resources into their AI-powered applications. --- docs/index.md | 42 ++++++++++++++++ examples/mcp/hugging_face_demo.rb | 51 +++++++++++++++++++ examples/mcp/notion_demo.rb | 48 ++++++++++++++++++ examples/mcp/slack_demo.rb | 48 ++++++++++++++++++ lib/shared_tools/mcp/README.md | 31 +++++++++++- lib/shared_tools/mcp/hugging_face_client.rb | 43 ++++++++++++++++ lib/shared_tools/mcp/notion_client.rb | 40 +++++++++++++++ lib/shared_tools/mcp/slack_client.rb | 54 +++++++++++++++++++++ 8 files changed, 356 insertions(+), 1 deletion(-) create mode 100755 examples/mcp/hugging_face_demo.rb create mode 100755 examples/mcp/notion_demo.rb create mode 100755 examples/mcp/slack_demo.rb create mode 100644 lib/shared_tools/mcp/hugging_face_client.rb create mode 100644 lib/shared_tools/mcp/notion_client.rb create mode 100644 lib/shared_tools/mcp/slack_client.rb diff --git a/docs/index.md b/docs/index.md index d1969cc..5341cf8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -111,6 +111,48 @@ browser.cleanup! - **[Docker ComposeRunTool](tools/index.md)** - Docker container command execution - **[ErrorHandlingTool](tools/index.md)** - Reference implementation for error handling patterns +## MCP Clients + +SharedTools bundles MCP (Model Context Protocol) clients that connect AI agents to +external services. Each client is opt-in — require only the ones you need. + +### Remote HTTP (API key only) + +| Client | Env var | Provides | +|--------|---------|----------| +| `require 'shared_tools/mcp/tavily_client'` | `TAVILY_API_KEY` | Web search, news, research, URL extraction | + +### Brew-installed (auto-installs via Homebrew) + +| Client | Env var | Provides | +|--------|---------|----------| +| `require 'shared_tools/mcp/github_client'` | `GITHUB_PERSONAL_ACCESS_TOKEN` | Repos, issues, PRs, code search | +| `require 'shared_tools/mcp/notion_client'` | `NOTION_TOKEN` | Pages, databases, search, content CRUD | +| `require 'shared_tools/mcp/slack_client'` | `SLACK_MCP_XOXP_TOKEN` | Channels, messages, threads, user info | +| `require 'shared_tools/mcp/hugging_face_client'` | `HF_TOKEN` | Models, datasets, Spaces, model cards | + +### npx Auto-download (Node.js required) + +| Client | Provides | +|--------|----------| +| `require 'shared_tools/mcp/memory_client'` | Persistent knowledge graph | +| `require 'shared_tools/mcp/sequential_thinking_client'` | Chain-of-thought reasoning | +| `require 'shared_tools/mcp/chart_client'` | Chart and visualisation generation | +| `require 'shared_tools/mcp/brave_search_client'` | Web and news search (`BRAVE_API_KEY`) | + +```ruby +# Load all available clients at once (skips any whose env vars are missing) +require 'shared_tools/mcp' + +# Or load a specific client +require 'shared_tools/mcp/notion_client' +client = RubyLLM::MCP.clients["notion"] +chat = RubyLLM.chat.with_tools(*client.tools) +chat.ask("Find my project planning pages and summarise what's in them") +``` + +See [MCP Clients README](https://github.com/madbomber/shared_tools/blob/main/lib/shared_tools/mcp/README.md) for full configuration details. + ## Guides - **[Authorization System](guides/authorization.md)** - Control when operations require approval diff --git a/examples/mcp/hugging_face_demo.rb b/examples/mcp/hugging_face_demo.rb new file mode 100755 index 0000000..7268d81 --- /dev/null +++ b/examples/mcp/hugging_face_demo.rb @@ -0,0 +1,51 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: Hugging Face MCP Client +# +# Browse the Hugging Face Hub — search models and datasets, read model cards, +# explore trending repositories, and discover AI tools. +# Requires Homebrew (installed automatically if missing). +# +# Prerequisites: +# Homebrew — https://brew.sh +# export HF_TOKEN=hf_... — create at https://huggingface.co/settings/tokens +# +# Run: +# bundle exec ruby -I lib -I examples examples/mcp/hugging_face_demo.rb + +require_relative 'common' + +title "Hugging Face MCP Client Demo" + +begin + require 'shared_tools/mcp/hugging_face_client' +rescue LoadError => e + puts "unable to load the client: #{e.message}" + exit +end + +client = RubyLLM::MCP.clients['hugging-face'] +@chat = new_chat.with_tools(*client.tools) + +title "Trending Models", char: '-' +ask "What are the most popular and trending text-generation models on Hugging Face right now? List the top 5 with their descriptions, download counts, and what makes each notable." + +title "Ruby / Rails Models", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask "Search Hugging Face for models or datasets related to Ruby programming, Rails, or software engineering code generation. What's available and how capable are they?" + +title "Small but Capable Models", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask "Find small language models (under 8B parameters) that are highly rated for instruction following or chat. Which would be good candidates to run locally on an Apple Silicon Mac?" + +title "Dataset Discovery", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask "What are the most downloaded datasets for training language models or fine-tuning on code? List the top results with their sizes and what they contain." + +title "Model Card", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask "Fetch the model card for 'meta-llama/Llama-3.2-3B-Instruct' and summarise its capabilities, intended use, training data, and any important limitations or safety considerations." + +title "Done", char: '-' +puts "Hugging Face brew-installed MCP client demonstrated." diff --git a/examples/mcp/notion_demo.rb b/examples/mcp/notion_demo.rb new file mode 100755 index 0000000..8a84c8b --- /dev/null +++ b/examples/mcp/notion_demo.rb @@ -0,0 +1,48 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: Notion MCP Client +# +# Full Notion workspace access — search pages and databases, read content, +# create and update pages, query databases. +# Requires Homebrew (installed automatically if missing). +# +# Prerequisites: +# Homebrew — https://brew.sh +# export NOTION_TOKEN=ntn_... — create at https://www.notion.so/profile/integrations +# Share relevant pages/databases with the integration before running. +# +# Run: +# bundle exec ruby -I lib -I examples examples/mcp/notion_demo.rb + +require_relative 'common' + +title "Notion MCP Client Demo" + +begin + require 'shared_tools/mcp/notion_client' +rescue LoadError => e + puts "unable to load the client: #{e.message}" + exit +end + +client = RubyLLM::MCP.clients['notion'] +@chat = new_chat.with_tools(*client.tools) + +title "Search Pages", char: '-' +ask "Search my Notion workspace for any pages or documents. List up to 5 results with their titles and a brief description of what each contains." + +title "Explore Databases", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask "Find any databases in my Notion workspace. For each one, describe what kind of data it tracks and list a few sample entries if available." + +title "Recent Activity", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask "Find the most recently edited pages in my Notion workspace. What were the last few things that were worked on?" + +title "Content Summary", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask "Pick the most interesting or content-rich page you can find in my Notion workspace and give me a detailed summary of what it contains." + +title "Done", char: '-' +puts "Notion brew-installed MCP client demonstrated." diff --git a/examples/mcp/slack_demo.rb b/examples/mcp/slack_demo.rb new file mode 100755 index 0000000..eee3c99 --- /dev/null +++ b/examples/mcp/slack_demo.rb @@ -0,0 +1,48 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Demo: Slack MCP Client +# +# Read channels, messages, threads, and user info from a Slack workspace. +# Requires Homebrew (installed automatically if missing). +# +# Prerequisites: +# Homebrew — https://brew.sh +# export SLACK_MCP_XOXP_TOKEN=xoxp-... # user OAuth token (recommended) +# # OR +# export SLACK_MCP_XOXB_TOKEN=xoxb-... # bot token (limited access) +# +# Run: +# bundle exec ruby -I lib -I examples examples/mcp/slack_demo.rb + +require_relative 'common' + +title "Slack MCP Client Demo" + +begin + require 'shared_tools/mcp/slack_client' +rescue LoadError => e + puts "unable to load the client: #{e.message}" + exit +end + +client = RubyLLM::MCP.clients['slack'] +@chat = new_chat.with_tools(*client.tools) + +title "Channel Overview", char: '-' +ask "List the channels in this Slack workspace. Group them by type (public vs private if visible) and give a brief description of what each channel appears to be used for based on its name and topic." + +title "Recent Messages", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask "What has been discussed in the most active channels recently? Summarise the key topics from the last few days of messages across 2-3 channels." + +title "Thread Deep-Dive", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask "Find an interesting or substantive conversation thread from the past week. Summarise the discussion, who was involved, and what conclusions (if any) were reached." + +title "Team Activity", char: '-' +@chat = new_chat.with_tools(*client.tools) +ask "Who have been the most active contributors in the workspace recently? What topics or projects are they focused on based on their messages?" + +title "Done", char: '-' +puts "Slack brew-installed MCP client demonstrated." diff --git a/lib/shared_tools/mcp/README.md b/lib/shared_tools/mcp/README.md index b473fc8..a9b22e9 100644 --- a/lib/shared_tools/mcp/README.md +++ b/lib/shared_tools/mcp/README.md @@ -16,6 +16,18 @@ no local binary, nothing to install. |------|-------------|----------|----------| | `tavily_client.rb` | `"tavily"` | `TAVILY_API_KEY` | AI-optimized web search, news, research | +### Brew-installed binary — `:stdio` transport + +The binary is installed automatically via Homebrew if not already present. +Requires **Homebrew** (`https://brew.sh`). + +| File | Client name | Requires | Provides | +|------|-------------|----------|----------| +| `github_client.rb` | `"github"` | `GITHUB_PERSONAL_ACCESS_TOKEN` | Repositories, issues, PRs, code search | +| `notion_client.rb` | `"notion"` | `NOTION_TOKEN` | Pages, databases, search, content CRUD | +| `slack_client.rb` | `"slack"` | `SLACK_MCP_XOXP_TOKEN` or `SLACK_MCP_XOXB_TOKEN` | Channels, messages, threads, user info | +| `hugging_face_client.rb` | `"hugging-face"` | `HF_TOKEN` | Models, datasets, Spaces, model cards | + ### npx Auto-download — `:stdio` transport via `npx -y` The npm package is downloaded automatically on first use. The only prerequisite is @@ -59,9 +71,27 @@ chat = RubyLLM.chat.with_tools(*tools) | Variable | Used by | Where to get it | |----------|---------|-----------------| | `TAVILY_API_KEY` | `tavily_client.rb` | https://tavily.com (free tier) | +| `GITHUB_PERSONAL_ACCESS_TOKEN` | `github_client.rb` | https://github.com/settings/tokens | +| `NOTION_TOKEN` | `notion_client.rb` | https://www.notion.so/profile/integrations | +| `SLACK_MCP_XOXP_TOKEN` | `slack_client.rb` | Slack OAuth — user token (full access) | +| `SLACK_MCP_XOXB_TOKEN` | `slack_client.rb` | Slack OAuth — bot token (limited access) | +| `HF_TOKEN` | `hugging_face_client.rb` | https://huggingface.co/settings/tokens | | `BRAVE_API_KEY` | `brave_search_client.rb` | https://brave.com/search/api/ (free tier) | | `MEMORY_FILE_PATH` | `memory_client.rb` | Optional — path to `.jsonl` persistence file | +### Slack token types + +| Token prefix | Type | Access | +|---|---|---| +| `xoxp-` | User OAuth | Full access to all channels, search, DMs the user can see | +| `xoxb-` | Bot token | Only channels the bot has been invited to; no message search | + +### Notion integration setup + +After creating an integration at https://www.notion.so/profile/integrations, you must +**share each page or database** with the integration for it to be accessible. The MCP server +can only see content that has been explicitly shared. + --- ## Removed Clients @@ -70,7 +100,6 @@ The following clients were removed because they required manual binary installat | Client | Reason removed | |--------|---------------| -| `github_mcp_server.rb` | Required `brew install github-mcp-server` | | `imcp.rb` | Required `brew install --cask loopwork/tap/iMCP` (macOS only) | --- diff --git a/lib/shared_tools/mcp/hugging_face_client.rb b/lib/shared_tools/mcp/hugging_face_client.rb new file mode 100644 index 0000000..ee88219 --- /dev/null +++ b/lib/shared_tools/mcp/hugging_face_client.rb @@ -0,0 +1,43 @@ +# shared_tools/mcp/hugging_face_client.rb +# +# Hugging Face MCP Server Client — requires brew installation +# +# Provides access to the Hugging Face Hub: search and inspect models, +# datasets, and Spaces; read model cards; run inference; browse +# trending repositories. +# +# Prerequisites: +# - Homebrew (https://brew.sh) +# - A Hugging Face user access token (free) +# Create one at https://huggingface.co/settings/tokens +# The hf-mcp-server binary is installed automatically via brew if missing. +# +# Configuration: +# export HF_TOKEN=hf_your_access_token_here +# +# Usage: +# require 'shared_tools/mcp/hugging_face_client' +# client = RubyLLM::MCP.clients["hugging-face"] +# chat = RubyLLM.chat.with_tools(*client.tools) +# +# Compatible with ruby_llm-mcp >= 0.7.0 + +require_relative "../utilities" + +SharedTools.verify_envars("HF_TOKEN") +SharedTools.package_install("hf-mcp-server") + +require "ruby_llm/mcp" + +RubyLLM::MCP.add_client( + name: "hugging-face", + transport_type: :stdio, + config: { + command: "hf-mcp-server", + args: [], + env: { + "TRANSPORT" => "stdio", + "DEFAULT_HF_TOKEN" => ENV.fetch("HF_TOKEN", ""), + }, + }, +) diff --git a/lib/shared_tools/mcp/notion_client.rb b/lib/shared_tools/mcp/notion_client.rb new file mode 100644 index 0000000..a886ee7 --- /dev/null +++ b/lib/shared_tools/mcp/notion_client.rb @@ -0,0 +1,40 @@ +# shared_tools/mcp/notion_client.rb +# +# Notion MCP Server Client — requires brew installation +# +# Provides full Notion workspace access: search pages and databases, +# read and update content, create pages, and query databases. +# +# Prerequisites: +# - Homebrew (https://brew.sh) +# - A Notion internal integration token +# Create one at https://www.notion.so/profile/integrations +# then share the relevant pages/databases with the integration. +# The notion-mcp-server binary is installed automatically via brew if missing. +# +# Configuration: +# export NOTION_TOKEN=ntn_your_integration_token_here +# +# Usage: +# require 'shared_tools/mcp/notion_client' +# client = RubyLLM::MCP.clients["notion"] +# chat = RubyLLM.chat.with_tools(*client.tools) +# +# Compatible with ruby_llm-mcp >= 0.7.0 + +require_relative "../utilities" + +SharedTools.verify_envars("NOTION_TOKEN") +SharedTools.package_install("notion-mcp-server") + +require "ruby_llm/mcp" + +RubyLLM::MCP.add_client( + name: "notion", + transport_type: :stdio, + config: { + command: "notion-mcp-server", + args: [], + env: { "NOTION_TOKEN" => ENV.fetch("NOTION_TOKEN", "") }, + }, +) diff --git a/lib/shared_tools/mcp/slack_client.rb b/lib/shared_tools/mcp/slack_client.rb new file mode 100644 index 0000000..f8a1a64 --- /dev/null +++ b/lib/shared_tools/mcp/slack_client.rb @@ -0,0 +1,54 @@ +# shared_tools/mcp/slack_client.rb +# +# Slack MCP Server Client — requires brew installation +# +# Provides Slack workspace access: read channels, messages, and threads, +# search message history, list users, and (optionally) post messages. +# +# Prerequisites: +# - Homebrew (https://brew.sh) +# - A Slack token — at least one of the following must be set: +# SLACK_MCP_XOXP_TOKEN — user OAuth token (xoxp-...) — full access +# SLACK_MCP_XOXB_TOKEN — bot token (xoxb-...) — limited to invited channels, no search +# The slack-mcp-server binary is installed automatically via brew if missing. +# +# Configuration: +# export SLACK_MCP_XOXP_TOKEN=xoxp-your-user-token # recommended +# # OR +# export SLACK_MCP_XOXB_TOKEN=xoxb-your-bot-token +# +# Optional — posting messages is disabled by default for safety: +# export SLACK_MCP_ADD_MESSAGE_TOOL=true # enable for all channels +# export SLACK_MCP_ADD_MESSAGE_TOOL=C012AB3CD,C98765 # enable for specific channels only +# +# Usage: +# require 'shared_tools/mcp/slack_client' +# client = RubyLLM::MCP.clients["slack"] +# chat = RubyLLM.chat.with_tools(*client.tools) +# +# Compatible with ruby_llm-mcp >= 0.7.0 + +require_relative "../utilities" + +xoxp = ENV.fetch("SLACK_MCP_XOXP_TOKEN", "") +xoxb = ENV.fetch("SLACK_MCP_XOXB_TOKEN", "") +raise LoadError, "SLACK_MCP_XOXP_TOKEN or SLACK_MCP_XOXB_TOKEN must be set" if xoxp.empty? && xoxb.empty? + +SharedTools.package_install("slack-mcp-server") + +require "ruby_llm/mcp" + +slack_env = {} +slack_env["SLACK_MCP_XOXP_TOKEN"] = xoxp unless xoxp.empty? +slack_env["SLACK_MCP_XOXB_TOKEN"] = xoxb unless xoxb.empty? +slack_env["SLACK_MCP_ADD_MESSAGE_TOOL"] = ENV["SLACK_MCP_ADD_MESSAGE_TOOL"] if ENV["SLACK_MCP_ADD_MESSAGE_TOOL"] + +RubyLLM::MCP.add_client( + name: "slack", + transport_type: :stdio, + config: { + command: "slack-mcp-server", + args: [], + env: slack_env, + }, +) From ba8019cd13c2544c91ee971f018126050d49aaf6 Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Thu, 26 Mar 2026 16:35:30 -0500 Subject: [PATCH 12/17] feat(mcp): add support for new MCP clients This commit adds support for several new MCP (Model Context Protocol) clients in the SharedTools library: - Tavily (web search, news, research, URL extraction) - requires API key only - GitHub (repos, issues, PRs, code search) - auto-installs via Homebrew - Notion (pages, databases, search, content CRUD) - auto-installs via Homebrew - Slack (channels, messages, threads, user info) - auto-installs via Homebrew - Hugging Face (models, datasets, Spaces, model cards) - auto-installs via Homebrew - Memory (persistent knowledge graph) - auto-downloads via npx - Sequential Thinking (chain-of-thought reasoning) - auto-downloads via npx - Chart (chart and visualisation generation) - auto-downloads via npx - Brave Search (web and news search) - auto-downloads via npx The new clients are opt-in, with each one requiring a specific environment variable to be set for authentication. Documentation on the new clients has been added to the README. This change introduces several new features and capabilities to the SharedTools library, allowing users to integrate a wide range of external services and tools into their applications. --- README.md | 54 +++- docs/getting-started/installation.md | 16 ++ examples/README.md | 155 +++++++++++- .../mcp/streamable_http_patch_test.rb | 178 +++++++++++++ test/shared_tools/utilities_test.rb | 234 ++++++++++++++++++ 5 files changed, 627 insertions(+), 10 deletions(-) create mode 100644 test/shared_tools/mcp/streamable_http_patch_test.rb create mode 100644 test/shared_tools/utilities_test.rb diff --git a/README.md b/README.md index 678ab5a..822c8da 100644 --- a/README.md +++ b/README.md @@ -410,6 +410,49 @@ error_tool.execute( --- +## MCP Clients + +SharedTools bundles [Model Context Protocol](https://modelcontextprotocol.io) clients that connect AI agents to external services. Each client is opt-in — require only the ones you need. + +### Remote HTTP (API key only) + +| Client | Env var | Provides | +|--------|---------|----------| +| `require 'shared_tools/mcp/tavily_client'` | `TAVILY_API_KEY` | Web search, news, research, URL extraction | + +### Brew-installed (auto-installs via Homebrew) + +| Client | Env var | Provides | +|--------|---------|----------| +| `require 'shared_tools/mcp/github_client'` | `GITHUB_PERSONAL_ACCESS_TOKEN` | Repos, issues, PRs, code search | +| `require 'shared_tools/mcp/notion_client'` | `NOTION_TOKEN` | Pages, databases, search, content CRUD | +| `require 'shared_tools/mcp/slack_client'` | `SLACK_MCP_XOXP_TOKEN` | Channels, messages, threads, user info | +| `require 'shared_tools/mcp/hugging_face_client'` | `HF_TOKEN` | Models, datasets, Spaces, model cards | + +### npx Auto-download (Node.js required) + +| Client | Provides | +|--------|----------| +| `require 'shared_tools/mcp/memory_client'` | Persistent knowledge graph | +| `require 'shared_tools/mcp/sequential_thinking_client'` | Chain-of-thought reasoning | +| `require 'shared_tools/mcp/chart_client'` | Chart and visualisation generation | +| `require 'shared_tools/mcp/brave_search_client'` | Web and news search (`BRAVE_API_KEY`) | + +```ruby +# Load all available clients at once (skips any whose env vars are missing) +require 'shared_tools/mcp' + +# Or load a specific client +require 'shared_tools/mcp/notion_client' +client = RubyLLM::MCP.clients["notion"] +chat = RubyLLM.chat.with_tools(*client.tools) +chat.ask("Find my project planning pages and summarise what's in them") +``` + +See [MCP Clients README](lib/shared_tools/mcp/README.md) for full configuration details. + +--- + ## Authorization System SharedTools includes a human-in-the-loop authorization system for safety: @@ -470,7 +513,16 @@ bundle exec ruby -I examples examples/doc_tool_demo.rb | `doc_tool_demo.rb` | Text, PDF, Word, spreadsheets | | `error_handling_tool_demo.rb` | Error handling patterns | | `eval_tool_demo.rb` | Code evaluation | -| `mcp_client_demo.rb` | MCP client integration | +| `mcp_client_demo.rb` | MCP client overview | +| `mcp/tavily_demo.rb` | Tavily web search (HTTP) | +| `mcp/github_demo.rb` | GitHub repos, issues, PRs | +| `mcp/notion_demo.rb` | Notion pages and databases | +| `mcp/slack_demo.rb` | Slack channels and messages | +| `mcp/hugging_face_demo.rb` | Hugging Face models and datasets | +| `mcp/memory_demo.rb` | Persistent knowledge graph | +| `mcp/sequential_thinking_demo.rb` | Chain-of-thought reasoning | +| `mcp/chart_demo.rb` | Chart generation | +| `mcp/brave_search_demo.rb` | Brave web search | | `notification_tool_demo.rb` | Desktop notifications, alerts, TTS | | `system_info_tool_demo.rb` | System info | | `weather_tool_demo.rb` | Weather + local forecast | diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 8f1cce6..2162e6e 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -102,6 +102,22 @@ brew install python3 apt-get install python3 ``` +## MCP Clients + +SharedTools bundles optional MCP (Model Context Protocol) clients. Each is opt-in via an extra require: + +```ruby +gem 'ruby_llm-mcp', '>= 0.7.0' # Add to Gemfile +``` + +| Transport | What's needed | +|-----------|---------------| +| Remote HTTP (Tavily) | Only an API key — no binaries | +| Brew-installed (GitHub, Notion, Slack, Hugging Face) | Homebrew — binaries are auto-installed on first use | +| npx (Memory, Sequential Thinking, Chart, Brave Search) | Node.js / npx — packages are auto-downloaded on first use | + +See [MCP Clients README](https://github.com/madbomber/shared_tools/blob/main/lib/shared_tools/mcp/README.md) for full configuration details. + ## Complete Setup Example For a full-featured installation with all optional dependencies: diff --git a/examples/README.md b/examples/README.md index 0880855..35681c0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -260,18 +260,149 @@ bundle exec ruby -I examples examples/notification_tool_demo.rb ### `mcp_client_demo.rb` -Model Context Protocol (MCP) client integration: connecting to MCP servers (Tavily, GitHub, iMCP), using MCP tools in LLM conversations, multi-client orchestration, and accessing MCP resources. +Model Context Protocol (MCP) client overview: loading multiple clients, using MCP tools in LLM conversations, and multi-client orchestration. -**Requires:** -- `ruby_llm-mcp` gem -- `TAVILY_API_KEY` for web search -- `GITHUB_PERSONAL_ACCESS_TOKEN` for GitHub operations -- `brew install github-mcp-server` and `brew install --cask loopwork/tap/iMCP` (macOS) +**Requires:** `ruby_llm-mcp` gem + +```bash +bundle exec ruby -I examples examples/mcp_client_demo.rb +``` + +--- + +## MCP Client Demos (`mcp/` subdirectory) + +Individual demos for each MCP client. Run from the project root with both `examples` and `examples/mcp` on the load path: + +```bash +bundle exec ruby -I lib -I examples examples/mcp/tavily_demo.rb +``` + +All MCP demos share a `examples/mcp/common.rb` helper and use the same `title`/`ask`/`new_chat` helpers as the main demos. + +--- + +### `mcp/tavily_demo.rb` + +AI-optimized web search, news, research, and URL extraction via the Tavily API. Uses the remote HTTP transport — no local binary required. + +**Requires:** `TAVILY_API_KEY` (free tier at https://tavily.com) ```bash export TAVILY_API_KEY="your-key" +bundle exec ruby -I lib -I examples examples/mcp/tavily_demo.rb +``` + +--- + +### `mcp/github_demo.rb` + +GitHub repository exploration: listing repos, reading issues and PRs, code search, contributor analysis, and release history. + +**Requires:** +- `GITHUB_PERSONAL_ACCESS_TOKEN` +- Homebrew (auto-installs `github-mcp-server`) + +```bash export GITHUB_PERSONAL_ACCESS_TOKEN="your-token" -bundle exec ruby -I examples examples/mcp_client_demo.rb +bundle exec ruby -I lib -I examples examples/mcp/github_demo.rb +``` + +--- + +### `mcp/notion_demo.rb` + +Full Notion workspace access: search pages and databases, read content, explore recent activity, and retrieve page summaries. + +**Requires:** +- `NOTION_TOKEN` (create at https://www.notion.so/profile/integrations) +- Homebrew (auto-installs `notion-mcp-server`) +- Pages and databases must be shared with the integration + +```bash +export NOTION_TOKEN="ntn_..." +bundle exec ruby -I lib -I examples examples/mcp/notion_demo.rb +``` + +--- + +### `mcp/slack_demo.rb` + +Slack workspace browsing: channel overview, recent message summaries, thread deep-dives, and team activity analysis. + +**Requires:** +- `SLACK_MCP_XOXP_TOKEN` (user OAuth, recommended) **or** `SLACK_MCP_XOXB_TOKEN` (bot token) +- Homebrew (auto-installs `slack-mcp-server`) + +```bash +export SLACK_MCP_XOXP_TOKEN="xoxp-..." +bundle exec ruby -I lib -I examples examples/mcp/slack_demo.rb +``` + +--- + +### `mcp/hugging_face_demo.rb` + +Browse the Hugging Face Hub: trending models, Ruby/Rails model search, small model discovery, dataset exploration, and model card retrieval. + +**Requires:** +- `HF_TOKEN` (create at https://huggingface.co/settings/tokens) +- Homebrew (auto-installs `hf-mcp-server`) + +```bash +export HF_TOKEN="hf_..." +bundle exec ruby -I lib -I examples examples/mcp/hugging_face_demo.rb +``` + +--- + +### `mcp/memory_demo.rb` + +Persistent knowledge graph across conversations using the `@modelcontextprotocol/server-memory` npm package. Store and retrieve facts, entities, and relations. + +**Requires:** Node.js / npx (package auto-downloaded on first use) + +```bash +bundle exec ruby -I lib -I examples examples/mcp/memory_demo.rb +``` + +--- + +### `mcp/sequential_thinking_demo.rb` + +Structured chain-of-thought reasoning using the `@modelcontextprotocol/server-sequential-thinking` npm package. + +**Requires:** Node.js / npx (package auto-downloaded on first use) + +```bash +bundle exec ruby -I lib -I examples examples/mcp/sequential_thinking_demo.rb +``` + +--- + +### `mcp/chart_demo.rb` + +Chart and visualisation generation using the `@antv/mcp-server-chart` npm package. + +**Requires:** Node.js / npx (package auto-downloaded on first use) + +```bash +bundle exec ruby -I lib -I examples examples/mcp/chart_demo.rb +``` + +--- + +### `mcp/brave_search_demo.rb` + +Web and news search via the Brave Search API using the `@modelcontextprotocol/server-brave-search` npm package. + +**Requires:** +- `BRAVE_API_KEY` (free tier at https://brave.com/search/api/) +- Node.js / npx (package auto-downloaded on first use) + +```bash +export BRAVE_API_KEY="your-key" +bundle exec ruby -I lib -I examples examples/mcp/brave_search_demo.rb ``` --- @@ -364,8 +495,14 @@ Debug logging (`RUBY_LLM_DEBUG=true`) is set in each demo's header so tool calls | `ANTHROPIC_API_KEY` | All demos (if using Anthropic) | | `OLLAMA_HOST` | All demos (if using Ollama; default: `http://localhost:11434`) | | `OPENWEATHER_API_KEY` | `weather_tool_demo.rb` | -| `TAVILY_API_KEY` | `mcp_client_demo.rb` | -| `GITHUB_PERSONAL_ACCESS_TOKEN` | `mcp_client_demo.rb` | +| `TAVILY_API_KEY` | `mcp/tavily_demo.rb` | +| `GITHUB_PERSONAL_ACCESS_TOKEN` | `mcp/github_demo.rb` | +| `NOTION_TOKEN` | `mcp/notion_demo.rb` | +| `SLACK_MCP_XOXP_TOKEN` | `mcp/slack_demo.rb` (user OAuth, recommended) | +| `SLACK_MCP_XOXB_TOKEN` | `mcp/slack_demo.rb` (bot token alternative) | +| `HF_TOKEN` | `mcp/hugging_face_demo.rb` | +| `BRAVE_API_KEY` | `mcp/brave_search_demo.rb` | +| `MEMORY_FILE_PATH` | `mcp/memory_demo.rb` (optional — path to `.jsonl` persistence file) | --- diff --git a/test/shared_tools/mcp/streamable_http_patch_test.rb b/test/shared_tools/mcp/streamable_http_patch_test.rb new file mode 100644 index 0000000..a1d5e4e --- /dev/null +++ b/test/shared_tools/mcp/streamable_http_patch_test.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require "test_helper" +require "ruby_llm/mcp" +require "shared_tools/mcp/streamable_http_patch" + +# Tests for the SSE line-ending normalization monkey-patch applied to +# RubyLLM::MCP::Native::Transports::StreamableHTTP#extract_sse_event. +# +# The patch fixes a server-side RFC 8895 violation where some hosted MCP +# servers (e.g. Tavily) terminate the data line with \n but use \r\n for the +# blank separator, yielding \n\r\n instead of the uniform \n\n or \r\n\r\n +# that the original parser requires. Without the patch the event boundary is +# never found and every tools/list request times out after 30 seconds. +# +# We use StreamableHTTP.allocate to obtain an instance without calling the +# complex constructor (which requires a coordinator, url, etc.), then define a +# lightweight parse_sse_event stub on that instance so we can test the +# normalization and splitting logic in complete isolation. +class StreamableHttpPatchTest < Minitest::Test + + TRANSPORT = RubyLLM::MCP::Native::Transports::StreamableHTTP + + # Build an instance that bypasses the real constructor and provides a + # predictable parse_sse_event so tests only verify buffer normalization. + def setup + @instance = TRANSPORT.allocate + + # Stub parse_sse_event to return a hash containing the raw event text. + # This lets us assert on what the patch passed to it. + @instance.define_singleton_method(:parse_sse_event) do |raw| + { raw: raw } + end + end + + # Wrap in a mutable String — the real buffer is always mutable (built by + # on_response_body_chunk), and gsub! requires it. frozen_string_literal: true + # at the top of this file would otherwise freeze bare string literals. + def extract(str) + @instance.send(:extract_sse_event, +str) + end + + # ------------------------------------------------------------------------- + # Returns nil when no event boundary is present + # ------------------------------------------------------------------------- + + def test_returns_nil_when_buffer_is_empty + assert_nil extract("") + end + + def test_returns_nil_when_buffer_has_no_event_boundary + assert_nil extract("data: hello") + end + + def test_returns_nil_for_single_newline_only + assert_nil extract("\n") + end + + # ------------------------------------------------------------------------- + # Uniform \n\n — standard LF-only events + # ------------------------------------------------------------------------- + + def test_extracts_event_with_uniform_lf_separator + buffer = "data: hello\n\n" + event, rest = extract(buffer) + + assert_equal "data: hello", event[:raw] + assert_equal "", rest + end + + def test_rest_contains_content_after_first_event_lf + buffer = "data: first\n\ndata: second\n\n" + event, rest = extract(buffer) + + assert_equal "data: first", event[:raw] + assert_equal "data: second\n\n", rest + end + + # ------------------------------------------------------------------------- + # Uniform \r\n\r\n — standard CRLF events (normalised to LF) + # ------------------------------------------------------------------------- + + def test_extracts_event_with_uniform_crlf_separator + buffer = "data: hello\r\n\r\n" + event, rest = extract(buffer) + + # After normalisation: "data: hello\n\n" — the data line itself loses \r + assert_equal "data: hello", event[:raw] + assert_equal "", rest + end + + def test_rest_contains_content_after_first_event_crlf + buffer = "data: first\r\n\r\ndata: second\r\n\r\n" + event, rest = extract(buffer) + + assert_equal "data: first", event[:raw] + # rest is normalised too + assert_equal "data: second\n\n", rest + end + + # ------------------------------------------------------------------------- + # Mixed \n\r\n — the Tavily / RFC violation case + # ------------------------------------------------------------------------- + + def test_extracts_event_with_mixed_lf_crlf_separator + # Data line ends with \n; blank separator is \r\n → produces \n\r\n + buffer = "data: hello\n\r\n" + event, rest = extract(buffer) + + assert_equal "data: hello", event[:raw] + assert_equal "", rest + end + + def test_extracts_multiline_event_with_mixed_line_endings + buffer = "event: tools/list\ndata: {\"result\":true}\n\r\n" + event, rest = extract(buffer) + + assert_equal "event: tools/list\ndata: {\"result\":true}", event[:raw] + assert_equal "", rest + end + + def test_rest_contains_content_after_mixed_ending_event + buffer = "data: first\n\r\ndata: second\n\n" + event, rest = extract(buffer) + + assert_equal "data: first", event[:raw] + assert_equal "data: second\n\n", rest + end + + # ------------------------------------------------------------------------- + # Standalone \r — bare CR normalised to LF + # ------------------------------------------------------------------------- + + def test_extracts_event_with_bare_cr_separator + buffer = "data: hello\r\r" + event, rest = extract(buffer) + + assert_equal "data: hello", event[:raw] + assert_equal "", rest + end + + # ------------------------------------------------------------------------- + # Idempotency — normalising already-normalised content is a no-op + # ------------------------------------------------------------------------- + + def test_normalisation_is_idempotent + buffer = "data: hello\n\n" + first_event, _ = extract(buffer.dup) + second_event, _ = extract(buffer.dup) + + assert_equal first_event, second_event + end + + def test_repeated_extraction_on_same_buffer_object_is_safe + buffer = +"data: hello\n\r\n" # mutable string (prefixed +) + event1, _ = extract(buffer) + # buffer has been normalised in-place; extracting again on a re-normalised + # buffer should still find no boundary (content was consumed as rest = "") + assert_equal "data: hello", event1[:raw] + end + + # ------------------------------------------------------------------------- + # rest is never nil — always a String + # ------------------------------------------------------------------------- + + def test_rest_is_empty_string_not_nil_when_nothing_follows + _, rest = extract("data: hello\n\n") + assert_instance_of String, rest + assert_equal "", rest + end + + def test_rest_is_empty_string_for_crlf_event_with_no_trailer + _, rest = extract("data: hello\r\n\r\n") + assert_instance_of String, rest + assert_equal "", rest + end + +end diff --git a/test/shared_tools/utilities_test.rb b/test/shared_tools/utilities_test.rb new file mode 100644 index 0000000..eea9aa5 --- /dev/null +++ b/test/shared_tools/utilities_test.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +require "test_helper" + +class UtilitiesTest < Minitest::Test + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + + # Temporarily set / unset environment variables and restore afterwards. + def with_env(vars) + old = vars.each_key.with_object({}) { |k, h| h[k] = ENV[k] } + vars.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v } + yield + ensure + old.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v } + end + + # Define a temporary singleton method named +name+ on +SharedTools+, + # yield, then remove it. Used to override private Kernel methods such as + # +system+ and ` that are called without an explicit receiver + # inside the utilities methods. + def stub_kernel_method(name, return_value) + SharedTools.define_singleton_method(name) { |*| return_value } + yield + ensure + SharedTools.singleton_class.remove_method(name) + end + + # Stub +system+ with a callable so successive calls can return different values. + def stub_system_callable(callable) + SharedTools.define_singleton_method(:system) { |*args| callable.call(*args) } + yield + ensure + SharedTools.singleton_class.remove_method(:system) + end + + # ------------------------------------------------------------------------- + # verify_envars + # ------------------------------------------------------------------------- + + def test_verify_envars_passes_when_all_vars_present + with_env("SHARED_TOOLS_TEST_A" => "hello", "SHARED_TOOLS_TEST_B" => "world") do + # Should not raise + SharedTools.verify_envars("SHARED_TOOLS_TEST_A", "SHARED_TOOLS_TEST_B") + end + end + + def test_verify_envars_raises_load_error_when_var_missing + with_env("SHARED_TOOLS_TEST_A" => nil) do + error = assert_raises(LoadError) do + SharedTools.verify_envars("SHARED_TOOLS_TEST_A") + end + assert_match "SHARED_TOOLS_TEST_A", error.message + end + end + + def test_verify_envars_raises_with_all_missing_var_names_in_message + with_env("SHARED_TOOLS_TEST_A" => nil, "SHARED_TOOLS_TEST_B" => nil) do + error = assert_raises(LoadError) do + SharedTools.verify_envars("SHARED_TOOLS_TEST_A", "SHARED_TOOLS_TEST_B") + end + assert_match "SHARED_TOOLS_TEST_A", error.message + assert_match "SHARED_TOOLS_TEST_B", error.message + end + end + + def test_verify_envars_treats_empty_string_as_missing + with_env("SHARED_TOOLS_TEST_A" => "") do + assert_raises(LoadError) do + SharedTools.verify_envars("SHARED_TOOLS_TEST_A") + end + end + end + + def test_verify_envars_only_reports_missing_vars_not_present_ones + with_env("SHARED_TOOLS_TEST_A" => "present", "SHARED_TOOLS_TEST_B" => nil) do + error = assert_raises(LoadError) do + SharedTools.verify_envars("SHARED_TOOLS_TEST_A", "SHARED_TOOLS_TEST_B") + end + refute_match "SHARED_TOOLS_TEST_A", error.message + assert_match "SHARED_TOOLS_TEST_B", error.message + end + end + + def test_verify_envars_passes_with_single_var_present + with_env("SHARED_TOOLS_TEST_A" => "value") do + SharedTools.verify_envars("SHARED_TOOLS_TEST_A") # should not raise + end + end + + # ------------------------------------------------------------------------- + # brew_install + # ------------------------------------------------------------------------- + + def test_brew_install_raises_load_error_when_brew_not_found + stub_kernel_method(:system, false) do + error = assert_raises(LoadError) do + SharedTools.brew_install("some-package") + end + assert_match "Homebrew", error.message + end + end + + def test_brew_install_skips_already_installed_package + # system("which brew") → true; brew list --versions → non-empty (installed) + stub_kernel_method(:system, true) do + stub_kernel_method(:`, "some-package 1.0.0") do + # Must not raise + SharedTools.brew_install("some-package") + end + end + end + + def test_brew_install_raises_when_package_install_fails + # First system call is "which brew" → true; second is "brew install" → false + calls = 0 + callable = ->(*) { calls += 1; calls == 1 } + stub_system_callable(callable) do + stub_kernel_method(:`, "") do # brew list returns empty → not installed + error = assert_raises(LoadError) do + SharedTools.brew_install("bad-package") + end + assert_match "bad-package", error.message + assert_match "could not be installed", error.message + end + end + end + + def test_brew_install_succeeds_when_package_installs_cleanly + stub_kernel_method(:system, true) do # which brew → true; brew install → true + stub_kernel_method(:`, "") do # brew list returns empty → not yet installed + SharedTools.brew_install("new-package") # should not raise + end + end + end + + # ------------------------------------------------------------------------- + # npm_install + # ------------------------------------------------------------------------- + + def test_npm_install_raises_when_npm_not_found + stub_kernel_method(:system, false) do + error = assert_raises(LoadError) do + SharedTools.npm_install("some-npm-package") + end + assert_match "npm", error.message + end + end + + def test_npm_install_skips_already_installed_package + stub_kernel_method(:system, true) do + # system("which pkg") returns true → already in PATH → skip + SharedTools.npm_install("some-npm-package") + end + end + + def test_npm_install_raises_when_package_install_fails + # npm present (first call true); package not in PATH (second call false); install fails (third false) + calls = 0 + callable = ->(*) { calls += 1; [true, false, false][calls - 1] } + stub_system_callable(callable) do + error = assert_raises(LoadError) do + SharedTools.npm_install("bad-npm-package") + end + assert_match "bad-npm-package", error.message + assert_match "could not be installed", error.message + end + end + + # ------------------------------------------------------------------------- + # gem_install + # ------------------------------------------------------------------------- + + def test_gem_install_raises_when_gem_binary_not_found + stub_kernel_method(:system, false) do + error = assert_raises(LoadError) do + SharedTools.gem_install("some-gem") + end + assert_match "gem", error.message + end + end + + def test_gem_install_skips_already_installed_gem + # gem present (true) and gem list -i returns true → already installed + stub_kernel_method(:system, true) do + SharedTools.gem_install("already-installed-gem") + end + end + + def test_gem_install_raises_when_install_fails + # gem present; gem list -i returns false (not installed); gem install returns false + calls = 0 + callable = ->(*) { calls += 1; [true, false, false][calls - 1] } + stub_system_callable(callable) do + error = assert_raises(LoadError) do + SharedTools.gem_install("bad-gem") + end + assert_match "bad-gem", error.message + assert_match "could not be installed", error.message + end + end + + # ------------------------------------------------------------------------- + # package_install — platform delegation + # ------------------------------------------------------------------------- + + def test_package_install_delegates_to_brew_install_on_darwin + # Verify that on darwin the brew_install path is followed — if brew is + # absent it should raise the Homebrew-specific error, not a platform error. + skip "Only relevant on macOS" unless RUBY_PLATFORM =~ /darwin/ + + stub_kernel_method(:system, false) do + error = assert_raises(LoadError) do + SharedTools.package_install("any-package") + end + assert_match "Homebrew", error.message + end + end + + def test_package_install_raises_for_unsupported_platform + # Temporarily override RUBY_PLATFORM by stubbing package_install itself + # to exercise the else branch; instead, test via the constant directly. + # Since we cannot change the constant at runtime we verify the error message + # format by pattern-matching against the actual platform string. + skip "Skipped on darwin and linux — tested via integration" if RUBY_PLATFORM =~ /darwin|linux/ + + assert_raises(LoadError) do + SharedTools.package_install("any-package") + end + end + +end From 1734316b1cf476fca52ea066ee0ea7bf3adcde1d Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Thu, 26 Mar 2026 16:53:31 -0500 Subject: [PATCH 13/17] feat(mcp): handle LoadError exceptions more robustly Introduce a more robust exception handling mechanism in the MCP client loader to better handle LoadError exceptions, which are a type of ScriptError and not a StandardError. The previous `rescue => e` approach would not catch LoadError exceptions, leading to "terminated with exception" errors when loading the MCP clients. The new `rescue Exception => e` approach ensures that all load failures are properly caught and handled, allowing the rest of the MCP clients to be loaded successfully. Additionally, a comment has been added to explain the rationale behind the change and the difference between LoadError (a ScriptError) and StandardError. --- lib/shared_tools/mcp.rb | 7 +++++-- test/shared_tools/utilities_test.rb | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/shared_tools/mcp.rb b/lib/shared_tools/mcp.rb index 4999952..3350176 100644 --- a/lib/shared_tools/mcp.rb +++ b/lib/shared_tools/mcp.rb @@ -36,8 +36,11 @@ threads = Dir[File.join(__dir__, "mcp", "*_client.rb")].map do |path| Thread.new do require path - rescue => e - warn "SharedTools::MCP — failed to load #{File.basename(path)}: #{e.message}" + rescue Exception => e + # LoadError (missing env var / package) is a ScriptError, not a StandardError, + # so bare `rescue => e` does not catch it. We silence all load failures here + # so that missing-key clients are simply skipped when requiring mcp.rb as a whole. + warn "SharedTools::MCP — skipping #{File.basename(path)}: #{e.message}" end end diff --git a/test/shared_tools/utilities_test.rb b/test/shared_tools/utilities_test.rb index eea9aa5..7570865 100644 --- a/test/shared_tools/utilities_test.rb +++ b/test/shared_tools/utilities_test.rb @@ -90,6 +90,20 @@ def test_verify_envars_passes_with_single_var_present end end + def test_verify_envars_raises_load_error_which_is_a_script_error_not_standard_error + # LoadError < ScriptError < Exception — NOT a StandardError. + # This matters in mcp.rb where `rescue => e` (StandardError only) would let + # the LoadError escape the thread, triggering "terminated with exception". + # The loader must use `rescue Exception => e` to catch it. + with_env("SHARED_TOOLS_TEST_A" => nil) do + error = assert_raises(LoadError) do + SharedTools.verify_envars("SHARED_TOOLS_TEST_A") + end + assert_kind_of ScriptError, error + refute_kind_of StandardError, error + end + end + # ------------------------------------------------------------------------- # brew_install # ------------------------------------------------------------------------- From 03a56d109d2954d1a5511808627ec369d642c28e Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Thu, 26 Mar 2026 16:59:05 -0500 Subject: [PATCH 14/17] feat(mcp): add client load tracking and reporting This commit adds functionality to the SharedTools module to track the success or failure of loading MCP clients, and provide methods to report on the overall load status. The key changes are: 1. A new `record_mcp_result` method that is called by the mcp.rb loader to record the outcome of each client load attempt. 2. New `mcp_loaded`, `mcp_failed`, and `mcp_status` methods that provide access to the load tracking data. 3. Updates to the mcp.rb loader to use the new tracking methods and provide better error handling/reporting when clients fail to load. This provides greater visibility into the MCP setup process, making it easier to diagnose and resolve issues with missing dependencies or configuration. --- lib/shared_tools/mcp.rb | 10 +++- lib/shared_tools/utilities.rb | 65 ++++++++++++++++++++++++ test/shared_tools/utilities_test.rb | 76 +++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/lib/shared_tools/mcp.rb b/lib/shared_tools/mcp.rb index 3350176..71abdf5 100644 --- a/lib/shared_tools/mcp.rb +++ b/lib/shared_tools/mcp.rb @@ -34,12 +34,18 @@ require_relative "mcp/streamable_http_patch" threads = Dir[File.join(__dir__, "mcp", "*_client.rb")].map do |path| + # Derive the canonical client name from the filename: + # "brave_search_client.rb" → "brave-search", "hugging_face_client.rb" → "hugging-face" + client_name = File.basename(path, "_client.rb").tr("_", "-") + Thread.new do require path + SharedTools.record_mcp_result(client_name) rescue Exception => e # LoadError (missing env var / package) is a ScriptError, not a StandardError, - # so bare `rescue => e` does not catch it. We silence all load failures here - # so that missing-key clients are simply skipped when requiring mcp.rb as a whole. + # so bare `rescue => e` does not catch it. We record the failure and warn so + # that missing-key clients are simply skipped when requiring mcp.rb as a whole. + SharedTools.record_mcp_result(client_name, error: e) warn "SharedTools::MCP — skipping #{File.basename(path)}: #{e.message}" end end diff --git a/lib/shared_tools/utilities.rb b/lib/shared_tools/utilities.rb index 4a72588..4ae7a6f 100644 --- a/lib/shared_tools/utilities.rb +++ b/lib/shared_tools/utilities.rb @@ -6,8 +6,73 @@ # Loaded automatically by lib/shared_tools.rb. module SharedTools + MCP_LOG_MUTEX = Mutex.new + @mcp_load_log = {} + class << self + # --------------------------------------------------------------------------- + # MCP load tracking + # --------------------------------------------------------------------------- + + # Record the outcome of loading a single MCP client. + # Called by mcp.rb from within each loader thread. + # + # SharedTools.record_mcp_result("tavily") # success + # SharedTools.record_mcp_result("notion", error: e) # failure + def record_mcp_result(name, error: nil) + MCP_LOG_MUTEX.synchronize do + @mcp_load_log[name] = error ? { status: :failed, reason: error.message } : { status: :ok } + end + end + + # Returns an array of client names that loaded successfully. + # + # SharedTools.mcp_loaded #=> ["github", "memory", "chart"] + def mcp_loaded + MCP_LOG_MUTEX.synchronize { @mcp_load_log.select { |_, v| v[:status] == :ok }.keys } + end + + # Returns a hash of client names that failed to load, mapped to their error messages. + # + # SharedTools.mcp_failed #=> {"tavily" => "Missing envars: TAVILY_API_KEY", ...} + def mcp_failed + MCP_LOG_MUTEX.synchronize do + @mcp_load_log.select { |_, v| v[:status] == :failed }.transform_values { |v| v[:reason] } + end + end + + # Prints a summary table of MCP client load results and returns the full log hash. + # + # SharedTools.mcp_status + # # MCP Client Status + # # ✓ github + # # ✓ memory + # # ✗ tavily — Missing envars: TAVILY_API_KEY + def mcp_status + log = MCP_LOG_MUTEX.synchronize { @mcp_load_log.dup } + + if log.empty? + puts "No MCP clients have been loaded yet. Try: require 'shared_tools/mcp'" + return log + end + + puts "MCP Client Status" + puts "-" * 40 + log.sort.each do |name, entry| + if entry[:status] == :ok + puts " \u2713 #{name}" + else + puts " \u2717 #{name} \u2014 #{entry[:reason]}" + end + end + puts "-" * 40 + puts " #{log.count { |_, v| v[:status] == :ok }} loaded, " \ + "#{log.count { |_, v| v[:status] == :failed }} skipped" + + log + end + # Returns true if all named environment variables are set and non-empty. # Warns for each missing variable and returns false if any are absent. # diff --git a/test/shared_tools/utilities_test.rb b/test/shared_tools/utilities_test.rb index 7570865..2aac6e6 100644 --- a/test/shared_tools/utilities_test.rb +++ b/test/shared_tools/utilities_test.rb @@ -36,6 +36,82 @@ def stub_system_callable(callable) SharedTools.singleton_class.remove_method(:system) end + # ------------------------------------------------------------------------- + # MCP load tracking + # ------------------------------------------------------------------------- + + def setup + # Reset the load log before every test + SharedTools::MCP_LOG_MUTEX.synchronize { SharedTools.instance_variable_set(:@mcp_load_log, {}) } + end + + def test_mcp_loaded_returns_empty_array_when_nothing_recorded + assert_equal [], SharedTools.mcp_loaded + end + + def test_mcp_failed_returns_empty_hash_when_nothing_recorded + assert_equal({}, SharedTools.mcp_failed) + end + + def test_record_mcp_result_records_success + SharedTools.record_mcp_result("tavily") + assert_includes SharedTools.mcp_loaded, "tavily" + assert_empty SharedTools.mcp_failed + end + + def test_record_mcp_result_records_failure_with_reason + err = LoadError.new("Missing envars: TAVILY_API_KEY") + SharedTools.record_mcp_result("tavily", error: err) + + assert_empty SharedTools.mcp_loaded + assert_equal "Missing envars: TAVILY_API_KEY", SharedTools.mcp_failed["tavily"] + end + + def test_mcp_loaded_returns_only_successful_clients + SharedTools.record_mcp_result("github") + SharedTools.record_mcp_result("tavily", error: LoadError.new("missing key")) + SharedTools.record_mcp_result("memory") + + assert_equal %w[github memory].sort, SharedTools.mcp_loaded.sort + end + + def test_mcp_failed_returns_only_failed_clients + SharedTools.record_mcp_result("github") + SharedTools.record_mcp_result("tavily", error: LoadError.new("missing key")) + SharedTools.record_mcp_result("notion", error: LoadError.new("missing token")) + + assert_equal %w[tavily notion].sort, SharedTools.mcp_failed.keys.sort + assert_equal "missing key", SharedTools.mcp_failed["tavily"] + assert_equal "missing token", SharedTools.mcp_failed["notion"] + end + + def test_mcp_status_outputs_summary_and_returns_log + SharedTools.record_mcp_result("github") + SharedTools.record_mcp_result("tavily", error: LoadError.new("Missing envars: TAVILY_API_KEY")) + + out, _ = capture_io { SharedTools.mcp_status } + + assert_match "github", out + assert_match "tavily", out + assert_match "TAVILY_API_KEY", out + assert_match "1 loaded", out + assert_match "1 skipped", out + end + + def test_mcp_status_returns_empty_message_when_nothing_loaded + out, _ = capture_io { SharedTools.mcp_status } + assert_match "No MCP clients", out + end + + def test_record_mcp_result_is_thread_safe + threads = 20.times.map do |i| + Thread.new { SharedTools.record_mcp_result("client-#{i}") } + end + threads.each(&:join) + + assert_equal 20, SharedTools.mcp_loaded.size + end + # ------------------------------------------------------------------------- # verify_envars # ------------------------------------------------------------------------- From e639062f8c5b1b6e0a4c08b19d7b1af5c8c14cef Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Thu, 26 Mar 2026 17:02:28 -0500 Subject: [PATCH 15/17] bumped version --- lib/shared_tools/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/shared_tools/version.rb b/lib/shared_tools/version.rb index 67b2efb..3292fb3 100644 --- a/lib/shared_tools/version.rb +++ b/lib/shared_tools/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module SharedTools - VERSION = "0.4.0" + VERSION = "0.4.1" end From 59dd5bbd2a0b80073f792805e202a5a4964244da Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Thu, 26 Mar 2026 18:01:31 -0500 Subject: [PATCH 16/17] updated the changelog file --- CHANGELOG.md | 50 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56bfd87..1f94ce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,53 @@ # Changelog -### Unreleased -- Changing direction. Beginning with 0.3.0 will only support RubyLLM. - ## Released + +### [0.4.1] - 2026-03-26 + +#### MCP Clients +- Added `SharedTools.mcp_status` — prints a formatted table of loaded vs. skipped clients +- Added `SharedTools.mcp_loaded` — returns array of successfully loaded client names +- Added `SharedTools.mcp_failed` — returns hash of skipped client names mapped to their error messages +- Added `SharedTools.record_mcp_result` — thread-safe internal method used by `mcp.rb` to record each client's load outcome +- Fixed `mcp.rb` thread exception handling: changed `rescue => e` to `rescue Exception => e` so `LoadError` (a `ScriptError`, not a `StandardError`) is caught and the thread does not terminate with an unhandled exception +- Added SSE line-ending normalization patch (`mcp/streamable_http_patch.rb`) for MCP servers that mix `\n` and `\r\n` line endings in SSE responses (e.g. Tavily), fixing a 30-second `tools/list` timeout +- Added brew-installed MCP clients: Notion (`notion_client.rb`), Slack (`slack_client.rb`), Hugging Face (`hugging_face_client.rb`) +- All `package_install` methods now raise `LoadError` on failure (consistent with `verify_envars`) +- Added demo scripts: `examples/mcp/notion_demo.rb`, `examples/mcp/slack_demo.rb`, `examples/mcp/hugging_face_demo.rb` + +#### Tests +- Added `test/shared_tools/utilities_test.rb` — 28 tests covering `verify_envars`, `brew_install`, `npm_install`, `gem_install`, `package_install`, and MCP load tracking methods +- Added `test/shared_tools/mcp/streamable_http_patch_test.rb` — 15 tests covering SSE buffer normalization for all line-ending variants (`\n\n`, `\r\n\r\n`, mixed `\n\r\n`, bare `\r`) + +#### Documentation +- Updated `README.md` with MCP Clients section covering all three transport categories +- Updated `examples/README.md` with entries for all 10 `examples/mcp/` demos and expanded environment variables table +- Updated `docs/getting-started/installation.md` with MCP client setup section +- Updated `docs/index.md` and `lib/shared_tools/mcp/README.md` with Notion, Slack, and Hugging Face client details + +### [0.4.0] - 2026-03-25 + +#### MCP Clients +- Added Tavily MCP client (`mcp/tavily_client.rb`) — AI-optimized web search via remote HTTP transport +- Added GitHub MCP client (`mcp/github_client.rb`) — repos, issues, PRs, code search via brew-installed binary +- Added Memory MCP client (`mcp/memory_client.rb`) — persistent knowledge graph via npx +- Added Sequential Thinking MCP client (`mcp/sequential_thinking_client.rb`) — chain-of-thought reasoning via npx +- Added Chart MCP client (`mcp/chart_client.rb`) — chart generation via npx +- Added Brave Search MCP client (`mcp/brave_search_client.rb`) — web/news search via npx +- Added `mcp.rb` bulk loader — requires all available clients concurrently in parallel threads; missing-key clients silently skipped +- Added `SharedTools.verify_envars` — raises `LoadError` listing missing environment variable names +- Added `SharedTools.package_install`, `brew_install`, `apt_install`, `dnf_install`, `npm_install`, `gem_install` in `utilities.rb` + +#### Tools +- Added `NotificationTool` — cross-platform desktop notifications (`notify`), modal alert dialogs (`alert`), and text-to-speech (`speak`) for macOS and Linux +- Added screenshot capability to `BrowserTool` +- Added array argument support to `Docker::ComposeRunTool` + +#### Examples +- Added `examples/mcp/` subdirectory with individual demo scripts for each MCP client +- Added `examples/mcp/common.rb` shared helper for MCP demos + + ### [0.2.1] 2025-07-03 - iMCP server app for MacOS is noisy logger so redirect stderr to /dev/null From 17cb104105315ed863478f5b76836b3c38623f88 Mon Sep 17 00:00:00 2001 From: Dewayne VanHoozer Date: Thu, 26 Mar 2026 18:11:51 -0500 Subject: [PATCH 17/17] fix: resolve post-merge conflicts preferring develop branch - lib/shared_tools.rb: remove duplicate method definitions merged from main (load_all_tools, tools, execute?); restore develop's STDIN.getch- based execute? and Zeitwerk-based load_all_tools/tools; ignore tools/enabler.rb which defines Tools::Enabler not SharedTools::Tools::Enabler - test/test_helper.rb: add with_stdin_input helper needed by merged tests - test eval tool tests: replace $stdin = StringIO.new pattern from main with with_stdin_input stub to match develop's STDIN.getch in execute? - .gitignore: add .claude/, .serena/, site/, *.gem, Gemfile.lock, local scratch files, and examples/.workflows/ --- .gitignore | 17 ++++++++ lib/shared_tools.rb | 43 ++----------------- .../tools/eval/python_eval_tool_test.rb | 13 +++--- .../tools/eval/ruby_eval_tool_test.rb | 13 +++--- .../tools/eval/shell_eval_tool_test.rb | 13 +++--- test/test_helper.rb | 10 +++++ 6 files changed, 46 insertions(+), 63 deletions(-) diff --git a/.gitignore b/.gitignore index 8c67b41..f7fcddb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,20 @@ /spec/reports/ /tmp/ .aigcm_msg + +# Local tooling and generated files +/.claude/ +/.serena/ +/site/ +/Gemfile.lock +*.gem + +# Local scratch files +CLAUDE.md +fix_problems_plan.md +more_tool_ideas.md +self_agency.md +temp.md +workflow_tool_gaps.md +screenshot_*.png +examples/.workflows/ diff --git a/lib/shared_tools.rb b/lib/shared_tools.rb index cbb9706..aaa7d7f 100644 --- a/lib/shared_tools.rb +++ b/lib/shared_tools.rb @@ -20,6 +20,7 @@ SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/notification.rb") SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/version.rb") # Defines VERSION constant, not Version class SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/incomplete") # Empty/incomplete tools directory +SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/enabler.rb") # Experimental; defines Tools::Enabler not SharedTools::Tools::Enabler # Ignore per-tool shim files (require-path shortcuts: require 'shared_tools/') Dir.glob("#{__dir__}/shared_tools/*_tool.rb").each { |f| SharedToolsLoader.ignore(f) } @@ -30,49 +31,13 @@ SharedToolsLoader.setup module SharedTools - @auto_execute ||= true # Auto-execute by default, no human-in-the-loop - + @auto_execute ||= true # Auto-execute by default, no human-in-the-loop class << self + def auto_execute(wildwest=true) @auto_execute = wildwest end - # Load all tool classes so they're available via ObjectSpace - # Call this when using AIA with --rq shared_tools - # Uses manual loading to gracefully handle missing dependencies - def load_all_tools - tools_dir = File.join(__dir__, 'shared_tools', 'tools') - Dir.glob(File.join(tools_dir, '*_tool.rb')).each do |tool_file| - begin - require tool_file - rescue LoadError => e - # Skip tools with missing dependencies - warn "SharedTools: Skipping #{File.basename(tool_file)} - #{e.message}" if ENV['DEBUG'] - end - end - end - - # Get all available tool classes (those that inherit from RubyLLM::Tool) - # Only returns tools that can be successfully instantiated without arguments (RubyLLM requirement) - def tools - load_all_tools - ObjectSpace.each_object(Class).select do |klass| - next false unless klass < RubyLLM::Tool - next false unless klass.to_s.start_with?('SharedTools::') - - # Actually try to instantiate the tool to verify it works - # RubyLLM calls tool.new without args, so tools must be instantiable this way - begin - klass.new - true - rescue ArgumentError, LoadError, StandardError => e - # Skip tools that can't be instantiated (missing args, missing platform drivers, etc.) - warn "SharedTools: Excluding #{klass} - #{e.message}" if ENV['DEBUG'] - false - end - end - end - def execute?(tool: 'unknown', stuff: '') # Return true if auto_execute is explicitly enabled return true if @auto_execute == true @@ -84,7 +49,7 @@ def execute?(tool: 'unknown', stuff: '') sleep 0.2 if defined?(AIA) # Allows CLI spinner to recycle print "\nIs it okay to proceed? (y/N" - $stdin.getc == "y" + STDIN.getch == "y" end # Force-load all tool classes into ObjectSpace. diff --git a/test/shared_tools/tools/eval/python_eval_tool_test.rb b/test/shared_tools/tools/eval/python_eval_tool_test.rb index 67f4b9f..bbee3c4 100644 --- a/test/shared_tools/tools/eval/python_eval_tool_test.rb +++ b/test/shared_tools/tools/eval/python_eval_tool_test.rb @@ -132,13 +132,10 @@ def test_handles_non_serializable_results def test_respects_auto_execute_false SharedTools.auto_execute(false) - original_stdin = $stdin - $stdin = StringIO.new('n') - result = @tool.execute(code: "2 + 2") - - assert result.key?(:error) - assert_includes result[:error], "declined" - ensure - $stdin = original_stdin + with_stdin_input('n') do + result = @tool.execute(code: "2 + 2") + assert result.key?(:error) + assert_includes result[:error], "declined" + end end end diff --git a/test/shared_tools/tools/eval/ruby_eval_tool_test.rb b/test/shared_tools/tools/eval/ruby_eval_tool_test.rb index 8febf6d..f6334bf 100644 --- a/test/shared_tools/tools/eval/ruby_eval_tool_test.rb +++ b/test/shared_tools/tools/eval/ruby_eval_tool_test.rb @@ -98,13 +98,10 @@ def test_handles_multiline_code def test_respects_auto_execute_false SharedTools.auto_execute(false) - original_stdin = $stdin - $stdin = StringIO.new('n') - result = @tool.execute(code: "2 + 2") - - assert result.key?(:error) - assert_includes result[:error], "declined" - ensure - $stdin = original_stdin + with_stdin_input('n') do + result = @tool.execute(code: "2 + 2") + assert result.key?(:error) + assert_includes result[:error], "declined" + end end end diff --git a/test/shared_tools/tools/eval/shell_eval_tool_test.rb b/test/shared_tools/tools/eval/shell_eval_tool_test.rb index 8bca04c..8ae727d 100644 --- a/test/shared_tools/tools/eval/shell_eval_tool_test.rb +++ b/test/shared_tools/tools/eval/shell_eval_tool_test.rb @@ -109,14 +109,11 @@ def test_handles_command_with_environment_variables def test_respects_auto_execute_false SharedTools.auto_execute(false) - original_stdin = $stdin - $stdin = StringIO.new('n') - result = @tool.execute(command: "echo 'test'") - - assert result.key?(:error) - assert_includes result[:error], "declined" - ensure - $stdin = original_stdin + with_stdin_input('n') do + result = @tool.execute(command: "echo 'test'") + assert result.key?(:error) + assert_includes result[:error], "declined" + end end def test_handles_command_with_special_characters diff --git a/test/test_helper.rb b/test/test_helper.rb index cc0e6b5..c3884f3 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -30,3 +30,13 @@ rescue LoadError # pdf-reader not available - some tests will be skipped end + +module Minitest + class Test + # Stub STDIN.getch to return +char+ for the duration of the block. + # Used by tests that exercise the human-in-the-loop execute? prompt. + def with_stdin_input(char) + STDIN.stub(:getch, char) { yield } + end + end +end