Commit 5647f52

mo khan <mo@mokhan.ca>
2026-01-20 19:04:18
feat: consolidate exe files and add plugin extension system
1 parent 14588be
exe/elelem
@@ -1,4 +1,96 @@
 #!/usr/bin/env ruby
 # frozen_string_literal: true
 
-exec "elelem-ollama"
+require "elelem"
+
+Signal.trap("INT") { exit 1 }
+
+PROVIDERS = {
+  "ollama" => -> (model) { Net::Llm::Ollama.new(model: model || "gpt-oss:latest") },
+  "anthropic" => -> (model) { Net::Llm::Claude.anthropic(model: model || "claude-opus-4-5-20250514") },
+  "vertex" => -> (model) { Net::Llm::Claude.vertex(model: model || "claude-opus-4-5@20251101") },
+  "openai" => -> (model) { Net::Llm::OpenAI.new(model: model || "gpt-4o") }
+}.freeze
+
+def parse_args(args)
+  opts = { provider: "ollama", model: nil, command: nil, args: [] }
+
+  while args.any?
+    case args.first
+    when "-p", "--provider"
+      args.shift
+      opts[:provider] = args.shift
+    when "-m", "--model"
+      args.shift
+      opts[:model] = args.shift
+    when "chat", "ask", "pipe", "files", "init", "help"
+      opts[:command] = args.shift
+    else
+      opts[:args] << args.shift
+    end
+  end
+
+  opts[:command] ||= "chat"
+  opts
+end
+
+def help
+  puts <<~HELP
+    elelem - Ruby coding agent
+
+    Usage: elelem [command] [options] [args]
+
+    Commands:
+      chat              Interactive REPL (default)
+      ask <question>    One-shot query
+      pipe <prompt>     Process stdin with prompt
+      files             Output files as XML (stdin or git ls-files)
+      init              Create .elelem/plugins/ directory
+
+    Options:
+      -p, --provider    ollama, anthropic, vertex, openai (default: ollama)
+      -m, --model       Override default model for provider
+
+    Examples:
+      elelem                              # Ollama chat
+      elelem -p anthropic                 # Anthropic chat
+      elelem ask "what is 2+2"            # One-shot with Ollama
+      elelem -p vertex ask "explain this" # One-shot with Vertex
+      echo "code" | elelem pipe "review"  # Pipe stdin
+  HELP
+end
+
+opts = parse_args(ARGV.dup)
+
+case opts[:command]
+when "help"
+  help
+when "init"
+  Elelem::Plugins.init
+  puts "Created .elelem/plugins/"
+when "chat"
+  client = PROVIDERS.fetch(opts[:provider]).call(opts[:model])
+  Elelem.start(client)
+when "ask"
+  prompt = opts[:args].join(" ")
+  abort "Usage: elelem ask <question>" if prompt.empty?
+  client = PROVIDERS.fetch(opts[:provider]).call(opts[:model])
+  puts Elelem.ask(client, prompt)
+when "pipe"
+  instruction = opts[:args].join(" ")
+  abort "Usage: elelem pipe <prompt>" if instruction.empty?
+  client = PROVIDERS.fetch(opts[:provider]).call(opts[:model])
+  puts Elelem.pipe(client, $stdin.read, instruction)
+when "files"
+  files = $stdin.stat.pipe? ? $stdin.readlines : `git ls-files`.lines
+  puts "<documents>"
+  files.each_with_index do |line, i|
+    path = line.strip
+    next if path.empty? || !File.file?(path)
+    puts %Q{<document index="#{i + 1}">}
+    puts %Q{<source>#{path}</source>}
+    puts %Q{<content><![CDATA[#{File.read(path)}]]></content>}
+    puts "</document>"
+  end
+  puts "</documents>"
+end
exe/elelem-anthropic
@@ -1,11 +0,0 @@
-#!/usr/bin/env ruby
-# frozen_string_literal: true
-
-require "elelem"
-
-Signal.trap("INT") { exit 1 }
-
-Elelem.start(Net::Llm::Claude.anthropic(
-  model: "claude-sonnet-4-20250514",
-  api_key: ENV.fetch("ANTHROPIC_API_KEY") { `pass api/console.anthropic.com/access-token | head -n1`.strip },
-))
exe/elelem-files
@@ -1,14 +0,0 @@
-#!/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
@@ -1,7 +0,0 @@
-#!/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
@@ -1,7 +0,0 @@
-#!/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
@@ -1,14 +0,0 @@
-#!/usr/bin/env ruby
-# frozen_string_literal: true
-
-ENV["GOOGLE_CLOUD_PROJECT"] || abort("GOOGLE_CLOUD_PROJECT not set")
-
-require "elelem"
-
-Signal.trap("INT") { exit 1 }
-
-Elelem.start(Net::Llm::Claude.vertex(
-  model: "claude-opus-4-5@20251101",
-  project: ENV.fetch("GOOGLE_CLOUD_PROJECT"),
-  region: ENV.fetch("GOOGLE_CLOUD_REGION", "us-east5"),
-))
lib/elelem/agent.rb
@@ -18,6 +18,7 @@ module Elelem
     end
 
     def repl
+      Elelem.emit(:repl_start, agent: self)
       terminal.say "elelem v#{VERSION}"
       loop do
         input = terminal.ask("> ")
@@ -25,6 +26,8 @@ module Elelem
         next if input.empty?
         input.start_with?("/") ? command(input) : turn(input)
       end
+    ensure
+      Elelem.emit(:repl_stop, agent: self)
     end
 
     def command(input)
@@ -41,6 +44,7 @@ module Elelem
     end
 
     def turn(input)
+      Elelem.emit(:turn_start, input: input)
       history << { role: "user", content: input }
       compact_if_needed
       ctx = []
@@ -59,6 +63,8 @@ module Elelem
       end
 
       history << { role: "assistant", content: content }
+      Elelem.emit(:turn_complete, input: input, output: content)
+      content
     end
 
     private
@@ -75,8 +81,10 @@ module Elelem
 
     def process(tool_call)
       name, args = tool_call[:name], tool_call[:arguments]
+      Elelem.emit(:tool_call, name: name, args: args)
       terminal.say toolbox.header(name, args)
       toolbox.run(name.to_s, args).tap do |result|
+        Elelem.emit(:tool_result, name: name, args: args, result: result)
         terminal.say toolbox.format_result(name, result)
       end
     end
lib/elelem/events.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Events
+    @handlers = Hash.new { |h, k| h[k] = [] }
+
+    class << self
+      def on(event, &block)
+        @handlers[event] << block
+      end
+
+      def emit(event, **payload)
+        @handlers[event].each { |h| h.call(payload) }
+      end
+
+      def clear(event = nil)
+        event ? @handlers.delete(event) : @handlers.clear
+      end
+
+      def handlers
+        @handlers
+      end
+    end
+  end
+
+  # Convenience methods at module level
+  def self.on(event, &block)
+    Events.on(event, &block)
+  end
+
+  def self.emit(event, **payload)
+    Events.emit(event, **payload)
+  end
+end
lib/elelem/plugins.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Plugins
+    LOAD_PATHS = [".elelem/plugins", "~/.elelem/plugins"].freeze
+
+    def self.load_all
+      LOAD_PATHS.each do |path|
+        dir = File.expand_path(path)
+        next unless File.directory?(dir)
+
+        Dir["#{dir}/*.rb"].sort.each do |file|
+          load(file)
+        rescue => e
+          warn "elelem: failed to load plugin #{file}: #{e.message}"
+        end
+      end
+    end
+
+    def self.load! = load_all
+
+    def self.init
+      dir = File.expand_path(LOAD_PATHS.first)
+      FileUtils.mkdir_p(dir)
+    end
+
+    def self.register(name, &block)
+      (@registry ||= {})[name] = block
+    end
+
+    def self.registry
+      @registry ||= {}
+    end
+  end
+end
lib/elelem/terminal.rb
@@ -2,10 +2,11 @@
 
 module Elelem
   class Terminal
-    def initialize(commands: [])
+    def initialize(commands: [], quiet: false)
       @commands = commands
+      @quiet = quiet
       @dots_thread = nil
-      setup_completion
+      setup_completion unless @quiet
     end
 
     def ask(prompt)
@@ -19,7 +20,7 @@ module Elelem
     end
 
     def markdown(text)
-      return if blank?(text)
+      return if @quiet || blank?(text)
 
       newline(n: 2)
       width = $stdout.winsize[1] rescue 80
@@ -33,14 +34,14 @@ module Elelem
     end
 
     def print(text)
-      return if blank?(text)
+      return if @quiet || blank?(text)
 
       stop_dots
       $stdout.print text
     end
 
     def say(text)
-      return if blank?(text)
+      return if @quiet || blank?(text)
 
       stop_dots
       $stdout.puts text
@@ -51,6 +52,8 @@ module Elelem
     end
 
     def waiting
+      return if @quiet
+
       @dots_thread = Thread.new do
         loop do
           $stdout.print "."
lib/elelem/toolbox.rb
@@ -67,12 +67,15 @@ module Elelem
       tool = tools[name]
       return { error: "unknown tool: #{name}" } unless tool
 
+      missing = (tool[:required] || []) - (args&.keys || [])
+      return { error: "missing required args: #{missing.join(', ')}" } if missing.any?
+
       @hooks[:before][name].each { |h| h.call(args) }
       result = tool[:fn].call(args)
       @hooks[:after][name].each { |h| h.call(args, result) }
       result
     rescue => e
-      { error: e.message }
+      { error: e.message, name: name, args: args }
     end
 
     def to_h
lib/elelem.rb
@@ -10,6 +10,8 @@ require "reline"
 require "stringio"
 require "tempfile"
 
+require_relative "elelem/events"
+require_relative "elelem/plugins"
 require_relative "elelem/agent"
 require_relative "elelem/mcp"
 require_relative "elelem/terminal"
@@ -17,6 +19,8 @@ require_relative "elelem/toolbox"
 require_relative "elelem/version"
 
 module Elelem
+  extend Events
+
   def self.sh(cmd, args: [], cwd: Dir.pwd, env: {})
     output = StringIO.new
 
@@ -31,7 +35,19 @@ module Elelem
     end
   end
 
-  def self.start(client)
-    Agent.new(client, Toolbox.new).repl
+  def self.start(client, toolbox: Toolbox.new)
+    Plugins.load!
+    Agent.new(client, toolbox).repl
+  end
+
+  def self.ask(client, prompt, toolbox: Toolbox.new)
+    Plugins.load!
+    agent = Agent.new(client, toolbox, terminal: Terminal.new(quiet: true))
+    agent.turn(prompt)
+    agent.history.last[:content]
+  end
+
+  def self.pipe(client, input, instruction, toolbox: Toolbox.new)
+    ask(client, "#{instruction}\n\n```\n#{input}\n```", toolbox:)
   end
 end
elelem.gemspec
@@ -25,13 +25,11 @@ 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/events.rb",
+    "lib/elelem/mcp.rb",
+    "lib/elelem/plugins.rb",
     "lib/elelem/terminal.rb",
     "lib/elelem/toolbox.rb",
     "lib/elelem/version.rb",