Commit 00db2f1

mo khan <mo@mokhan.ca>
2026-01-21 19:46:14
refactor: convert default tools into default plugins
1 parent 713eb42
lib/elelem/plugins/execute.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:execute) do |toolbox|
+  toolbox.add("execute",
+    description: "Run shell command (supports pipes and redirections)",
+    params: { command: { type: "string" } },
+    required: ["command"],
+    aliases: ["bash", "sh", "exec"]
+  ) do |a|
+    Elelem.sh("bash", args: ["-c", a["command"]]) { |x| $stdout.print(x) }
+  end
+end
lib/elelem/plugins/mcp.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:mcp) do |toolbox|
+  mcp = Elelem::MCP.new
+  mcp.tools.each do |name, tool|
+    fn = tool[:fn]
+    toolbox.add(name,
+      description: tool[:description],
+      params: tool[:params],
+      required: tool[:required],
+      &fn
+    )
+  end
+end
lib/elelem/plugins/read.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:read) do |toolbox|
+  toolbox.add("read",
+    description: "Read file",
+    params: { path: { type: "string" } },
+    required: ["path"],
+    aliases: ["open"]
+  ) do |a|
+    path = Pathname.new(a["path"]).expand_path
+    path.exist? ? { content: path.read, path: a["path"] } : { error: "not found" }
+  end
+
+  toolbox.after("read") do |_, result|
+    if result[:error]
+      $stdout.puts "  ! #{result[:error]}"
+    else
+      system("bat", "--style=plain", "--paging=never", result[:path])
+    end
+  end
+end
lib/elelem/plugins/verify.rb
@@ -30,12 +30,15 @@ module Elelem
     toolbox.after("write") do |_, result|
       next if result[:error]
 
-      result[:verify] = {}
       Verifiers.for(result[:path]).each do |cmd|
-        $stdout.puts "\n  → verify: #{cmd}"
+        $stdout.puts "\n  -> verify: #{cmd}"
         v = Elelem.sh("bash", args: ["-c", cmd]) { |x| $stdout.print(x) }
-        result[:verify][cmd] = v
-        break if v[:exit_status] != 0
+        status = v[:exit_status] == 0 ? "\u2713" : "\u2717"
+        $stdout.puts "  #{status} #{cmd}"
+        if v[:exit_status] != 0
+          $stdout.puts v[:content].lines.first(5).map { |l| "    #{l}" }.join
+          break
+        end
       end
     end
   end
lib/elelem/plugins/write.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:write) do |toolbox|
+  toolbox.add("write",
+    description: "Write file",
+    params: { path: { type: "string" }, content: { type: "string" } },
+    required: ["path", "content"]
+  ) do |a|
+    path = Pathname.new(a["path"]).expand_path
+    FileUtils.mkdir_p(path.dirname)
+    { bytes: path.write(a["content"]), path: a["path"] }
+  end
+
+  toolbox.after("write") do |_, result|
+    if result[:error]
+      $stdout.puts "  ! #{result[:error]}"
+    else
+      $stdout.puts "  -> #{result[:path]}"
+    end
+  end
+end
lib/elelem/agent.rb
@@ -13,9 +13,7 @@ module Elelem
       @terminal = terminal || Terminal.new(commands: COMMANDS)
       @history = history || []
       @memory = nil
-      @toolbox.add("task", task_tool)
-      @mcp = MCP.new
-      @mcp.tools.each { |name, tool| @toolbox.add(name, tool) }
+      register_task_tool
     end
 
     def repl
@@ -69,24 +67,22 @@ module Elelem
     def process(tool_call)
       name, args = tool_call[:name], tool_call[:arguments]
       terminal.say toolbox.header(name, args)
-      toolbox.run(name.to_s, args).tap do |result|
-        terminal.say toolbox.format_result(name, result)
-      end
+      toolbox.run(name.to_s, args)
     end
 
-    def task_tool
-      {
+    def register_task_tool
+      agent = self
+      @toolbox.add("task",
         description: "Delegate subtask to focused agent (complex searches, multi-file analysis)",
         params: { prompt: { type: "string" } },
-        required: ["prompt"],
-        fn: ->(a) {
-          sub = Agent.new(client, toolbox, terminal: terminal, history: [
-            { role: "system", content: "Research agent. Search, analyze, report. Be concise." }
-          ])
-          sub.turn(a["prompt"])
-          { result: sub.history.last[:content] }
-        }
-      }
+        required: ["prompt"]
+      ) do |a|
+        sub = Agent.new(agent.client, agent.toolbox, terminal: agent.terminal, history: [
+          { role: "system", content: "Research agent. Search, analyze, report. Be concise." }
+        ])
+        sub.turn(a["prompt"])
+        { result: sub.history.last[:content] }
+      end
     end
 
     def fetch_response(ctx)
lib/elelem/toolbox.rb
@@ -2,50 +2,17 @@
 
 module Elelem
   class Toolbox
-    TOOLS = {
-      "read" => {
-        description: "Read file",
-        params: { path: { type: "string" } },
-        required: ["path"],
-        fn: lambda do |a|
-          path = Pathname.new(a["path"]).expand_path
-          path.exist? ? { content: path.read } : { error: "not found" }
-        end
-      },
-      "write" => {
-        description: "Write file",
-        params: { path: { type: "string" }, content: { type: "string" } },
-        required: ["path", "content"],
-        fn: lambda do |a|
-          path = Pathname.new(a["path"]).expand_path
-          FileUtils.mkdir_p(path.dirname)
-          { bytes: path.write(a["content"]), path: a["path"] }
-        end
-      },
-      "execute" => {
-        description: "Run shell command (supports pipes and redirections)",
-        params: { command: { type: "string" } },
-        required: ["command"],
-        fn: ->(a) { Elelem.sh("bash", args: ["-c", a["command"]]) { |x| $stdout.print(x) } }
-      }
-    }.freeze
+    attr_reader :tools, :hooks, :aliases
 
-    ALIASES = {
-      "bash" => "execute",
-      "sh" => "execute",
-      "exec" => "execute",
-      "open" => "read"
-    }.freeze
-
-    attr_reader :tools, :hooks
-
-    def initialize(tools = TOOLS.dup)
-      @tools = tools
+    def initialize
+      @tools = {}
+      @aliases = {}
       @hooks = { before: Hash.new { |h, k| h[k] = [] }, after: Hash.new { |h, k| h[k] = [] } }
     end
 
-    def add(name, tool)
-      @tools[name] = tool
+    def add(name, description:, params: {}, required: [], aliases: [], &fn)
+      @tools[name] = { description: description, params: params, required: required, fn: fn }
+      aliases.each { |a| @aliases[a] = name }
     end
 
     def before(tool_name, &block)
@@ -62,7 +29,7 @@ module Elelem
     end
 
     def run(name, args)
-      name = ALIASES.fetch(name, name)
+      name = @aliases.fetch(name, name)
       tool = tools[name]
       return { error: "unknown tool: #{name}" } unless tool
 
@@ -89,33 +56,5 @@ module Elelem
         }
       end
     end
-
-    def format_result(name, result)
-      return if result[:exit_status] && !result[:verify]
-
-      parts = []
-      format_verify_results(parts, result[:verify]) if result[:verify]
-      format_content(parts, result)
-      parts.join("\n") unless parts.empty?
-    end
-
-    private
-
-    def format_verify_results(parts, verify)
-      verify.each do |cmd, v|
-        status = v[:exit_status] == 0 ? "✓" : "✗"
-        parts << "  #{status} #{cmd}"
-        next if v[:exit_status] == 0
-
-        parts << v[:content].lines.first(5).map { |l| "    #{l}" }.join
-      end
-    end
-
-    def format_content(parts, result)
-      text = result[:content] || result[:error] || ""
-      return if text.strip.empty?
-
-      parts << (result[:error] ? "  ! #{text.lines.first&.strip}" : text)
-    end
   end
 end
spec/elelem/toolbox_spec.rb
@@ -3,6 +3,28 @@
 RSpec.describe Elelem::Toolbox do
   subject { described_class.new }
 
+  before do
+    subject.add("read",
+      description: "Read file",
+      params: { path: { type: "string" } },
+      required: ["path"],
+      aliases: ["open"]
+    ) { |a| { content: File.read(a["path"]) } }
+
+    subject.add("write",
+      description: "Write file",
+      params: { path: { type: "string" }, content: { type: "string" } },
+      required: ["path", "content"]
+    ) { |a| { bytes: File.write(a["path"], a["content"]) } }
+
+    subject.add("execute",
+      description: "Run shell command",
+      params: { command: { type: "string" } },
+      required: ["command"],
+      aliases: ["bash", "sh", "exec"]
+    ) { |a| { output: `#{a["command"]}` } }
+  end
+
   describe "#to_a" do
     it "returns all tools in API format" do
       tool_names = subject.to_a.map { |t| t.dig(:function, :name) }