earl-bot 0.1.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 (89) hide show
  1. checksums.yaml +7 -0
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +40 -0
  4. data/CLAUDE.md +260 -0
  5. data/Gemfile +9 -0
  6. data/Gemfile.lock +177 -0
  7. data/LICENSE +21 -0
  8. data/README.md +106 -0
  9. data/Rakefile +11 -0
  10. data/bin/README.md +21 -0
  11. data/bin/ci +49 -0
  12. data/bin/claude-context +155 -0
  13. data/bin/claude-usage +110 -0
  14. data/bin/coverage +221 -0
  15. data/bin/rubocop +10 -0
  16. data/bin/watch-ci +198 -0
  17. data/config/earl-claude-home/.claude/CLAUDE.md +10 -0
  18. data/config/earl-claude-home/.claude/settings.json +34 -0
  19. data/earl-bot.gemspec +42 -0
  20. data/exe/earl +51 -0
  21. data/exe/earl-install +129 -0
  22. data/exe/earl-permission-server +39 -0
  23. data/lib/earl/claude_session/stats.rb +76 -0
  24. data/lib/earl/claude_session.rb +468 -0
  25. data/lib/earl/command_executor/constants.rb +53 -0
  26. data/lib/earl/command_executor/heartbeat_display.rb +54 -0
  27. data/lib/earl/command_executor/lifecycle_handler.rb +61 -0
  28. data/lib/earl/command_executor/session_handler.rb +126 -0
  29. data/lib/earl/command_executor/spawn_handler.rb +99 -0
  30. data/lib/earl/command_executor/stats_formatter.rb +66 -0
  31. data/lib/earl/command_executor/usage_handler.rb +132 -0
  32. data/lib/earl/command_executor.rb +128 -0
  33. data/lib/earl/command_parser.rb +57 -0
  34. data/lib/earl/config.rb +94 -0
  35. data/lib/earl/cron_parser.rb +105 -0
  36. data/lib/earl/formatting.rb +14 -0
  37. data/lib/earl/heartbeat_config.rb +101 -0
  38. data/lib/earl/heartbeat_scheduler/config_reloading.rb +64 -0
  39. data/lib/earl/heartbeat_scheduler/execution.rb +105 -0
  40. data/lib/earl/heartbeat_scheduler/heartbeat_state.rb +41 -0
  41. data/lib/earl/heartbeat_scheduler/lifecycle.rb +75 -0
  42. data/lib/earl/heartbeat_scheduler.rb +131 -0
  43. data/lib/earl/logging.rb +12 -0
  44. data/lib/earl/mattermost/api_client.rb +85 -0
  45. data/lib/earl/mattermost.rb +261 -0
  46. data/lib/earl/mcp/approval_handler.rb +304 -0
  47. data/lib/earl/mcp/config.rb +62 -0
  48. data/lib/earl/mcp/github_pat_handler.rb +450 -0
  49. data/lib/earl/mcp/handler_base.rb +13 -0
  50. data/lib/earl/mcp/heartbeat_handler.rb +310 -0
  51. data/lib/earl/mcp/memory_handler.rb +89 -0
  52. data/lib/earl/mcp/server.rb +123 -0
  53. data/lib/earl/mcp/tmux_handler.rb +562 -0
  54. data/lib/earl/memory/prompt_builder.rb +40 -0
  55. data/lib/earl/memory/store.rb +125 -0
  56. data/lib/earl/message_queue.rb +56 -0
  57. data/lib/earl/permission_config.rb +22 -0
  58. data/lib/earl/question_handler/question_posting.rb +58 -0
  59. data/lib/earl/question_handler.rb +116 -0
  60. data/lib/earl/runner/idle_management.rb +44 -0
  61. data/lib/earl/runner/lifecycle.rb +73 -0
  62. data/lib/earl/runner/message_handling.rb +121 -0
  63. data/lib/earl/runner/reaction_handling.rb +42 -0
  64. data/lib/earl/runner/response_lifecycle.rb +96 -0
  65. data/lib/earl/runner/service_builder.rb +48 -0
  66. data/lib/earl/runner/startup.rb +73 -0
  67. data/lib/earl/runner/thread_context_builder.rb +43 -0
  68. data/lib/earl/runner.rb +70 -0
  69. data/lib/earl/safari_automation.rb +497 -0
  70. data/lib/earl/session_manager/persistence.rb +46 -0
  71. data/lib/earl/session_manager/session_creation.rb +108 -0
  72. data/lib/earl/session_manager.rb +92 -0
  73. data/lib/earl/session_store.rb +84 -0
  74. data/lib/earl/streaming_response.rb +219 -0
  75. data/lib/earl/tmux/parsing.rb +80 -0
  76. data/lib/earl/tmux/processes.rb +34 -0
  77. data/lib/earl/tmux/sessions.rb +41 -0
  78. data/lib/earl/tmux.rb +122 -0
  79. data/lib/earl/tmux_monitor/alert_dispatcher.rb +53 -0
  80. data/lib/earl/tmux_monitor/output_analyzer.rb +35 -0
  81. data/lib/earl/tmux_monitor/permission_forwarder.rb +80 -0
  82. data/lib/earl/tmux_monitor/question_forwarder.rb +124 -0
  83. data/lib/earl/tmux_monitor.rb +249 -0
  84. data/lib/earl/tmux_session_store.rb +133 -0
  85. data/lib/earl/tool_input_formatter.rb +44 -0
  86. data/lib/earl/version.rb +5 -0
  87. data/lib/earl.rb +87 -0
  88. data/lib/tasks/.keep +1 -0
  89. metadata +248 -0
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ module Mcp
5
+ # Handles permission approval flow: posts a permission request to Mattermost,
6
+ # adds reaction options, and waits for a user reaction to approve or deny.
7
+ # Tracks per-tool approvals persisted to a global file (allowed_tools.json)
8
+ # so "always allow" applies across all threads.
9
+ class ApprovalHandler
10
+ include Logging
11
+ include HandlerBase
12
+
13
+ TOOL_NAME = "permission_prompt"
14
+ TOOL_NAMES = [TOOL_NAME].freeze
15
+
16
+ INPUT_FORMATTERS = {
17
+ "Bash" => ->(input) { input["command"].to_s[0..500] },
18
+ "Edit" => lambda { |input|
19
+ "#{input["file_path"]}\n#{input.fetch("new_string", input.fetch("content", ""))[0..300]}"
20
+ },
21
+ "Write" => lambda { |input|
22
+ "#{input["file_path"]}\n#{input.fetch("new_string", input.fetch("content", ""))[0..300]}"
23
+ }
24
+ }.freeze
25
+
26
+ TOOL_SCHEMA = {
27
+ name: TOOL_NAME,
28
+ description: "Request permission to execute a tool",
29
+ inputSchema: {
30
+ type: "object",
31
+ properties: {
32
+ tool_name: { type: "string", description: "Name of the tool requesting permission" },
33
+ input: { type: "object", description: "The tool's input parameters" }
34
+ },
35
+ required: %w[tool_name input]
36
+ }
37
+ }.freeze
38
+
39
+ def self.allowed_tools_path
40
+ @allowed_tools_path ||= File.join(Earl.config_root, "allowed_tools.json")
41
+ end
42
+
43
+ # Bundles tool_name and input that travel together through the approval flow.
44
+ ToolRequest = Data.define(:tool_name, :input)
45
+
46
+ # Reaction emoji sets for the permission approval flow.
47
+ module Reactions
48
+ APPROVE = %w[+1 white_check_mark].freeze
49
+ DENY = %w[-1].freeze
50
+ ALL = %w[+1 white_check_mark -1].freeze
51
+ end
52
+
53
+ def initialize(config:, api_client:)
54
+ @config = config
55
+ @api = api_client
56
+ @allowed_tools = load_allowed_tools
57
+ @mutex = Mutex.new
58
+ end
59
+
60
+ def tool_definitions
61
+ [TOOL_SCHEMA]
62
+ end
63
+
64
+ def call(_name, arguments)
65
+ request = build_request(arguments)
66
+ format_handler_result(handle(request))
67
+ end
68
+
69
+ def handle(request)
70
+ return allow_result(request.input) if auto_approved?(request.tool_name)
71
+
72
+ post_and_wait(request)
73
+ end
74
+
75
+ private
76
+
77
+ def build_request(arguments)
78
+ ToolRequest.new(
79
+ tool_name: arguments["tool_name"] || "unknown",
80
+ input: arguments["input"] || {}
81
+ )
82
+ end
83
+
84
+ def auto_approved?(tool_name)
85
+ return false unless @mutex.synchronize { @allowed_tools.include?(tool_name) }
86
+
87
+ log(:info, "Auto-allowing #{tool_name} (previously approved)")
88
+ true
89
+ end
90
+
91
+ def post_and_wait(request)
92
+ post_id = post_permission_request(request)
93
+ return deny_result("Failed to post permission request") unless post_id
94
+
95
+ add_reaction_options(post_id)
96
+ decision = wait_for_reaction(post_id, request)
97
+ delete_permission_post(post_id)
98
+ decision
99
+ end
100
+
101
+ def allow_result(input)
102
+ { behavior: "allow", updatedInput: input }
103
+ end
104
+
105
+ def deny_result(reason = "Denied")
106
+ { behavior: "deny", message: reason }
107
+ end
108
+
109
+ def format_handler_result(result)
110
+ { content: [{ type: "text", text: JSON.generate(result) }] }
111
+ end
112
+
113
+ # Mattermost posting for permission requests.
114
+ module PermissionPosting
115
+ private
116
+
117
+ def post_permission_request(request)
118
+ message = permission_message(request)
119
+ response = @api.post("/posts", {
120
+ channel_id: @config.platform_channel_id,
121
+ message: message,
122
+ root_id: @config.platform_thread_id
123
+ })
124
+
125
+ return unless response.is_a?(Net::HTTPSuccess)
126
+
127
+ JSON.parse(response.body)["id"]
128
+ end
129
+
130
+ def permission_message(request)
131
+ tool_name = request.tool_name
132
+ input_summary = format_input(tool_name, request.input)
133
+ format_permission_text(tool_name, input_summary)
134
+ end
135
+
136
+ def format_permission_text(tool_name, input_summary)
137
+ ":lock: **Permission Request**\nClaude wants to run: `#{tool_name}`\n```\n#{input_summary}\n```\n" \
138
+ "React: :+1: allow once | :white_check_mark: always allow `#{tool_name}` | :-1: deny"
139
+ end
140
+
141
+ def format_input(tool_name, input)
142
+ formatter = INPUT_FORMATTERS[tool_name]
143
+ formatter ? formatter.call(input) : JSON.generate(input)[0..500]
144
+ end
145
+
146
+ def add_reaction_options(post_id)
147
+ Reactions::ALL.each do |emoji|
148
+ @api.post("/reactions", {
149
+ user_id: @config.platform_bot_id,
150
+ post_id: post_id,
151
+ emoji_name: emoji
152
+ })
153
+ end
154
+ end
155
+
156
+ def delete_permission_post(post_id)
157
+ @api.delete("/posts/#{post_id}")
158
+ rescue StandardError => error
159
+ log(:warn, "Failed to delete permission post #{post_id}: #{error.message}")
160
+ end
161
+ end
162
+
163
+ # WebSocket-based reaction polling for permission decisions.
164
+ module ReactionPolling
165
+ # Bundles poll_for_reaction parameters into a single context object.
166
+ PollContext = Data.define(:ws, :post_id, :request, :deadline)
167
+
168
+ private
169
+
170
+ def wait_for_reaction(post_id, request)
171
+ deadline = Time.now + (@config.permission_timeout_ms / 1000.0)
172
+ websocket = connect_websocket
173
+
174
+ return deny_result("WebSocket connection failed") unless websocket
175
+
176
+ context = PollContext.new(ws: websocket, post_id: post_id, request: request, deadline: deadline)
177
+ poll_for_reaction(context) || deny_result("Timed out waiting for approval")
178
+ ensure
179
+ close_websocket(websocket)
180
+ end
181
+
182
+ def close_websocket(websocket)
183
+ websocket&.close
184
+ rescue StandardError
185
+ nil
186
+ end
187
+
188
+ def connect_websocket
189
+ ws = WebSocket::Client::Simple.connect(@config.websocket_url)
190
+ token = @config.platform_token
191
+ ws_ref = ws
192
+ ws.on(:open) do
193
+ ws_ref.send(JSON.generate({ seq: 1, action: "authentication_challenge", data: { token: token } }))
194
+ end
195
+ ws
196
+ rescue StandardError => error
197
+ log(:error, "MCP WebSocket connect failed: #{error.message}")
198
+ nil
199
+ end
200
+
201
+ def poll_for_reaction(context)
202
+ reaction_queue = Queue.new
203
+ register_reaction_listener(context, reaction_queue)
204
+ poll_reaction_loop(context, reaction_queue)
205
+ end
206
+
207
+ def register_reaction_listener(context, reaction_queue)
208
+ websocket, target_post_id = context.deconstruct
209
+ websocket&.on(:message) do |msg|
210
+ reaction_data = extract_reaction_data(msg.data)
211
+ reaction_queue.push(reaction_data) if reaction_data && reaction_data["post_id"] == target_post_id
212
+ end
213
+ end
214
+
215
+ def extract_reaction_data(data)
216
+ return unless data && !data.empty?
217
+
218
+ parsed = JSON.parse(data)
219
+ event_name, event_data = parsed.values_at("event", "data")
220
+ return unless event_name == "reaction_added"
221
+
222
+ JSON.parse(event_data&.dig("reaction") || "{}")
223
+ rescue JSON::ParserError
224
+ log(:debug, "MCP approval: skipped unparsable WebSocket message")
225
+ nil
226
+ end
227
+
228
+ def poll_reaction_loop(context, reaction_queue)
229
+ loop do
230
+ return nil if (context.deadline - Time.now) <= 0
231
+
232
+ reaction = dequeue_reaction(reaction_queue)
233
+ next unless valid_user_reaction?(reaction)
234
+
235
+ return process_reaction(reaction["emoji_name"], context.request)
236
+ end
237
+ end
238
+
239
+ def valid_user_reaction?(reaction)
240
+ return false unless reaction
241
+
242
+ user_id = reaction["user_id"]
243
+ user_id != @config.platform_bot_id && allowed_reactor?(user_id)
244
+ end
245
+
246
+ def dequeue_reaction(reaction_queue)
247
+ reaction_queue.pop(true)
248
+ rescue ThreadError
249
+ sleep 0.5
250
+ nil
251
+ end
252
+
253
+ def allowed_reactor?(user_id)
254
+ allowed = @config.allowed_users
255
+ return true if allowed.empty?
256
+
257
+ response = @api.get("/users/#{user_id}")
258
+ return false unless response.is_a?(Net::HTTPSuccess)
259
+
260
+ user = JSON.parse(response.body)
261
+ allowed.include?(user["username"])
262
+ end
263
+
264
+ def process_reaction(emoji_name, request)
265
+ if Reactions::APPROVE.include?(emoji_name)
266
+ persist_always_allow(request.tool_name) if emoji_name == "white_check_mark"
267
+ allow_result(request.input)
268
+ elsif Reactions::DENY.include?(emoji_name)
269
+ deny_result("Denied by user")
270
+ end
271
+ end
272
+
273
+ def persist_always_allow(tool_name)
274
+ @mutex.synchronize { @allowed_tools.add(tool_name) }
275
+ save_allowed_tools
276
+ end
277
+ end
278
+
279
+ # Persistence for globally allowed tool list.
280
+ module AllowedToolsPersistence
281
+ private
282
+
283
+ def load_allowed_tools
284
+ path = self.class.allowed_tools_path
285
+ return Set.new unless File.exist?(path)
286
+
287
+ Set.new(JSON.parse(File.read(path)))
288
+ rescue JSON::ParserError, Errno::ENOENT
289
+ Set.new
290
+ end
291
+
292
+ def save_allowed_tools
293
+ File.write(self.class.allowed_tools_path, JSON.generate(@allowed_tools.to_a))
294
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ENOSPC, IOError => error
295
+ log(:warn, "Failed to save allowed tools: #{error.message}")
296
+ end
297
+ end
298
+
299
+ include PermissionPosting
300
+ include ReactionPolling
301
+ include AllowedToolsPersistence
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ module Mcp
5
+ # Reads MCP permission server configuration from environment variables,
6
+ # set by the parent EARL process when spawning Claude with --permission-prompt-tool.
7
+ class Config
8
+ attr_reader :allowed_users, :permission_timeout_ms
9
+
10
+ # Groups platform connection fields into a single struct.
11
+ PlatformConnection = Data.define(:url, :token, :channel_id, :thread_id, :bot_id)
12
+
13
+ def initialize
14
+ @platform = PlatformConnection.new(
15
+ url: required_env("PLATFORM_URL"),
16
+ token: required_env("PLATFORM_TOKEN"),
17
+ channel_id: required_env("PLATFORM_CHANNEL_ID"),
18
+ thread_id: required_env("PLATFORM_THREAD_ID"),
19
+ bot_id: required_env("PLATFORM_BOT_ID")
20
+ )
21
+ @allowed_users = ENV.fetch("ALLOWED_USERS", "").split(",").map(&:strip)
22
+ @permission_timeout_ms = ENV.fetch("PERMISSION_TIMEOUT_MS", "86400000").to_i
23
+ end
24
+
25
+ def platform_url = @platform.url
26
+ def platform_token = @platform.token
27
+ def platform_channel_id = @platform.channel_id
28
+ def platform_thread_id = @platform.thread_id
29
+ def platform_bot_id = @platform.bot_id
30
+
31
+ def api_url(path)
32
+ "#{platform_url}/api/v4#{path}"
33
+ end
34
+
35
+ def websocket_url
36
+ "#{platform_url.sub(%r{^https://}, "wss://").sub(%r{^http://}, "ws://")}/api/v4/websocket"
37
+ end
38
+
39
+ # Build a Config-like object compatible with ApiClient
40
+ def to_api_config
41
+ ApiConfig.new(
42
+ mattermost_url: platform_url,
43
+ bot_token: platform_token,
44
+ bot_id: platform_bot_id
45
+ )
46
+ end
47
+
48
+ # Minimal config struct compatible with ApiClient, built from platform env vars.
49
+ ApiConfig = Struct.new(:mattermost_url, :bot_token, :bot_id, keyword_init: true) do
50
+ def api_url(path)
51
+ "#{mattermost_url}/api/v4#{path}"
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def required_env(key)
58
+ ENV.fetch(key) { raise "Missing required env var: #{key}" }
59
+ end
60
+ end
61
+ end
62
+ end