Commit ebfc50c

mo khan <mo@mokhan.ca>
2025-08-13 22:54:08
refactor: extract tool class
1 parent c57d8ee
lib/elelem/configuration.rb
@@ -41,7 +41,7 @@ module Elelem
     end
 
     def tools
-      @tools ||= Tools.new(self)
+      @tools ||= Tools.new(self, [BashTool.new(self)] + mcp_tools)
     end
 
     private
@@ -49,5 +49,9 @@ module Elelem
     def scheme
       host.match?(/\A(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\z/) ? "http" : "https"
     end
+
+    def mcp_tools(clients = [MCPClient.new(self)])
+      @mcp_tools ||= clients.map { |client| client.tools.map { |tool| MCPTool.new(client, tui, tool) } }.flatten
+    end
   end
 end
lib/elelem/mcp_client.rb
@@ -34,7 +34,7 @@ module Elelem
       @worker_thread&.alive? && @stdin && !@stdin.closed?
     end
 
-    def call_tool(name, arguments = {})
+    def call(name, arguments = {})
       send_request(
         method: "tools/call",
         params: {
lib/elelem/tool.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+module Elelem
+  class Tool
+    attr_reader :name, :description, :parameters
+
+    def initialize(name, description, parameters)
+      @name = name
+      @description = description
+      @parameters = parameters
+    end
+
+    def banner
+      [name, description].join(": ")
+    end
+
+    def to_h
+      {
+        type: "function",
+        function: {
+          name: name,
+          description: description,
+          parameters: parameters
+        }
+      }
+    end
+  end
+
+  class BashTool < Tool
+    attr_reader :tui
+
+    def initialize(tui)
+      @tui = tui
+      super("bash", "Execute a shell command.", {
+        parameters: {
+          type: "object",
+          properties: {
+            command: { type: "string" }
+          },
+          required: ["command"]
+        }
+      })
+    end
+
+    def call(args)
+      command = args["command"]
+      output_buffer = []
+
+      Open3.popen3("/bin/sh", "-c", command) do |stdin, stdout, stderr, wait_thread|
+        stdin.close
+        streams = [stdout, stderr]
+
+        until streams.empty?
+          ready = IO.select(streams, nil, nil, 0.1)
+
+          if ready
+            ready[0].each do |io|
+              data = io.read_nonblock(4096)
+              output_buffer << data
+
+              if io == stderr
+                tui.say(data, colour: :red, newline: false)
+              else
+                tui.say(data, newline: false)
+              end
+            rescue IO::WaitReadable
+              next
+            rescue EOFError
+              streams.delete(io)
+            end
+          elsif !wait_thread.alive?
+            break
+          end
+        end
+
+        wait_thread.value
+      end
+
+      output_buffer.join
+    end
+  end
+
+  class MCPTool < Tool
+    attr_reader :client, :tui
+
+    def initialize(client, tui, tool)
+      @client = client
+      @tui = tui
+      super(tool["name"], tool["description"], tool["inputSchema"] || {})
+    end
+
+    def call(args)
+      result = client.call(name, args)
+      output = result.dig("content", 0, "text") || result.to_s
+      tui.say(output)
+      output
+    end
+  end
+end
lib/elelem/tools.rb
@@ -2,79 +2,24 @@
 
 module Elelem
   class Tools
-    DEFAULT_TOOLS = [
-      {
-        type: "function",
-        function: {
-          name: "bash",
-          description: "Execute a shell command.",
-          parameters: {
-            type: "object",
-            properties: {
-              command: { type: "string" }
-            },
-            required: ["command"]
-          }
-        },
-        handler: lambda { |args|
-          stdout, stderr, _status = Open3.capture3("/bin/sh", "-c", args["command"])
-          stdout + stderr
-        }
-      }
-    ].freeze
-
-    def initialize(configuration, tools = DEFAULT_TOOLS)
+    def initialize(configuration, tools)
       @configuration = configuration
-      client = MCPClient.new(configuration)
-      @tools = tools + client.tools.map do |tool|
-        configuration.logger.debug(tool)
-        {
-          type: "function",
-          function: {
-            name: tool["name"],
-            description: tool["description"],
-            parameters: tool["inputSchema"] || {}
-          },
-          handler: lambda { |args|
-            result = client.call_tool(tool["name"], args)
-            output = result.dig("content", 0, "text") || result.to_s
-            configuration.tui.say(output)
-            output
-          }
-        }
-      end
+      @tools = tools
     end
 
     def banner
-      tools.map do |h|
-        [
-          h.dig(:function, :name),
-          h.dig(:function, :description)
-        ].join(": ")
-      end.sort.join("\n  ")
+      tools.map(&:banner).sort.join("\n  ")
     end
 
     def execute(tool_call)
       name = tool_call.dig("function", "name")
       args = tool_call.dig("function", "arguments")
 
-      tool = tools.find { |tool| tool.dig(:function, :name) == name }
-      tool.fetch(:handler).call(args).tap do |result|
-        configuration.tui.say(result)
-      end
+      tools.find { |tool| tool.name == name }&.call(args)
     end
 
     def to_h
-      tools.map do |tool|
-        {
-          type: tool[:type],
-          function: {
-            name: tool.dig(:function, :name),
-            description: tool.dig(:function, :description),
-            parameters: tool.dig(:function, :parameters)
-          }
-        }
-      end
+      tools.map(&:to_h)
     end
 
     private
lib/elelem.rb
@@ -4,7 +4,6 @@ require "json"
 require "logger"
 require "net/http"
 require "open3"
-require "securerandom"
 require "thor"
 require "uri"
 
@@ -15,6 +14,7 @@ require_relative "elelem/configuration"
 require_relative "elelem/conversation"
 require_relative "elelem/mcp_client"
 require_relative "elelem/state"
+require_relative "elelem/tool"
 require_relative "elelem/tools"
 require_relative "elelem/tui"
 require_relative "elelem/version"