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,450 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ module Mcp
5
+ # MCP handler exposing a manage_github_pats tool to create fine-grained
6
+ # GitHub personal access tokens via Safari automation (osascript).
7
+ # Conforms to the Server handler interface: tool_definitions, handles?, call.
8
+ class GithubPatHandler
9
+ include Logging
10
+ include HandlerBase
11
+
12
+ TOOL_NAMES = %w[manage_github_pats].freeze
13
+ VALID_ACTIONS = %w[create].freeze
14
+
15
+ # Bundles PAT creation parameters that travel together through the flow.
16
+ PatRequest = Data.define(:name, :repo, :permissions, :expiration)
17
+
18
+ # Reaction emoji sets for the confirmation flow.
19
+ module Reactions
20
+ APPROVE = %w[+1 white_check_mark].freeze
21
+ DENY = %w[-1].freeze
22
+ ALL = (APPROVE + DENY).freeze
23
+ end
24
+
25
+ # Valid permission names and access levels for fine-grained PATs.
26
+ module Permissions
27
+ NAMES = %w[
28
+ actions administration contents issues pull_requests
29
+ metadata packages workflows environments
30
+ ].freeze
31
+ LEVELS = %w[read write].freeze
32
+ end
33
+
34
+ def initialize(config:, api_client:, safari_adapter: SafariAutomation)
35
+ @config = config
36
+ @api = api_client
37
+ @safari = safari_adapter
38
+ end
39
+
40
+ def tool_definitions
41
+ [tool_definition]
42
+ end
43
+
44
+ def call(name, arguments)
45
+ return unless handles?(name)
46
+
47
+ action = arguments["action"]
48
+ actions_list = VALID_ACTIONS.join(", ")
49
+ return text_content("Error: action is required (#{actions_list})") unless action
50
+ unless VALID_ACTIONS.include?(action)
51
+ return text_content("Error: unknown action '#{action}'. Valid: #{actions_list}")
52
+ end
53
+
54
+ send("handle_#{action}", arguments)
55
+ end
56
+
57
+ private
58
+
59
+ # --- helpers ---
60
+
61
+ def http_success?(response)
62
+ response.is_a?(Net::HTTPSuccess)
63
+ end
64
+
65
+ def error_response?(value)
66
+ value.is_a?(Hash) && value.key?(:content)
67
+ end
68
+
69
+ def text_content(text)
70
+ { content: [{ type: "text", text: text }] }
71
+ end
72
+
73
+ # Request building and validation.
74
+ module RequestValidation
75
+ private
76
+
77
+ def handle_create(arguments)
78
+ request = build_pat_request(arguments)
79
+ return request if error_response?(request)
80
+
81
+ execute_create(request)
82
+ end
83
+
84
+ def build_pat_request(arguments)
85
+ name_error = validate_name(arguments["name"])
86
+ return name_error if name_error
87
+
88
+ repo_error = validate_repo(arguments["repo"])
89
+ return repo_error if repo_error
90
+
91
+ build_validated_request(arguments)
92
+ end
93
+
94
+ def build_validated_request(arguments)
95
+ permissions = validate_and_normalize_permissions(arguments["permissions"])
96
+ return permissions if error_response?(permissions)
97
+
98
+ expiration = parse_expiration(arguments["expiration_days"])
99
+ return expiration if error_response?(expiration)
100
+
101
+ PatRequest.new(name: arguments["name"], repo: arguments["repo"],
102
+ permissions: permissions, expiration: expiration)
103
+ end
104
+
105
+ def validate_name(name)
106
+ return nil if valid_string?(name)
107
+
108
+ text_content("Error: name is required for create")
109
+ end
110
+
111
+ def validate_repo(repo)
112
+ return text_content("Error: repo is required for create (e.g. 'owner/repo')") unless valid_string?(repo)
113
+ return nil if repo.match?(%r{\A[\w.-]+/[\w.-]+\z})
114
+
115
+ text_content("Error: repo must be in 'owner/repo' format")
116
+ end
117
+
118
+ def valid_string?(value)
119
+ value.is_a?(String) && !value.strip.empty?
120
+ end
121
+
122
+ def validate_and_normalize_permissions(permissions)
123
+ unless permissions.is_a?(Hash) && !permissions.empty?
124
+ return text_content("Error: permissions is required (e.g. {\"contents\": \"write\"})")
125
+ end
126
+
127
+ normalized = permissions.each_with_object({}) { |(perm, level), acc| acc[perm.to_s] = level.to_s.downcase }
128
+ error = check_permissions(normalized)
129
+ return text_content("Error: #{error}") if error
130
+
131
+ normalized
132
+ end
133
+
134
+ def check_permissions(permissions)
135
+ permissions.each do |perm, level|
136
+ unless Permissions::NAMES.include?(perm)
137
+ return "unknown permission '#{perm}'. Valid: #{Permissions::NAMES.join(", ")}"
138
+ end
139
+ unless Permissions::LEVELS.include?(level)
140
+ return "invalid access level '#{level}' for '#{perm}'. Valid: #{Permissions::LEVELS.join(", ")}"
141
+ end
142
+ end
143
+ nil
144
+ end
145
+
146
+ def parse_expiration(raw)
147
+ return 365 unless raw
148
+
149
+ value = raw.to_i
150
+ return text_content("Error: expiration_days must be a positive integer") unless value.positive?
151
+
152
+ value
153
+ end
154
+ end
155
+
156
+ include RequestValidation
157
+
158
+ # Safari automation execution.
159
+ module SafariExecution
160
+ private
161
+
162
+ def execute_create(request)
163
+ confirmation = request_create_confirmation(request)
164
+ case confirmation
165
+ when :approved then create_pat(request)
166
+ when :error then text_content("Error: confirmation failed (could not post or connect to Mattermost)")
167
+ else text_content("PAT creation denied by user.")
168
+ end
169
+ end
170
+
171
+ def create_pat(request)
172
+ run_safari_automation(request)
173
+ token = @safari.extract_token
174
+ return token_extraction_error unless token && !token.empty?
175
+
176
+ text_content(format_success(request, token))
177
+ rescue SafariAutomation::Error => error
178
+ error_msg = error.message
179
+ log(:error, "Safari automation failed during PAT creation: #{error_msg}")
180
+ text_content("Error: Safari automation failed — #{error_msg}")
181
+ end
182
+
183
+ def token_extraction_error
184
+ text_content(
185
+ "Error: failed to extract token from page. " \
186
+ "Verify Safari is logged into GitHub and the page loaded correctly."
187
+ )
188
+ end
189
+
190
+ def run_safari_automation(request)
191
+ @safari.navigate("https://github.com/settings/personal-access-tokens/new")
192
+ sleep 2
193
+ @safari.fill_token_name(request.name)
194
+ @safari.apply_expiration(request.expiration)
195
+ @safari.select_repository(request.repo)
196
+ @safari.apply_permissions(request.permissions)
197
+ @safari.click_generate
198
+ @safari.confirm_generation
199
+ end
200
+
201
+ def format_success(request, token)
202
+ perms = request.permissions.map { |perm, level| "#{perm}:#{level}" }.join(", ")
203
+ "PAT created successfully.\n" \
204
+ "- **Name:** #{request.name}\n" \
205
+ "- **Repo:** #{request.repo}\n" \
206
+ "- **Permissions:** #{perms}\n" \
207
+ "- **Expires:** #{request.expiration} days\n" \
208
+ "- **Token:** `#{token}`"
209
+ end
210
+ end
211
+
212
+ include SafariExecution
213
+
214
+ # WebSocket-based reaction polling for PAT confirmation.
215
+ module ConfirmationFlow
216
+ private
217
+
218
+ def request_create_confirmation(request)
219
+ post_id = post_confirmation_request(request)
220
+ return :error unless post_id
221
+
222
+ add_reaction_options(post_id)
223
+ wait_for_confirmation(post_id)
224
+ ensure
225
+ delete_confirmation_post(post_id) if post_id
226
+ end
227
+
228
+ def post_confirmation_request(request)
229
+ message = format_confirmation_message(request)
230
+ response = post_confirmation_to_channel(message)
231
+ return log_confirmation_failure(response) unless http_success?(response)
232
+
233
+ JSON.parse(response.body)["id"]
234
+ rescue IOError, JSON::ParserError, Errno::ECONNREFUSED, Errno::ECONNRESET => error
235
+ log(:error, "Failed to post PAT confirmation: #{error.message}")
236
+ nil
237
+ end
238
+
239
+ def post_confirmation_to_channel(message)
240
+ @api.post("/posts", {
241
+ channel_id: @config.platform_channel_id,
242
+ message: message,
243
+ root_id: @config.platform_thread_id
244
+ })
245
+ end
246
+
247
+ def log_confirmation_failure(response)
248
+ status = extract_http_status(response)
249
+ log(:error, "PAT confirmation post failed (HTTP #{status})")
250
+ nil
251
+ end
252
+
253
+ def extract_http_status(response)
254
+ response.is_a?(Net::HTTPResponse) ? response.code : "unknown"
255
+ end
256
+
257
+ def format_confirmation_message(request)
258
+ perms_list = request.permissions.map { |perm, level| "`#{perm}`: #{level}" }.join(", ")
259
+ ":key: **GitHub PAT Request**\n" \
260
+ "Claude wants to create a fine-grained PAT\n" \
261
+ "- **Name:** #{request.name}\n" \
262
+ "- **Repo:** #{request.repo}\n" \
263
+ "- **Permissions:** #{perms_list}\n" \
264
+ "- **Expiration:** #{request.expiration} days\n" \
265
+ "React: :+1: approve | :-1: deny"
266
+ end
267
+
268
+ def add_reaction_options(post_id)
269
+ Reactions::ALL.each do |emoji|
270
+ response = @api.post("/reactions", {
271
+ user_id: @config.platform_bot_id,
272
+ post_id: post_id,
273
+ emoji_name: emoji
274
+ })
275
+ log(:warn, "Failed to add reaction #{emoji} to post #{post_id}") unless http_success?(response)
276
+ end
277
+ rescue IOError, Errno::ECONNREFUSED, Errno::ECONNRESET => error
278
+ log(:error, "Failed to add reaction options to post #{post_id}: #{error.message}")
279
+ end
280
+ end
281
+
282
+ include ConfirmationFlow
283
+
284
+ # WebSocket polling for PAT confirmation reactions.
285
+ module ConfirmationPolling
286
+ private
287
+
288
+ def wait_for_confirmation(post_id)
289
+ deadline = Time.now + (@config.permission_timeout_ms / 1000.0)
290
+ websocket = connect_websocket
291
+ return :error unless websocket
292
+
293
+ poll_confirmation(websocket, post_id, deadline)
294
+ ensure
295
+ close_websocket(websocket)
296
+ end
297
+
298
+ def close_websocket(websocket)
299
+ websocket&.close
300
+ rescue IOError, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EPIPE => error
301
+ log(:debug, "Failed to close PAT confirmation WebSocket: #{error.message}")
302
+ end
303
+
304
+ def connect_websocket
305
+ websocket = WebSocket::Client::Simple.connect(@config.websocket_url)
306
+ token = @config.platform_token
307
+ ws_ref = websocket
308
+ websocket.on(:open) do
309
+ ws_ref.send(JSON.generate({ seq: 1, action: "authentication_challenge", data: { token: token } }))
310
+ end
311
+ websocket
312
+ rescue IOError, SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH => error
313
+ log(:error, "PAT confirmation WebSocket failed: #{error.message}")
314
+ nil
315
+ end
316
+
317
+ def poll_confirmation(websocket, post_id, deadline)
318
+ queue = setup_reaction_listener(websocket, post_id)
319
+ poll_reaction_loop(deadline, queue)
320
+ end
321
+
322
+ def setup_reaction_listener(websocket, post_id)
323
+ queue = Queue.new
324
+ websocket.on(:message) do |msg|
325
+ reaction = extract_reaction(msg)
326
+ queue.push(reaction) if reaction_matches?(reaction, post_id)
327
+ end
328
+ queue
329
+ end
330
+
331
+ def reaction_matches?(reaction, post_id)
332
+ reaction && reaction["post_id"] == post_id
333
+ end
334
+
335
+ def extract_reaction(msg)
336
+ raw = msg.data
337
+ return unless raw && !raw.empty?
338
+
339
+ parsed = JSON.parse(raw)
340
+ event_name, nested_data = parsed.values_at("event", "data")
341
+ return unless event_name == "reaction_added"
342
+
343
+ JSON.parse(nested_data&.dig("reaction") || "{}")
344
+ rescue JSON::ParserError
345
+ log(:debug, "PAT confirmation: skipped unparsable WebSocket message")
346
+ nil
347
+ end
348
+
349
+ def poll_reaction_loop(deadline, queue)
350
+ loop do
351
+ return :denied if (deadline - Time.now) <= 0
352
+
353
+ reaction = dequeue_reaction(queue)
354
+ next unless reaction
355
+
356
+ result = evaluate_reaction(reaction)
357
+ return result if result
358
+ end
359
+ end
360
+
361
+ def dequeue_reaction(queue)
362
+ queue.pop(true)
363
+ rescue ThreadError
364
+ sleep 0.5
365
+ nil
366
+ end
367
+
368
+ def evaluate_reaction(reaction)
369
+ user_id = reaction["user_id"]
370
+ emoji = reaction["emoji_name"]
371
+ return nil if user_id == @config.platform_bot_id
372
+ return nil unless allowed_reactor?(user_id)
373
+
374
+ return :approved if Reactions::APPROVE.include?(emoji)
375
+
376
+ :denied if Reactions::DENY.include?(emoji)
377
+ end
378
+
379
+ def allowed_reactor?(user_id)
380
+ allowed = @config.allowed_users
381
+ return true if allowed.empty?
382
+
383
+ response = @api.get("/users/#{user_id}")
384
+ return false unless http_success?(response)
385
+
386
+ user = JSON.parse(response.body)
387
+ allowed.include?(user["username"])
388
+ rescue IOError, JSON::ParserError, SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => error
389
+ log(:warn, "Failed to verify reactor #{user_id}: #{error.message}")
390
+ false
391
+ end
392
+
393
+ def delete_confirmation_post(post_id)
394
+ @api.delete("/posts/#{post_id}")
395
+ rescue IOError, SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE => error
396
+ log(:warn, "Failed to delete PAT confirmation post #{post_id}: #{error.message}")
397
+ end
398
+ end
399
+
400
+ include ConfirmationPolling
401
+
402
+ # Tool definition builder.
403
+ module ToolDefinitionBuilder
404
+ private
405
+
406
+ def tool_definition
407
+ {
408
+ name: "manage_github_pats",
409
+ description: pat_tool_description,
410
+ inputSchema: pat_input_schema
411
+ }
412
+ end
413
+
414
+ def pat_tool_description
415
+ "Create fine-grained GitHub personal access tokens via Safari automation. " \
416
+ "Requires Mattermost approval before execution."
417
+ end
418
+
419
+ def pat_input_schema
420
+ {
421
+ type: "object",
422
+ properties: pat_properties,
423
+ required: %w[action]
424
+ }
425
+ end
426
+
427
+ def pat_properties
428
+ {
429
+ action: { type: "string", enum: VALID_ACTIONS, description: "Action to perform" },
430
+ name: { type: "string", description: "Token name (required for create)" },
431
+ repo: { type: "string", description: "Repository in 'owner/repo' format (required for create)" },
432
+ permissions: pat_permissions_property,
433
+ expiration_days: { type: "integer", description: "Token expiration in days (default 365)" }
434
+ }
435
+ end
436
+
437
+ def pat_permissions_property
438
+ {
439
+ type: "object",
440
+ description: "Permission map, e.g. {\"contents\": \"write\", \"issues\": \"read\"}. " \
441
+ "Valid permissions: #{Permissions::NAMES.join(", ")}. Levels: read, write.",
442
+ additionalProperties: { type: "string", enum: Permissions::LEVELS }
443
+ }
444
+ end
445
+ end
446
+
447
+ include ToolDefinitionBuilder
448
+ end
449
+ end
450
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ module Mcp
5
+ # Shared handler interface for MCP tool routing. Including classes define
6
+ # a TOOL_NAMES constant; `handles?` checks membership against it.
7
+ module HandlerBase
8
+ def handles?(name)
9
+ self.class::TOOL_NAMES.include?(name)
10
+ end
11
+ end
12
+ end
13
+ end