elelem 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +93 -16
  4. data/Rakefile +0 -11
  5. data/exe/elelem +2 -79
  6. data/lib/elelem/agent.rb +33 -124
  7. data/lib/elelem/commands.rb +33 -0
  8. data/lib/elelem/conversation.rb +25 -0
  9. data/lib/elelem/mcp/oauth.rb +217 -0
  10. data/lib/elelem/mcp/token_storage.rb +60 -0
  11. data/lib/elelem/mcp.rb +164 -17
  12. data/lib/elelem/net/claude.rb +6 -4
  13. data/lib/elelem/net/ollama.rb +5 -2
  14. data/lib/elelem/net/openai.rb +6 -4
  15. data/lib/elelem/net.rb +0 -3
  16. data/lib/elelem/permissions.rb +45 -0
  17. data/lib/elelem/plugins/builtins.rb +96 -0
  18. data/lib/elelem/plugins/edit.rb +3 -3
  19. data/lib/elelem/plugins/eval.rb +4 -4
  20. data/lib/elelem/plugins/execute.rb +5 -5
  21. data/lib/elelem/plugins/git.rb +20 -0
  22. data/lib/elelem/plugins/glob.rb +13 -0
  23. data/lib/elelem/plugins/grep.rb +21 -0
  24. data/lib/elelem/plugins/list.rb +14 -0
  25. data/lib/elelem/plugins/mcp.rb +14 -8
  26. data/lib/elelem/plugins/permissions.json +6 -0
  27. data/lib/elelem/plugins/read.rb +6 -6
  28. data/lib/elelem/plugins/task.rb +14 -0
  29. data/lib/elelem/plugins/tools.rb +13 -0
  30. data/lib/elelem/plugins/verify.rb +4 -4
  31. data/lib/elelem/plugins/write.rb +17 -6
  32. data/lib/elelem/plugins/zz_confirm.rb +9 -0
  33. data/lib/elelem/plugins.rb +6 -6
  34. data/lib/elelem/system_prompt.rb +123 -29
  35. data/lib/elelem/terminal.rb +7 -1
  36. data/lib/elelem/tool.rb +6 -15
  37. data/lib/elelem/toolbox.rb +13 -4
  38. data/lib/elelem/version.rb +1 -1
  39. data/lib/elelem.rb +96 -5
  40. metadata +99 -3
  41. data/lib/elelem/plugins/confirm.rb +0 -12
  42. data/lib/elelem/templates/system_prompt.erb +0 -53
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class MCP
5
+ class OAuth
6
+ CALLBACK_PORT = 18273
7
+ REDIRECT_URI = "http://127.0.0.1:#{CALLBACK_PORT}/callback"
8
+
9
+ def initialize(resource_url, http: Elelem::Net.http)
10
+ @resource_url = resource_url
11
+ @http = http
12
+ @storage = TokenStorage.new
13
+ end
14
+
15
+ def token
16
+ stored = @storage.load(@resource_url)
17
+ return stored[:access_token] if stored && !expired?(stored)
18
+ return refresh(stored[:refresh_token]) if stored&.dig(:refresh_token)
19
+
20
+ authorize
21
+ end
22
+
23
+ private
24
+
25
+ def expired?(stored)
26
+ return false unless stored[:expires_at]
27
+
28
+ Time.now.to_i >= stored[:expires_at] - 60
29
+ end
30
+
31
+ def authorize
32
+ metadata = discover_auth_server
33
+ client = load_or_register_client(metadata)
34
+ verifier, challenge = generate_pkce
35
+ state = SecureRandom.hex(16)
36
+
37
+ auth_url = build_auth_url(metadata, client, challenge, state)
38
+ open_browser(auth_url)
39
+
40
+ code = wait_for_callback(state)
41
+ tokens = exchange_code(metadata, client, code, verifier)
42
+
43
+ @storage.save(
44
+ @resource_url,
45
+ access_token: tokens["access_token"],
46
+ refresh_token: tokens["refresh_token"],
47
+ expires_in: tokens["expires_in"]
48
+ )
49
+
50
+ tokens["access_token"]
51
+ end
52
+
53
+ def refresh(refresh_token)
54
+ metadata = discover_auth_server
55
+ client = load_or_register_client(metadata)
56
+ uri = URI.parse(metadata["token_endpoint"])
57
+
58
+ body = {
59
+ grant_type: "refresh_token",
60
+ refresh_token: refresh_token,
61
+ client_id: client[:client_id]
62
+ }
63
+
64
+ response = post_form(uri, body)
65
+ tokens = JSON.parse(response.body)
66
+
67
+ @storage.save(
68
+ @resource_url,
69
+ access_token: tokens["access_token"],
70
+ refresh_token: tokens["refresh_token"] || refresh_token,
71
+ expires_in: tokens["expires_in"]
72
+ )
73
+
74
+ tokens["access_token"]
75
+ rescue StandardError => e
76
+ warn "Token refresh failed: #{e.message}"
77
+ authorize
78
+ end
79
+
80
+ def discover_auth_server
81
+ resource_uri = URI.parse(@resource_url)
82
+ metadata_url = "#{resource_uri.scheme}://#{resource_uri.host}/.well-known/oauth-protected-resource"
83
+
84
+ resource_metadata = fetch_json(metadata_url)
85
+ auth_server_url = resource_metadata["authorization_servers"]&.first
86
+ raise "No authorization server found" unless auth_server_url
87
+
88
+ auth_metadata_url = "#{auth_server_url}/.well-known/oauth-authorization-server"
89
+ fetch_json(auth_metadata_url)
90
+ end
91
+
92
+ def load_or_register_client(metadata)
93
+ stored = @storage.load_client(@resource_url)
94
+ return stored if stored
95
+
96
+ client = register_client(metadata)
97
+ @storage.save_client(@resource_url, client)
98
+ @storage.load_client(@resource_url)
99
+ end
100
+
101
+ def register_client(metadata)
102
+ endpoint = metadata["registration_endpoint"]
103
+ raise "Dynamic registration not supported" unless endpoint
104
+
105
+ body = {
106
+ client_name: "elelem",
107
+ redirect_uris: [REDIRECT_URI],
108
+ grant_types: %w[authorization_code refresh_token],
109
+ response_types: ["code"],
110
+ token_endpoint_auth_method: "none"
111
+ }
112
+
113
+ response = post_json(endpoint, body)
114
+ JSON.parse(response.body)
115
+ end
116
+
117
+ def generate_pkce
118
+ verifier = SecureRandom.urlsafe_base64(32)
119
+ challenge = Base64.urlsafe_encode64(
120
+ Digest::SHA256.digest(verifier),
121
+ padding: false
122
+ )
123
+ [verifier, challenge]
124
+ end
125
+
126
+ def build_auth_url(metadata, client, challenge, state)
127
+ params = {
128
+ response_type: "code",
129
+ client_id: client[:client_id],
130
+ redirect_uri: REDIRECT_URI,
131
+ scope: metadata["scopes_supported"]&.join(" ") || "openid",
132
+ state: state,
133
+ code_challenge: challenge,
134
+ code_challenge_method: "S256"
135
+ }
136
+
137
+ "#{metadata["authorization_endpoint"]}?#{URI.encode_www_form(params)}"
138
+ end
139
+
140
+ def open_browser(url)
141
+ commands = ["xdg-open", "open", "start"]
142
+ commands.each do |cmd|
143
+ return if system(cmd, url, out: File::NULL, err: File::NULL)
144
+ end
145
+ warn "Open this URL in your browser: #{url}"
146
+ end
147
+
148
+ def wait_for_callback(expected_state)
149
+ code = nil
150
+ @server = WEBrick::HTTPServer.new(
151
+ Port: CALLBACK_PORT,
152
+ Logger: WEBrick::Log.new(File::NULL),
153
+ AccessLog: []
154
+ )
155
+
156
+ at_exit { @server&.shutdown }
157
+
158
+ @server.mount_proc("/callback") do |req, res|
159
+ state = req.query["state"]
160
+ raise "State mismatch" unless state == expected_state
161
+
162
+ code = req.query["code"]
163
+ res.content_type = "text/html"
164
+ res.body = "<html><body><h1>Authorization complete</h1><p>You can close this window.</p></body></html>"
165
+ @server.shutdown
166
+ end
167
+
168
+ Timeout.timeout(120) { @server.start }
169
+ code
170
+ rescue Timeout::Error
171
+ @server.shutdown
172
+ raise "OAuth callback timed out"
173
+ end
174
+
175
+ def exchange_code(metadata, client, code, verifier)
176
+ uri = URI.parse(metadata["token_endpoint"])
177
+
178
+ body = {
179
+ grant_type: "authorization_code",
180
+ code: code,
181
+ redirect_uri: REDIRECT_URI,
182
+ client_id: client[:client_id],
183
+ code_verifier: verifier
184
+ }
185
+
186
+ response = post_form(uri, body)
187
+ JSON.parse(response.body)
188
+ end
189
+
190
+ def fetch_json(url)
191
+ response = nil
192
+ @http.get(url) { |r| response = r }
193
+ JSON.parse(response.body)
194
+ end
195
+
196
+ def post_json(url, body)
197
+ response = nil
198
+ @http.post(
199
+ url,
200
+ headers: { "Content-Type" => "application/json" },
201
+ body: body.to_json
202
+ ) { |r| response = r }
203
+ response
204
+ end
205
+
206
+ def post_form(uri, body)
207
+ response = nil
208
+ @http.post(
209
+ uri.to_s,
210
+ headers: { "Content-Type" => "application/x-www-form-urlencoded" },
211
+ body: URI.encode_www_form(body)
212
+ ) { |r| response = r }
213
+ response
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class MCP
5
+ class TokenStorage
6
+ STORAGE_DIR = File.expand_path("~/.config/elelem/tokens")
7
+
8
+ def initialize
9
+ FileUtils.mkdir_p(STORAGE_DIR, mode: 0o700)
10
+ end
11
+
12
+ def save(resource_url, access_token:, refresh_token: nil, expires_in: nil)
13
+ data = {
14
+ access_token: access_token,
15
+ refresh_token: refresh_token,
16
+ expires_at: expires_in ? Time.now.to_i + expires_in : nil
17
+ }
18
+ path = token_path(resource_url)
19
+ File.write(path, data.to_json)
20
+ File.chmod(0o600, path)
21
+ end
22
+
23
+ def load(resource_url)
24
+ path = token_path(resource_url)
25
+ return nil unless File.exist?(path)
26
+
27
+ JSON.parse(File.read(path), symbolize_names: true)
28
+ rescue JSON::ParserError
29
+ nil
30
+ end
31
+
32
+ def save_client(resource_url, client_data)
33
+ path = client_path(resource_url)
34
+ File.write(path, client_data.to_json)
35
+ File.chmod(0o600, path)
36
+ end
37
+
38
+ def load_client(resource_url)
39
+ path = client_path(resource_url)
40
+ return nil unless File.exist?(path)
41
+
42
+ JSON.parse(File.read(path), symbolize_names: true)
43
+ rescue JSON::ParserError
44
+ nil
45
+ end
46
+
47
+ private
48
+
49
+ def token_path(resource_url)
50
+ hash = Digest::SHA256.hexdigest(resource_url)[0, 16]
51
+ File.join(STORAGE_DIR, "#{hash}.json")
52
+ end
53
+
54
+ def client_path(resource_url)
55
+ hash = Digest::SHA256.hexdigest(resource_url)[0, 16]
56
+ File.join(STORAGE_DIR, "#{hash}_client.json")
57
+ end
58
+ end
59
+ end
60
+ end
data/lib/elelem/mcp.rb CHANGED
@@ -1,9 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "mcp/token_storage"
4
+ require_relative "mcp/oauth"
5
+
3
6
  module Elelem
7
+ # https://modelcontextprotocol.io/specification/2025-11-25/server/tools.md
4
8
  class MCP
5
- def initialize(config_path = ".mcp.json")
6
- @config = File.exist?(config_path) ? JSON.parse(IO.read(config_path)) : {}
9
+ CONFIG_PATHS = [
10
+ "~/.elelem/mcp.json",
11
+ ".elelem/mcp.json"
12
+ ].freeze
13
+
14
+ def initialize(configurations = CONFIG_PATHS)
15
+ @config = load_config(configurations)
7
16
  @servers = {}
8
17
  end
9
18
 
@@ -29,44 +38,83 @@ module Elelem
29
38
 
30
39
  private
31
40
 
41
+ def load_config(configurations)
42
+ configurations.each_with_object({}) do |path, merged|
43
+ file = File.expand_path(path)
44
+ next unless File.exist?(file)
45
+
46
+ config = JSON.parse(IO.read(file))
47
+ servers = config.fetch("mcpServers", {})
48
+ merged["mcpServers"] = (merged["mcpServers"] || {}).merge(servers)
49
+ end
50
+ end
51
+
32
52
  def server(name)
33
- @servers[name] ||= Server.new(**@config.dig("mcpServers", name).transform_keys(&:to_sym))
53
+ @servers[name] ||= build_server(@config.dig("mcpServers", name))
34
54
  end
35
55
 
36
- class Server
37
- def initialize(command:, args: [], env: {})
38
- resolved_env = env.transform_values { |v| v.gsub(/\$\{(\w+)\}/) { ENV[$1] } }
39
- @stdin, @stdout, @stderr, @wait = Open3.popen3(resolved_env, command, *args)
40
- @id = 0
41
- initialize!
56
+ def build_server(config)
57
+ if config["type"] == "http"
58
+ HttpServer.new(url: config["url"], headers: config["headers"] || {})
59
+ else
60
+ Server.new(**config.transform_keys(&:to_sym))
42
61
  end
62
+ end
43
63
 
64
+ module ServerInterface
44
65
  def tools
45
66
  request("tools/list")["tools"]
46
67
  end
47
68
 
48
69
  def call(name, args)
49
70
  result = request("tools/call", { name: name, arguments: args })
50
- { content: result["content"]&.map { |c| c["text"] }&.join("\n") }
71
+ logger.info({ tool: name, args: args, result: result }.to_json)
72
+ content = extract_content(result)
73
+ result["isError"] ? { error: content } : { content: content }
51
74
  end
52
75
 
53
- def close
54
- @stdin.close rescue nil
55
- @stdout.close rescue nil
56
- @stderr.close rescue nil
57
- @wait.kill rescue nil
76
+ def extract_content(result)
77
+ if (structured = result["structuredContent"])
78
+ structured
79
+ else
80
+ result["content"]&.map { |c| c["text"] }&.join("\n")
81
+ end
82
+ end
83
+
84
+ def logger
85
+ @logger ||= Logger.new(File.expand_path("~/.elelem/mcp.log"))
58
86
  end
59
87
 
60
88
  private
61
89
 
62
- def initialize!
90
+ def handshake!
63
91
  request("initialize", {
64
- protocolVersion: "2024-11-05",
92
+ protocolVersion: "2025-06-18",
65
93
  capabilities: {},
66
94
  clientInfo: { name: "elelem", version: VERSION }
67
95
  })
68
96
  notify("notifications/initialized")
69
97
  end
98
+ end
99
+
100
+ class Server
101
+ include ServerInterface
102
+
103
+ def initialize(command:, args: [], env: {})
104
+ resolved_env = env.transform_values do |v|
105
+ v.gsub(/\$\{(\w+)\}/) { ENV[$1] || raise("Missing environment variable: #{$1}") }
106
+ end
107
+ @stdin, @stdout, @stderr, @wait = Open3.popen3(resolved_env, command, *args)
108
+ @id = 0
109
+ handshake!
110
+ end
111
+
112
+ def close
113
+ [@stdin, @stdout, @stderr].each { |io| io.close rescue nil }
114
+ @wait.kill rescue nil
115
+ end
116
+
117
+ private
70
118
 
71
119
  def request(method, params = {})
72
120
  send_msg(id: @id += 1, method: method, params: params)
@@ -92,5 +140,104 @@ module Elelem
92
140
  end
93
141
  end
94
142
  end
143
+
144
+ class HttpServer
145
+ include ServerInterface
146
+
147
+ def initialize(url:, headers: {}, http: Elelem::Net.http)
148
+ @url = url
149
+ @headers = resolve_headers(headers)
150
+ @http = http
151
+ @id = 0
152
+ @session_id = nil
153
+ @access_token = nil
154
+ handshake!
155
+ end
156
+
157
+ def close
158
+ end
159
+
160
+ private
161
+
162
+ def resolve_headers(headers)
163
+ headers.transform_values do |v|
164
+ v.gsub(/\$\{(\w+)\}/) do
165
+ ENV[$1] || raise("Missing environment variable: #{$1}")
166
+ end
167
+ end
168
+ end
169
+
170
+ def request(method, params = {})
171
+ msg = { jsonrpc: "2.0", id: @id += 1, method: method, params: params }
172
+ response = post(msg)
173
+ raise response["error"]["message"] if response["error"]
174
+ response["result"]
175
+ end
176
+
177
+ def notify(method, params = {})
178
+ msg = { jsonrpc: "2.0", method: method, params: params }
179
+ post(msg)
180
+ end
181
+
182
+ def post(msg, retry_auth: true)
183
+ result = nil
184
+ needs_auth = false
185
+ error = nil
186
+
187
+ @http.post(@url, headers: request_headers, body: msg) do |response|
188
+ case response
189
+ when ::Net::HTTPSuccess
190
+ @session_id ||= response["Mcp-Session-Id"]
191
+ result = parse_response(response)
192
+ when ::Net::HTTPUnauthorized
193
+ needs_auth = true
194
+ else
195
+ error = "HTTP #{response.code}: #{response.body}"
196
+ end
197
+ end
198
+
199
+ raise error if error
200
+ if needs_auth
201
+ raise "Authorization failed" unless retry_auth
202
+
203
+ @access_token = OAuth.new(@url, http: @http).token
204
+ return post(msg, retry_auth: false)
205
+ end
206
+ result
207
+ end
208
+
209
+ def request_headers
210
+ base = { "Accept" => "application/json, text/event-stream" }
211
+ base["Mcp-Session-Id"] = @session_id if @session_id
212
+ base["Authorization"] = "Bearer #{@access_token}" if @access_token
213
+ @headers.merge(base)
214
+ end
215
+
216
+ def parse_response(response)
217
+ if response.content_type&.include?("text/event-stream")
218
+ parse_sse(response)
219
+ elsif response.body && !response.body.empty?
220
+ JSON.parse(response.body)
221
+ end
222
+ end
223
+
224
+ def parse_sse(response)
225
+ buffer = String.new
226
+ result = nil
227
+
228
+ response.read_body do |chunk|
229
+ buffer << chunk
230
+
231
+ while (index = buffer.index("\n"))
232
+ line = buffer.slice!(0, index + 1).strip
233
+ next unless line.start_with?("data: ")
234
+
235
+ result = JSON.parse(line.delete_prefix("data: "))
236
+ end
237
+ end
238
+
239
+ result
240
+ end
241
+ end
95
242
  end
96
243
  end
@@ -38,7 +38,7 @@ module Elelem
38
38
  handle_event(event, tool_calls, &block)
39
39
  end
40
40
 
41
- finalize_tool_calls(tool_calls)
41
+ finalize_tool_calls(tool_calls, &block)
42
42
  end
43
43
 
44
44
  private
@@ -72,19 +72,21 @@ module Elelem
72
72
 
73
73
  case delta["type"]
74
74
  when "text_delta"
75
- block.call(content: delta["text"], thinking: nil)
75
+ block.call(type: "saying", text: delta["text"])
76
76
  when "thinking_delta"
77
- block.call(content: nil, thinking: delta["thinking"])
77
+ block.call(type: "thinking", text: delta["thinking"])
78
78
  when "input_json_delta"
79
79
  tool_calls.last[:args] << delta["partial_json"].to_s if tool_calls.any?
80
80
  end
81
81
  end
82
82
 
83
- def finalize_tool_calls(tool_calls)
83
+ def finalize_tool_calls(tool_calls, &block)
84
84
  tool_calls.each do |tool_call|
85
85
  args = tool_call.delete(:args)
86
86
  tool_call[:arguments] = args.empty? ? {} : JSON.parse(args)
87
+ block.call(type: "tool_call", id: tool_call[:id], name: tool_call[:name], arguments: tool_call[:arguments])
87
88
  end
89
+ tool_calls
88
90
  end
89
91
 
90
92
  def stream(messages, system_prompt, tools)
@@ -35,11 +35,14 @@ module Elelem
35
35
  message = event["message"] || {}
36
36
 
37
37
  unless event["done"]
38
- block.call(content: message["content"], thinking: message["thinking"])
38
+ block.call(type: "saying", text: message["content"]) if message["content"]
39
+ block.call(type: "thinking", text: message["thinking"]) if message["thinking"]
39
40
  end
40
41
 
41
42
  if message["tool_calls"]
42
- tool_calls.concat(parse_tool_calls(message["tool_calls"]))
43
+ parsed = parse_tool_calls(message["tool_calls"])
44
+ parsed.each { |tc| block.call(type: "tool_call", **tc) }
45
+ tool_calls.concat(parsed)
43
46
  end
44
47
  end
45
48
 
@@ -18,7 +18,7 @@ module Elelem
18
18
  handle_event(event, tool_calls, &block)
19
19
  end
20
20
 
21
- finalize_tool_calls(tool_calls)
21
+ finalize_tool_calls(tool_calls, &block)
22
22
  end
23
23
 
24
24
  private
@@ -30,7 +30,7 @@ module Elelem
30
30
  def handle_event(event, tool_calls, &block)
31
31
  delta = event.dig("choices", 0, "delta") || {}
32
32
 
33
- block.call(content: delta["content"], thinking: nil) if delta["content"]
33
+ block.call(type: "saying", text: delta["content"]) if delta["content"]
34
34
 
35
35
  accumulate_tool_calls(delta["tool_calls"], tool_calls) if delta["tool_calls"]
36
36
  end
@@ -72,13 +72,15 @@ module Elelem
72
72
  end
73
73
  end
74
74
 
75
- def finalize_tool_calls(tool_calls)
75
+ def finalize_tool_calls(tool_calls, &block)
76
76
  tool_calls.values.map do |tool_call|
77
- {
77
+ result = {
78
78
  id: tool_call[:id],
79
79
  name: tool_call[:name],
80
80
  arguments: JSON.parse(tool_call[:args])
81
81
  }
82
+ block.call(type: "tool_call", **result)
83
+ result
82
84
  end
83
85
  end
84
86
  end
data/lib/elelem/net.rb CHANGED
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/hippie"
4
- require "json"
5
-
6
3
  require_relative "net/ollama"
7
4
  require_relative "net/openai"
8
5
  require_relative "net/claude"
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class Permissions
5
+ LOAD_PATHS = [
6
+ File.expand_path("plugins/permissions.json", __dir__),
7
+ "~/.elelem/permissions.json",
8
+ ".elelem/permissions.json"
9
+ ].freeze
10
+
11
+ def initialize
12
+ @rules = LOAD_PATHS.reduce({}) do |rules, path|
13
+ rules.merge(load_config(File.expand_path(path)))
14
+ end
15
+ end
16
+
17
+ def check(tool_name, args, terminal:)
18
+ policy = @rules[tool_name.to_sym] || :ask
19
+ case policy
20
+ when :allow then true
21
+ when :deny then raise "Permission denied: #{tool_name}"
22
+ when :ask then prompt(tool_name, args, terminal)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def load_config(path)
29
+ return {} unless File.exist?(path)
30
+
31
+ JSON.parse(File.read(path)).transform_keys(&:to_sym).transform_values(&:to_sym)
32
+ rescue JSON::ParserError
33
+ {}
34
+ end
35
+
36
+ def prompt(tool_name, args, terminal)
37
+ return true unless $stdin.tty?
38
+
39
+ answer = terminal.ask(" Allow? [Y/n] > ")&.downcase
40
+ raise "User denied permission: #{tool_name}" if answer == "n"
41
+
42
+ true
43
+ end
44
+ end
45
+ end