Commit ab77b45

mo khan <mo@mokhan.ca>
2026-01-26 22:41:37
feat: add more tools
1 parent d7ba2e7
lib/elelem/plugins/confirm.rb
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-Elelem::Plugins.register(:confirm) do |agent|
-  agent.toolbox.before("execute") do |args|
-    next unless $stdin.tty?
-
-    cmd = args["command"]
-    answer = agent.terminal.ask("  Allow? [Y/n] > ")&.downcase
-    raise "User denied permission to execute: #{cmd}" if answer == "n"
-  end
-end
lib/elelem/plugins/git.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:git) do |agent|
+  allowed = %w[status diff log show branch checkout add reset stash].freeze
+
+  agent.toolbox.add("git",
+    description: "Run git command",
+    params: { command: { type: "string" }, args: { type: "array" } },
+    required: ["command"]
+  ) do |a|
+    cmd = a["command"]
+    next { error: "not allowed: #{cmd}" } unless allowed.include?(cmd)
+
+    agent.toolbox.exec("git", cmd, *(a["args"] || []))
+  end
+
+  agent.toolbox.after("git") do |_, result|
+    agent.terminal.say "  ! #{result[:error]}" if result[:error]
+  end
+end
lib/elelem/plugins/glob.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:glob) do |agent|
+  agent.toolbox.add("glob",
+    description: "Find files matching pattern",
+    params: { pattern: { type: "string" }, path: { type: "string" } },
+    required: ["pattern"]
+  ) do |a|
+    path = a["path"] || "."
+    result = agent.toolbox.exec("fd", "--glob", a["pattern"], path)
+    result[:ok] ? result : agent.toolbox.exec("find", path, "-name", a["pattern"])
+  end
+end
lib/elelem/plugins/grep.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:grep) do |agent|
+  agent.toolbox.add("grep",
+    description: "Search file contents",
+    params: { pattern: { type: "string" }, path: { type: "string" }, glob: { type: "string" } },
+    required: ["pattern"]
+  ) do |a|
+    path = a["path"] || "."
+    glob = a["glob"]
+    rg_args = ["rg", "-n", a["pattern"], path]
+    rg_args += ["-g", glob] if glob
+    result = agent.toolbox.exec(*rg_args)
+    next result if result[:ok]
+
+    grep_args = ["grep", "-rn"]
+    grep_args += ["--include", glob] if glob
+    grep_args += [a["pattern"], path]
+    agent.toolbox.exec(*grep_args)
+  end
+end
lib/elelem/plugins/list.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:list) do |agent|
+  agent.toolbox.add("list",
+    description: "List directory contents",
+    params: { path: { type: "string" }, recursive: { type: "boolean" } },
+    required: [],
+    aliases: ["ls"]
+  ) do |a|
+    path = a["path"] || "."
+    flags = a["recursive"] ? "-laR" : "-la"
+    agent.toolbox.exec("ls", flags, path)
+  end
+end
lib/elelem/plugins/permissions.json
@@ -0,0 +1,6 @@
+{
+  "read": "allow",
+  "write": "ask",
+  "edit": "ask",
+  "execute": "ask"
+}
lib/elelem/plugins/write.rb
@@ -12,6 +12,17 @@ Elelem::Plugins.register(:write) do |agent|
     { bytes: path.write(a["content"]), path: a["path"] }
   end
 
+  agent.toolbox.before("write") do |args|
+    path = Pathname.new(args["path"]).expand_path
+    next unless path.exist? && $stdin.tty?
+
+    Tempfile.create(["elelem", File.extname(path)]) do |t|
+      t.write(args["content"])
+      t.flush
+      system("diff", "--color=always", "-u", path.to_s, t.path)
+    end
+  end
+
   agent.toolbox.after("write") do |_, result|
     if result[:error]
       agent.terminal.say "  ! #{result[:error]}"
lib/elelem/plugins/zz_confirm.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:confirm) do |agent|
+  permissions = Elelem::Permissions.new
+
+  agent.toolbox.tools.each_key do |tool_name|
+    agent.toolbox.before(tool_name) do |args|
+      permissions.check(tool_name, args, terminal: agent.terminal)
+    end
+  end
+end
lib/elelem/permissions.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Elelem
+  class Permissions
+    LOAD_PATHS = [
+      File.expand_path("plugins/permissions.json", __dir__),
+      "~/.elelem/permissions.json",
+      ".elelem/permissions.json"
+    ].freeze
+
+    def initialize
+      @rules = LOAD_PATHS.reduce({}) do |rules, path|
+        rules.merge(load_config(File.expand_path(path)))
+      end
+    end
+
+    def check(tool_name, args, terminal:)
+      policy = @rules[tool_name.to_sym] || :ask
+      case policy
+      when :allow then true
+      when :deny then raise "Permission denied: #{tool_name}"
+      when :ask then prompt(tool_name, args, terminal)
+      end
+    end
+
+    private
+
+    def load_config(path)
+      return {} unless File.exist?(path)
+
+      JSON.parse(File.read(path)).transform_keys(&:to_sym).transform_values(&:to_sym)
+    rescue JSON::ParserError
+      {}
+    end
+
+    def prompt(tool_name, args, terminal)
+      return true unless $stdin.tty?
+
+      answer = terminal.ask("  Allow #{tool_name}? [Y/n] > ")&.downcase
+      raise "User denied permission: #{tool_name}" if answer == "n"
+
+      true
+    end
+  end
+end
lib/elelem/toolbox.rb
@@ -44,6 +44,11 @@ module Elelem
       failure(error: e.message, name: name, args: args)
     end
 
+    def exec(*args)
+      command = args.flatten.map { |a| Shellwords.escape(a.to_s) }.join(" ")
+      run("execute", { "command" => command })
+    end
+
     def to_a
       tools.values.map(&:to_h)
     end
lib/elelem.rb
@@ -23,6 +23,7 @@ require_relative "elelem/commands"
 require_relative "elelem/conversation"
 require_relative "elelem/mcp"
 require_relative "elelem/net"
+require_relative "elelem/permissions"
 require_relative "elelem/plugins"
 require_relative "elelem/system_prompt"
 require_relative "elelem/terminal"
spec/elelem/permissions_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Permissions do
+  subject { described_class.new }
+
+  let(:terminal) { double(ask: nil) }
+
+  describe "#check" do
+    context "with default allow policies" do
+      it "allows read without prompting" do
+        expect(subject.check("read", {}, terminal: terminal)).to be true
+        expect(terminal).not_to have_received(:ask)
+      end
+    end
+
+    context "with deny policy" do
+      it "raises an error" do
+        permissions = described_class.new
+        permissions.instance_variable_set(:@rules, { write: :deny })
+        expect { permissions.check("write", {}, terminal: terminal) }.to raise_error(/Permission denied/)
+      end
+    end
+
+    context "with ask policy in non-TTY mode" do
+      before { allow($stdin).to receive(:tty?).and_return(false) }
+
+      it "returns true without prompting" do
+        expect(subject.check("execute", {}, terminal: terminal)).to be true
+      end
+    end
+  end
+end
spec/elelem/toolbox_spec.rb
@@ -48,4 +48,16 @@ RSpec.describe Elelem::Toolbox do
       expect(result[:error]).to include("unknown tool")
     end
   end
+
+  describe "#exec" do
+    it "escapes arguments and runs execute" do
+      result = subject.exec("echo", "hello world")
+      expect(result[:output]).to include("hello world")
+    end
+
+    it "handles arrays of arguments" do
+      result = subject.exec("echo", ["a", "b"])
+      expect(result[:output]).to include("a")
+    end
+  end
 end
elelem.gemspec
@@ -27,6 +27,8 @@ Gem::Specification.new do |spec|
     "exe/elelem",
     "lib/elelem.rb",
     "lib/elelem/agent.rb",
+    "lib/elelem/commands.rb",
+    "lib/elelem/conversation.rb",
     "lib/elelem/mcp.rb",
     "lib/elelem/mcp/oauth.rb",
     "lib/elelem/mcp/token_storage.rb",
@@ -34,15 +36,24 @@ Gem::Specification.new do |spec|
     "lib/elelem/net/claude.rb",
     "lib/elelem/net/ollama.rb",
     "lib/elelem/net/openai.rb",
+    "lib/elelem/permissions.rb",
     "lib/elelem/plugins.rb",
-    "lib/elelem/plugins/confirm.rb",
+    "lib/elelem/plugins/builtins.rb",
     "lib/elelem/plugins/edit.rb",
     "lib/elelem/plugins/eval.rb",
     "lib/elelem/plugins/execute.rb",
+    "lib/elelem/plugins/git.rb",
+    "lib/elelem/plugins/glob.rb",
+    "lib/elelem/plugins/grep.rb",
+    "lib/elelem/plugins/list.rb",
     "lib/elelem/plugins/mcp.rb",
+    "lib/elelem/plugins/permissions.json",
     "lib/elelem/plugins/read.rb",
+    "lib/elelem/plugins/task.rb",
+    "lib/elelem/plugins/tools.rb",
     "lib/elelem/plugins/verify.rb",
     "lib/elelem/plugins/write.rb",
+    "lib/elelem/plugins/zz_confirm.rb",
     "lib/elelem/system_prompt.rb",
     "lib/elelem/templates/system_prompt.erb",
     "lib/elelem/terminal.rb",
@@ -67,6 +78,7 @@ Gem::Specification.new do |spec|
   spec.add_dependency "pathname", "~> 0.1"
   spec.add_dependency "reline", "~> 0.6"
   spec.add_dependency "securerandom", "~> 0.1"
+  spec.add_dependency "shellwords", "~> 0.2"
   spec.add_dependency "stringio", "~> 3.0"
   spec.add_dependency "tempfile", "~> 0.3"
   spec.add_dependency "uri", "~> 1.0"
Gemfile.lock
@@ -15,6 +15,7 @@ PATH
       pathname (~> 0.1)
       reline (~> 0.6)
       securerandom (~> 0.1)
+      shellwords (~> 0.2)
       stringio (~> 3.0)
       tempfile (~> 0.3)
       uri (~> 1.0)
@@ -83,6 +84,7 @@ GEM
       rspec-support (~> 3.13.0)
     rspec-support (3.13.6)
     securerandom (0.4.1)
+    shellwords (0.2.2)
     simpleidn (0.2.3)
     stringio (3.2.0)
     tempfile (0.3.1)