Commit bce2f1f

mo khan <mo@mokhan.ca>
2026-01-16 23:56:59
refactor: create separate scripts for each provider
1 parent 129c0da
exe/elelem
@@ -3,8 +3,5 @@
 
 require "elelem"
 
-Signal.trap("INT") do
-  exit(1)
-end
-
-Elelem::Application.start
+Signal.trap("INT") { exit 1 }
+Elelem.start(Net::Llm::Ollama.new(model: "gpt-oss"))
exe/elelem-anthropic
@@ -0,0 +1,7 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+ENV["ANTHROPIC_API_KEY"] || abort("ANTHROPIC_API_KEY not set")
+require "elelem"
+Signal.trap("INT") { exit 1 }
+Elelem.start(Net::Llm::Anthropic.new(model: "claude-sonnet-4-20250514"))
exe/elelem-files
@@ -0,0 +1,14 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+puts "<documents>"
+$stdin.each_line.with_index(1) do |line, i|
+  path = line.strip
+  next if path.empty? || !File.file?(path)
+  content = File.read(path)
+  puts %Q{<document index="#{i}">}
+  puts %Q{<source>#{path}</source>}
+  puts %Q{<content><![CDATA[#{content}]]></content>}
+  puts "</document>"
+end
+puts "</documents>"
exe/elelem-ollama
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "elelem"
+Signal.trap("INT") { exit 1 }
+Elelem.start(Net::Llm::Ollama.new(model: "gpt-oss"))
exe/elelem-openai
@@ -0,0 +1,7 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+ENV["OPENAI_API_KEY"] || abort("OPENAI_API_KEY not set")
+require "elelem"
+Signal.trap("INT") { exit 1 }
+Elelem.start(Net::Llm::OpenAI.new(model: "gpt-4"))
exe/elelem-vertex-ai
@@ -0,0 +1,8 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+ENV["GOOGLE_CLOUD_PROJECT"] || abort("GOOGLE_CLOUD_PROJECT not set")
+ENV["GOOGLE_CLOUD_REGION"] || abort("GOOGLE_CLOUD_REGION not set")
+require "elelem"
+Signal.trap("INT") { exit 1 }
+Elelem.start(Net::Llm::VertexAI.new(model: "claude-sonnet-4@20250514"))
lib/elelem/agent.rb
@@ -2,298 +2,104 @@
 
 module Elelem
   class Agent
-    PROVIDERS = %w[ollama anthropic openai vertex-ai].freeze
-    ANTHROPIC_MODELS = %w[claude-sonnet-4-20250514 claude-opus-4-20250514 claude-haiku-3-5-20241022].freeze
-    VERTEX_MODELS = %w[claude-sonnet-4@20250514 claude-opus-4-5@20251101].freeze
-    COMMANDS = %w[/env /provider /model /shell /clear /context /exit /help].freeze
-    ENV_VARS = %w[ANTHROPIC_API_KEY OPENAI_API_KEY OPENAI_BASE_URL OLLAMA_HOST GOOGLE_CLOUD_PROJECT GOOGLE_CLOUD_REGION].freeze
+    COMMANDS = %w[/clear /context /exit /help].freeze
 
-    attr_reader :conversation, :client, :toolbox, :provider, :terminal
+    attr_reader :history, :client, :toolbox, :terminal
 
-    def initialize(provider, model, toolbox, terminal: nil)
-      @conversation = Conversation.new
-      @provider = provider
+    def initialize(client, toolbox, terminal: nil)
+      @client = client
       @toolbox = toolbox
-      @client = build_client(provider, model)
-      @terminal = terminal || default_terminal
+      @history = [{ role: "system", content: system_prompt }]
+      @terminal = terminal || Terminal.new(commands: COMMANDS)
     end
 
     def repl
+      terminal.say "elelem v#{VERSION}"
       loop do
         input = terminal.ask("> ")
         break if input.nil?
-        if input.start_with?("/")
-          handle_slash_command(input)
-        else
-          conversation.add(role: :user, content: input)
-          result = execute_turn(conversation.history)
-          conversation.add(role: result[:role], content: result[:content])
-        end
+        next if input.empty?
+        input.start_with?("/") ? command(input) : turn(input)
       end
     end
 
     private
 
-    def default_terminal
-      Terminal.new(
-        commands: COMMANDS,
-        env_vars: ENV_VARS,
-        providers: PROVIDERS
-      )
-    end
-
-    def handle_slash_command(input)
+    def command(input)
       case input
-      when "/exit" then exit
+      when "/exit" then exit(0)
       when "/clear"
-        conversation.clear
-        terminal.say "  → Conversation cleared"
+        @history = [{ role: "system", content: system_prompt }]
+        terminal.say "  → context cleared"
       when "/context"
-        terminal.say conversation.dump, markdown: true
-      when "/shell"
-        transcript = start_shell
-        conversation.add(role: :user, content: transcript) unless transcript.strip.empty?
-        terminal.say "  → Shell session captured"
-      when "/provider"
-        terminal.select("Provider?", PROVIDERS) do |selected_provider|
-          terminal.select("Model?", models_for(selected_provider)) do |m|
-            switch_client(selected_provider, m)
-          end
-        end
-      when "/model"
-        terminal.select("Model?", models_for(provider)) do |m|
-          switch_model(m)
-        end
-      when "/env"
-        terminal.say "  Usage: /env VAR cmd..."
-        terminal.say ""
-        ENV_VARS.each do |var|
-          value = ENV[var]
-          if value
-            masked = value.length > 8 ? "#{value[0..3]}...#{value[-4..]}" : "****"
-            terminal.say "  #{var}=#{masked}"
-          else
-            terminal.say "  #{var}=(not set)"
-          end
-        end
-      when %r{^/env\s+(\w+)\s+(.+)$}
-        var_name = $1
-        command = $2
-        result = Elelem.shell.execute("sh", args: ["-c", command])
-        if result["exit_status"].zero?
-          value = result["stdout"].lines.first&.strip
-          if value && !value.empty?
-            ENV[var_name] = value
-            terminal.say "  → Set #{var_name}"
-          else
-            terminal.say "  ⚠ Command produced no output"
-          end
-        else
-          terminal.say "  ⚠ Command failed: #{result['stderr']}"
-        end
+        terminal.say JSON.pretty_generate(history)
       else
-        terminal.say help_banner
+        terminal.say "/clear /context /exit"
       end
     end
 
-    def strip_ansi(text)
-      text.gsub(/^Script started.*?\n/, '')
-          .gsub(/\nScript done.*$/, '')
-          .gsub(/\e\[[0-9;]*[a-zA-Z]/, '')
-          .gsub(/\e\[\?[0-9]+[hl]/, '')
-          .gsub(/[\b]/, '')
-          .gsub(/\r/, '')
-    end
+    def turn(input)
+      history << { role: "user", content: input }
+      ctx, errors = [], 0
+
+      loop do
+        terminal.waiting
+        content, tool_calls = fetch_response(ctx)
+        terminal.newline
+        return if content.nil?
+
+        terminal.say(terminal.markdown(content)) unless content.empty?
+        ctx << { role: "assistant", content: content, tool_calls: tool_calls.empty? ? nil : tool_calls }.compact
+
+        break if tool_calls.empty?
+
+        tool_calls.each do |tc|
+          name, args = tc[:name], tc[:arguments]
+          terminal.say "\n#{format_tool_display(name, args)}"
+          result = toolbox.run(name, args)
+          terminal.say format_tool_result(name, result)
+          ctx << { role: "tool", tool_call_id: tc[:id], content: result.to_json }
+          errors += 1 if result[:error]
+        end
 
-    def start_shell
-      Tempfile.create do |file|
-        system("script -q #{file.path}", chdir: Dir.pwd)
-        strip_ansi(File.read(file.path))
+        break if errors >= 3
       end
-    end
 
-    def help_banner
-      <<~HELP
-  /env VAR cmd...
-  /provider
-  /model
-  /shell
-  /clear
-  /context
-  /exit
-  /help
-      HELP
+      history << { role: "assistant", content: ctx.map { |c| c[:content] }.join("\n") }
     end
 
-    def build_client(provider_name, model = nil)
-      model_opts = model ? { model: model } : {}
+    def fetch_response(ctx)
+      content, tool_calls = "", []
+      client.fetch(history + ctx, toolbox.to_h) do |chunk|
+        terminal.print(terminal.dim(chunk[:thinking])) if chunk[:thinking]
 
-      case provider_name
-      when "ollama"     then Net::Llm::Ollama.new(**model_opts)
-      when "anthropic"  then Net::Llm::Anthropic.new(**model_opts)
-      when "openai"     then Net::Llm::OpenAI.new(**model_opts)
-      when "vertex-ai"  then Net::Llm::VertexAI.new(**model_opts)
-      else
-        raise Error, "Unknown provider: #{provider_name}"
-      end
-    end
-
-    def models_for(provider_name)
-      case provider_name
-      when "ollama"
-        client_for_models = provider_name == provider ? client : build_client(provider_name)
-        client_for_models.tags["models"]&.map { |m| m["name"] } || []
-      when "openai"
-        client_for_models = provider_name == provider ? client : build_client(provider_name)
-        client_for_models.models["data"]&.map { |m| m["id"] } || []
-      when "anthropic"
-        ANTHROPIC_MODELS
-      when "vertex-ai"
-        VERTEX_MODELS
-      else
-        []
+        case chunk[:type]
+        when :delta then content += chunk[:content].to_s
+        when :complete then content, tool_calls = chunk[:content].to_s, chunk[:tool_calls] || []
+        end
       end
-    rescue KeyError => e
-      terminal.say "  ⚠ Missing credentials: #{e.message}"
-      []
+      [content, tool_calls]
     rescue => e
-      terminal.say "  ⚠ Could not fetch models: #{e.message}"
-      []
-    end
-
-    def switch_client(new_provider, model)
-      @provider = new_provider
-      @client = build_client(new_provider, model)
-      terminal.say "  → Switched to #{new_provider}/#{client.model}"
-    end
-
-    def switch_model(model)
-      @client = build_client(provider, model)
-      terminal.say "  → Switched to #{provider}/#{client.model}"
+      terminal.say "\n  ✗ #{e.message}"
+      [nil, []]
     end
 
     def format_tool_display(name, args)
-      display_name = name.split("_").map(&:capitalize).join(" ")
-      formatted_args = case name
-      when "exec"
-        [args["cmd"], *Array(args["args"])].join(" ")
-      when "read", "write"
-        args["path"]
-      when "list"
-        args["path"] || "."
-      when "grep", "web_search"
-        args["query"]
-      when "fetch"
-        args["url"]
-      when "patch"
-        "diff"
-      when "eval"
-        args["ruby"].to_s.lines.first&.strip&.slice(0, 40) || "..."
-      else
-        args.values.first.to_s
-      end
-      "+ #{display_name}(#{formatted_args})"
+      "+ #{name}(#{args})"
     end
 
     def format_tool_result(name, result)
-      text = extract_result_text(result)
-      return nil if text.nil? || text.strip.empty?
-
-      if result[:error]
-        "  ! #{text.lines.first&.strip}"
-      else
-        format_output(name, text)
-      end
-    end
-
-    def extract_result_text(result)
-      return if result.nil?
-      return result["stdout"] if result["stdout"]
-      return result["stderr"] if result["stderr"]
-      return result[:error] if result[:error]
-      return result[:content] if result[:content]
-      ""
-    end
-
-    def format_output(name, text)
-      lines = text.to_s.lines
-      case name
-      when "read"
-        "  = #{lines.size} lines"
-      when "write"
-        "  = Wrote file"
-      else
-        truncate_lines(lines)
-      end
-    end
-
-    def truncate_lines(lines, max: 10)
-      if lines.size > max
-        lines.first(max).join.rstrip + "\n... (#{lines.size - max} more lines)"
-      else
-        lines.join.rstrip
-      end
-    end
+      text = result["stdout"] || result["stderr"] || result[:content] || result[:error] || ""
+      return nil if text.strip.empty?
 
-    def format_tool_calls_for_api(tool_calls)
-      tool_calls.map do |tc|
-        args = openai_client? ? JSON.dump(tc[:arguments]) : tc[:arguments]
-        {
-          id: tc[:id],
-          type: "function",
-          function: { name: tc[:name], arguments: args }
-        }
-      end
-    end
-
-    def openai_client?
-      client.is_a?(Net::Llm::OpenAI)
+      result[:error] ? "  ! #{text.lines.first&.strip}" : text
     end
 
-    def execute_turn(messages)
-      tools = toolbox.tools
-      turn_context = []
-      errors = 0
-
-      loop do
-        content = ""
-        tool_calls = []
-
-        terminal.waiting
-        begin
-          client.fetch(messages + turn_context, tools) do |chunk|
-            case chunk[:type]
-            when :delta
-              content += chunk[:content] if chunk[:content]
-            when :complete
-              content = chunk[:content] if chunk[:content]
-              tool_calls = chunk[:tool_calls] || []
-            end
-          end
-        rescue => e
-          terminal.say "\n  ✗ API Error: #{e.message}"
-          return { role: "assistant", content: "[Error: #{e.message}]" }
-        end
-
-        terminal.say("\n#{content}", markdown: true) unless content.to_s.empty?
-        api_tool_calls = tool_calls.any? ? format_tool_calls_for_api(tool_calls) : nil
-        turn_context << { role: "assistant", content: content, tool_calls: api_tool_calls }.compact
-
-        if tool_calls.any?
-          tool_calls.each do |call|
-            name, args = call[:name], call[:arguments]
-            terminal.say "\n#{format_tool_display(name, args)}"
-            result = toolbox.run_tool(name, args)
-            terminal.say format_tool_result(name, result)
-            turn_context << { role: "tool", tool_call_id: call[:id], content: JSON.dump(result) }
-            errors += 1 if result[:error]
-          end
-          return { role: "assistant", content: "[Stopped: too many errors]" } if errors >= 3
-          next
-        end
-
-        return { role: "assistant", content: content }
-      end
+    def system_prompt
+      <<~PROMPT.strip
+        Terminal agent. Act directly, verify your work. Stay grounded - only respond to what is asked.
+        pwd: #{Dir.pwd}
+      PROMPT
     end
   end
 end
lib/elelem/application.rb
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  class Application < Thor
-    PROVIDERS = %w[ollama anthropic openai vertex-ai].freeze
-
-    desc "chat", "Start the REPL"
-    method_option :provider,
-                  aliases: "-p",
-                  type: :string,
-                  desc: "LLM provider (#{PROVIDERS.join(', ')})",
-                  default: ENV.fetch("ELELEM_PROVIDER", "ollama")
-    method_option :model,
-                  aliases: "-m",
-                  type: :string,
-                  desc: "Model name (uses provider default if not specified)"
-    def chat(*)
-      provider = options[:provider]
-      model = options[:model]
-      say "Agent (#{provider})", :green
-      agent = Agent.new(provider, model, Toolbox.new)
-      agent.repl
-    end
-
-    desc "files", "Generate CXML of the files"
-    def files
-      puts '<documents>'
-      $stdin.read.split("\n").map(&:strip).reject(&:empty?).each_with_index do |file, i|
-        next unless File.file?(file)
-
-        puts "  <document index=\"#{i + 1}\">"
-        puts "    <source><![CDATA[#{file}]]></source>"
-        puts "    <document_content><![CDATA[#{File.read(file)}]]></document_content>"
-        puts "  </document>"
-      end
-      puts '</documents>'
-    end
-
-    desc "version", "The version of this CLI"
-    def version
-      say "v#{Elelem::VERSION}"
-    end
-    map %w[--version -v] => :version
-  end
-end
lib/elelem/conversation.rb
@@ -1,55 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  class Conversation
-    ROLES = %i[system assistant user tool].freeze
-
-    def initialize(items = default_context)
-      @items = items
-    end
-
-    def history
-      @items.dup
-    end
-
-    def add(role: :user, content: "")
-      role = role.to_sym
-      raise "unknown role: #{role}" unless ROLES.include?(role)
-      return if content.nil? || content.empty?
-
-      if @items.last && @items.last[:role] == role
-        @items.last[:content] += content
-      else
-        @items.push({ role: role, content: normalize(content) })
-      end
-    end
-
-    def clear
-      @items = default_context
-    end
-
-    def dump
-      history.map do |item|
-        "## #{item[:role].to_s.capitalize}\n\n#{item[:content]}"
-      end.join("\n\n---\n\n")
-    end
-
-    private
-
-    def default_context
-      [{ role: "system", content: system_prompt }]
-    end
-
-    def system_prompt
-      ERB.new(Pathname.new(__dir__).join("system_prompt.erb").read).result(binding)
-    end
-
-    def normalize(content)
-      if content.is_a?(Array)
-        content.join(", ")
-      else
-        content.to_s
-      end
-    end
-  end
-end
lib/elelem/system_prompt.erb
@@ -1,14 +0,0 @@
-You are a trusted terminal agent. You act on behalf of the user - executing tasks directly through bash, files, and git. Be capable, be direct, be done.
-
-## Principles
-
-- Act, don't explain. Execute the task.
-- Read before write. Understand existing code first.
-- Small focused changes. One thing at a time.
-- Verify your work. Run tests, check output.
-- Stay grounded. Only respond to what the user asks. Never invent problems or scenarios.
-- Be concise. Give direct answers based on tool output.
-
-## System
-
-<%= `uname -s`.strip %> · <%= ENV['PWD'] %>
lib/elelem/terminal.rb
@@ -2,12 +2,9 @@
 
 module Elelem
   class Terminal
-    def initialize(commands: [], providers: [], env_vars: [])
+    def initialize(commands: [])
       @commands = commands
-      @providers = providers
-      @env_vars = env_vars
-      @spinner_thread = nil
-      @glow_available = system("which glow > /dev/null 2>&1")
+      @dots_thread = nil
       setup_completion
     end
 
@@ -15,49 +12,53 @@ module Elelem
       Reline.readline(prompt, true)&.strip
     end
 
-    def say(message, markdown: false)
-      stop_spinner
-      if markdown && @glow_available
-        IO.popen("glow -", "w") { |io| io.puts message }
-      else
-        $stdout.puts message
+    def dim(text)
+      "\e[2m#{text}\e[0m"
+    end
+
+    def markdown(text)
+      width = $stdout.winsize[1] rescue 80
+      IO.popen(["glow", "-s", "dark", "-w", width.to_s, "-"], "r+") do |io|
+        io.write(text)
+        io.close_write
+        io.read
       end
+    rescue Errno::ENOENT
+      text
     end
 
-    def write(message)
-      stop_spinner
+    def print(message)
+      stop_dots
       $stdout.print message
     end
 
+    def say(message)
+      stop_dots
+      $stdout.puts message
+    end
+
+    def newline
+      say("")
+    end
+
     def waiting
-      @spinner_thread = Thread.new do
-        frames = %w[| / - \\]
-        i = 0
+      @dots_thread = Thread.new do
         loop do
-          $stdout.print "\r#{frames[i % frames.length]} "
+          $stdout.print "."
           $stdout.flush
-          i += 1
           sleep 0.1
         end
       end
     end
 
-    def select(question, options, &block)
-      CLI::UI::Prompt.ask(question) do |handler|
-        options.each do |option|
-          handler.option(option) { |selected| block.call(selected) }
-        end
-      end
-    end
-
     private
 
-    def stop_spinner
-      return unless @spinner_thread
+    def stop_dots
+      return unless @dots_thread
 
-      @spinner_thread.kill
-      @spinner_thread = nil
-      $stdout.print "\r  \r"
+      @dots_thread.kill
+      @dots_thread = nil
+      newline
     end
 
     def setup_completion
@@ -67,43 +68,13 @@ module Elelem
 
     def complete(target, preposing)
       line = "#{preposing}#{target}"
-
-      if line.start_with?('/') && !preposing.include?(' ')
-        return @commands.select { |c| c.start_with?(line) }
-      end
-
-      case preposing.strip
-      when '/provider'
-        @providers.select { |p| p.start_with?(target) }
-      when '/env'
-        @env_vars.select { |v| v.start_with?(target) }
-      when %r{^/env\s+\w+\s+pass(\s+show)?\s*$}
-        subcommands = %w[show ls insert generate edit rm]
-        matches = subcommands.select { |c| c.start_with?(target) }
-        matches.any? ? matches : complete_pass_entries(target)
-      when %r{^/env\s+\w+$}
-        complete_commands(target)
-      else
-        complete_files(target)
-      end
-    end
-
-    def complete_commands(target)
-      result = Elelem.shell.execute("bash", args: ["-c", "compgen -c #{target}"])
-      result["stdout"].lines.map(&:strip).first(20)
+      return @commands.select { |c| c.start_with?(line) } if line.start_with?("/") && !preposing.include?(" ")
+      complete_files(target)
     end
 
     def complete_files(target)
-      result = Elelem.shell.execute("bash", args: ["-c", "compgen -f #{target}"])
+      result = Elelem.sh("bash", args: ["-c", "compgen -f #{target}"])
       result["stdout"].lines.map(&:strip).first(20)
     end
-
-    def complete_pass_entries(target)
-      store = ENV.fetch("PASSWORD_STORE_DIR", File.expand_path("~/.password-store"))
-      result = Elelem.shell.execute("find", args: ["-L", store, "-name", "*.gpg"])
-      result["stdout"].lines.map { |l|
-        l.strip.sub("#{store}/", "").sub(/\.gpg$/, "")
-      }.select { |e| e.start_with?(target) }.first(20)
-    end
   end
 end
lib/elelem/tool.rb
@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  class Tool
-    attr_reader :name
-
-    def initialize(schema, &block)
-      @name = schema.dig(:function, :name)
-      @schema = schema
-      @block = block
-    end
-
-    def call(args)
-      unless valid?(args)
-        actual = args.keys
-        expected = @schema.dig(:function, :parameters)
-        return { error: "Invalid args for #{@name}.", actual: actual, expected: expected }
-      end
-
-      @block.call(args)
-    end
-
-    def valid?(args)
-      JSON::Validator.validate(@schema.dig(:function, :parameters), args)
-    end
-
-    def to_h
-      @schema&.to_h
-    end
-
-    class << self
-      def build(name, description, properties, required = [])
-        new({
-          type: "function",
-          function: {
-            name: name,
-            description: description,
-            parameters: {
-              type: "object",
-              properties: properties,
-              required: required
-            }
-          }
-        }) do |args|
-          yield args
-        end
-      end
-    end
-  end
-end
lib/elelem/toolbox.rb
@@ -2,112 +2,79 @@
 
 module Elelem
   class Toolbox
-    READ_TOOL = Tool.build("read", "Read complete contents of a file. Requires exact file path.", { path: { type: "string" } }, ["path"]) do |args|
-      path = args["path"]
-      full_path = Pathname.new(path).expand_path
-      full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
+    TOOLS = {
+      "read" => {
+        desc: "Read file contents",
+        params: { path: { type: "string" } },
+        required: ["path"],
+        fn: ->(a) { p = Pathname.new(a["path"]).expand_path; p.exist? ? { content: p.read } : { error: "not found" } }
+      },
+      "write" => {
+        desc: "Write file",
+        params: { path: { type: "string" }, content: { type: "string" } },
+        required: ["path", "content"],
+        fn: ->(a) { p = Pathname.new(a["path"]).expand_path; FileUtils.mkdir_p(p.dirname); { bytes: p.write(a["content"]) } }
+      },
+      "exec" => {
+        desc: "Run shell command",
+        params: { cmd: { type: "string" }, args: { type: "array", items: { type: "string" } }, stdin: { type: "string" } },
+        required: ["cmd"],
+        fn: ->(a) { Elelem.sh(a["cmd"], args: a["args"] || [], stdin: a["stdin"]) }
+      },
+      "web_fetch" => {
+        desc: "Fetch URL content",
+        params: { url: { type: "string" } },
+        required: ["url"],
+        fn: ->(a) { r = Net::Hippie::Client.new.get(a["url"]); { status: r.code.to_i, body: r.body } }
+      },
+      "web_search" => {
+        desc: "Search web via DuckDuckGo",
+        params: { query: { type: "string" } },
+        required: ["query"],
+        fn: ->(a) { q = CGI.escape(a["query"]); JSON.parse(Net::Hippie::Client.new.get("https://api.duckduckgo.com/?q=#{q}&format=json&no_html=1").body) }
+      },
+      "eval" => {
+        desc: "Execute Ruby code",
+        params: { ruby: { type: "string" } },
+        required: ["ruby"],
+        fn: nil
+      }
+    }.freeze
+
+    ALIASES = { "bash" => "exec", "sh" => "exec", "open" => "read" }.freeze
+
+    attr_reader :tools
+
+    def initialize(tools = TOOLS.dup)
+      @tools = tools
     end
 
-    EXEC_TOOL = Tool.build("exec", "Run shell commands. Returns stdout/stderr/exit_status.", { cmd: { type: "string" }, args: { type: "array", items: { type: "string" } }, env: { type: "object", additionalProperties: { type: "string" } }, cwd: { type: "string", description: "Working directory (defaults to current)" }, stdin: { type: "string" } }, ["cmd"]) do |args|
-      Elelem.shell.execute(
-        args["cmd"],
-        args: args["args"] || [],
-        env: args["env"] || {},
-        cwd: args["cwd"].to_s.empty? ? Dir.pwd : args["cwd"],
-        stdin: args["stdin"]
-      )
-    end
-
-    GREP_TOOL = Tool.build("grep", "Search all git-tracked files using git grep. Returns file paths with matching line numbers.", { query: { type: "string" } }, ["query"]) do |args|
-      Elelem.shell.execute("git", args: ["grep", "-nI", args["query"]])
-    end
-
-    LIST_TOOL = Tool.build("list", "List all git-tracked files in the repository, optionally filtered by path.", { path: { type: "string" } }) do |args|
-      Elelem.shell.execute("git", args: args["path"] ? ["ls-files", "--", args["path"]] : ["ls-files"])
-    end
-
-    PATCH_TOOL = Tool.build("patch", "Apply a unified diff patch via 'git apply'. Use for surgical edits to existing files.", { diff: { type: "string" } }, ["diff"]) do |args|
-      Elelem.shell.execute("git", args: ["apply", "--index", "--whitespace=nowarn", "-p1"], stdin: args["diff"])
-    end
-
-    WRITE_TOOL = Tool.build("write", "Write complete file contents (overwrites existing files). Creates parent directories automatically.", { path: { type: "string" }, content: { type: "string" } }, ["path", "content"]) do |args|
-      full_path = Pathname.new(args["path"]).expand_path
-      FileUtils.mkdir_p(full_path.dirname)
-      { bytes_written: full_path.write(args["content"]) }
-    end
-
-    FETCH_TOOL = Tool.build("fetch", "Fetch content from a URL. Returns status, headers, and body.", { url: { type: "string", description: "The URL to fetch" } }, ["url"]) do |args|
-      client = Net::Hippie::Client.new
-      response = client.get(args["url"])
-      { status: response.code.to_i, body: response.body }
-    end
-
-    WEB_SEARCH_TOOL = Tool.build("web_search", "Search the web using DuckDuckGo. Returns raw API response.", { query: { type: "string", description: "The search query" } }, ["query"]) do |args|
-      query = CGI.escape(args["query"])
-      url = "https://api.duckduckgo.com/?q=#{query}&format=json&no_html=1"
-      client = Net::Hippie::Client.new
-      response = client.get(url)
-      JSON.parse(response.body)
-    end
-
-    TOOL_ALIASES = {
-      "bash" => "exec",
-      "duckduckgo" => "web_search",
-      "ddg" => "web_search",
-      "search_engine" => "web_search",
-      "execute" => "exec",
-      "get" => "fetch",
-      "open" => "read",
-      "search" => "grep",
-      "sh" => "exec",
-      "web" => "fetch",
-    }
-
-    def initialize
-      @tools_by_name = {}
-      add_tool(eval_tool(binding))
-      add_tool(EXEC_TOOL)
-      add_tool(FETCH_TOOL)
-      add_tool(GREP_TOOL)
-      add_tool(LIST_TOOL)
-      add_tool(PATCH_TOOL)
-      add_tool(READ_TOOL)
-      add_tool(WEB_SEARCH_TOOL)
-      add_tool(WRITE_TOOL)
-    end
-
-    def add_tool(tool)
-      @tools_by_name[tool.name] = tool
-    end
-
-    def register_tool(name, description, properties = {}, required = [], &block)
-      add_tool(Tool.build(name, description, properties, required, &block))
-    end
-
-    def tools
-      @tools_by_name.values.map(&:to_h)
-    end
-
-    def run_tool(name, args)
-      resolved_name = TOOL_ALIASES.fetch(name, name)
-      tool = @tools_by_name[resolved_name]
-      return { error: "Unknown tool", name: name, args: args } unless tool
-
-      tool.call(args)
-    rescue => error
-      { error: error.message, name: name, args: args, backtrace: error.backtrace.first(5) }
-    end
-
-    def tool_schema(name)
-      @tools_by_name[name]&.to_h
+    def to_h
+      tools.map do |name, t|
+        {
+          type: "function",
+          function: {
+            name: name,
+            description: t[:desc],
+            parameters: {
+              type: "object",
+              properties: t[:params],
+              required: t[:required]
+            }
+          }
+        }
+      end
     end
 
-    private
+    def run(name, args)
+      name = ALIASES.fetch(name, name)
+      tool = tools[name]
+      return { error: "unknown tool: #{name}" } unless tool
+      return { result: binding.eval(args["ruby"]) } if name == "eval"
 
-    def eval_tool(target_binding)
-      Tool.build("eval", "Evaluates Ruby code with full access to register new tools via the `register_tool(name, desc, properties, required) { |args| ... }` method.", { ruby: { type: "string" } }, ["ruby"]) do |args|
-        { result: target_binding.eval(args["ruby"]) }
-      end
+      tool[:fn].call(args)
+    rescue => e
+      { error: e.message }
     end
   end
 end
lib/elelem.rb
@@ -1,26 +1,16 @@
 # frozen_string_literal: true
 
 require "cgi"
-require "cli/ui"
-require "erb"
 require "fileutils"
 require "json"
-require "json-schema"
-require "logger"
 require "net/hippie"
 require "net/llm"
 require "open3"
 require "pathname"
 require "reline"
-require "set"
-require "thor"
-require "timeout"
 
 require_relative "elelem/agent"
-require_relative "elelem/application"
-require_relative "elelem/conversation"
 require_relative "elelem/terminal"
-require_relative "elelem/tool"
 require_relative "elelem/toolbox"
 require_relative "elelem/version"
 
@@ -30,28 +20,12 @@ Reline.output = $stdout
 module Elelem
   class Error < StandardError; end
 
-  class Shell
-    def execute(command, args: [], env: {}, cwd: Dir.pwd, stdin: nil)
-      cmd = command.is_a?(Array) ? command.first : command
-      cmd_args = command.is_a?(Array) ? command[1..] + args : args
-      stdout, stderr, status = Open3.capture3(
-        env,
-        cmd,
-        *cmd_args,
-        chdir: cwd,
-        stdin_data: stdin
-      )
-      {
-        "exit_status" => status.exitstatus,
-        "stdout" => stdout.to_s,
-        "stderr" => stderr.to_s
-      }
-    end
+  def self.sh(cmd, args: [], env: {}, cwd: Dir.pwd, stdin: nil)
+    stdout, stderr, status = Open3.capture3(env, cmd, *args, chdir: cwd, stdin_data: stdin)
+    { "exit_status" => status.exitstatus, "stdout" => stdout, "stderr" => stderr }
   end
 
-  class << self
-    def shell
-      @shell ||= Shell.new
-    end
+  def self.start(client)
+    Agent.new(client, Toolbox.new).repl
   end
 end
spec/elelem/agent_e2e_spec.rb
@@ -1,59 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.describe Elelem::Agent do
-  let(:toolbox) { Elelem::Toolbox.new }
-  let(:fake_client) { instance_double(Net::Llm::Ollama, model: "test-model") }
-
-  before do
-    allow(Net::Llm::Ollama).to receive(:new).and_return(fake_client)
-  end
-
-  describe "slash commands" do
-    describe "/clear" do
-      it "clears the conversation" do
-        terminal = Elelem::FakeTerminal.new(inputs: ["/clear", nil])
-        agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
-        agent.conversation.add(role: :user, content: "hello")
-
-        agent.repl
-
-        expect(terminal.output).to include("  → Conversation cleared")
-      end
-    end
-
-    describe "/env" do
-      it "shows help and env vars when called without arguments" do
-        terminal = Elelem::FakeTerminal.new(inputs: ["/env", nil])
-        agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
-
-        agent.repl
-
-        expect(terminal.output).to include("  Usage: /env VAR cmd...")
-        expect(terminal.output.any? { |line| line.include?("ANTHROPIC_API_KEY") }).to be true
-      end
-
-      it "sets environment variable from command output" do
-        terminal = Elelem::FakeTerminal.new(inputs: ["/env TEST_VAR echo hello", nil])
-        agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
-
-        agent.repl
-
-        expect(terminal.output).to include("  → Set TEST_VAR")
-        expect(ENV["TEST_VAR"]).to eq("hello")
-      end
-    end
-
-    describe "/help" do
-      it "shows help banner" do
-        terminal = Elelem::FakeTerminal.new(inputs: ["/help", nil])
-        agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
-
-        agent.repl
-
-        expect(terminal.output.join).to include("/env VAR cmd...")
-        expect(terminal.output.join).to include("/provider")
-        expect(terminal.output.join).to include("/clear")
-      end
-    end
-  end
-end
spec/elelem/agent_spec.rb
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.describe Elelem::Agent do
-  let(:mock_client) { double("client", model: "test-model") }
-  let(:agent) do
-    agent = described_class.allocate
-    agent.instance_variable_set(:@conversation, Elelem::Conversation.new)
-    agent.instance_variable_set(:@provider, "ollama")
-    agent.instance_variable_set(:@toolbox, Elelem::Toolbox.new)
-    agent.instance_variable_set(:@client, mock_client)
-    agent
-  end
-
-  describe "#initialize" do
-    it "creates a new conversation" do
-      expect(agent.conversation).to be_a(Elelem::Conversation)
-    end
-
-    it "stores the client" do
-      expect(agent.client).to eq(mock_client)
-    end
-
-    it "initializes toolbox with all tools" do
-      tool_names = agent.toolbox.tools.map { |t| t.dig(:function, :name) }
-      expect(tool_names).to include("read", "write", "exec", "grep", "list")
-    end
-  end
-end
spec/elelem/conversation_spec.rb
@@ -1,99 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.describe Elelem::Conversation do
-  let(:conversation) { described_class.new }
-
-  describe "#history" do
-    it "returns history with system prompt" do
-      history = conversation.history
-
-      expect(history.length).to eq(1)
-      expect(history[0][:role]).to eq("system")
-      expect(history[0][:content]).to be_a(String)
-    end
-
-    context "with populated conversation" do
-      before do
-        conversation.add(role: :user, content: "Hello")
-        conversation.add(role: :assistant, content: "Hi there")
-      end
-
-      it "preserves all conversation items" do
-        history = conversation.history
-
-        expect(history.length).to eq(3)
-        expect(history[1][:role]).to eq(:user)
-        expect(history[1][:content]).to eq("Hello")
-        expect(history[2][:role]).to eq(:assistant)
-        expect(history[2][:content]).to eq("Hi there")
-      end
-
-      it "returns a copy, not the original array" do
-        history = conversation.history
-        original_items = conversation.instance_variable_get(:@items)
-
-        expect(history).not_to be(original_items)
-      end
-    end
-  end
-
-  describe "#add" do
-    it "adds user message to conversation" do
-      conversation.add(role: :user, content: "test message")
-      history = conversation.history
-
-      expect(history.length).to eq(2)
-      expect(history[1][:content]).to eq("test message")
-    end
-
-    it "merges consecutive messages with same role" do
-      conversation.add(role: :user, content: "part 1")
-      conversation.add(role: :user, content: "part 2")
-      history = conversation.history
-
-      expect(history.length).to eq(2)
-      expect(history[1][:content]).to eq("part 1part 2")
-    end
-
-    it "ignores nil content" do
-      conversation.add(role: :user, content: nil)
-      history = conversation.history
-
-      expect(history.length).to eq(1)
-    end
-
-    it "ignores empty content" do
-      conversation.add(role: :user, content: "")
-      history = conversation.history
-
-      expect(history.length).to eq(1)
-    end
-
-    it "raises error for unknown role" do
-      expect {
-        conversation.add(role: :unknown, content: "test")
-      }.to raise_error(/unknown role/)
-    end
-  end
-
-  describe "#clear" do
-    it "resets conversation to default context" do
-      conversation.add(role: :user, content: "test")
-      conversation.clear
-      history = conversation.history
-
-      expect(history.length).to eq(1)
-      expect(history[0][:role]).to eq("system")
-    end
-  end
-
-  describe "#dump" do
-    it "returns markdown representation" do
-      conversation.add(role: :user, content: "test")
-      result = conversation.dump
-
-      expect(result).to include("## System")
-      expect(result).to include("## User")
-    end
-  end
-end
spec/elelem/toolbox_spec.rb
@@ -3,97 +3,32 @@
 RSpec.describe Elelem::Toolbox do
   subject { described_class.new }
 
-  describe "#tools" do
-    it "returns all tools" do
-      tool_names = subject.tools.map { |t| t.dig(:function, :name) }
-      expect(tool_names).to include("grep", "list", "read", "patch", "write", "exec", "fetch", "web_search", "eval")
+  describe "#to_h" do
+    it "returns all tools in API format" do
+      tool_names = subject.to_h.map { |t| t.dig(:function, :name) }
+      expect(tool_names).to include("read", "write", "exec", "web_fetch", "web_search", "eval")
     end
   end
 
-  describe "aliases" do
-    it "resolves web and get aliases to fetch" do
-      expect(Elelem::Toolbox::TOOL_ALIASES["web"]).to eq("fetch")
-      expect(Elelem::Toolbox::TOOL_ALIASES["get"]).to eq("fetch")
-    end
-
-    it "resolves duckduckgo alias to web_search" do
-      expect(Elelem::Toolbox::TOOL_ALIASES["duckduckgo"]).to eq("web_search")
-    end
-
-    it "resolves bash alias to exec" do
-      expect(Elelem::Toolbox::TOOL_ALIASES["bash"]).to eq("exec")
-    end
-  end
-
-  describe "#run_tool" do
-    it "executes tools" do
-      result = subject.run_tool("read", { "path" => __FILE__ })
+  describe "#run" do
+    it "executes read tool" do
+      result = subject.run("read", { "path" => __FILE__ })
       expect(result[:content]).to include("RSpec.describe")
     end
 
-    it "resolves aliases" do
-      result = subject.run_tool("open", { "path" => __FILE__ })
+    it "resolves open alias to read" do
+      result = subject.run("open", { "path" => __FILE__ })
       expect(result[:content]).to include("RSpec.describe")
     end
 
-    it "returns unknown tool error for non-existent tools" do
-      result = subject.run_tool("nonexistent", {})
-      expect(result[:error]).to include("Unknown tool")
-    end
-  end
-
-  describe "meta-programming with eval tool" do
-    it "allows LLM to register new tools dynamically" do
-      subject.run_tool("eval", {
-        "ruby" => <<~RUBY
-          register_tool("hello", "Says hello to a name", { name: { type: "string" } }, ["name"]) do |args|
-            { greeting: "Hello, " + args['name']+ "!" }
-          end
-        RUBY
-      })
-
-      expect(subject.tools).to include(hash_including({
-        type: "function",
-        function: {
-          name: "hello",
-          description: "Says hello to a name",
-          parameters: {
-            type: "object",
-            properties: { name: { type: "string" } },
-            required: ["name"]
-          }
-        }
-      }))
-    end
-
-    it "allows LLM to call dynamically created tools" do
-      subject.run_tool("eval", {
-        "ruby" => <<~RUBY
-          register_tool("add", "Adds two numbers", { a: { type: "number" }, b: { type: "number" } }, ["a", "b"]) do |args|
-            { sum: args["a"] + args["b"] }
-          end
-        RUBY
-      })
-
-      result = subject.run_tool("add", { "a" => 5, "b" => 3 })
-      expect(result[:sum]).to eq(8)
+    it "returns error for unknown tools" do
+      result = subject.run("nonexistent", {})
+      expect(result[:error]).to include("unknown tool")
     end
 
-    it "allows LLM to inspect tool schemas" do
-      result = subject.run_tool("eval", { "ruby" => "tool_schema('read')" })
-      expect(result[:result]).to be_a(Hash)
-      expect(result[:result].dig(:function, :name)).to eq("read")
-    end
-
-    it "executes arbitrary Ruby code" do
-      result = subject.run_tool("eval", { "ruby" => "2 + 2" })
+    it "executes eval tool" do
+      result = subject.run("eval", { "ruby" => "2 + 2" })
       expect(result[:result]).to eq(4)
     end
-
-    it "handles errors gracefully" do
-      result = subject.run_tool("eval", { "ruby" => "undefined_variable" })
-      expect(result[:error]).to include("undefined")
-      expect(result[:backtrace]).to be_an(Array)
-    end
   end
 end
elelem.gemspec
@@ -25,13 +25,14 @@ Gem::Specification.new do |spec|
     "README.md",
     "Rakefile",
     "exe/elelem",
+    "exe/elelem-anthropic",
+    "exe/elelem-files",
+    "exe/elelem-ollama",
+    "exe/elelem-openai",
+    "exe/elelem-vertex-ai",
     "lib/elelem.rb",
     "lib/elelem/agent.rb",
-    "lib/elelem/application.rb",
-    "lib/elelem/conversation.rb",
-    "lib/elelem/system_prompt.erb",
     "lib/elelem/terminal.rb",
-    "lib/elelem/tool.rb",
     "lib/elelem/toolbox.rb",
     "lib/elelem/version.rb",
   ]
@@ -40,18 +41,11 @@ Gem::Specification.new do |spec|
   spec.require_paths = ["lib"]
 
   spec.add_dependency "cgi", "~> 0.1"
-  spec.add_dependency "cli-ui", "~> 2.0"
-  spec.add_dependency "erb", "~> 6.0"
   spec.add_dependency "fileutils", "~> 1.0"
   spec.add_dependency "json", "~> 2.0"
-  spec.add_dependency "json-schema", "~> 6.0"
-  spec.add_dependency "logger", "~> 1.0"
   spec.add_dependency "net-hippie", "~> 1.0"
   spec.add_dependency "net-llm", "~> 0.5", ">= 0.5.0"
   spec.add_dependency "open3", "~> 0.1"
   spec.add_dependency "pathname", "~> 0.1"
   spec.add_dependency "reline", "~> 0.6"
-  spec.add_dependency "set", "~> 1.0"
-  spec.add_dependency "thor", "~> 1.0"
-  spec.add_dependency "timeout", "~> 0.1"
 end
Gemfile.lock
@@ -3,30 +3,19 @@ PATH
   specs:
     elelem (0.8.0)
       cgi (~> 0.1)
-      cli-ui (~> 2.0)
-      erb (~> 6.0)
       fileutils (~> 1.0)
       json (~> 2.0)
-      json-schema (~> 6.0)
-      logger (~> 1.0)
       net-hippie (~> 1.0)
       net-llm (~> 0.5, >= 0.5.0)
       open3 (~> 0.1)
       pathname (~> 0.1)
       reline (~> 0.6)
-      set (~> 1.0)
-      thor (~> 1.0)
-      timeout (~> 0.1)
 
 GEM
   remote: https://rubygems.org/
   specs:
-    addressable (2.8.8)
-      public_suffix (>= 2.0.2, < 8.0)
     base64 (0.3.0)
-    bigdecimal (4.0.1)
     cgi (0.5.1)
-    cli-ui (2.7.0)
     date (3.5.1)
     diff-lcs (1.6.2)
     erb (6.0.1)
@@ -37,9 +26,6 @@ GEM
       rdoc (>= 4.0.0)
       reline (>= 0.4.2)
     json (2.18.0)
-    json-schema (6.1.0)
-      addressable (~> 2.8)
-      bigdecimal (>= 3.1, < 5)
     logger (1.7.0)
     net-hippie (1.4.0)
       base64 (~> 0.1)
@@ -62,7 +48,6 @@ GEM
     psych (5.3.1)
       date
       stringio
-    public_suffix (7.0.2)
     rake (13.3.1)
     rdoc (7.1.0)
       erb
@@ -83,10 +68,7 @@ GEM
       diff-lcs (>= 1.2.0, < 2.0)
       rspec-support (~> 3.13.0)
     rspec-support (3.13.6)
-    set (1.1.2)
     stringio (3.2.0)
-    thor (1.5.0)
-    timeout (0.6.0)
     tsort (0.2.0)
     uri (1.1.1)