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
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
|