Commit 5647f52
Changed files (13)
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",