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.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +40 -0
- data/CLAUDE.md +260 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +177 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/Rakefile +11 -0
- data/bin/README.md +21 -0
- data/bin/ci +49 -0
- data/bin/claude-context +155 -0
- data/bin/claude-usage +110 -0
- data/bin/coverage +221 -0
- data/bin/rubocop +10 -0
- data/bin/watch-ci +198 -0
- data/config/earl-claude-home/.claude/CLAUDE.md +10 -0
- data/config/earl-claude-home/.claude/settings.json +34 -0
- data/earl-bot.gemspec +42 -0
- data/exe/earl +51 -0
- data/exe/earl-install +129 -0
- data/exe/earl-permission-server +39 -0
- data/lib/earl/claude_session/stats.rb +76 -0
- data/lib/earl/claude_session.rb +468 -0
- data/lib/earl/command_executor/constants.rb +53 -0
- data/lib/earl/command_executor/heartbeat_display.rb +54 -0
- data/lib/earl/command_executor/lifecycle_handler.rb +61 -0
- data/lib/earl/command_executor/session_handler.rb +126 -0
- data/lib/earl/command_executor/spawn_handler.rb +99 -0
- data/lib/earl/command_executor/stats_formatter.rb +66 -0
- data/lib/earl/command_executor/usage_handler.rb +132 -0
- data/lib/earl/command_executor.rb +128 -0
- data/lib/earl/command_parser.rb +57 -0
- data/lib/earl/config.rb +94 -0
- data/lib/earl/cron_parser.rb +105 -0
- data/lib/earl/formatting.rb +14 -0
- data/lib/earl/heartbeat_config.rb +101 -0
- data/lib/earl/heartbeat_scheduler/config_reloading.rb +64 -0
- data/lib/earl/heartbeat_scheduler/execution.rb +105 -0
- data/lib/earl/heartbeat_scheduler/heartbeat_state.rb +41 -0
- data/lib/earl/heartbeat_scheduler/lifecycle.rb +75 -0
- data/lib/earl/heartbeat_scheduler.rb +131 -0
- data/lib/earl/logging.rb +12 -0
- data/lib/earl/mattermost/api_client.rb +85 -0
- data/lib/earl/mattermost.rb +261 -0
- data/lib/earl/mcp/approval_handler.rb +304 -0
- data/lib/earl/mcp/config.rb +62 -0
- data/lib/earl/mcp/github_pat_handler.rb +450 -0
- data/lib/earl/mcp/handler_base.rb +13 -0
- data/lib/earl/mcp/heartbeat_handler.rb +310 -0
- data/lib/earl/mcp/memory_handler.rb +89 -0
- data/lib/earl/mcp/server.rb +123 -0
- data/lib/earl/mcp/tmux_handler.rb +562 -0
- data/lib/earl/memory/prompt_builder.rb +40 -0
- data/lib/earl/memory/store.rb +125 -0
- data/lib/earl/message_queue.rb +56 -0
- data/lib/earl/permission_config.rb +22 -0
- data/lib/earl/question_handler/question_posting.rb +58 -0
- data/lib/earl/question_handler.rb +116 -0
- data/lib/earl/runner/idle_management.rb +44 -0
- data/lib/earl/runner/lifecycle.rb +73 -0
- data/lib/earl/runner/message_handling.rb +121 -0
- data/lib/earl/runner/reaction_handling.rb +42 -0
- data/lib/earl/runner/response_lifecycle.rb +96 -0
- data/lib/earl/runner/service_builder.rb +48 -0
- data/lib/earl/runner/startup.rb +73 -0
- data/lib/earl/runner/thread_context_builder.rb +43 -0
- data/lib/earl/runner.rb +70 -0
- data/lib/earl/safari_automation.rb +497 -0
- data/lib/earl/session_manager/persistence.rb +46 -0
- data/lib/earl/session_manager/session_creation.rb +108 -0
- data/lib/earl/session_manager.rb +92 -0
- data/lib/earl/session_store.rb +84 -0
- data/lib/earl/streaming_response.rb +219 -0
- data/lib/earl/tmux/parsing.rb +80 -0
- data/lib/earl/tmux/processes.rb +34 -0
- data/lib/earl/tmux/sessions.rb +41 -0
- data/lib/earl/tmux.rb +122 -0
- data/lib/earl/tmux_monitor/alert_dispatcher.rb +53 -0
- data/lib/earl/tmux_monitor/output_analyzer.rb +35 -0
- data/lib/earl/tmux_monitor/permission_forwarder.rb +80 -0
- data/lib/earl/tmux_monitor/question_forwarder.rb +124 -0
- data/lib/earl/tmux_monitor.rb +249 -0
- data/lib/earl/tmux_session_store.rb +133 -0
- data/lib/earl/tool_input_formatter.rb +44 -0
- data/lib/earl/version.rb +5 -0
- data/lib/earl.rb +87 -0
- data/lib/tasks/.keep +1 -0
- 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
|