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,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class CommandExecutor
5
+ # Handles tmux session commands: !sessions, !session <name> show/status/
6
+ # input/nudge/approve/deny/kill.
7
+ module SessionHandler
8
+ PANE_STATUS_LABELS = {
9
+ active: "\u{1F7E2} Active", permission: "\u{1F7E0} Waiting for permission", idle: "\u{1F7E1} Idle"
10
+ }.freeze
11
+
12
+ private
13
+
14
+ def handle_sessions(ctx)
15
+ tmux = @deps.tmux
16
+ return reply(ctx, ":x: tmux is not installed.") unless tmux.available?
17
+
18
+ panes = tmux.list_all_panes
19
+ return reply(ctx, "No tmux sessions running.") if panes.empty?
20
+
21
+ claude_panes = panes.select { |pane| tmux.claude_on_tty?(pane[:tty]) }
22
+ return reply(ctx, "No Claude sessions found across #{panes.size} tmux panes.") if claude_panes.empty?
23
+
24
+ reply(ctx, format_sessions_table(claude_panes))
25
+ end
26
+
27
+ def format_sessions_table(claude_panes)
28
+ rows = claude_panes.map { |pane| format_claude_pane_row(pane) }
29
+ header = [
30
+ "#### :computer: Claude Sessions (#{rows.size})",
31
+ "| Pane | Project | Status |", "|------|---------|--------|"
32
+ ]
33
+ (header + rows).join("\n")
34
+ end
35
+
36
+ def format_claude_pane_row(pane)
37
+ target, path = pane.values_at(:target, :path)
38
+ project = File.basename(path)
39
+ status = detect_pane_status(target)
40
+ label = PANE_STATUS_LABELS.fetch(status, "\u{1F7E1} Idle")
41
+ "| `#{target}` | #{project} | #{label} |"
42
+ end
43
+
44
+ def detect_pane_status(target)
45
+ output = @deps.tmux.capture_pane(target, lines: 20)
46
+ return :permission if output.include?("Do you want to proceed?")
47
+ return :active if output.include?("esc to interrupt")
48
+
49
+ :idle
50
+ rescue Tmux::Error => error
51
+ log(:debug, "detect_pane_status failed for #{target}: #{error.message}")
52
+ :idle
53
+ end
54
+
55
+ def handle_session_show(ctx)
56
+ with_tmux_session(ctx) do
57
+ target = ctx.arg
58
+ output = @deps.tmux.capture_pane(target)
59
+ reply(ctx, "#### :computer: `#{target}` pane output\n```\n#{truncate_output(output)}\n```")
60
+ end
61
+ end
62
+
63
+ def handle_session_status(ctx)
64
+ with_tmux_session(ctx) do
65
+ target = ctx.arg
66
+ output = @deps.tmux.capture_pane(target, lines: 200)
67
+ truncated = truncate_output(output, 3000)
68
+ reply(ctx, "#### :mag: `#{target}` status\n```\n#{truncated}\n```\n_AI summary not yet implemented._")
69
+ end
70
+ end
71
+
72
+ def handle_session_input(ctx)
73
+ with_tmux_session(ctx) do
74
+ target = ctx.arg
75
+ text = ctx.args[1]
76
+ @deps.tmux.send_keys(target, text)
77
+ reply(ctx, ":keyboard: Sent to `#{target}`: `#{text}`")
78
+ end
79
+ end
80
+
81
+ def handle_session_nudge(ctx)
82
+ with_tmux_session(ctx) do
83
+ target = ctx.arg
84
+ @deps.tmux.send_keys(target, "Are you stuck? What's your current status?")
85
+ reply(ctx, ":wave: Nudged `#{target}`.")
86
+ end
87
+ end
88
+
89
+ def handle_session_approve(ctx) = send_tmux_key_action(ctx, "Enter", ":white_check_mark: Approved permission on")
90
+ def handle_session_deny(ctx) = send_tmux_key_action(ctx, "Escape", ":no_entry_sign: Denied permission on")
91
+
92
+ def handle_session_kill(ctx)
93
+ name = ctx.arg
94
+ store = @deps.tmux_store
95
+ @deps.tmux.kill_session(name)
96
+ store&.delete(name)
97
+ reply(ctx, ":skull: Tmux session `#{name}` killed.")
98
+ rescue Tmux::NotFound
99
+ store&.delete(name)
100
+ reply(ctx, ":x: Session `#{name}` not found (cleaned up store).")
101
+ rescue Tmux::Error => error
102
+ reply(ctx, ":x: Error killing session: #{error.message}")
103
+ end
104
+
105
+ def with_tmux_session(ctx)
106
+ yield
107
+ rescue Tmux::NotFound
108
+ reply(ctx, ":x: Session `#{ctx.arg}` not found.")
109
+ rescue Tmux::Error => error
110
+ reply(ctx, ":x: Error: #{error.message}")
111
+ end
112
+
113
+ def truncate_output(output, max_length = 3500)
114
+ output.length > max_length ? "\u2026#{output[-max_length..]}" : output
115
+ end
116
+
117
+ def send_tmux_key_action(ctx, key, message_prefix)
118
+ with_tmux_session(ctx) do
119
+ target = ctx.arg
120
+ @deps.tmux.send_keys_raw(target, key)
121
+ reply(ctx, "#{message_prefix} `#{target}`.")
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class CommandExecutor
5
+ # Handles the !spawn command — creates a new Claude session in tmux.
6
+ module SpawnHandler
7
+ # Bundles spawn parameters extracted from the command.
8
+ SpawnRequest = Struct.new(:name, :prompt, :working_dir, keyword_init: true) do
9
+ def to_session_info(ctx)
10
+ TmuxSessionStore::TmuxSessionInfo.new(
11
+ name: name, channel_id: ctx.channel_id, thread_id: ctx.thread_id,
12
+ working_dir: working_dir, prompt: prompt, created_at: Time.now.iso8601
13
+ )
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def handle_spawn(ctx)
20
+ prompt = ctx.arg
21
+ return reply(ctx, ':x: Usage: `!spawn "prompt" [--name N] [--dir D]`') if prompt.to_s.strip.empty?
22
+
23
+ req = build_spawn_request(prompt, ctx.args[1].to_s)
24
+ validate_and_spawn(ctx, req)
25
+ rescue Tmux::Error => error
26
+ reply(ctx, ":x: Failed to spawn session: #{error.message}")
27
+ end
28
+
29
+ def build_spawn_request(prompt, flags_str)
30
+ parsed = parse_spawn_flags(flags_str)
31
+ name = parsed[:name] || generate_session_name
32
+ SpawnRequest.new(name: name, prompt: prompt, working_dir: parsed[:dir])
33
+ end
34
+
35
+ def generate_session_name
36
+ "earl-#{Time.now.strftime("%Y%m%d%H%M%S")}"
37
+ end
38
+
39
+ def validate_and_spawn(ctx, req)
40
+ name = req.name
41
+ return if spawn_name_invalid?(ctx, name)
42
+ return if spawn_dir_invalid?(ctx, req.working_dir)
43
+ return if spawn_name_taken?(ctx, name)
44
+
45
+ spawn_tmux_session(ctx, req)
46
+ end
47
+
48
+ def spawn_name_invalid?(ctx, name)
49
+ return false unless name.match?(/[.:]/)
50
+
51
+ reply(ctx, ":x: Invalid session name `#{name}`: cannot contain `.` or `:` (tmux reserved).")
52
+ true
53
+ end
54
+
55
+ def spawn_dir_invalid?(ctx, working_dir)
56
+ return false unless working_dir
57
+ return false if Dir.exist?(working_dir)
58
+
59
+ reply(ctx, ":x: Directory not found: `#{working_dir}`")
60
+ true
61
+ end
62
+
63
+ def spawn_name_taken?(ctx, name)
64
+ return false unless @deps.tmux.session_exists?(name)
65
+
66
+ reply(ctx, ":x: Session `#{name}` already exists.")
67
+ true
68
+ end
69
+
70
+ def spawn_tmux_session(ctx, req)
71
+ name = req.name
72
+ command = "claude #{Shellwords.shellescape(req.prompt)}"
73
+ @deps.tmux.create_session(name: name, command: command, working_dir: req.working_dir)
74
+ persist_spawn_info(req.to_session_info(ctx))
75
+ reply(ctx, spawn_success_message(req))
76
+ end
77
+
78
+ def persist_spawn_info(info)
79
+ store = @deps.tmux_store
80
+ store&.save(info)
81
+ end
82
+
83
+ def spawn_success_message(req)
84
+ name = req.name
85
+ ":rocket: Spawned tmux session `#{name}`\n" \
86
+ "- **Prompt:** #{req.prompt}\n" \
87
+ "- **Dir:** #{req.working_dir || Dir.pwd}\n" \
88
+ "Use `!session #{name}` to check output."
89
+ end
90
+
91
+ def parse_spawn_flags(str)
92
+ {
93
+ dir: str[/--dir\s+(\S+)/, 1],
94
+ name: str[/--name\s+(\S+)/, 1]
95
+ }.compact
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class CommandExecutor
5
+ # Formats session statistics for the !stats command.
6
+ module StatsFormatter
7
+ private
8
+
9
+ def format_stats(stats)
10
+ total_in = stats.total_input_tokens
11
+ total_out = stats.total_output_tokens
12
+ cost = stats.total_cost
13
+ header = ["#### :bar_chart: Session Stats", "| Metric | Value |", "|--------|-------|"]
14
+ rows = [format_token_row(total_in, total_out)]
15
+ append_optional_stats(rows, stats)
16
+ rows << format_cost_row(cost)
17
+ (header + rows).join("\n")
18
+ end
19
+
20
+ def append_optional_stats(lines, stats)
21
+ append_context_line(lines, stats)
22
+ append_model_line(lines, stats.model_id)
23
+ append_ttft_line(lines, stats.time_to_first_token)
24
+ append_speed_line(lines, stats.tokens_per_second)
25
+ end
26
+
27
+ def append_context_line(lines, stats)
28
+ pct = stats.context_percent
29
+ return unless pct
30
+
31
+ lines << "| **Context used** | #{format("%.1f%%", pct)} of #{format_number(stats.context_window)} |"
32
+ end
33
+
34
+ def append_model_line(lines, model)
35
+ lines << "| **Model** | `#{model}` |" if model
36
+ end
37
+
38
+ def append_ttft_line(lines, ttft)
39
+ lines << "| **Last TTFT** | #{format("%.1fs", ttft)} |" if ttft
40
+ end
41
+
42
+ def append_speed_line(lines, tps)
43
+ lines << "| **Last speed** | #{format("%.0f", tps)} tok/s |" if tps
44
+ end
45
+
46
+ def format_persisted_stats(persisted)
47
+ total_in, total_out, cost = persisted.to_h.values_at(
48
+ :total_input_tokens, :total_output_tokens, :total_cost
49
+ )
50
+ token_row = format_token_row(total_in || 0, total_out || 0)
51
+ cost_row = format_cost_row(cost)
52
+ ["#### :bar_chart: Session Stats (stopped)", "| Metric | Value |", "|--------|-------|",
53
+ token_row, cost_row].join("\n")
54
+ end
55
+
56
+ def format_token_row(total_in, total_out)
57
+ total = format_number(total_in + total_out)
58
+ "| **Total tokens** | #{total} (in: #{format_number(total_in)}, out: #{format_number(total_out)}) |"
59
+ end
60
+
61
+ def format_cost_row(cost)
62
+ "| **Cost** | $#{format("%.4f", cost)} |"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class CommandExecutor
5
+ # Handles !usage and !context commands — fetches external data
6
+ # via background threads and posts formatted results.
7
+ module UsageHandler
8
+ CONTEXT_CATEGORIES = {
9
+ "messages" => "Messages", "system_prompt" => "System prompt",
10
+ "system_tools" => "System tools", "custom_agents" => "Custom agents",
11
+ "memory_files" => "Memory files", "skills" => "Skills",
12
+ "free_space" => "Free space", "autocompact_buffer" => "Autocompact buffer"
13
+ }.freeze
14
+
15
+ private
16
+
17
+ def handle_usage(ctx)
18
+ reply(ctx, ":hourglass: Fetching usage data (takes ~15s)...")
19
+ run_usage_fetch(ctx)
20
+ end
21
+
22
+ def run_usage_fetch(ctx)
23
+ Thread.new { usage_fetch_body(ctx) }
24
+ end
25
+
26
+ def usage_fetch_body(ctx)
27
+ data = fetch_usage_data
28
+ message = data ? format_usage(data) : ":x: Failed to fetch usage data."
29
+ reply(ctx, message)
30
+ rescue StandardError => error
31
+ msg = error.message
32
+ log(:error, "Usage command error: #{msg}")
33
+ reply(ctx, ":x: Error fetching usage: #{msg}")
34
+ end
35
+
36
+ def fetch_usage_data
37
+ output, status = Open3.capture2(USAGE_SCRIPT, "--json", err: File::NULL)
38
+ return nil unless status.success?
39
+
40
+ JSON.parse(output)
41
+ rescue JSON::ParserError
42
+ nil
43
+ end
44
+
45
+ def format_usage(data)
46
+ lines = ["#### :bar_chart: Claude Usage"]
47
+ append_usage_section(lines, data["session"], "Session")
48
+ append_usage_section(lines, data["week"], "Week")
49
+ append_usage_section(lines, data["sonnet_week"], "Sonnet")
50
+ append_usage_extra(lines, data["extra"])
51
+ lines.join("\n")
52
+ end
53
+
54
+ def append_usage_section(lines, section, label)
55
+ return unless section&.dig("percent_used")
56
+
57
+ lines << "- **#{label}:** #{section["percent_used"]}% used \u2014 resets #{section["resets"]}"
58
+ end
59
+
60
+ def append_usage_extra(lines, extra)
61
+ return unless extra&.dig("percent_used")
62
+
63
+ pct, spent, budget, resets = extra.values_at("percent_used", "spent", "budget", "resets")
64
+ lines << "- **Extra:** #{pct}% used (#{spent} / #{budget}) \u2014 resets #{resets}"
65
+ end
66
+
67
+ def handle_context(ctx)
68
+ sid = @deps.session_manager.claude_session_id_for(ctx.thread_id)
69
+ unless sid
70
+ reply(ctx, "No session found for this thread.")
71
+ return
72
+ end
73
+
74
+ reply(ctx, ":hourglass: Fetching context data (takes ~20s)...")
75
+ run_context_fetch(ctx, sid)
76
+ end
77
+
78
+ def run_context_fetch(ctx, sid)
79
+ Thread.new { context_fetch_body(ctx, sid) }
80
+ end
81
+
82
+ def context_fetch_body(ctx, sid)
83
+ data = fetch_context_data(sid)
84
+ message = data ? format_context(data) : ":x: Failed to fetch context data."
85
+ reply(ctx, message)
86
+ rescue StandardError => error
87
+ msg = error.message
88
+ log(:error, "Context command error: #{msg}")
89
+ reply(ctx, ":x: Error fetching context: #{msg}")
90
+ end
91
+
92
+ def fetch_context_data(session_id)
93
+ output, status = Open3.capture2(CONTEXT_SCRIPT, session_id, "--json", err: File::NULL) # nosemgrep
94
+ return nil unless status.success?
95
+
96
+ JSON.parse(output)
97
+ rescue JSON::ParserError
98
+ nil
99
+ end
100
+
101
+ def format_context(data)
102
+ model, used, total, pct, cats = data.values_at("model", "used_tokens", "total_tokens", "percent_used",
103
+ "categories")
104
+ lines = [
105
+ "#### :brain: Context Window Usage",
106
+ "- **Model:** `#{model}`",
107
+ "- **Used:** #{used} / #{total} tokens (#{pct})"
108
+ ]
109
+ append_context_categories(lines, cats)
110
+ lines.join("\n")
111
+ end
112
+
113
+ def append_context_categories(lines, cats)
114
+ return unless cats
115
+
116
+ lines << ""
117
+ CONTEXT_CATEGORIES.each do |key, label|
118
+ append_category_line(lines, cats[key], label)
119
+ end
120
+ end
121
+
122
+ def append_category_line(lines, cat, label)
123
+ tokens = cat&.fetch("tokens", nil)
124
+ return unless tokens
125
+
126
+ pct_val = cat["percent"]
127
+ pct = pct_val ? " (#{pct_val})" : ""
128
+ lines << "- **#{label}:** #{tokens} tokens#{pct}"
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+
6
+ require_relative "command_executor/constants"
7
+ require_relative "command_executor/lifecycle_handler"
8
+ require_relative "command_executor/session_handler"
9
+ require_relative "command_executor/spawn_handler"
10
+ require_relative "command_executor/stats_formatter"
11
+ require_relative "command_executor/usage_handler"
12
+ require_relative "command_executor/heartbeat_display"
13
+
14
+ module Earl
15
+ # Executes `!` commands parsed by CommandParser, dispatching to the
16
+ # appropriate session manager or mattermost action.
17
+ class CommandExecutor
18
+ include Logging
19
+ include Formatting
20
+ include Constants
21
+ include LifecycleHandler
22
+ include SessionHandler
23
+ include SpawnHandler
24
+ include StatsFormatter
25
+ include UsageHandler
26
+ include HeartbeatDisplay
27
+
28
+ # Bundles dispatch context so thread_id + channel_id don't travel as separate args.
29
+ CommandContext = Data.define(:thread_id, :channel_id, :arg, :args) do
30
+ def post_params(message)
31
+ { channel_id: channel_id, message: message, root_id: thread_id }
32
+ end
33
+ end
34
+
35
+ # Groups injected dependencies to keep ivar count low.
36
+ Deps = Struct.new(:session_manager, :mattermost, :config, :heartbeat_scheduler,
37
+ :tmux_store, :tmux, :runner, keyword_init: true)
38
+
39
+ def initialize(session_manager:, mattermost:, config:, **extras)
40
+ heartbeat_scheduler, tmux_store, tmux_adapter, runner = extras.values_at(:heartbeat_scheduler, :tmux_store,
41
+ :tmux_adapter, :runner)
42
+ @deps = Deps.new(
43
+ session_manager: session_manager, mattermost: mattermost, config: config,
44
+ heartbeat_scheduler: heartbeat_scheduler, tmux_store: tmux_store, tmux: tmux_adapter || Tmux, runner: runner
45
+ )
46
+ @working_dirs = {} # thread_id -> path
47
+ end
48
+
49
+ # Returns { passthrough: "/command" } for passthrough commands so the
50
+ # runner can route them through the normal message pipeline.
51
+ # Returns nil for all other commands (handled inline).
52
+ def execute(command, thread_id:, channel_id:)
53
+ cmd_name = command.name
54
+ slash = PASSTHROUGH_COMMANDS[cmd_name]
55
+ return { passthrough: slash } if slash
56
+
57
+ ctx = build_context(command, thread_id, channel_id)
58
+ dispatch_command(cmd_name, ctx)
59
+ nil
60
+ end
61
+
62
+ def working_dir_for(thread_id)
63
+ @working_dirs[thread_id]
64
+ end
65
+
66
+ private
67
+
68
+ def dispatch_command(name, ctx)
69
+ handler = DISPATCH[name]
70
+ send(handler, ctx) if handler
71
+ end
72
+
73
+ def build_context(command, thread_id, channel_id)
74
+ cmd_args = command.args
75
+ CommandContext.new(thread_id: thread_id, channel_id: channel_id, arg: cmd_args.first, args: cmd_args)
76
+ end
77
+
78
+ def handle_help(ctx)
79
+ reply(ctx, HELP_TABLE)
80
+ end
81
+
82
+ def handle_update(ctx)
83
+ reply(ctx, ":arrows_counterclockwise: Updating EARL...")
84
+ save_restart_context(ctx, "update")
85
+ @deps.runner&.request_update
86
+ end
87
+
88
+ def handle_restart(ctx)
89
+ reply(ctx, ":arrows_counterclockwise: Restarting EARL...")
90
+ save_restart_context(ctx, "restart")
91
+ @deps.runner&.request_restart
92
+ end
93
+
94
+ def save_restart_context(ctx, command)
95
+ config_dir = Earl.config_root
96
+ FileUtils.mkdir_p(config_dir)
97
+ path = File.join(config_dir, "restart_context.json")
98
+ data = ctx.deconstruct_keys(%i[channel_id thread_id]).merge(command: command)
99
+ File.write(path, JSON.generate(data))
100
+ rescue StandardError => error
101
+ log(:warn, "Failed to save restart context: #{error.message}")
102
+ end
103
+
104
+ def handle_permissions(ctx)
105
+ reply(ctx, "Permission mode is controlled via `EARL_SKIP_PERMISSIONS` env var.")
106
+ end
107
+
108
+ def handle_stats(ctx)
109
+ session = @deps.session_manager.get(ctx.thread_id)
110
+ return reply(ctx, format_stats(session.stats)) if session
111
+
112
+ reply_persisted_stats(ctx)
113
+ end
114
+
115
+ def reply_persisted_stats(ctx)
116
+ persisted = @deps.session_manager.persisted_session_for(ctx.thread_id)
117
+ if persisted&.total_cost
118
+ reply(ctx, format_persisted_stats(persisted))
119
+ else
120
+ reply(ctx, "No active session for this thread.")
121
+ end
122
+ end
123
+
124
+ def reply(ctx, message)
125
+ @deps.mattermost.create_post(**ctx.post_params(message))
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ # Parses `!` prefixed commands from user messages in Mattermost.
5
+ # Returns a ParsedCommand struct or nil if the message is not a command.
6
+ class CommandParser
7
+ # A parsed chat command with name and arguments.
8
+ ParsedCommand = Struct.new(:name, :args, keyword_init: true)
9
+
10
+ COMMANDS = {
11
+ /\A!stop\z/i => :stop,
12
+ /\A!escape\z/i => :escape,
13
+ /\A!kill\z/i => :kill,
14
+ /\A!help\z/i => :help,
15
+ /\A!stats\z/i => :stats,
16
+ /\A!cost\z/i => :stats,
17
+ /\A!compact\z/i => :compact,
18
+ /\A!cd\s+(.+)\z/i => :cd,
19
+ /\A!permissions\z/i => :permissions,
20
+ /\A!heartbeats\z/i => :heartbeats,
21
+ /\A!usage\z/i => :usage,
22
+ /\A!context\z/i => :context,
23
+ /\A!sessions\z/i => :sessions,
24
+ # Specific !session subcommands must come before catch-all
25
+ /\A!session\s+(\S+)\s+status\z/i => :session_status,
26
+ /\A!session\s+(\S+)\s+kill\z/i => :session_kill,
27
+ /\A!session\s+(\S+)\s+nudge\z/i => :session_nudge,
28
+ /\A!session\s+(\S+)\s+approve\z/i => :session_approve,
29
+ /\A!session\s+(\S+)\s+deny\z/i => :session_deny,
30
+ /\A!session\s+(\S+)\s+"([^"]+)"\z/i => :session_input,
31
+ /\A!session\s+(\S+)\s+'([^']+)'\z/i => :session_input,
32
+ /\A!session\s+(\S+)\z/i => :session_show,
33
+ /\A!update\z/i => :update,
34
+ /\A!restart\z/i => :restart,
35
+ /\A!spawn\s+"([^"]+)"(.*)\z/i => :spawn
36
+ }.freeze
37
+
38
+ def self.command?(text)
39
+ text.strip.start_with?("!")
40
+ end
41
+
42
+ def self.parse(text)
43
+ stripped = text.strip
44
+ return nil unless stripped.start_with?("!")
45
+
46
+ COMMANDS.each do |pattern, name|
47
+ match = stripped.match(pattern)
48
+ next unless match
49
+
50
+ args = match.captures
51
+ return ParsedCommand.new(name: name, args: args)
52
+ end
53
+
54
+ nil
55
+ end
56
+ end
57
+ end