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
data/bin/watch-ci ADDED
@@ -0,0 +1,198 @@
1
+ #!/bin/bash
2
+
3
+ # Script to watch CI status for the current branch
4
+ # Usage: bin/watch-ci [interval_seconds]
5
+
6
+ set -e
7
+
8
+ # Default interval (in seconds)
9
+ INTERVAL=${1:-10}
10
+
11
+ # Colors for output
12
+ RED='\033[0;31m'
13
+ GREEN='\033[0;32m'
14
+ YELLOW='\033[1;33m'
15
+ BLUE='\033[0;34m'
16
+ NC='\033[0m' # No Color
17
+
18
+ # Clear screen function
19
+ clear_screen() {
20
+ printf "\033[2J\033[H"
21
+ }
22
+
23
+ # Check if we're in a git repository
24
+ if ! git rev-parse --git-dir > /dev/null 2>&1; then
25
+ echo "❌ Not in a git repository"
26
+ exit 1
27
+ fi
28
+
29
+ # Check if gh CLI is available
30
+ if ! command -v gh >/dev/null 2>&1; then
31
+ echo "❌ GitHub CLI (gh) not found. Install with: brew install gh"
32
+ exit 1
33
+ fi
34
+
35
+ # Check if jq is available
36
+ if ! command -v jq >/dev/null 2>&1; then
37
+ echo "❌ jq not found. Install with: brew install jq"
38
+ exit 1
39
+ fi
40
+
41
+ # Get current branch
42
+ BRANCH=$(git branch --show-current)
43
+ if [ -z "$BRANCH" ]; then
44
+ echo "❌ Not on a branch (detached HEAD)"
45
+ exit 1
46
+ fi
47
+
48
+ echo -e "🔍 Watching CI for branch: ${BLUE}$BRANCH${NC}"
49
+ echo "⏱️ Refresh interval: ${INTERVAL}s (press Ctrl+C to stop)"
50
+ echo ""
51
+
52
+ # Function to display CI status
53
+ show_ci_status() {
54
+ local timestamp=$(date "+%Y-%m-%d %H:%M:%S")
55
+
56
+ clear_screen
57
+ echo -e "🔍 CI Status for branch: ${BLUE}$BRANCH${NC}"
58
+ echo "⏱️ Last updated: $timestamp (refreshing every ${INTERVAL}s)"
59
+ echo "📝 Press Ctrl+C to stop watching"
60
+ echo ""
61
+
62
+ # Try to get PR checks first
63
+ PR_CHECKS=$(gh pr checks 2>/dev/null) || true
64
+ if [ -n "$PR_CHECKS" ]; then
65
+ echo "📊 CI Check Results:"
66
+ echo "===================="
67
+
68
+ # Parse and colorize the output
69
+ while IFS=$'\t' read -r name status duration url; do
70
+ case "$status" in
71
+ "pass")
72
+ echo -e "✅ ${GREEN}$name${NC} - ${duration}"
73
+ ;;
74
+ "fail")
75
+ echo -e "❌ ${RED}$name${NC} - ${duration}"
76
+ echo -e " 📋 ${BLUE}$url${NC}"
77
+ ;;
78
+ "pending")
79
+ echo -e "⏳ ${YELLOW}$name${NC} - running..."
80
+ ;;
81
+ *)
82
+ echo -e "❓ ${YELLOW}$name${NC} - $status"
83
+ ;;
84
+ esac
85
+ done <<< "$PR_CHECKS"
86
+
87
+ # Check if all passed
88
+ if echo "$PR_CHECKS" | grep -q "fail"; then
89
+ echo ""
90
+ echo -e "${RED}❌ Some checks failed${NC}"
91
+ echo ""
92
+ echo "📋 Failure logs:"
93
+ echo "==============="
94
+ gh run view --log-failed
95
+ return 1
96
+ elif echo "$PR_CHECKS" | grep -q "pending"; then
97
+ echo ""
98
+ echo -e "${YELLOW}⏳ Checks still running...${NC}"
99
+ return 0
100
+ else
101
+ echo ""
102
+ echo -e "${GREEN}✅ All checks passed!${NC}"
103
+ echo "🎉 Exiting since all checks have passed"
104
+ return 2
105
+ fi
106
+ else
107
+ # No PR found, check workflow runs for the branch directly
108
+ echo "📊 CI Workflow Status (latest run on $BRANCH):"
109
+ echo "=============================================="
110
+
111
+ # Get the latest workflow run for this branch
112
+ RUN_ID=$(gh run list --branch "$BRANCH" --limit 1 --json databaseId --jq '.[0].databaseId' 2>/dev/null)
113
+
114
+ if [ -z "$RUN_ID" ]; then
115
+ echo "❓ No CI runs found for branch $BRANCH"
116
+ echo "💡 Make sure you've pushed your branch"
117
+ return 0
118
+ fi
119
+
120
+ # Get run status and jobs
121
+ RUN_INFO=$(gh run view "$RUN_ID" --json status,conclusion,jobs 2>/dev/null)
122
+ RUN_STATUS=$(echo "$RUN_INFO" | jq -r '.status')
123
+ RUN_CONCLUSION=$(echo "$RUN_INFO" | jq -r '.conclusion')
124
+
125
+ # Display each job
126
+ echo "$RUN_INFO" | jq -r '.jobs[] | "\(.name)\t\(.status)\t\(.conclusion)"' | while IFS=$'\t' read -r name status conclusion; do
127
+ if [ "$status" = "completed" ]; then
128
+ case "$conclusion" in
129
+ "success")
130
+ echo -e "✅ ${GREEN}$name${NC}"
131
+ ;;
132
+ "failure")
133
+ echo -e "❌ ${RED}$name${NC}"
134
+ ;;
135
+ "cancelled")
136
+ echo -e "⛔ ${YELLOW}$name${NC} - cancelled"
137
+ ;;
138
+ *)
139
+ echo -e "❓ ${YELLOW}$name${NC} - $conclusion"
140
+ ;;
141
+ esac
142
+ else
143
+ echo -e "⏳ ${YELLOW}$name${NC} - $status..."
144
+ fi
145
+ done
146
+
147
+ # Overall status
148
+ echo ""
149
+ if [ "$RUN_STATUS" = "completed" ]; then
150
+ if [ "$RUN_CONCLUSION" = "success" ]; then
151
+ echo -e "${GREEN}✅ All jobs passed!${NC}"
152
+ echo "🎉 Exiting since all jobs have passed"
153
+ return 2
154
+ else
155
+ echo -e "${RED}❌ Workflow $RUN_CONCLUSION${NC}"
156
+ echo ""
157
+ echo "📋 Failure logs:"
158
+ echo "==============="
159
+ gh run view "$RUN_ID" --log-failed
160
+ return 1
161
+ fi
162
+ else
163
+ echo -e "${YELLOW}⏳ Workflow still running...${NC}"
164
+ return 0
165
+ fi
166
+ fi
167
+
168
+ echo ""
169
+ echo "🛠️ Quick commands:"
170
+ echo " gh pr view - View PR details"
171
+ echo " gh pr checks - Show detailed check status"
172
+ echo " gh run list - List recent runs"
173
+ }
174
+
175
+ # Initial display
176
+ show_ci_status
177
+ if [ $? -eq 2 ]; then
178
+ # All checks already passed on initial check
179
+ exit 0
180
+ fi
181
+
182
+ # Watch loop
183
+ while true; do
184
+ sleep $INTERVAL
185
+ show_ci_status
186
+ case $? in
187
+ 2)
188
+ # All checks passed, exit successfully
189
+ exit 0
190
+ ;;
191
+ 1)
192
+ # Some checks failed, continue watching
193
+ ;;
194
+ 0)
195
+ # Checks still running, continue watching
196
+ ;;
197
+ esac
198
+ done
@@ -0,0 +1,10 @@
1
+ # EARL Bot Session
2
+
3
+ You are running as an EARL-managed Claude session. EARL is a Mattermost bot that spawns Claude CLI sessions and streams responses back as threaded replies.
4
+
5
+ ## Guidelines
6
+
7
+ - You are interacting with users via Mattermost through EARL
8
+ - Be concise — responses are displayed in Mattermost threads
9
+ - Tool approvals are handled via Mattermost emoji reactions
10
+ - Your HOME directory is isolated from the host user's personal config
@@ -0,0 +1,34 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(git status:*)",
5
+ "Bash(git log:*)",
6
+ "Bash(git diff:*)",
7
+ "Bash(git branch:*)",
8
+ "Bash(git show:*)",
9
+ "Bash(ls:*)",
10
+ "Bash(cat:*)",
11
+ "Bash(head:*)",
12
+ "Bash(tail:*)",
13
+ "Bash(wc:*)",
14
+ "Bash(which:*)",
15
+ "Bash(echo:*)",
16
+ "Bash(date:*)",
17
+ "Bash(pwd:*)",
18
+ "Read",
19
+ "Glob",
20
+ "Grep",
21
+ "WebFetch",
22
+ "WebSearch"
23
+ ],
24
+ "deny": [
25
+ "Bash(rm -rf:*)",
26
+ "Bash(sudo:*)",
27
+ "Bash(curl -X DELETE:*)",
28
+ "Bash(curl -X PUT:*)",
29
+ "Bash(curl -X PATCH:*)",
30
+ "Bash(curl *| bash:*)",
31
+ "Bash(curl *| sh:*)"
32
+ ]
33
+ }
34
+ }
data/earl-bot.gemspec ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/earl/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "earl-bot"
7
+ spec.version = Earl::VERSION
8
+ spec.authors = ["Eric Boehs"]
9
+ spec.email = ["ericboehs@gmail.com"]
10
+
11
+ spec.summary = "A Mattermost bot that spawns Claude Code CLI sessions"
12
+ spec.description = "EARL (Eric's Automated Response Line) connects to Mattermost via " \
13
+ "WebSocket, listens for messages, spawns Claude Code CLI sessions, and " \
14
+ "streams responses back as threaded replies."
15
+ spec.homepage = "https://github.com/ericboehs/earl"
16
+ spec.license = "MIT"
17
+ spec.required_ruby_version = ">= 3.2"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "https://github.com/ericboehs/earl"
21
+ spec.metadata["changelog_uri"] = "https://github.com/ericboehs/earl/blob/main/CHANGELOG.md"
22
+ spec.metadata["rubygems_mfa_required"] = "true"
23
+
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z`.split("\x0").select { |f| File.exist?(f) }.reject do |f|
26
+ f.start_with?("test/", "docs/", ".github/", ".") && !f.start_with?(".ruby-version")
27
+ end
28
+ end
29
+ spec.bindir = "exe"
30
+ spec.executables = %w[earl earl-install earl-permission-server]
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.add_dependency "websocket-client-simple", "~> 0.9"
34
+
35
+ spec.add_development_dependency "bundler-audit", "~> 0.9"
36
+ spec.add_development_dependency "minitest", "~> 5.0"
37
+ spec.add_development_dependency "rake", "~> 13.0"
38
+ spec.add_development_dependency "reek", "~> 6.0"
39
+ spec.add_development_dependency "rubocop", "~> 1.0"
40
+ spec.add_development_dependency "rubocop-rake", "~> 0.7"
41
+ spec.add_development_dependency "simplecov", "~> 0.22"
42
+ end
data/exe/earl ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $stdout.sync = true
5
+ $stderr.sync = true
6
+
7
+ original_verbose = $VERBOSE
8
+ $VERBOSE = nil
9
+ begin
10
+ require "bundler/setup"
11
+ rescue LoadError
12
+ # Running as installed gem without Bundler — dependencies resolved by RubyGems
13
+ end
14
+ $VERBOSE = original_verbose
15
+ require_relative "../lib/earl"
16
+
17
+ pid_file = File.join(Earl.config_root, "earl.pid")
18
+
19
+ case ARGV[0]
20
+ when "restart"
21
+ unless File.exist?(pid_file)
22
+ warn "No PID file found at #{pid_file} — is EARL running?"
23
+ exit 1
24
+ end
25
+
26
+ pid = File.read(pid_file).strip.to_i
27
+ begin
28
+ Process.kill("HUP", pid)
29
+ puts "Sent SIGHUP to EARL (pid #{pid})"
30
+ rescue Errno::ESRCH
31
+ warn "Process #{pid} not found — removing stale PID file"
32
+ File.delete(pid_file)
33
+ exit 1
34
+ end
35
+ exit 0
36
+ when "update"
37
+ repo_dir = File.expand_path("..", __dir__)
38
+ Dir.chdir(repo_dir) do
39
+ system("git", "pull", "--ff-only") || warn("git pull failed")
40
+ system({ "RUBYOPT" => "-W0" }, "bundle", "install", "--quiet") || warn("bundle install failed")
41
+ end
42
+ puts "Updated. Run 'earl restart' to apply."
43
+ exit 0
44
+ end
45
+
46
+ Earl.logger.level = ENV["EARL_DEBUG"] ? Logger::DEBUG : Logger::INFO
47
+ Earl.logger.info "Starting EARL..."
48
+ File.write(pid_file, Process.pid.to_s)
49
+ runner = Earl::Runner.new
50
+ at_exit { FileUtils.rm_f(pid_file) }
51
+ runner.start
data/exe/earl-install ADDED
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ EARL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
5
+ PROD_CONFIG_DIR="$HOME/.config/earl"
6
+ DEV_CONFIG_DIR="$HOME/.config/earl-dev"
7
+ PROD_INSTALL_DIR="$HOME/.local/share/earl"
8
+ PROD_BIN="$HOME/bin/earl"
9
+ NEEDS_ENV=false
10
+
11
+ echo "==> Installing EARL (dev + prod)..."
12
+
13
+ # --- Setup config dirs for both environments ---
14
+ for CONFIG_DIR in "$PROD_CONFIG_DIR" "$DEV_CONFIG_DIR"; do
15
+ ENV_LABEL=$([ "$CONFIG_DIR" = "$DEV_CONFIG_DIR" ] && echo "dev" || echo "prod")
16
+ echo ""
17
+ echo " [$ENV_LABEL] Creating $CONFIG_DIR/"
18
+ mkdir -p "$CONFIG_DIR/logs" "$CONFIG_DIR/memory" "$CONFIG_DIR/mcp"
19
+
20
+ # Copy default Claude project config (if not present)
21
+ CLAUDE_HOME="$CONFIG_DIR/claude-home"
22
+ CLAUDE_SRC="$EARL_DIR/config/earl-claude-home/.claude"
23
+ if [ ! -d "$CLAUDE_SRC" ]; then
24
+ echo " [$ENV_LABEL] WARNING: Source Claude config not found at $CLAUDE_SRC" >&2
25
+ echo " [$ENV_LABEL] Claude subprocesses will run without project-level config." >&2
26
+ elif [ ! -d "$CLAUDE_HOME/.claude" ]; then
27
+ echo " [$ENV_LABEL] Copying default Claude project config to $CLAUDE_HOME/"
28
+ mkdir -p "$CLAUDE_HOME"
29
+ cp -r "$CLAUDE_SRC" "$CLAUDE_HOME/.claude"
30
+ else
31
+ echo " [$ENV_LABEL] Claude project config already exists (skipping)"
32
+ fi
33
+
34
+ # Create env file template (if not present)
35
+ ENV_FILE="$CONFIG_DIR/env"
36
+ if [ ! -f "$ENV_FILE" ]; then
37
+ cat > "$ENV_FILE" <<'ENVTEMPLATE'
38
+ # EARL environment variables — fill in and restart the agent
39
+ MATTERMOST_URL=https://your-mattermost-server.example.com
40
+ MATTERMOST_BOT_TOKEN=your-bot-token
41
+ MATTERMOST_BOT_ID=your-bot-user-id
42
+ EARL_CHANNEL_ID=your-channel-id
43
+ EARL_ALLOWED_USERS=your-username
44
+ # EARL_CHANNELS=chan1:/path1,chan2:/path2
45
+ # EARL_SKIP_PERMISSIONS=true
46
+ # EARL_DEBUG=1
47
+ ENVTEMPLATE
48
+ chmod 600 "$ENV_FILE"
49
+ echo " [$ENV_LABEL] Created $ENV_FILE — fill in your secrets."
50
+ NEEDS_ENV=true
51
+ fi
52
+ done
53
+
54
+ if [ "$NEEDS_ENV" = true ]; then
55
+ echo ""
56
+ echo " One or more env files need secrets filled in. Do that, then re-run this script."
57
+ echo ""
58
+ exit 0
59
+ fi
60
+
61
+ # --- Production: clone repo, create wrapper ---
62
+
63
+ echo ""
64
+ echo " [prod] Setting up production install..."
65
+
66
+ # Clone/update production repo
67
+ if [ -d "$PROD_INSTALL_DIR/.git" ]; then
68
+ echo " [prod] Updating checkout at $PROD_INSTALL_DIR..."
69
+ if ! git -C "$PROD_INSTALL_DIR" pull --ff-only; then
70
+ echo "" >&2
71
+ echo " [prod] ERROR: Failed to update production checkout." >&2
72
+ echo " [prod] The local branch may have diverged. Try:" >&2
73
+ echo " cd $PROD_INSTALL_DIR && git reset --hard origin/main && git pull" >&2
74
+ exit 1
75
+ fi
76
+ else
77
+ echo " [prod] Cloning EARL to $PROD_INSTALL_DIR..."
78
+ mkdir -p "$(dirname "$PROD_INSTALL_DIR")"
79
+ git clone "https://github.com/ericboehs/earl.git" "$PROD_INSTALL_DIR"
80
+ fi
81
+
82
+ echo " [prod] Running bundle install..."
83
+ if ! (cd "$PROD_INSTALL_DIR" && bundle install --quiet); then
84
+ echo "" >&2
85
+ echo " [prod] ERROR: bundle install failed. Re-running for details:" >&2
86
+ (cd "$PROD_INSTALL_DIR" && bundle install) || true
87
+ exit 1
88
+ fi
89
+
90
+ # Create ~/bin/earl wrapper
91
+ mkdir -p "$(dirname "$PROD_BIN")"
92
+ cat > "$PROD_BIN" <<WRAPPER
93
+ #!/usr/bin/env bash
94
+ set -euo pipefail
95
+
96
+ # Force production environment (prevent direnv leaking EARL_ENV=development)
97
+ export EARL_ENV=production
98
+
99
+ # PATH setup (mise shims, homebrew, ~/bin)
100
+ export PATH="\$HOME/bin:\$HOME/.local/share/mise/shims:\$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
101
+ export LANG="en_US.UTF-8"
102
+ export LC_ALL="en_US.UTF-8"
103
+
104
+ # Load EARL environment variables
105
+ ENV_FILE="$PROD_CONFIG_DIR/env"
106
+ if [ ! -f "\$ENV_FILE" ]; then
107
+ echo "FATAL: Environment file not found: \$ENV_FILE" >&2
108
+ echo " Run 'earl-install' to create it, then fill in your secrets." >&2
109
+ exit 1
110
+ fi
111
+ set -a
112
+ # shellcheck source=/dev/null
113
+ source "\$ENV_FILE"
114
+ set +a
115
+
116
+ # Ensure Claude home dir exists
117
+ mkdir -p "$PROD_CONFIG_DIR/claude-home"
118
+
119
+ cd "$PROD_INSTALL_DIR"
120
+ exec ruby exe/earl "\$@"
121
+ WRAPPER
122
+ chmod +x "$PROD_BIN"
123
+ echo " [prod] Created $PROD_BIN"
124
+
125
+ echo ""
126
+ echo "==> EARL installed!"
127
+ echo ""
128
+ echo " Dev: cd ~/Code/ericboehs/earl && exe/earl"
129
+ echo " Prod: earl (runs from $PROD_INSTALL_DIR)"
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $stdout.sync = true
5
+ $stderr.sync = true
6
+
7
+ begin
8
+ require "bundler/setup"
9
+ rescue LoadError
10
+ # Running as installed gem without Bundler
11
+ end
12
+ require_relative "../lib/earl"
13
+
14
+ Earl.logger.level = Logger::WARN
15
+
16
+ config = Earl::Mcp::Config.new
17
+ api_config = config.to_api_config
18
+ api_client = Earl::Mattermost::ApiClient.new(api_config)
19
+
20
+ store = Earl::Memory::Store.new
21
+ approval_handler = Earl::Mcp::ApprovalHandler.new(config: config, api_client: api_client)
22
+ memory_handler = Earl::Mcp::MemoryHandler.new(
23
+ store: store, username: ENV.fetch("EARL_CURRENT_USERNAME", nil)
24
+ )
25
+ heartbeat_handler = Earl::Mcp::HeartbeatHandler.new(
26
+ default_channel_id: ENV.fetch("PLATFORM_CHANNEL_ID", nil)
27
+ )
28
+ tmux_store = Earl::TmuxSessionStore.new
29
+ tmux_handler = Earl::Mcp::TmuxHandler.new(
30
+ config: config, api_client: api_client, tmux_store: tmux_store
31
+ )
32
+ github_pat_handler = Earl::Mcp::GithubPatHandler.new(
33
+ config: config, api_client: api_client
34
+ )
35
+
36
+ server = Earl::Mcp::Server.new(handlers: [approval_handler, memory_handler, heartbeat_handler, tmux_handler,
37
+ github_pat_handler])
38
+
39
+ server.run
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class ClaudeSession
5
+ # Tracks usage statistics, timing, and cost across the session.
6
+ Stats = Struct.new(
7
+ :total_cost, :total_input_tokens, :total_output_tokens,
8
+ :turn_input_tokens, :turn_output_tokens,
9
+ :cache_read_tokens, :cache_creation_tokens,
10
+ :context_window, :model_id,
11
+ :message_sent_at, :first_token_at, :complete_at,
12
+ keyword_init: true
13
+ ) do
14
+ def time_to_first_token
15
+ return nil unless message_sent_at && first_token_at
16
+
17
+ first_token_at - message_sent_at
18
+ end
19
+
20
+ def tokens_per_second
21
+ return nil unless first_token_at && complete_at && turn_output_tokens&.positive?
22
+
23
+ duration = complete_at - first_token_at
24
+ return nil unless duration.positive?
25
+
26
+ turn_output_tokens / duration
27
+ end
28
+
29
+ def context_percent
30
+ return nil unless context_window&.positive?
31
+
32
+ context_tokens = turn_input_tokens + cache_read_tokens + cache_creation_tokens
33
+ return nil unless context_tokens.positive?
34
+
35
+ (context_tokens.to_f / context_window * 100)
36
+ end
37
+
38
+ def reset_turn
39
+ self.turn_input_tokens = 0
40
+ self.turn_output_tokens = 0
41
+ self.cache_read_tokens = 0
42
+ self.cache_creation_tokens = 0
43
+ self.message_sent_at = nil
44
+ self.first_token_at = nil
45
+ self.complete_at = nil
46
+ end
47
+
48
+ def begin_turn
49
+ reset_turn
50
+ self.message_sent_at = Time.now
51
+ end
52
+
53
+ def format_summary(prefix)
54
+ parts = ["#{prefix}:", format_token_stats, *format_timing_stats]
55
+ parts << "model=#{model_id}" if model_id
56
+ parts.join(" | ")
57
+ end
58
+
59
+ def format_token_stats
60
+ total = total_input_tokens + total_output_tokens
61
+ "#{total} tokens (turn: in:#{turn_input_tokens} out:#{turn_output_tokens})"
62
+ end
63
+
64
+ def format_timing_stats
65
+ parts = []
66
+ pct = context_percent
67
+ parts << format("%.0f%% context", pct) if pct
68
+ ttft = time_to_first_token
69
+ parts << format("TTFT: %.1fs", ttft) if ttft
70
+ tps = tokens_per_second
71
+ parts << format("%.0f tok/s", tps) if tps
72
+ parts
73
+ end
74
+ end
75
+ end
76
+ end