Comparing changes
v0.9.2
→
v0.10.0
25 commits
52 files changed
Commits
Changed files (52)
exe
lib
elelem
plugins
templates
spec
.elelem/plugins/gitlab.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:gitlab) do |agent|
+ agent.toolbox.after("gitlab_search") do |_args, result|
+ IO.popen(["jq", "-C", "."], "r+") do |io|
+ io.write(result.to_json)
+ io.close_write
+ agent.terminal.say(io.read)
+ end
+ end
+end
.elelem/mcp.json
@@ -0,0 +1,14 @@
+{
+ "mcpServers": {
+ "gitlab": {
+ "type": "http",
+ "url": "https://gitlab.com/api/v4/mcp"
+ },
+ "playwright": {
+ "command": "npx",
+ "args": [
+ "@playwright/mcp@latest"
+ ]
+ }
+ }
+}
exe/elelem
@@ -2,84 +2,7 @@
# frozen_string_literal: true
require "elelem"
-require "optparse"
Signal.trap("INT") { exit 1 }
-class App
- MODELS = {
- "ollama" => "gpt-oss:latest",
- "anthropic" => "claude-opus-4-5-20250514",
- "vertex" => "claude-opus-4-5@20251101",
- "openai" => "gpt-4o"
- }.freeze
-
- PROVIDERS = {
- "ollama" => ->(model) { Elelem::Net::Ollama.new(model: model, host: ENV.fetch("OLLAMA_HOST", "localhost:11434")) },
- "anthropic" => ->(model) { Elelem::Net::Claude.anthropic(model: model, api_key: ENV.fetch("ANTHROPIC_API_KEY")) },
- "vertex" => ->(model) { Elelem::Net::Claude.vertex(model: model, project: ENV.fetch("GOOGLE_CLOUD_PROJECT"), region: ENV.fetch("GOOGLE_CLOUD_REGION", "us-east5")) },
- "openai" => ->(model) { Elelem::Net::OpenAI.new(model: model, api_key: ENV.fetch("OPENAI_API_KEY")) }
- }.freeze
-
- def initialize(args)
- @provider = "ollama"
- @model = nil
- @args = parse(args)
- end
-
- def run
- command = @args.shift || "chat"
- send(command.tr("-", "_"))
- rescue NoMethodError
- abort "Unknown command: #{command}"
- end
-
- private
-
- def parse(args)
- @parser = OptionParser.new do |o|
- o.banner = "Usage: elelem [command] [options] [args]"
- o.separator "\nCommands:"
- o.separator " chat Interactive REPL (default)"
- o.separator " ask <prompt> One-shot query (reads stdin if piped)"
- o.separator " files Output files as XML (no options)"
- o.separator " help Show this help"
- o.separator "\nOptions:"
- o.on("-p", "--provider NAME", "ollama, anthropic, vertex, openai") { |p| @provider = p }
- o.on("-m", "--model NAME", "Override default model") { |m| @model = m }
- o.on("-h", "--help") { puts o; exit }
- end
- @parser.parse!(args)
- end
-
- def help
- puts @parser
- end
-
- def client
- model = @model || MODELS.fetch(@provider)
- PROVIDERS.fetch(@provider).call(model)
- end
-
- def chat = Elelem.start(client)
-
- def ask
- abort "Usage: elelem ask <prompt>" if @args.empty?
- prompt = @args.join(" ")
- prompt = "#{prompt}\n\n```\n#{$stdin.read}\n```" if $stdin.stat.pipe?
- Elelem::Terminal.new.markdown Elelem.ask(client, prompt)
- end
-
- def 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}"><source>#{path}</source><content><![CDATA[#{File.read(path)}]]></content></document>}
- end
- puts "</documents>"
- end
-end
-
-App.new(ARGV).run
+Elelem::CLI.new(ARGV).run
lib/elelem/mcp/oauth.rb
@@ -0,0 +1,217 @@
+# frozen_string_literal: true
+
+module Elelem
+ class MCP
+ class OAuth
+ CALLBACK_PORT = 18273
+ REDIRECT_URI = "http://127.0.0.1:#{CALLBACK_PORT}/callback"
+
+ def initialize(resource_url, http: Elelem::Net.http)
+ @resource_url = resource_url
+ @http = http
+ @storage = TokenStorage.new
+ end
+
+ def token
+ stored = @storage.load(@resource_url)
+ return stored[:access_token] if stored && !expired?(stored)
+ return refresh(stored[:refresh_token]) if stored&.dig(:refresh_token)
+
+ authorize
+ end
+
+ private
+
+ def expired?(stored)
+ return false unless stored[:expires_at]
+
+ Time.now.to_i >= stored[:expires_at] - 60
+ end
+
+ def authorize
+ metadata = discover_auth_server
+ client = load_or_register_client(metadata)
+ verifier, challenge = generate_pkce
+ state = SecureRandom.hex(16)
+
+ auth_url = build_auth_url(metadata, client, challenge, state)
+ open_browser(auth_url)
+
+ code = wait_for_callback(state)
+ tokens = exchange_code(metadata, client, code, verifier)
+
+ @storage.save(
+ @resource_url,
+ access_token: tokens["access_token"],
+ refresh_token: tokens["refresh_token"],
+ expires_in: tokens["expires_in"]
+ )
+
+ tokens["access_token"]
+ end
+
+ def refresh(refresh_token)
+ metadata = discover_auth_server
+ client = load_or_register_client(metadata)
+ uri = URI.parse(metadata["token_endpoint"])
+
+ body = {
+ grant_type: "refresh_token",
+ refresh_token: refresh_token,
+ client_id: client[:client_id]
+ }
+
+ response = post_form(uri, body)
+ tokens = JSON.parse(response.body)
+
+ @storage.save(
+ @resource_url,
+ access_token: tokens["access_token"],
+ refresh_token: tokens["refresh_token"] || refresh_token,
+ expires_in: tokens["expires_in"]
+ )
+
+ tokens["access_token"]
+ rescue StandardError => e
+ warn "Token refresh failed: #{e.message}"
+ authorize
+ end
+
+ def discover_auth_server
+ resource_uri = URI.parse(@resource_url)
+ metadata_url = "#{resource_uri.scheme}://#{resource_uri.host}/.well-known/oauth-protected-resource"
+
+ resource_metadata = fetch_json(metadata_url)
+ auth_server_url = resource_metadata["authorization_servers"]&.first
+ raise "No authorization server found" unless auth_server_url
+
+ auth_metadata_url = "#{auth_server_url}/.well-known/oauth-authorization-server"
+ fetch_json(auth_metadata_url)
+ end
+
+ def load_or_register_client(metadata)
+ stored = @storage.load_client(@resource_url)
+ return stored if stored
+
+ client = register_client(metadata)
+ @storage.save_client(@resource_url, client)
+ @storage.load_client(@resource_url)
+ end
+
+ def register_client(metadata)
+ endpoint = metadata["registration_endpoint"]
+ raise "Dynamic registration not supported" unless endpoint
+
+ body = {
+ client_name: "elelem",
+ redirect_uris: [REDIRECT_URI],
+ grant_types: %w[authorization_code refresh_token],
+ response_types: ["code"],
+ token_endpoint_auth_method: "none"
+ }
+
+ response = post_json(endpoint, body)
+ JSON.parse(response.body)
+ end
+
+ def generate_pkce
+ verifier = SecureRandom.urlsafe_base64(32)
+ challenge = Base64.urlsafe_encode64(
+ Digest::SHA256.digest(verifier),
+ padding: false
+ )
+ [verifier, challenge]
+ end
+
+ def build_auth_url(metadata, client, challenge, state)
+ params = {
+ response_type: "code",
+ client_id: client[:client_id],
+ redirect_uri: REDIRECT_URI,
+ scope: metadata["scopes_supported"]&.join(" ") || "openid",
+ state: state,
+ code_challenge: challenge,
+ code_challenge_method: "S256"
+ }
+
+ "#{metadata["authorization_endpoint"]}?#{URI.encode_www_form(params)}"
+ end
+
+ def open_browser(url)
+ commands = ["xdg-open", "open", "start"]
+ commands.each do |cmd|
+ return if system(cmd, url, out: File::NULL, err: File::NULL)
+ end
+ warn "Open this URL in your browser: #{url}"
+ end
+
+ def wait_for_callback(expected_state)
+ code = nil
+ @server = WEBrick::HTTPServer.new(
+ Port: CALLBACK_PORT,
+ Logger: WEBrick::Log.new(File::NULL),
+ AccessLog: []
+ )
+
+ at_exit { @server&.shutdown }
+
+ @server.mount_proc("/callback") do |req, res|
+ state = req.query["state"]
+ raise "State mismatch" unless state == expected_state
+
+ code = req.query["code"]
+ res.content_type = "text/html"
+ res.body = "<html><body><h1>Authorization complete</h1><p>You can close this window.</p></body></html>"
+ @server.shutdown
+ end
+
+ Timeout.timeout(120) { @server.start }
+ code
+ rescue Timeout::Error
+ @server.shutdown
+ raise "OAuth callback timed out"
+ end
+
+ def exchange_code(metadata, client, code, verifier)
+ uri = URI.parse(metadata["token_endpoint"])
+
+ body = {
+ grant_type: "authorization_code",
+ code: code,
+ redirect_uri: REDIRECT_URI,
+ client_id: client[:client_id],
+ code_verifier: verifier
+ }
+
+ response = post_form(uri, body)
+ JSON.parse(response.body)
+ end
+
+ def fetch_json(url)
+ response = nil
+ @http.get(url) { |r| response = r }
+ JSON.parse(response.body)
+ end
+
+ def post_json(url, body)
+ response = nil
+ @http.post(
+ url,
+ headers: { "Content-Type" => "application/json" },
+ body: body.to_json
+ ) { |r| response = r }
+ response
+ end
+
+ def post_form(uri, body)
+ response = nil
+ @http.post(
+ uri.to_s,
+ headers: { "Content-Type" => "application/x-www-form-urlencoded" },
+ body: URI.encode_www_form(body)
+ ) { |r| response = r }
+ response
+ end
+ end
+ end
+end
lib/elelem/mcp/token_storage.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Elelem
+ class MCP
+ class TokenStorage
+ STORAGE_DIR = File.expand_path("~/.config/elelem/tokens")
+
+ def initialize
+ FileUtils.mkdir_p(STORAGE_DIR, mode: 0o700)
+ end
+
+ def save(resource_url, access_token:, refresh_token: nil, expires_in: nil)
+ data = {
+ access_token: access_token,
+ refresh_token: refresh_token,
+ expires_at: expires_in ? Time.now.to_i + expires_in : nil
+ }
+ path = token_path(resource_url)
+ File.write(path, data.to_json)
+ File.chmod(0o600, path)
+ end
+
+ def load(resource_url)
+ path = token_path(resource_url)
+ return nil unless File.exist?(path)
+
+ JSON.parse(File.read(path), symbolize_names: true)
+ rescue JSON::ParserError
+ nil
+ end
+
+ def save_client(resource_url, client_data)
+ path = client_path(resource_url)
+ File.write(path, client_data.to_json)
+ File.chmod(0o600, path)
+ end
+
+ def load_client(resource_url)
+ path = client_path(resource_url)
+ return nil unless File.exist?(path)
+
+ JSON.parse(File.read(path), symbolize_names: true)
+ rescue JSON::ParserError
+ nil
+ end
+
+ private
+
+ def token_path(resource_url)
+ hash = Digest::SHA256.hexdigest(resource_url)[0, 16]
+ File.join(STORAGE_DIR, "#{hash}.json")
+ end
+
+ def client_path(resource_url)
+ hash = Digest::SHA256.hexdigest(resource_url)[0, 16]
+ File.join(STORAGE_DIR, "#{hash}_client.json")
+ end
+ end
+ end
+end
lib/elelem/net/claude.rb
@@ -38,7 +38,7 @@ module Elelem
handle_event(event, tool_calls, &block)
end
- finalize_tool_calls(tool_calls)
+ finalize_tool_calls(tool_calls, &block)
end
private
@@ -72,19 +72,21 @@ module Elelem
case delta["type"]
when "text_delta"
- block.call(content: delta["text"], thinking: nil)
+ block.call(type: "saying", text: delta["text"])
when "thinking_delta"
- block.call(content: nil, thinking: delta["thinking"])
+ block.call(type: "thinking", text: delta["thinking"])
when "input_json_delta"
tool_calls.last[:args] << delta["partial_json"].to_s if tool_calls.any?
end
end
- def finalize_tool_calls(tool_calls)
+ def finalize_tool_calls(tool_calls, &block)
tool_calls.each do |tool_call|
args = tool_call.delete(:args)
tool_call[:arguments] = args.empty? ? {} : JSON.parse(args)
+ block.call(type: "tool_call", id: tool_call[:id], name: tool_call[:name], arguments: tool_call[:arguments])
end
+ tool_calls
end
def stream(messages, system_prompt, tools)
lib/elelem/net/ollama.rb
@@ -35,11 +35,14 @@ module Elelem
message = event["message"] || {}
unless event["done"]
- block.call(content: message["content"], thinking: message["thinking"])
+ block.call(type: "saying", text: message["content"]) if message["content"]
+ block.call(type: "thinking", text: message["thinking"]) if message["thinking"]
end
if message["tool_calls"]
- tool_calls.concat(parse_tool_calls(message["tool_calls"]))
+ parsed = parse_tool_calls(message["tool_calls"])
+ parsed.each { |tc| block.call(type: "tool_call", **tc) }
+ tool_calls.concat(parsed)
end
end
lib/elelem/net/openai.rb
@@ -18,7 +18,7 @@ module Elelem
handle_event(event, tool_calls, &block)
end
- finalize_tool_calls(tool_calls)
+ finalize_tool_calls(tool_calls, &block)
end
private
@@ -30,7 +30,7 @@ module Elelem
def handle_event(event, tool_calls, &block)
delta = event.dig("choices", 0, "delta") || {}
- block.call(content: delta["content"], thinking: nil) if delta["content"]
+ block.call(type: "saying", text: delta["content"]) if delta["content"]
accumulate_tool_calls(delta["tool_calls"], tool_calls) if delta["tool_calls"]
end
@@ -72,13 +72,15 @@ module Elelem
end
end
- def finalize_tool_calls(tool_calls)
+ def finalize_tool_calls(tool_calls, &block)
tool_calls.values.map do |tool_call|
- {
+ result = {
id: tool_call[:id],
name: tool_call[:name],
arguments: JSON.parse(tool_call[:args])
}
+ block.call(type: "tool_call", **result)
+ result
end
end
end
lib/elelem/plugins/builtins.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:builtins) do |agent|
+ agent.commands.register("exit", description: "Exit elelem") { exit(0) }
+
+ agent.commands.register("clear", description: "Clear conversation history") do
+ agent.conversation.clear!
+ agent.terminal.say " → context cleared"
+ end
+
+ agent.commands.register("context", description: "Show conversation context") do |args|
+ messages = agent.context
+
+ case args
+ when nil, ""
+ messages.each_with_index do |msg, i|
+ role = msg[:role]
+ preview = msg[:content].to_s.lines.first&.strip&.slice(0, 60) || ""
+ preview += "..." if msg[:content].to_s.length > 60
+ agent.terminal.say " #{i + 1}. #{role}: #{preview}"
+ end
+ when "json"
+ agent.terminal.say JSON.pretty_generate(messages)
+ when /^\d+$/
+ index = args.to_i - 1
+ if index >= 0 && index < messages.length
+ content = messages[index][:content].to_s
+ agent.terminal.say(agent.terminal.markdown(content))
+ else
+ agent.terminal.say " Invalid index: #{args}"
+ end
+ else
+ agent.terminal.say " Usage: /context [json|<number>]"
+ end
+ end
+
+ strip_ansi = ->(text) do
+ text
+ .gsub(/^Script started.*?\n/, "")
+ .gsub(/\nScript done.*$/, "")
+ .gsub(/\e\].*?(?:\a|\e\\)/, "")
+ .gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
+ .gsub(/\e[PX^_].*?\e\\/, "")
+ .gsub(/\e./, "")
+ .gsub(/[\b]/, "")
+ .gsub(/\r/, "")
+ end
+
+ agent.commands.register("shell", description: "Start interactive shell") do
+ transcript = Tempfile.create do |file|
+ system("script", "-q", file.path, chdir: Dir.pwd)
+ strip_ansi.call(File.read(file.path))
+ end
+ agent.conversation.add(role: "user", content: transcript) unless transcript.strip.empty?
+ end
+
+ agent.commands.register("init", description: "Generate AGENTS.md") do
+ system_prompt = <<~PROMPT
+ AGENTS.md generator. Analyze codebase and write AGENTS.md to project root.
+
+ # AGENTS.md Spec (https://agents.md/)
+ A file providing context and instructions for AI coding agents.
+
+ ## Recommended Sections
+ - Commands: build, test, lint commands
+ - Code Style: conventions, patterns
+ - Architecture: key components and flow
+ - Testing: how to run tests
+
+ ## Process
+ 1. Read README.md if present
+ 2. Identify language (Gemfile, package.json, go.mod)
+ 3. Find test scripts (bin/test, npm test)
+ 4. Check linter configs
+ 5. Write concise AGENTS.md
+
+ Keep it minimal. No fluff.
+ PROMPT
+
+ agent.fork(system_prompt: system_prompt).turn("Generate AGENTS.md for this project")
+ end
+
+ agent.commands.register("reload", description: "Reload plugins and source") do
+ lib_dir = File.expand_path("../..", __dir__)
+ original_verbose, $VERBOSE = $VERBOSE, nil
+ Dir["#{lib_dir}/**/*.rb"].sort.each { |f| load(f) }
+ $VERBOSE = original_verbose
+ agent.toolbox = Elelem::Toolbox.new
+ agent.commands = Elelem::Commands.new
+ Elelem::Plugins.reload!(agent)
+ end
+
+ agent.commands.register("help", description: "Show available commands") do
+ agent.terminal.say agent.commands.names.join(" ")
+ end
+end
lib/elelem/plugins/confirm.rb
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-
-Elelem::Plugins.register(:confirm) do |toolbox|
- toolbox.before("execute") do |args|
- next unless $stdin.tty?
-
- cmd = args["command"]
- $stdout.print " Allow? [Y/n] > "
- answer = $stdin.gets&.strip&.downcase
- raise "User denied permission to execute: #{cmd}" if answer == "n"
- end
-end
lib/elelem/plugins/edit.rb
@@ -1,14 +1,14 @@
# frozen_string_literal: true
-Elelem::Plugins.register(:edit) do |toolbox|
- toolbox.add("edit",
+Elelem::Plugins.register(:edit) do |agent|
+ agent.toolbox.add("edit",
description: "Replace first occurrence of text in file",
params: { path: { type: "string" }, old: { type: "string" }, new: { type: "string" } },
required: ["path", "old", "new"]
) do |a|
path = Pathname.new(a["path"]).expand_path
content = path.read
- toolbox
+ agent.toolbox
.run("write", { "path" => a["path"], "content" => content.sub(a["old"], a["new"]) })
.merge(replaced: a["old"], with: a["new"])
end
lib/elelem/plugins/eval.rb
@@ -1,16 +1,16 @@
# frozen_string_literal: true
-Elelem::Plugins.register(:eval) do |toolbox|
+Elelem::Plugins.register(:eval) do |agent|
description = <<~'DESC'
Evaluate Ruby code. Available API:
name = "search"
- toolbox.add(name, description: "Search using rg", params: { query: { type: "string" } }, required: ["query"], aliases: []) do |args|
- toolbox.run("execute", { "command" => "rg --json -nI -F #{args["query"]}" })
+ agent.toolbox.add(name, description: "Search using rg", params: { query: { type: "string" } }, required: ["query"], aliases: []) do |args|
+ agent.toolbox.run("execute", { "command" => "rg --json -nI -F #{args["query"]}" })
end
DESC
- toolbox.add("eval",
+ agent.toolbox.add("eval",
description: description,
params: { ruby: { type: "string" } },
required: ["ruby"]
lib/elelem/plugins/execute.rb
@@ -1,18 +1,18 @@
# frozen_string_literal: true
-Elelem::Plugins.register(:execute) do |toolbox|
- toolbox.add("execute",
+Elelem::Plugins.register(:execute) do |agent|
+ agent.toolbox.add("execute",
description: "Run shell command (supports pipes and redirections)",
params: { command: { type: "string" } },
required: ["command"],
aliases: ["bash", "sh", "exec", "execute<|channel|>"]
) do |a|
- Elelem.sh("bash", args: ["-c", a["command"]]) { |x| $stdout.print(x) }
+ Elelem.sh("bash", args: ["-c", a["command"]]) { |x| agent.terminal.print(x) }
end
- toolbox.after("execute") do |args, result|
+ agent.toolbox.after("execute") do |args, result|
next if result[:exit_status] == 0
- $stdout.puts toolbox.header("execute", args, state: "x")
+ agent.terminal.say agent.toolbox.header("execute", args, state: "x")
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/mcp.rb
@@ -1,14 +1,20 @@
# frozen_string_literal: true
-Elelem::Plugins.register(:mcp) do |toolbox|
+Elelem::Plugins.register(:mcp) do |agent|
mcp = Elelem::MCP.new
at_exit { mcp.close }
- mcp.tools.each do |name, tool|
- toolbox.add(name,
- description: tool[:description],
- params: tool[:params],
- required: tool[:required],
- &tool[:fn]
- )
+
+ Thread.new do
+ mcp.tools.each do |name, tool|
+ agent.toolbox.add(
+ name,
+ description: tool[:description],
+ params: tool[:params],
+ required: tool[:required],
+ &tool[:fn]
+ )
+ end
+ rescue => e
+ warn "MCP failed: #{e.message}"
end
end
lib/elelem/plugins/permissions.json
@@ -0,0 +1,6 @@
+{
+ "read": "allow",
+ "write": "ask",
+ "edit": "ask",
+ "execute": "ask"
+}
lib/elelem/plugins/read.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
-Elelem::Plugins.register(:read) do |toolbox|
- toolbox.add("read",
+Elelem::Plugins.register(:read) do |agent|
+ agent.toolbox.add("read",
description: "Read file",
params: { path: { type: "string" } },
required: ["path"],
@@ -11,11 +11,11 @@ Elelem::Plugins.register(:read) do |toolbox|
path.exist? ? { content: path.read, path: a["path"] } : { error: "not found" }
end
- toolbox.after("read") do |_, result|
+ agent.toolbox.after("read") do |_, result|
if result[:error]
- $stdout.puts " ! #{result[:error]}"
- elsif !system("bat", "--paging=never", result[:path])
- $stdout.puts result[:content]
+ agent.terminal.say " ! #{result[:error]}"
+ else
+ agent.terminal.display_file(result[:path], fallback: result[:content])
end
end
end
lib/elelem/plugins/task.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:task) do |agent|
+ agent.toolbox.add("task",
+ description: "Delegate subtask to focused agent (complex searches, multi-file analysis)",
+ params: { prompt: { type: "string" } },
+ required: ["prompt"]
+ ) do |a|
+ sub = Elelem::Agent.new(agent.client, toolbox: agent.toolbox, terminal: agent.terminal,
+ system_prompt: "Research agent. Search, analyze, report. Be concise.")
+ sub.turn(a["prompt"])
+ { result: sub.conversation.last[:content] }
+ end
+end
lib/elelem/plugins/tools.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:tools) do |agent|
+ agent.commands.register("tools", description: "List available tools") do
+ agent.toolbox.tools.each_value do |tool|
+ agent.terminal.say ""
+ agent.terminal.say " #{tool.name}"
+ agent.terminal.say " #{tool.description}"
+ tool.params.each { |k, v| agent.terminal.say " #{k}: #{v[:type] || v["type"]}" }
+ agent.terminal.say " aliases: #{tool.aliases.join(", ")}" if tool.aliases.any?
+ end
+ end
+end
lib/elelem/plugins/verify.rb
@@ -27,16 +27,16 @@ module Elelem
end
end
- Plugins.register(:verify) do |toolbox|
- toolbox.add("verify",
+ Plugins.register(:verify) do |agent|
+ agent.toolbox.add("verify",
description: "Verify file syntax and run tests",
params: { path: { type: "string" } },
required: ["path"]
) do |a|
path = a["path"]
Verifiers.for(path).inject({verified: []}) do |memo, cmd|
- $stdout.puts toolbox.header("execute", { "command" => cmd })
- v = toolbox.run("execute", { "command" => cmd })
+ agent.terminal.say agent.toolbox.header("execute", { "command" => cmd })
+ v = agent.toolbox.run("execute", { "command" => cmd })
break v.merge(path: path, command: cmd) if v[:exit_status] != 0
memo[:verified] << cmd
lib/elelem/plugins/write.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
-Elelem::Plugins.register(:write) do |toolbox|
- toolbox.add("write",
+Elelem::Plugins.register(:write) do |agent|
+ agent.toolbox.add("write",
description: "Write file",
params: { path: { type: "string" }, content: { type: "string" } },
required: ["path", "content"],
@@ -12,12 +12,23 @@ Elelem::Plugins.register(:write) do |toolbox|
{ bytes: path.write(a["content"]), path: a["path"] }
end
- toolbox.after("write") do |_, result|
+ 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]
- $stdout.puts " ! #{result[:error]}"
+ agent.terminal.say " ! #{result[:error]}"
else
- system("bat", "--paging=never", result[:path]) || $stdout.puts(" -> #{result[:path]}")
- toolbox.run("verify", { "path" => result[:path] })
+ agent.terminal.display_file(result[:path], fallback: " -> #{result[:path]}")
+ agent.toolbox.run("verify", { "path" => result[:path] })
end
end
end
lib/elelem/plugins/zz_confirm.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:confirm) do |agent|
+ permissions = Elelem::Permissions.new
+
+ agent.toolbox.before do |args, tool_name:|
+ permissions.check(tool_name, args, terminal: agent.terminal)
+ end
+end
lib/elelem/templates/system_prompt.erb
@@ -1,53 +0,0 @@
-Terminal coding agent. Be concise. Verify your work.
-
-# Tools
-- read(path): file contents
-- write(path, content): create/overwrite file
-- execute(command): shell command
-- eval(ruby): execute Ruby code; use to create tools for repetitive tasks
-- task(prompt): delegate complex searches or multi-file analysis to a focused subagent
-
-# Editing
-Use execute(`patch -p1`) for multi-line changes: `echo "DIFF" | patch -p1`
-Use execute(`sed`) for single-line changes: `sed -i'' 's/old/new/' file`
-Use write for new files or full rewrites
-
-# Search
-Use execute(`rg`) for text search: `rg -n "pattern" .`
-Use execute(`fd`) for file discovery: `fd -e rb .`
-Use execute(`sg`) (ast-grep) for structural search: `sg -p 'def $NAME' -l ruby`
-
-# Task Management
-For complex tasks:
-1. State plan before acting
-2. Work through steps one at a time
-3. Summarize what was done
-
-# Long Tasks
-For complex multi-step work, write notes to .elelem/scratch.md
-
-# Policy
-- Explain before non-trivial commands
-- Verify changes (read file, run tests)
-- No interactive flags (-i, -p)
-- Use `man` when you need to understand how to execute a program
-
-# Environment
-pwd: <%= pwd %>
-platform: <%= platform %>
-date: <%= date %>
-self (this agent's source): <%= elelem_source %>
-<%= git_branch %>
-
-# Codebase
-<%= repo_map %>
-<% if agents_md %>
-
-# Project Instructions
-<%= agents_md %>
-<% end %>
-<% if memory %>
-
-# Earlier Context
-<%= memory %>
-<% end %>
lib/elelem/agent.rb
@@ -2,40 +2,16 @@
module Elelem
class Agent
- COMMANDS = %w[/clear /context /init /reload /shell /exit /help].freeze
- MAX_CONTEXT_MESSAGES = 50
- INIT_PROMPT = <<~PROMPT
- AGENTS.md generator. Analyze codebase and write AGENTS.md to project root.
+ attr_reader :conversation, :client, :toolbox, :terminal, :commands
+ attr_writer :terminal, :toolbox, :commands
- # AGENTS.md Spec (https://agents.md/)
- A file providing context and instructions for AI coding agents.
-
- ## Recommended Sections
- - Commands: build, test, lint commands
- - Code Style: conventions, patterns
- - Architecture: key components and flow
- - Testing: how to run tests
-
- ## Process
- 1. Read README.md if present
- 2. Identify language (Gemfile, package.json, go.mod)
- 3. Find test scripts (bin/test, npm test)
- 4. Check linter configs
- 5. Write concise AGENTS.md
-
- Keep it minimal. No fluff.
- PROMPT
-
- attr_reader :history, :client, :toolbox, :terminal
-
- def initialize(client, toolbox, terminal: nil, history: nil, system_prompt: nil)
+ def initialize(client, toolbox: Toolbox.new, terminal: nil, system_prompt: nil, commands: nil)
@client = client
@toolbox = toolbox
- @terminal = terminal || Terminal.new(commands: COMMANDS)
- @history = history || []
+ @commands = commands || Commands.new
+ @terminal = terminal
+ @conversation = Conversation.new
@system_prompt = system_prompt
- @memory = nil
- register_task_tool
end
def repl
@@ -49,27 +25,21 @@ module Elelem
end
def command(input)
- case input
- when "/exit" then exit(0)
- when "/init" then init_agents_md
- when "/reload" then reload_source!
- when "/shell"
- transcript = start_shell
- history << { role: "user", content: transcript } unless transcript.strip.empty?
- when "/clear"
- @history = []
- @memory = nil
- terminal.say " → context cleared"
- when "/context"
- terminal.say JSON.pretty_generate(combined_history)
- else
- terminal.say COMMANDS.join(" ")
- end
+ parts = input.delete_prefix("/").split(" ", 2)
+ name, args = parts[0], parts[1]
+ commands.run(name, args) || terminal.say(commands.names.join(" "))
+ end
+
+ def context
+ @conversation.to_a(system_prompt: system_prompt)
+ end
+
+ def fork(system_prompt:)
+ Agent.new(client, toolbox: toolbox, terminal: terminal, system_prompt: system_prompt)
end
def turn(input)
- compact_if_needed
- history << { role: "user", content: input }
+ @conversation.add(role: "user", content: input)
ctx = []
content = nil
@@ -85,7 +55,7 @@ module Elelem
end
end
- history << { role: "assistant", content: content }
+ @conversation.add(role: "assistant", content: content)
content
end
@@ -97,90 +67,29 @@ module Elelem
toolbox.run(name.to_s, args)
end
- def register_task_tool
- @toolbox.add("task",
- description: "Delegate subtask to focused agent (complex searches, multi-file analysis)",
- params: { prompt: { type: "string" } },
- required: ["prompt"]
- ) do |a|
- sub = Agent.new(client, toolbox, terminal: terminal,
- system_prompt: "Research agent. Search, analyze, report. Be concise.")
- sub.turn(a["prompt"])
- { result: sub.history.last[:content] }
- end
- end
-
- def init_agents_md
- sub = Agent.new(client, toolbox, terminal: terminal, system_prompt: INIT_PROMPT)
- sub.turn("Generate AGENTS.md for this project")
- end
-
- def reload_source!
- lib_dir = File.expand_path("..", __dir__)
- original_verbose, $VERBOSE = $VERBOSE, nil
- Dir["#{lib_dir}/**/*.rb"].sort.each { |f| load(f) }
- $VERBOSE = original_verbose
- @toolbox = Toolbox.new
- Plugins.reload!(@toolbox)
- register_task_tool
- end
-
- def start_shell
- Tempfile.create do |file|
- system("script", "-q", file.path, chdir: Dir.pwd)
- strip_ansi(File.read(file.path))
- 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 fetch_response(ctx)
- content = ""
- tool_calls = client.fetch(combined_history + ctx, toolbox.to_a) do |delta|
- content += delta[:content].to_s
- terminal.print(terminal.think(delta[:thinking])) if delta[:thinking]
+ content = String.new
+ tool_calls = []
+
+ client.fetch(@conversation.to_a(system_prompt: system_prompt) + ctx, toolbox.to_a) do |event|
+ case event[:type]
+ when "saying"
+ content << event[:text].to_s
+ when "thinking"
+ terminal.print(terminal.think(event[:text]))
+ when "tool_call"
+ tool_calls << { id: event[:id], name: event[:name], arguments: event[:arguments] }
+ end
end
+
[content, tool_calls]
rescue => e
terminal.say "\n ✗ #{e.message}"
["Error: #{e.message} #{e.backtrace.join("\n")}", []]
end
- def combined_history
- [{ role: "system", content: system_prompt }] + history
- end
-
def system_prompt
- @system_prompt || SystemPrompt.new(memory: @memory).render
- end
-
- def compact_if_needed
- return if history.length <= MAX_CONTEXT_MESSAGES
-
- terminal.say " → compacting context"
- keep = MAX_CONTEXT_MESSAGES / 2
- old = history.first(history.length - keep)
-
- to_summarize = @memory ? [{ role: "memory", content: @memory }, *old] : old
- @memory = summarize(to_summarize)
- @history = history.last(keep)
- end
-
- def summarize(messages)
- text = messages.map { |message| { role: message[:role], content: message[:content] } }.to_json
-
- String.new.tap do |buffer|
- client.fetch([{ role: "user", content: "Summarize key facts:\n#{text}" }], []) do |d|
- buffer << d[:content].to_s
- end
- end
+ @system_prompt || SystemPrompt.new.render
end
end
end
lib/elelem/commands.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Elelem
+ class Commands
+ def initialize
+ @registry = {}
+ end
+
+ def register(name, description: "", &handler)
+ @registry[name] = { description: description, handler: handler }
+ end
+
+ def run(name, args = nil)
+ entry = @registry[name]
+ return false unless entry
+
+ entry[:handler].arity == 0 ? entry[:handler].call : entry[:handler].call(args)
+ true
+ end
+
+ def names
+ @registry.keys.map { |name| "/#{name}" }
+ end
+
+ def each
+ @registry.each { |name, entry| yield "/#{name}", entry[:description] }
+ end
+
+ def include?(name)
+ @registry.key?(name)
+ end
+ end
+end
lib/elelem/conversation.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Elelem
+ class Conversation
+ ROLES = %w[user assistant tool].freeze
+
+ def initialize
+ @messages = []
+ end
+
+ def add(role:, content:, **extra)
+ raise ArgumentError, "invalid role: #{role}" unless ROLES.include?(role)
+ @messages << { role: role, content: content, **extra }.compact
+ end
+
+ def last = @messages.last
+ def length = @messages.length
+ def clear! = @messages.clear
+
+ def to_a(system_prompt: nil)
+ base = system_prompt ? [{ role: "system", content: system_prompt }] : []
+ base + @messages
+ end
+ end
+end
lib/elelem/mcp.rb
@@ -1,9 +1,18 @@
# frozen_string_literal: true
+require_relative "mcp/token_storage"
+require_relative "mcp/oauth"
+
module Elelem
+ # https://modelcontextprotocol.io/specification/2025-11-25/server/tools.md
class MCP
- def initialize(config_path = ".mcp.json")
- @config = File.exist?(config_path) ? JSON.parse(IO.read(config_path)) : {}
+ CONFIG_PATHS = [
+ "~/.elelem/mcp.json",
+ ".elelem/mcp.json"
+ ].freeze
+
+ def initialize(configurations = CONFIG_PATHS)
+ @config = load_config(configurations)
@servers = {}
end
@@ -29,44 +38,83 @@ module Elelem
private
+ def load_config(configurations)
+ configurations.each_with_object({}) do |path, merged|
+ file = File.expand_path(path)
+ next unless File.exist?(file)
+
+ config = JSON.parse(IO.read(file))
+ servers = config.fetch("mcpServers", {})
+ merged["mcpServers"] = (merged["mcpServers"] || {}).merge(servers)
+ end
+ end
+
def server(name)
- @servers[name] ||= Server.new(**@config.dig("mcpServers", name).transform_keys(&:to_sym))
+ @servers[name] ||= build_server(@config.dig("mcpServers", name))
end
- class Server
- def initialize(command:, args: [], env: {})
- resolved_env = env.transform_values { |v| v.gsub(/\$\{(\w+)\}/) { ENV[$1] } }
- @stdin, @stdout, @stderr, @wait = Open3.popen3(resolved_env, command, *args)
- @id = 0
- initialize!
+ def build_server(config)
+ if config["type"] == "http"
+ HttpServer.new(url: config["url"], headers: config["headers"] || {})
+ else
+ Server.new(**config.transform_keys(&:to_sym))
end
+ end
+ module ServerInterface
def tools
request("tools/list")["tools"]
end
def call(name, args)
result = request("tools/call", { name: name, arguments: args })
- { content: result["content"]&.map { |c| c["text"] }&.join("\n") }
+ logger.info({ tool: name, args: args, result: result }.to_json)
+ content = extract_content(result)
+ result["isError"] ? { error: content } : { content: content }
end
- def close
- @stdin.close rescue nil
- @stdout.close rescue nil
- @stderr.close rescue nil
- @wait.kill rescue nil
+ def extract_content(result)
+ if (structured = result["structuredContent"])
+ structured
+ else
+ result["content"]&.map { |c| c["text"] }&.join("\n")
+ end
+ end
+
+ def logger
+ @logger ||= Logger.new(File.expand_path("~/.elelem/mcp.log"))
end
private
- def initialize!
+ def handshake!
request("initialize", {
- protocolVersion: "2024-11-05",
+ protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "elelem", version: VERSION }
})
notify("notifications/initialized")
end
+ end
+
+ class Server
+ include ServerInterface
+
+ def initialize(command:, args: [], env: {})
+ resolved_env = env.transform_values do |v|
+ v.gsub(/\$\{(\w+)\}/) { ENV[$1] || raise("Missing environment variable: #{$1}") }
+ end
+ @stdin, @stdout, @stderr, @wait = Open3.popen3(resolved_env, command, *args)
+ @id = 0
+ handshake!
+ end
+
+ def close
+ [@stdin, @stdout, @stderr].each { |io| io.close rescue nil }
+ @wait.kill rescue nil
+ end
+
+ private
def request(method, params = {})
send_msg(id: @id += 1, method: method, params: params)
@@ -92,5 +140,104 @@ module Elelem
end
end
end
+
+ class HttpServer
+ include ServerInterface
+
+ def initialize(url:, headers: {}, http: Elelem::Net.http)
+ @url = url
+ @headers = resolve_headers(headers)
+ @http = http
+ @id = 0
+ @session_id = nil
+ @access_token = nil
+ handshake!
+ end
+
+ def close
+ end
+
+ private
+
+ def resolve_headers(headers)
+ headers.transform_values do |v|
+ v.gsub(/\$\{(\w+)\}/) do
+ ENV[$1] || raise("Missing environment variable: #{$1}")
+ end
+ end
+ end
+
+ def request(method, params = {})
+ msg = { jsonrpc: "2.0", id: @id += 1, method: method, params: params }
+ response = post(msg)
+ raise response["error"]["message"] if response["error"]
+ response["result"]
+ end
+
+ def notify(method, params = {})
+ msg = { jsonrpc: "2.0", method: method, params: params }
+ post(msg)
+ end
+
+ def post(msg, retry_auth: true)
+ result = nil
+ needs_auth = false
+ error = nil
+
+ @http.post(@url, headers: request_headers, body: msg) do |response|
+ case response
+ when ::Net::HTTPSuccess
+ @session_id ||= response["Mcp-Session-Id"]
+ result = parse_response(response)
+ when ::Net::HTTPUnauthorized
+ needs_auth = true
+ else
+ error = "HTTP #{response.code}: #{response.body}"
+ end
+ end
+
+ raise error if error
+ if needs_auth
+ raise "Authorization failed" unless retry_auth
+
+ @access_token = OAuth.new(@url, http: @http).token
+ return post(msg, retry_auth: false)
+ end
+ result
+ end
+
+ def request_headers
+ base = { "Accept" => "application/json, text/event-stream" }
+ base["Mcp-Session-Id"] = @session_id if @session_id
+ base["Authorization"] = "Bearer #{@access_token}" if @access_token
+ @headers.merge(base)
+ end
+
+ def parse_response(response)
+ if response.content_type&.include?("text/event-stream")
+ parse_sse(response)
+ elsif response.body && !response.body.empty?
+ JSON.parse(response.body)
+ end
+ end
+
+ def parse_sse(response)
+ buffer = String.new
+ result = nil
+
+ response.read_body do |chunk|
+ buffer << chunk
+
+ while (index = buffer.index("\n"))
+ line = buffer.slice!(0, index + 1).strip
+ next unless line.start_with?("data: ")
+
+ result = JSON.parse(line.delete_prefix("data: "))
+ end
+ end
+
+ result
+ end
+ end
end
end
lib/elelem/net.rb
@@ -1,8 +1,5 @@
# frozen_string_literal: true
-require "net/hippie"
-require "json"
-
require_relative "net/ollama"
require_relative "net/openai"
require_relative "net/claude"
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? [Y/n] > ")&.downcase
+ raise "User denied permission: #{tool_name}" if answer == "n"
+
+ true
+ end
+ end
+end
lib/elelem/plugins.rb
@@ -8,15 +8,15 @@ module Elelem
".elelem/plugins"
].freeze
- def self.setup!(toolbox)
+ def self.setup!(agent)
load_plugins
- registry.each_value { |plugin| plugin.call(toolbox) }
+ registry.each_value { |plugin| plugin.call(agent) }
end
- def self.reload!(toolbox)
- @registry = {}
+ def self.reload!(agent)
+ registry.clear
load_plugins
- registry.each_value { |plugin| plugin.call(toolbox) }
+ registry.each_value { |plugin| plugin.call(agent) }
end
def self.load_plugins
@@ -33,7 +33,7 @@ module Elelem
end
def self.register(name, &block)
- (@registry ||= {})[name] = block
+ registry[name] = block
end
def self.registry
lib/elelem/system_prompt.rb
@@ -2,56 +2,150 @@
module Elelem
class SystemPrompt
- TEMPLATE_PATH = File.expand_path("templates/system_prompt.erb", __dir__)
+ TEMPLATE = <<~ERB
+ Terminal coding agent. Be concise. Verify your work.
- attr_reader :memory
+ # Tools
+ - read(path): file contents
+ - write(path, content): create/overwrite file
+ - execute(command): shell command
+ - eval(ruby): execute Ruby code; use to create tools for repetitive tasks
+ - task(prompt): delegate complex searches or multi-file analysis to a focused subagent
- def initialize(memory: nil)
- @memory = memory
- end
+ # Editing
+ Use execute(`patch -p1`) for multi-line changes: `echo "DIFF" | patch -p1`
+ Use execute(`sed`) for single-line changes: `sed -i'' 's/old/new/' file`
+ Use write for new files or full rewrites
+
+ # Search
+ Use execute(`rg`) for text search: `rg -n "pattern" .`
+ Use execute(`fd`) for file discovery: `fd -e rb .`
+ Use execute(`sg`) (ast-grep) for structural search: `sg -p 'def $NAME' -l ruby`
+
+ # Task Management
+ For complex tasks:
+ 1. State plan before acting
+ 2. Work through steps one at a time
+ 3. Summarize what was done
+
+ # Long Tasks
+ For complex multi-step work, write notes to .elelem/scratch.md
+
+ # Policy
+ - Explain before non-trivial commands
+ - Verify changes (read file, run tests)
+ - No interactive flags (-i, -p)
+ - Use `man` when you need to understand how to execute a program
+
+ # Environment
+ pwd: <%= pwd %>
+ platform: <%= platform %>
+ date: <%= date %>
+ self: <%= elelem_source %>
+ <%= git_info %>
+
+ <% if repo_map && !repo_map.empty? %>
+ # Codebase
+ ```
+ <%= repo_map %>```
+ <% end %>
+ <%= agents_md %>
+ ERB
def render
- ERB.new(template, trim_mode: "-").result(binding)
+ ERB.new(TEMPLATE, trim_mode: "-").result(binding)
end
private
- def template
- File.read(TEMPLATE_PATH)
+ def pwd = Dir.pwd
+ def platform = RUBY_PLATFORM.split("-").last
+ def date = Date.today
+
+ def elelem_source
+ spec = Gem.loaded_specs["elelem"]
+ spec ? spec.gem_dir : File.expand_path("../..", __dir__)
end
- def pwd
- Dir.pwd
+ def git_info
+ return unless File.exist?(".git")
+ "branch: #{`git branch --show-current`.strip}"
+ rescue Errno::ENOENT
+ nil
end
- def elelem_source
- File.expand_path("../..", __dir__)
+ def repo_map
+ files = `git ls-files '*.rb' 2>/dev/null`.lines.map(&:strip)
+ return "" if files.empty?
+
+ symbols = extract_symbols(files)
+ format_symbols(symbols, budget: 2000)
end
- def platform
- RUBY_PLATFORM.split("-").last
+ def extract_symbols(files)
+ output, status = Open3.capture2("sg", "run", "-p", "def $NAME", "-l", "ruby", "--json=compact", ".", err: File::NULL)
+ return ctags_fallback(files) unless status.success?
+
+ parse_sg_output(output, files)
end
- def date
- Date.today
+ def parse_sg_output(output, tracked_files)
+ JSON.parse(output).filter_map do |match|
+ file = match["file"]
+ next unless tracked_files.include?(file)
+ { file: file, name: match.dig("metaVariables", "single", "NAME", "text") }
+ end
+ rescue JSON::ParserError
+ []
end
- def git_branch
- return unless File.exist?(".git")
+ def ctags_fallback(files)
+ return [] if files.empty?
- "branch: #{`git branch --show-current`.strip}"
- rescue
- nil
+ output = IO.popen(["ctags", "-x", "--languages=Ruby", "--kinds-Ruby=cfm", "-L", "-"], "r+") do |io|
+ io.puts(files)
+ io.close_write
+ io.read
+ end
+
+ output.lines.map do |line|
+ parts = line.split(/\s+/, 4)
+ { file: parts[2], name: parts[0] }
+ end
+ rescue Errno::ENOENT
+ []
end
- def repo_map
- `ctags -x --sort=no --languages=Ruby,Python,JavaScript,TypeScript,Go,Rust -R . 2>/dev/null`
- .lines
- .reject { |l| l.include?("vendor/") || l.include?("node_modules/") || l.include?("spec/") }
- .first(100)
- .join
- rescue
- ""
+ def format_symbols(symbols, budget:)
+ tree = build_tree(symbols)
+ render_tree(tree, budget: budget)
+ end
+
+ def build_tree(symbols)
+ tree = {}
+ symbols.group_by { |s| s[:file] }.each do |file, syms|
+ parts = file.split("/")
+ node = tree
+ parts[0..-2].each { |dir| node = (node[dir + "/"] ||= {}) }
+ node[parts.last] = syms.map { |s| s[:name] }.uniq
+ end
+ tree
+ end
+
+ def render_tree(node, indent: 0, budget:, result: String.new)
+ node.each do |key, value|
+ if value.is_a?(Hash)
+ line = " " * indent + key + "\n"
+ return result if result.length + line.length > budget
+ result << line
+ render_tree(value, indent: indent + 1, budget: budget, result: result)
+ else
+ line = " " * indent + key.sub(/\.rb$/, "") + ": " + value.join(" ") + "\n"
+ return result if result.length + line.length > budget
+ result << line
+ end
+ end
+ result
end
def agents_md
lib/elelem/terminal.rb
@@ -51,6 +51,12 @@ module Elelem
n.times { $stdout.puts("") }
end
+ def display_file(path, fallback: nil)
+ return if @quiet
+
+ system("bat", "--paging=never", path) || say(fallback || path)
+ end
+
def waiting
return if @quiet
@@ -66,7 +72,7 @@ module Elelem
private
def blank?(text)
- text.nil? || text.strip.empty?
+ text.nil? || text.to_s.strip.empty?
end
def stop_dots
lib/elelem/tool.rb
@@ -7,11 +7,12 @@ module Elelem
def initialize(name, description:, params: {}, required: [], aliases: [], &fn)
@name = name
@description = description
- @params = params
- @required = required
- @aliases = aliases
+ @params = params.freeze
+ @required = required.freeze
+ @aliases = aliases.freeze
@fn = fn
- @schema = JSONSchemer.schema(schema_hash)
+ @schema_hash = { type: "object", properties: @params, required: @required }.freeze
+ @schema = JSONSchemer.schema(@schema_hash)
end
def call(args)
@@ -30,19 +31,9 @@ module Elelem
function: {
name: name,
description: description,
- parameters: schema_hash
+ parameters: @schema_hash
}
}
end
-
- private
-
- def schema_hash
- {
- type: "object",
- properties: params,
- required: required
- }
- end
end
end
lib/elelem/toolbox.rb
@@ -16,17 +16,19 @@ module Elelem
tool.aliases.each { |a| @aliases[a] = name }
end
- def before(tool_name, &block)
+ def before(tool_name = :*, &block)
@hooks[:before][tool_name] << block
end
- def after(tool_name, &block)
+ def after(tool_name = :*, &block)
@hooks[:after][tool_name] << block
end
def header(name, args, state: "+")
- name = tool_for(name)&.name || "#{name}?"
- "\n#{state} #{name}(#{args})"
+ tool = tool_for(name)
+ color = tool ? "36" : "33"
+ name = tool&.name || "#{name}?"
+ "\n#{state} \e[#{color}m#{name}\e[0m(#{args})"
end
def run(name, args)
@@ -36,14 +38,21 @@ module Elelem
errors = tool.validate(args)
return failure(error: errors.join(", ")) if errors.any?
+ @hooks[:before][:*].each { |h| h.call(args, tool_name: tool.name) }
@hooks[:before][tool.name].each { |h| h.call(args) }
result = tool.call(args)
+ @hooks[:after][:*].each { |h| h.call(args, result, tool_name: tool.name) }
@hooks[:after][tool.name].each { |h| h.call(args, result) }
result[:error] ? failure(result) : success(result)
rescue => e
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/version.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Elelem
- VERSION = "0.9.2"
+ VERSION = "0.10.0"
end
\ No newline at end of file
lib/elelem.rb
@@ -1,19 +1,30 @@
# frozen_string_literal: true
+require "base64"
require "date"
+require "digest"
require "erb"
require "fileutils"
require "json"
require "json_schemer"
+require "logger"
+require "net/hippie"
require "open3"
+require "optparse"
require "pathname"
require "reline"
+require "securerandom"
require "stringio"
require "tempfile"
+require "uri"
+require "webrick"
require_relative "elelem/agent"
+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"
@@ -37,14 +48,94 @@ module Elelem
end
def self.start(client, toolbox: Toolbox.new)
- Plugins.setup!(toolbox)
- Agent.new(client, toolbox).repl
+ agent = Agent.new(client, toolbox: toolbox)
+ Plugins.setup!(agent)
+ agent.terminal = Terminal.new(commands: agent.commands.names)
+ agent.repl
end
def self.ask(client, prompt, toolbox: Toolbox.new)
- Plugins.setup!(toolbox)
- agent = Agent.new(client, toolbox, terminal: Terminal.new(quiet: true))
+ agent = Agent.new(client, toolbox: toolbox, terminal: Terminal.new(quiet: true))
+ Plugins.setup!(agent)
agent.turn(prompt)
- agent.history.last[:content]
+ agent.conversation.last[:content]
+ end
+
+ class CLI
+ MODELS = {
+ "ollama" => "gpt-oss:latest",
+ "anthropic" => "claude-opus-4-5-20250514",
+ "vertex" => "claude-opus-4-5@20251101",
+ "openai" => "gpt-4o"
+ }.freeze
+
+ PROVIDERS = {
+ "ollama" => ->(model) { Elelem::Net::Ollama.new(model: model, host: ENV.fetch("OLLAMA_HOST", "localhost:11434")) },
+ "anthropic" => ->(model) { Elelem::Net::Claude.anthropic(model: model, api_key: ENV.fetch("ANTHROPIC_API_KEY")) },
+ "vertex" => ->(model) { Elelem::Net::Claude.vertex(model: model, project: ENV.fetch("GOOGLE_CLOUD_PROJECT"), region: ENV.fetch("GOOGLE_CLOUD_REGION", "us-east5")) },
+ "openai" => ->(model) { Elelem::Net::OpenAI.new(model: model, api_key: ENV.fetch("OPENAI_API_KEY")) }
+ }.freeze
+
+ def initialize(args)
+ @provider = "ollama"
+ @model = nil
+ @args = parse(args)
+ end
+
+ def run
+ command = @args.shift || "chat"
+ send(command.tr("-", "_"))
+ rescue NoMethodError
+ abort "Unknown command: #{command}"
+ end
+
+ private
+
+ def parse(args)
+ @parser = OptionParser.new do |o|
+ o.banner = "Usage: elelem [command] [options] [args]"
+ o.separator "\nCommands:"
+ o.separator " chat Interactive REPL (default)"
+ o.separator " ask <prompt> One-shot query (reads stdin if piped)"
+ o.separator " files Output files as XML (no options)"
+ o.separator " help Show this help"
+ o.separator "\nOptions:"
+ o.on("-p", "--provider NAME", "ollama, anthropic, vertex, openai") { |p| @provider = p }
+ o.on("-m", "--model NAME", "Override default model") { |m| @model = m }
+ o.on("-h", "--help") { puts o; exit }
+ end
+ @parser.parse!(args)
+ end
+
+ def help
+ puts @parser
+ end
+
+ def client
+ model = @model || MODELS.fetch(@provider)
+ PROVIDERS.fetch(@provider).call(model)
+ end
+
+ def chat
+ Elelem.start(client)
+ end
+
+ def ask
+ abort "Usage: elelem ask <prompt>" if @args.empty?
+ prompt = @args.join(" ")
+ prompt = "#{prompt}\n\n```\n#{$stdin.read}\n```" if $stdin.stat.pipe?
+ Elelem::Terminal.new.markdown Elelem.ask(client, prompt)
+ end
+
+ def 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}"><source>#{path}</source><content><![CDATA[#{File.read(path)}]]></content></document>}
+ end
+ puts "</documents>"
+ end
end
end
spec/elelem/mcp/http_server_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::MCP::HttpServer do
+ let(:url) { "https://mcp.example.com/sse" }
+ let(:http) { double("http") }
+
+ describe "#parse_sse" do
+ subject { described_class.allocate }
+
+ it "parses single SSE event" do
+ response = double
+ allow(response).to receive(:read_body).and_yield("data: {\"result\": \"ok\"}\n\n")
+
+ result = subject.send(:parse_sse, response)
+ expect(result).to eq({ "result" => "ok" })
+ end
+
+ it "parses chunked SSE events" do
+ response = double
+ chunks = ["data: {\"id\"", ": 1}\n\ndata: {\"id\": 2}\n\n"]
+ allow(response).to receive(:read_body) do |&block|
+ chunks.each { |c| block.call(c) }
+ end
+
+ result = subject.send(:parse_sse, response)
+ expect(result).to eq({ "id" => 2 })
+ end
+
+ it "ignores non-data lines" do
+ response = double
+ allow(response).to receive(:read_body).and_yield("event: message\ndata: {\"value\": 42}\n\n")
+
+ result = subject.send(:parse_sse, response)
+ expect(result).to eq({ "value" => 42 })
+ end
+
+ it "returns last event when multiple present" do
+ response = double
+ allow(response).to receive(:read_body).and_yield("data: {\"n\": 1}\n\ndata: {\"n\": 2}\n\n")
+
+ result = subject.send(:parse_sse, response)
+ expect(result).to eq({ "n" => 2 })
+ end
+ end
+
+ describe "#parse_response" do
+ subject { described_class.allocate }
+
+ it "parses JSON response" do
+ response = double(content_type: "application/json", body: '{"tools": []}')
+ result = subject.send(:parse_response, response)
+ expect(result).to eq({ "tools" => [] })
+ end
+
+ it "parses SSE response" do
+ response = double(content_type: "text/event-stream")
+ allow(response).to receive(:read_body).and_yield("data: {\"ok\": true}\n\n")
+
+ result = subject.send(:parse_response, response)
+ expect(result).to eq({ "ok" => true })
+ end
+
+ it "returns nil for empty body" do
+ response = double(content_type: "application/json", body: "")
+ result = subject.send(:parse_response, response)
+ expect(result).to be_nil
+ end
+ end
+
+ describe "#request_headers" do
+ subject do
+ server = described_class.allocate
+ server.instance_variable_set(:@headers, {})
+ server.instance_variable_set(:@session_id, nil)
+ server.instance_variable_set(:@access_token, nil)
+ server
+ end
+
+ it "includes Accept header" do
+ headers = subject.send(:request_headers)
+ expect(headers["Accept"]).to eq("application/json, text/event-stream")
+ end
+
+ it "includes session ID when set" do
+ subject.instance_variable_set(:@session_id, "abc123")
+ headers = subject.send(:request_headers)
+ expect(headers["Mcp-Session-Id"]).to eq("abc123")
+ end
+
+ it "includes authorization when access_token set" do
+ subject.instance_variable_set(:@access_token, "token123")
+ headers = subject.send(:request_headers)
+ expect(headers["Authorization"]).to eq("Bearer token123")
+ end
+ end
+end
spec/elelem/mcp/oauth_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::MCP::OAuth do
+ let(:resource_url) { "https://mcp.example.com/sse" }
+ let(:http) { double("http") }
+ let(:storage) { instance_double(Elelem::MCP::TokenStorage) }
+
+ subject { described_class.new(resource_url, http: http) }
+
+ before do
+ allow(Elelem::MCP::TokenStorage).to receive(:new).and_return(storage)
+ end
+
+ describe "#token" do
+ context "with valid cached token" do
+ it "returns cached access_token" do
+ allow(storage).to receive(:load).with(resource_url).and_return({
+ access_token: "cached_token",
+ expires_at: Time.now.to_i + 3600
+ })
+
+ expect(subject.token).to eq("cached_token")
+ end
+ end
+
+ context "with expired token and refresh_token" do
+ let(:auth_metadata) do
+ {
+ "authorization_endpoint" => "https://auth.example.com/authorize",
+ "token_endpoint" => "https://auth.example.com/token",
+ "registration_endpoint" => "https://auth.example.com/register"
+ }
+ end
+
+ it "refreshes using refresh_token" do
+ allow(storage).to receive(:load).with(resource_url).and_return({
+ access_token: "old_token",
+ refresh_token: "refresh_abc",
+ expires_at: Time.now.to_i - 100
+ })
+
+ allow(storage).to receive(:load_client).with(resource_url).and_return({
+ client_id: "elelem-123"
+ })
+
+ resource_response = double(body: { "authorization_servers" => ["https://auth.example.com"] }.to_json)
+ auth_response = double(body: auth_metadata.to_json)
+ token_response = double(body: { "access_token" => "new_token", "expires_in" => 3600 }.to_json)
+
+ allow(http).to receive(:get).with("https://mcp.example.com/.well-known/oauth-protected-resource").and_yield(resource_response)
+ allow(http).to receive(:get).with("https://auth.example.com/.well-known/oauth-authorization-server").and_yield(auth_response)
+ allow(http).to receive(:post).and_yield(token_response)
+ allow(storage).to receive(:save)
+
+ expect(subject.token).to eq("new_token")
+ end
+ end
+ end
+
+ describe "PKCE generation" do
+ it "generates valid verifier and challenge" do
+ verifier, challenge = subject.send(:generate_pkce)
+
+ expect(verifier.length).to be >= 43
+ expect(challenge.length).to be >= 43
+ expect(challenge).not_to include("+", "/", "=")
+
+ expected_challenge = Base64.urlsafe_encode64(
+ Digest::SHA256.digest(verifier),
+ padding: false
+ )
+ expect(challenge).to eq(expected_challenge)
+ end
+ end
+
+ describe "expiration check" do
+ it "considers token expired when within 60 seconds of expiry" do
+ stored = { expires_at: Time.now.to_i + 30 }
+ expect(subject.send(:expired?, stored)).to be true
+ end
+
+ it "considers token valid when more than 60 seconds remain" do
+ stored = { expires_at: Time.now.to_i + 120 }
+ expect(subject.send(:expired?, stored)).to be false
+ end
+
+ it "considers token valid when no expires_at" do
+ stored = { expires_at: nil }
+ expect(subject.send(:expired?, stored)).to be false
+ end
+ end
+end
spec/elelem/mcp/token_storage_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::MCP::TokenStorage do
+ subject { described_class.new }
+
+ let(:resource_url) { "https://example.com/mcp" }
+ let(:token_dir) { File.expand_path("~/.config/elelem/tokens") }
+
+ after do
+ Dir.glob(File.join(token_dir, "*.json")).each { |f| File.delete(f) rescue nil }
+ end
+
+ describe "#save and #load" do
+ it "stores and retrieves tokens" do
+ subject.save(resource_url, access_token: "abc123", refresh_token: "refresh456", expires_in: 3600)
+
+ stored = subject.load(resource_url)
+ expect(stored[:access_token]).to eq("abc123")
+ expect(stored[:refresh_token]).to eq("refresh456")
+ expect(stored[:expires_at]).to be > Time.now.to_i
+ end
+
+ it "returns nil for unknown resource" do
+ expect(subject.load("https://unknown.com")).to be_nil
+ end
+
+ it "handles missing refresh_token" do
+ subject.save(resource_url, access_token: "abc123")
+
+ stored = subject.load(resource_url)
+ expect(stored[:access_token]).to eq("abc123")
+ expect(stored[:refresh_token]).to be_nil
+ end
+ end
+
+ describe "#save_client and #load_client" do
+ it "stores and retrieves client registration" do
+ client_data = { "client_id" => "elelem-123", "client_secret" => nil }
+ subject.save_client(resource_url, client_data)
+
+ stored = subject.load_client(resource_url)
+ expect(stored[:client_id]).to eq("elelem-123")
+ end
+
+ it "returns nil for unknown client" do
+ expect(subject.load_client("https://unknown.com")).to be_nil
+ end
+ end
+
+ describe "file permissions" do
+ it "creates token files with 0600 permissions" do
+ subject.save(resource_url, access_token: "secret")
+
+ files = Dir.glob(File.join(token_dir, "*.json"))
+ expect(files).not_to be_empty
+ files.each do |f|
+ mode = File.stat(f).mode & 0o777
+ expect(mode).to eq(0o600)
+ end
+ end
+ end
+end
spec/elelem/commands_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Commands do
+ subject { described_class.new }
+
+ describe "#register" do
+ it "registers a command with handler" do
+ called = false
+ subject.register("test") { called = true }
+ subject.run("test")
+ expect(called).to be true
+ end
+
+ it "stores description" do
+ subject.register("test", description: "Test command") { }
+ expect(subject.include?("test")).to be true
+ end
+ end
+
+ describe "#run" do
+ it "returns true when command exists" do
+ subject.register("test") { }
+ expect(subject.run("test")).to be true
+ end
+
+ it "returns false when command does not exist" do
+ expect(subject.run("nonexistent")).to be false
+ end
+ end
+
+ describe "#names" do
+ it "returns command names with slash prefix" do
+ subject.register("exit") { }
+ subject.register("help") { }
+ expect(subject.names).to contain_exactly("/exit", "/help")
+ end
+ end
+
+ describe "#include?" do
+ it "returns true for registered commands" do
+ subject.register("test") { }
+ expect(subject.include?("test")).to be true
+ end
+
+ it "returns false for unregistered commands" do
+ expect(subject.include?("test")).to be false
+ end
+ end
+end
spec/elelem/conversation_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Conversation do
+ subject { described_class.new }
+
+ describe "#add" do
+ it "appends messages" do
+ subject.add(role: "user", content: "hello")
+ subject.add(role: "assistant", content: "hi")
+ expect(subject.length).to eq(2)
+ end
+
+ it "validates role" do
+ expect { subject.add(role: "invalid", content: "test") }.to raise_error(ArgumentError, /invalid role/)
+ end
+
+ it "accepts user role" do
+ expect { subject.add(role: "user", content: "test") }.not_to raise_error
+ end
+
+ it "accepts assistant role" do
+ expect { subject.add(role: "assistant", content: "test") }.not_to raise_error
+ end
+
+ it "accepts tool role" do
+ expect { subject.add(role: "tool", content: "test") }.not_to raise_error
+ end
+ end
+
+ describe "#last" do
+ it "returns the last message" do
+ subject.add(role: "user", content: "hello")
+ subject.add(role: "assistant", content: "hi")
+ expect(subject.last[:content]).to eq("hi")
+ end
+ end
+
+ describe "#clear!" do
+ it "removes all messages" do
+ subject.add(role: "user", content: "hello")
+ subject.clear!
+ expect(subject.length).to eq(0)
+ end
+ end
+
+ describe "#to_a" do
+ it "returns messages without system prompt by default" do
+ subject.add(role: "user", content: "hello")
+ expect(subject.to_a).to eq([{ role: "user", content: "hello" }])
+ end
+
+ it "includes system prompt when provided" do
+ subject.add(role: "user", content: "hello")
+ result = subject.to_a(system_prompt: "You are helpful.")
+ expect(result.first).to eq({ role: "system", content: "You are helpful." })
+ expect(result.last).to eq({ role: "user", content: "hello" })
+ end
+ end
+end
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,55 @@ 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
+
+ describe "hooks" do
+ it "runs tool-specific before hooks" do
+ called = false
+ subject.before("read") { |_args| called = true }
+ subject.run("read", { "path" => __FILE__ })
+ expect(called).to be true
+ end
+
+ it "runs tool-specific after hooks" do
+ result_seen = nil
+ subject.after("read") { |_args, result| result_seen = result }
+ subject.run("read", { "path" => __FILE__ })
+ expect(result_seen[:content]).to include("RSpec.describe")
+ end
+
+ it "runs global before hooks with tool_name" do
+ tool_names = []
+ subject.before { |_args, tool_name:| tool_names << tool_name }
+ subject.run("read", { "path" => __FILE__ })
+ subject.run("write", { "path" => "/dev/null", "content" => "" })
+ expect(tool_names).to eq(["read", "write"])
+ end
+
+ it "runs global after hooks with tool_name" do
+ tool_names = []
+ subject.after { |_args, _result, tool_name:| tool_names << tool_name }
+ subject.run("read", { "path" => __FILE__ })
+ expect(tool_names).to eq(["read"])
+ end
+
+ it "runs global hooks before tool-specific hooks" do
+ order = []
+ subject.before { |_args, tool_name:| order << :global }
+ subject.before("read") { |_args| order << :specific }
+ subject.run("read", { "path" => __FILE__ })
+ expect(order).to eq([:global, :specific])
+ end
+ end
end
.gitignore
@@ -13,7 +13,6 @@
*.log
target/
*.gem
-.mcp.json
# rspec failure tracking
.rspec_status
CHANGELOG.md
@@ -1,3 +1,32 @@
+## [0.10.0] - 2026-01-27
+
+### Added
+- **Async MCP loading** for faster startup - tools load in background thread
+- **HTTP MCP servers** with SSE support and session management
+- **OAuth authentication** for MCP servers with PKCE, automatic token refresh
+- **Global hooks** - `toolbox.before`/`toolbox.after` without tool name applies to all tools
+- **`/context` improvements**: `/context <n>` to view entry, `/context json` for full dump
+- **ast-grep (`sg`) support** for building repo maps - faster and more accurate than ctags
+- **New tools**: `glob`, `grep`, `list`, `git`, `task`, `/tools` command
+- **Permissions system** (`lib/elelem/permissions.rb`) for tool access control
+- **OpenAI reasoning mode** - enables `Reasoning: high` for o-series models
+- **Test coverage** for OAuth, token storage, HTTP MCP, SSE parsing, global hooks
+
+### Changed
+- **BREAKING: Plugin API** - plugins now receive `agent` instead of `toolbox`
+ - Old: `Elelem::Plugins.register(:name) { |toolbox| toolbox.add(...) }`
+ - New: `Elelem::Plugins.register(:name) { |agent| agent.toolbox.add(...) }`
+ - Plugins can now access `agent.terminal`, `agent.commands`, `agent.conversation`
+- Extracted `Conversation` class from `Agent` for better separation of concerns
+- Extracted `Commands` class for slash command handling
+- Refactored LLM fetch interface to emit separate events for thinking/content/tool_calls
+- Simplified system prompt with inline ERB template
+- Renamed confirm plugin to `zz_confirm` to ensure it loads last
+- MCP logs now write to `~/.elelem/mcp.log` instead of working directory
+- Tool schema now frozen to prevent mutation
+- Uses `Open3.capture2` instead of backticks for thread safety
+- Improved ANSI escape sequence stripping in `/shell` transcripts
+
## [0.9.2] - 2026-01-22
### Fixed
elelem.gemspec
@@ -27,22 +27,34 @@ 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",
"lib/elelem/net.rb",
"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",
"lib/elelem/tool.rb",
"lib/elelem/toolbox.rb",
@@ -52,17 +64,23 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]
+ spec.add_dependency "base64", "~> 0.1"
spec.add_dependency "date", "~> 3.0"
+ spec.add_dependency "digest", "~> 3.0"
spec.add_dependency "erb", "~> 6.0"
spec.add_dependency "fileutils", "~> 1.0"
spec.add_dependency "json", "~> 2.0"
spec.add_dependency "json_schemer", "~> 2.0"
+ spec.add_dependency "logger", "~> 1.0"
spec.add_dependency "net-hippie", "~> 1.0"
spec.add_dependency "open3", "~> 0.1"
spec.add_dependency "optparse", "~> 0.1"
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"
+ spec.add_dependency "webrick", "~> 1.9"
end
Gemfile.lock
@@ -1,20 +1,26 @@
PATH
remote: .
specs:
- elelem (0.9.2)
+ elelem (0.10.0)
+ base64 (~> 0.1)
date (~> 3.0)
+ digest (~> 3.0)
erb (~> 6.0)
fileutils (~> 1.0)
json (~> 2.0)
json_schemer (~> 2.0)
+ logger (~> 1.0)
net-hippie (~> 1.0)
open3 (~> 0.1)
optparse (~> 0.1)
pathname (~> 0.1)
reline (~> 0.6)
+ securerandom (~> 0.1)
+ shellwords (~> 0.2)
stringio (~> 3.0)
tempfile (~> 0.3)
uri (~> 1.0)
+ webrick (~> 1.9)
GEM
remote: https://rubygems.org/
@@ -23,6 +29,7 @@ GEM
bigdecimal (4.0.1)
date (3.5.1)
diff-lcs (1.6.2)
+ digest (3.2.1)
erb (6.0.1)
fileutils (1.8.0)
hana (1.3.7)
@@ -77,11 +84,14 @@ GEM
diff-lcs (>= 1.2.0, < 2.0)
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)
tsort (0.2.0)
uri (1.1.1)
+ webrick (1.9.2)
PLATFORMS
ruby
Rakefile
@@ -5,15 +5,4 @@ require "rspec/core/rake_task"
RSpec::Core::RakeTask.new(:spec)
-task :files do
- IO.popen(%w[git ls-files], chdir: __dir__, err: IO::NULL) do |ls|
- ls.readlines.each do |f|
- next if f.start_with?(*%w[bin/ spec/ pkg/ .git .rspec Gemfile Rakefile])
- next if f.strip.end_with?(*%w[.toml .txt .md])
-
- puts f
- end
- end
-end
-
task default: %i[spec]
README.md
@@ -31,17 +31,18 @@ Elelem relies on several external tools. Install the ones you need:
| Tool | Purpose | Install |
|------|---------|---------|
-| [Ollama](https://ollama.ai/) | Default LLM provider | https://ollama.ai/download |
-| [glow](https://github.com/charmbracelet/glow) | Markdown rendering | `brew install glow` / `go install github.com/charmbracelet/glow@latest` |
+| [ast-grep](https://ast-grep.github.io/) | Structural search (`sg`) | `brew install ast-grep` / `cargo install ast-grep` |
| [ctags](https://ctags.io/) | Repo map generation | `brew install universal-ctags` / `apt install universal-ctags` |
-| [ripgrep](https://github.com/BurntSushi/ripgrep) | Text search (`rg`) | `brew install ripgrep` / `apt install ripgrep` |
| [fd](https://github.com/sharkdp/fd) | File discovery | `brew install fd` / `apt install fd-find` |
-| [ast-grep](https://ast-grep.github.io/) | Structural search (`sg`) | `brew install ast-grep` / `cargo install ast-grep` |
-| [Git](https://git-scm.com/) | Version control | `brew install git` / `apt install git` |
+| [git](https://git-scm.com/) | Version control | `brew install git` / `apt install git` |
+| [glow](https://github.com/charmbracelet/glow) | Markdown rendering | `brew install glow` / `go install github.com/charmbracelet/glow@latest` |
+| [jq](https://jqlang.github.io/jq/) | JSON processing | `brew install jq` / `apt install jq` |
+| [ollama](https://ollama.ai/) | Default LLM provider | https://ollama.ai/download |
+| [ripgrep](https://github.com/BurntSushi/ripgrep) | Text search (`rg`) | `brew install ripgrep` / `apt install ripgrep` |
**Required:** Git, Ollama (or another LLM provider)
-**Recommended:** glow, ctags, ripgrep, fd
+**Recommended:** glow, jq, ctags, ripgrep, fd
**Optional:** ast-grep (for structural code search)
@@ -128,18 +129,94 @@ Each provider reads its configuration from environment variables:
* **Conversation History** – persists across turns; can be cleared.
* **Context Dump** – `/context` shows the current conversation state.
-## Toolbox Overview
-
-The `Toolbox` class is defined in `lib/elelem/toolbox.rb`. It supplies
-three tools, each represented by a JSON schema that the LLM can call.
+## Tools
+
+Built-in tools available to the LLM:
+
+| Tool | Purpose | Parameters |
+| --------- | -------------------------- | ------------------------- |
+| `read` | Read file contents | `path` |
+| `write` | Write file | `path`, `content` |
+| `edit` | Replace text in file | `path`, `old`, `new` |
+| `execute` | Run shell command | `command` |
+| `eval` | Execute Ruby code | `ruby` |
+| `glob` | Find files by pattern | `pattern`, `path` |
+| `grep` | Search file contents | `pattern`, `path`, `glob` |
+| `list` | List directory | `path`, `recursive` |
+| `git` | Run git command | `command`, `args` |
+| `task` | Delegate to sub-agent | `prompt` |
+| `verify` | Check syntax and run tests | `path` |
+
+Aliases: `bash`, `sh`, `exec` → `execute`; `open` → `read`; `ls` → `list`
+
+## Plugins
+
+Plugins extend elelem with custom tools and commands. They are loaded from:
+- `lib/elelem/plugins/` (built-in)
+- `~/.elelem/plugins/` (user global)
+- `.elelem/plugins/` (project local)
+
+### Writing a Plugin
+
+```ruby
+# ~/.elelem/plugins/hello.rb
+Elelem::Plugins.register(:hello) do |agent|
+ # Add a tool
+ agent.toolbox.add("hello",
+ description: "Say hello",
+ params: { name: { type: "string" } },
+ required: ["name"]
+ ) do |args|
+ { message: "Hello, #{args["name"]}!" }
+ end
+
+ # Add a command
+ agent.commands.register("greet", description: "Greet the user") do
+ agent.terminal.say "Hello!"
+ end
+
+ # Add hooks
+ agent.toolbox.before("execute") { |args| puts "Running: #{args["command"]}" }
+ agent.toolbox.after("execute") { |args, result| puts "Exit: #{result[:exit_status]}" }
+
+ # Global hook (runs for all tools)
+ agent.toolbox.before { |args, tool_name:| puts "Calling #{tool_name}" }
+end
+```
-| Tool | Purpose | Parameters |
-| --------- | ------------------ | ------------------ |
-| `read` | Read file contents | `path` |
-| `write` | Write file | `path`, `content` |
-| `execute` | Run shell command | `command` |
+### Plugin API
+
+Plugins receive an `agent` object with access to:
+- `agent.toolbox` - add tools, register hooks
+- `agent.terminal` - output to the user (`say`, `ask`, `markdown`)
+- `agent.commands` - register slash commands
+- `agent.conversation` - access message history
+- `agent.client` - the LLM client
+- `agent.fork(system_prompt:)` - create a sub-agent
+
+## MCP Configuration
+
+Configure MCP servers in `~/.elelem/mcp.json` or `.elelem/mcp.json`:
+
+```json
+{
+ "mcpServers": {
+ "gitlab": {
+ "command": "npx",
+ "args": ["-y", "@anthropics/gitlab-mcp"],
+ "env": {
+ "GITLAB_TOKEN": "${GITLAB_TOKEN}"
+ }
+ },
+ "remote": {
+ "type": "http",
+ "url": "https://mcp.example.com/sse"
+ }
+ }
+}
+```
-Aliases: `bash`, `sh`, `exec` → `execute`; `open` → `read`
+HTTP servers support OAuth authentication automatically.
## Known Limitations