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/ci
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
steps = []
|
|
7
|
+
failures = []
|
|
8
|
+
|
|
9
|
+
def run_step(name, command, steps, failures)
|
|
10
|
+
print " #{name}... "
|
|
11
|
+
$stdout.flush
|
|
12
|
+
output, status = Open3.capture2e(command)
|
|
13
|
+
if status.success?
|
|
14
|
+
puts "OK"
|
|
15
|
+
steps << { name: name, status: :ok }
|
|
16
|
+
else
|
|
17
|
+
puts "FAIL"
|
|
18
|
+
failures << { name: name, output: output }
|
|
19
|
+
steps << { name: name, status: :fail }
|
|
20
|
+
end
|
|
21
|
+
rescue Errno::ENOENT => e
|
|
22
|
+
puts "FAIL"
|
|
23
|
+
failures << { name: name, output: "Command not found: #{e.message}" }
|
|
24
|
+
steps << { name: name, status: :fail }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
puts "Running CI pipeline..."
|
|
28
|
+
puts ""
|
|
29
|
+
|
|
30
|
+
run_step("Style: RuboCop", "bundle exec rubocop --no-server", steps, failures)
|
|
31
|
+
run_step("Quality: Reek", "bundle exec reek", steps, failures)
|
|
32
|
+
run_step("Security: Bundler audit", "bundle exec bundle-audit check --update", steps, failures)
|
|
33
|
+
run_step("Security: Semgrep", "semgrep --config=r/ruby --metrics=off --error --quiet lib/", steps, failures)
|
|
34
|
+
run_step("Lint: Actionlint", "actionlint .github/workflows/*.yml", steps, failures)
|
|
35
|
+
run_step("Tests: Minitest", "bundle exec rake test", steps, failures)
|
|
36
|
+
run_step("Coverage: Report", "ruby bin/coverage", steps, failures)
|
|
37
|
+
|
|
38
|
+
puts ""
|
|
39
|
+
if failures.empty?
|
|
40
|
+
puts "All #{steps.size} steps passed!"
|
|
41
|
+
else
|
|
42
|
+
puts "#{failures.size} of #{steps.size} steps failed:"
|
|
43
|
+
failures.each do |f|
|
|
44
|
+
puts ""
|
|
45
|
+
puts "--- #{f[:name]} ---"
|
|
46
|
+
puts f[:output]
|
|
47
|
+
end
|
|
48
|
+
exit 1
|
|
49
|
+
end
|
data/bin/claude-context
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Scrape Claude Code /context output via tmux.
|
|
3
|
+
# Resumes an existing session, runs /context, captures output, exits.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# bin/claude-context <session-id> # Human-readable output
|
|
7
|
+
# bin/claude-context <session-id> --json # JSON output for programmatic use
|
|
8
|
+
#
|
|
9
|
+
# Requires: tmux, claude CLI
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
SESSION_ID="${1:?Usage: $0 <session-id> [--json]}"
|
|
13
|
+
JSON_MODE=false
|
|
14
|
+
if [[ "${2:-}" == "--json" ]]; then
|
|
15
|
+
JSON_MODE=true
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
TMUX_SESSION="earl-ctx-$$"
|
|
19
|
+
TIMEOUT=30
|
|
20
|
+
|
|
21
|
+
cleanup() { tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true; }
|
|
22
|
+
trap cleanup EXIT
|
|
23
|
+
|
|
24
|
+
# Resume the Claude session in detached tmux (wide to avoid text wrapping)
|
|
25
|
+
tmux new-session -d -s "$TMUX_SESSION" -x 200 -y 80 "CLAUDECODE= claude --resume $SESSION_ID --fork-session 2>&1; sleep 30"
|
|
26
|
+
|
|
27
|
+
# Wait for the session to be ready (prompt indicators)
|
|
28
|
+
for _ in $(seq 1 "$TIMEOUT"); do
|
|
29
|
+
sleep 1
|
|
30
|
+
if tmux capture-pane -t "$TMUX_SESSION" -p -S -20 2>/dev/null | grep -q 'PR #\|Try \|Welcome\|INSERT'; then
|
|
31
|
+
break
|
|
32
|
+
fi
|
|
33
|
+
done
|
|
34
|
+
|
|
35
|
+
# Extra settle time after prompt appears
|
|
36
|
+
sleep 5
|
|
37
|
+
|
|
38
|
+
# Clear any pending input and send /context
|
|
39
|
+
# NOTE: Do NOT send Escape — Claude TUI uses vim-style modes and Escape
|
|
40
|
+
# switches from INSERT to NORMAL mode, where "/" starts a search.
|
|
41
|
+
# First Enter triggers autocomplete menu, second Enter selects /context
|
|
42
|
+
tmux send-keys -t "$TMUX_SESSION" C-u
|
|
43
|
+
sleep 0.5
|
|
44
|
+
tmux send-keys -t "$TMUX_SESSION" '/context' Enter
|
|
45
|
+
sleep 2
|
|
46
|
+
tmux send-keys -t "$TMUX_SESSION" Enter
|
|
47
|
+
sleep 5
|
|
48
|
+
|
|
49
|
+
# Capture the pane content (large scrollback to get full /context output)
|
|
50
|
+
OUTPUT=$(tmux capture-pane -t "$TMUX_SESSION" -p -S -200 2>/dev/null)
|
|
51
|
+
|
|
52
|
+
# Exit Claude cleanly
|
|
53
|
+
tmux send-keys -t "$TMUX_SESSION" C-u
|
|
54
|
+
sleep 0.5
|
|
55
|
+
tmux send-keys -t "$TMUX_SESSION" '/exit' Enter
|
|
56
|
+
sleep 1
|
|
57
|
+
|
|
58
|
+
# /context output uses unicode icons (⛁ ⛶ ⛝) as prefixes for each category line.
|
|
59
|
+
# We anchor our greps on these icons to avoid matching conversation history that may
|
|
60
|
+
# contain similar text (e.g., diffs of this very script).
|
|
61
|
+
#
|
|
62
|
+
# Example lines:
|
|
63
|
+
# ⛁ System prompt: 3k tokens (1.5%)
|
|
64
|
+
# ⛶ Free space: 133k (66.7%)
|
|
65
|
+
# ⛝ Autocompact buffer: 33k tokens (16.5%)
|
|
66
|
+
|
|
67
|
+
# Parse the context summary line: "claude-opus-4-6 · 34k/200k tokens (17%)"
|
|
68
|
+
# The · is a UTF-8 middle dot (U+00B7), match with [^a-z]*
|
|
69
|
+
# Take the LAST match in case scrollback has older /context outputs
|
|
70
|
+
summary_line=$(echo "$OUTPUT" | grep -o '[a-z0-9-]* [^a-z]* [0-9.]*k*/[0-9.]*k* tokens ([0-9.]*%)' | tail -1 || echo "")
|
|
71
|
+
|
|
72
|
+
if [[ -z "$summary_line" ]]; then
|
|
73
|
+
if [[ "$JSON_MODE" == true ]]; then
|
|
74
|
+
echo '{"error": "Could not parse context data"}'
|
|
75
|
+
else
|
|
76
|
+
echo "⚠️ Could not parse context data."
|
|
77
|
+
echo "Raw output (last 30 lines):"
|
|
78
|
+
echo "$OUTPUT" | tail -30
|
|
79
|
+
fi
|
|
80
|
+
exit 1
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
model=$(echo "$summary_line" | sed 's/ [^a-z].*//')
|
|
84
|
+
used_tokens=$(echo "$summary_line" | grep -o '[0-9.]*k*/' | sed 's/\///')
|
|
85
|
+
total_tokens=$(echo "$summary_line" | grep -o '/[0-9.]*k* tokens' | sed 's|^/||' | sed 's/ tokens//')
|
|
86
|
+
percent=$(echo "$summary_line" | grep -o '([0-9.]*%)' | tr -d '()')
|
|
87
|
+
|
|
88
|
+
# Helper: extract token count from a /context category line.
|
|
89
|
+
# Anchors on unicode icons (⛁ ⛶ ⛝) to avoid matching conversation history.
|
|
90
|
+
parse_tokens() {
|
|
91
|
+
echo "$OUTPUT" | grep "[⛁⛶⛝] $1" | tail -1 | grep -o '[0-9.]*k* tokens' | sed 's/ tokens//' || echo ""
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Helper: extract percentage from a /context category line
|
|
95
|
+
parse_pct() {
|
|
96
|
+
echo "$OUTPUT" | grep "[⛁⛶⛝] $1" | tail -1 | grep -o '([0-9.]*%)' | tr -d '()' || echo ""
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Parse category breakdowns
|
|
100
|
+
system_prompt=$(parse_tokens 'System prompt:')
|
|
101
|
+
system_tools=$(parse_tokens 'System tools:')
|
|
102
|
+
custom_agents=$(parse_tokens 'Custom agents:')
|
|
103
|
+
memory_files=$(parse_tokens 'Memory files:')
|
|
104
|
+
skills_val=$(parse_tokens 'Skills:')
|
|
105
|
+
messages=$(parse_tokens 'Messages:')
|
|
106
|
+
free_space=$(echo "$OUTPUT" | grep '[⛁⛶⛝] Free space:' | tail -1 | grep -o '[0-9.]*k*' | head -1 || echo "")
|
|
107
|
+
autocompact=$(echo "$OUTPUT" | grep '[⛁⛶⛝] Autocompact buffer:' | tail -1 | grep -o '[0-9.]*k* tokens' | sed 's/ tokens//' || echo "")
|
|
108
|
+
|
|
109
|
+
# Parse percentage breakdowns
|
|
110
|
+
system_prompt_pct=$(parse_pct 'System prompt:')
|
|
111
|
+
system_tools_pct=$(parse_pct 'System tools:')
|
|
112
|
+
custom_agents_pct=$(parse_pct 'Custom agents:')
|
|
113
|
+
memory_files_pct=$(parse_pct 'Memory files:')
|
|
114
|
+
skills_pct=$(parse_pct 'Skills:')
|
|
115
|
+
messages_pct=$(parse_pct 'Messages:')
|
|
116
|
+
free_space_pct=$(parse_pct 'Free space:')
|
|
117
|
+
autocompact_pct=$(echo "$OUTPUT" | grep '[⛁⛶⛝] Autocompact buffer:' | tail -1 | grep -o '([0-9.]*%)' | tr -d '()' || echo "")
|
|
118
|
+
|
|
119
|
+
if [[ "$JSON_MODE" == true ]]; then
|
|
120
|
+
# Helper to output a JSON string or null
|
|
121
|
+
json_str() { if [[ -n "$1" ]]; then echo "\"$1\""; else echo "null"; fi; }
|
|
122
|
+
|
|
123
|
+
cat <<JSON
|
|
124
|
+
{
|
|
125
|
+
"model": "$model",
|
|
126
|
+
"used_tokens": "$used_tokens",
|
|
127
|
+
"total_tokens": "$total_tokens",
|
|
128
|
+
"percent_used": "$percent",
|
|
129
|
+
"categories": {
|
|
130
|
+
"system_prompt": {"tokens": $(json_str "$system_prompt"), "percent": $(json_str "$system_prompt_pct")},
|
|
131
|
+
"system_tools": {"tokens": $(json_str "$system_tools"), "percent": $(json_str "$system_tools_pct")},
|
|
132
|
+
"custom_agents": {"tokens": $(json_str "$custom_agents"), "percent": $(json_str "$custom_agents_pct")},
|
|
133
|
+
"memory_files": {"tokens": $(json_str "$memory_files"), "percent": $(json_str "$memory_files_pct")},
|
|
134
|
+
"skills": {"tokens": $(json_str "$skills_val"), "percent": $(json_str "$skills_pct")},
|
|
135
|
+
"messages": {"tokens": $(json_str "$messages"), "percent": $(json_str "$messages_pct")},
|
|
136
|
+
"free_space": {"tokens": $(json_str "$free_space"), "percent": $(json_str "$free_space_pct")},
|
|
137
|
+
"autocompact_buffer": {"tokens": $(json_str "$autocompact"), "percent": $(json_str "$autocompact_pct")}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
JSON
|
|
141
|
+
else
|
|
142
|
+
echo "📊 Claude Context Usage"
|
|
143
|
+
echo ""
|
|
144
|
+
echo " Model: $model"
|
|
145
|
+
echo " Context: $used_tokens / $total_tokens tokens ($percent)"
|
|
146
|
+
echo ""
|
|
147
|
+
[[ -n "$messages" ]] && echo " Messages: $messages tokens ($messages_pct)"
|
|
148
|
+
[[ -n "$system_prompt" ]] && echo " System prompt: $system_prompt tokens ($system_prompt_pct)"
|
|
149
|
+
[[ -n "$system_tools" ]] && echo " System tools: $system_tools tokens ($system_tools_pct)"
|
|
150
|
+
[[ -n "$custom_agents" ]] && echo " Custom agents: $custom_agents tokens ($custom_agents_pct)"
|
|
151
|
+
[[ -n "$memory_files" ]] && echo " Memory files: $memory_files tokens ($memory_files_pct)"
|
|
152
|
+
[[ -n "$skills_val" ]] && echo " Skills: $skills_val tokens ($skills_pct)"
|
|
153
|
+
[[ -n "$free_space" ]] && echo " Free space: $free_space ($free_space_pct)"
|
|
154
|
+
[[ -n "$autocompact" ]] && echo " Autocompact buf: $autocompact tokens ($autocompact_pct)"
|
|
155
|
+
fi
|
data/bin/claude-usage
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Scrape Claude Code /usage output via tmux.
|
|
3
|
+
# Spawns an interactive Claude session, runs /usage, captures output, exits.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# bin/claude-usage # Human-readable output
|
|
7
|
+
# bin/claude-usage --json # JSON output for programmatic use
|
|
8
|
+
#
|
|
9
|
+
# Requires: tmux, claude CLI
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
SESSION="claude-usage-$$"
|
|
13
|
+
TIMEOUT=15
|
|
14
|
+
|
|
15
|
+
cleanup() { tmux kill-session -t "$SESSION" 2>/dev/null || true; }
|
|
16
|
+
trap cleanup EXIT
|
|
17
|
+
|
|
18
|
+
# Start interactive Claude in detached tmux (wide to avoid text wrapping)
|
|
19
|
+
tmux new-session -d -s "$SESSION" -x 200 -y 50 "CLAUDECODE= claude 2>&1"
|
|
20
|
+
|
|
21
|
+
# Wait for the welcome screen
|
|
22
|
+
for _ in $(seq 1 "$TIMEOUT"); do
|
|
23
|
+
sleep 1
|
|
24
|
+
if tmux capture-pane -t "$SESSION" -p 2>/dev/null | grep -q "Welcome\|Opus\|Claude"; then
|
|
25
|
+
break
|
|
26
|
+
fi
|
|
27
|
+
done
|
|
28
|
+
|
|
29
|
+
# Send /usage — first Enter selects from autocomplete, second confirms
|
|
30
|
+
tmux send-keys -t "$SESSION" '/usage' Enter
|
|
31
|
+
sleep 1
|
|
32
|
+
tmux send-keys -t "$SESSION" Enter
|
|
33
|
+
sleep 3
|
|
34
|
+
|
|
35
|
+
# Capture the pane content
|
|
36
|
+
OUTPUT=$(tmux capture-pane -t "$SESSION" -p 2>/dev/null)
|
|
37
|
+
|
|
38
|
+
# Exit Claude cleanly
|
|
39
|
+
tmux send-keys -t "$SESSION" C-u
|
|
40
|
+
sleep 0.5
|
|
41
|
+
tmux send-keys -t "$SESSION" '/exit' Enter
|
|
42
|
+
sleep 1
|
|
43
|
+
|
|
44
|
+
# Parse the captured output
|
|
45
|
+
session_pct=$(echo "$OUTPUT" | grep -A1 "Current session" | grep -o '[0-9]*% used' | grep -o '[0-9]*' || echo "")
|
|
46
|
+
session_reset=$(echo "$OUTPUT" | grep -A2 "Current session" | grep "Resets" | sed 's/^[[:space:]]*//' | sed 's/Resets //' || echo "")
|
|
47
|
+
|
|
48
|
+
# "Current week (all models)" — use head -1 to pick only the first match
|
|
49
|
+
week_pct=$(echo "$OUTPUT" | grep -A1 "Current week (all models)" | grep -o '[0-9]*% used' | head -1 | grep -o '[0-9]*' || echo "")
|
|
50
|
+
week_reset=$(echo "$OUTPUT" | grep -A2 "Current week (all models)" | grep "Resets" | head -1 | sed 's/^[[:space:]]*//' | sed 's/Resets //' || echo "")
|
|
51
|
+
|
|
52
|
+
# "Current week (Sonnet only)" — separate field
|
|
53
|
+
sonnet_pct=$(echo "$OUTPUT" | grep -A1 "Current week (Sonnet only)" | grep -o '[0-9]*% used' | head -1 | grep -o '[0-9]*' || echo "")
|
|
54
|
+
sonnet_reset=$(echo "$OUTPUT" | grep -A2 "Current week (Sonnet only)" | grep "Resets" | head -1 | sed 's/^[[:space:]]*//' | sed 's/Resets //' || echo "")
|
|
55
|
+
|
|
56
|
+
# Fallback: if "all models" label not found, try plain "Current week" (older CLI)
|
|
57
|
+
if [[ -z "$week_pct" ]]; then
|
|
58
|
+
week_pct=$(echo "$OUTPUT" | grep -A1 "Current week" | grep -o '[0-9]*% used' | head -1 | grep -o '[0-9]*' || echo "")
|
|
59
|
+
week_reset=$(echo "$OUTPUT" | grep -A2 "Current week" | grep "Resets" | head -1 | sed 's/^[[:space:]]*//' | sed 's/Resets //' || echo "")
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
extra_pct=$(echo "$OUTPUT" | grep -A1 "Extra usage" | grep -o '[0-9]*% used' | grep -o '[0-9]*' || echo "")
|
|
63
|
+
extra_detail=$(echo "$OUTPUT" | grep -A2 "Extra usage" | grep "spent" | sed 's/^[[:space:]]*//' || echo "")
|
|
64
|
+
extra_reset=$(echo "$extra_detail" | sed 's/.*Resets //' || echo "")
|
|
65
|
+
extra_spent=$(echo "$extra_detail" | grep -o '\$[0-9.]*' | head -1 || echo "")
|
|
66
|
+
extra_budget=$(echo "$extra_detail" | grep -o '\$[0-9.]*' | tail -1 || echo "")
|
|
67
|
+
|
|
68
|
+
if [[ "${1:-}" == "--json" ]]; then
|
|
69
|
+
cat <<JSON
|
|
70
|
+
{
|
|
71
|
+
"session": {
|
|
72
|
+
"percent_used": ${session_pct:-null},
|
|
73
|
+
"resets": "${session_reset}"
|
|
74
|
+
},
|
|
75
|
+
"week": {
|
|
76
|
+
"percent_used": ${week_pct:-null},
|
|
77
|
+
"resets": "${week_reset}"
|
|
78
|
+
},
|
|
79
|
+
"sonnet_week": {
|
|
80
|
+
"percent_used": ${sonnet_pct:-null},
|
|
81
|
+
"resets": "${sonnet_reset}"
|
|
82
|
+
},
|
|
83
|
+
"extra": {
|
|
84
|
+
"percent_used": ${extra_pct:-null},
|
|
85
|
+
"spent": "${extra_spent}",
|
|
86
|
+
"budget": "${extra_budget}",
|
|
87
|
+
"resets": "${extra_reset}"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
JSON
|
|
91
|
+
else
|
|
92
|
+
echo "📊 Claude Usage"
|
|
93
|
+
echo ""
|
|
94
|
+
if [[ -n "$session_pct" ]]; then
|
|
95
|
+
echo " Session: ${session_pct}% used — resets ${session_reset}"
|
|
96
|
+
fi
|
|
97
|
+
if [[ -n "$week_pct" ]]; then
|
|
98
|
+
echo " Week: ${week_pct}% used — resets ${week_reset}"
|
|
99
|
+
fi
|
|
100
|
+
if [[ -n "$sonnet_pct" ]]; then
|
|
101
|
+
echo " Sonnet: ${sonnet_pct}% used — resets ${sonnet_reset}"
|
|
102
|
+
fi
|
|
103
|
+
if [[ -n "$extra_pct" ]]; then
|
|
104
|
+
echo " Extra: ${extra_pct}% used (${extra_spent} / ${extra_budget}) — resets ${extra_reset}"
|
|
105
|
+
fi
|
|
106
|
+
if [[ -z "$session_pct" && -z "$week_pct" ]]; then
|
|
107
|
+
echo " ⚠️ Could not parse usage data. Raw output:"
|
|
108
|
+
echo "$OUTPUT" | sed -n '/Current session/,/Esc to cancel/p'
|
|
109
|
+
fi
|
|
110
|
+
fi
|
data/bin/coverage
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/inline"
|
|
5
|
+
|
|
6
|
+
gemfile do
|
|
7
|
+
source "https://rubygems.org"
|
|
8
|
+
gem "nokogiri"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Path to the SimpleCov HTML report
|
|
12
|
+
coverage_file = File.join(Dir.pwd, "coverage", "index.html")
|
|
13
|
+
|
|
14
|
+
unless File.exist?(coverage_file)
|
|
15
|
+
puts "❌ No coverage report found at #{coverage_file}"
|
|
16
|
+
puts "Run tests first: bundle exec rake test"
|
|
17
|
+
exit 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Parse the HTML file
|
|
21
|
+
doc = Nokogiri::HTML(File.read(coverage_file))
|
|
22
|
+
|
|
23
|
+
# Extract overall line coverage
|
|
24
|
+
line_percent = doc.css(".covered_percent span").first&.text&.strip
|
|
25
|
+
total_lines = doc.css(".t-line-summary b").first&.text&.strip
|
|
26
|
+
covered_lines = doc.css(".t-line-summary .green b").first&.text&.strip
|
|
27
|
+
missed_lines = doc.css(".t-line-summary .red b").first&.text&.strip
|
|
28
|
+
|
|
29
|
+
# Extract overall branch coverage (first t-branch-summary is the overall stats)
|
|
30
|
+
overall_branch_summary = doc.css(".t-branch-summary").first
|
|
31
|
+
if overall_branch_summary
|
|
32
|
+
branch_percent = overall_branch_summary.css("span").last.text.strip.gsub(/[()]/, "")
|
|
33
|
+
branch_summary_spans = overall_branch_summary.css("span b")
|
|
34
|
+
overall_total_branches = branch_summary_spans[0]&.text&.strip
|
|
35
|
+
overall_covered_branches = branch_summary_spans[1]&.text&.strip
|
|
36
|
+
overall_missed_branches = branch_summary_spans[2]&.text&.strip
|
|
37
|
+
else
|
|
38
|
+
# No branch coverage available
|
|
39
|
+
branch_percent = nil
|
|
40
|
+
overall_total_branches = nil
|
|
41
|
+
overall_covered_branches = nil
|
|
42
|
+
overall_missed_branches = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Extract timestamp
|
|
46
|
+
timestamp = doc.css(".timestamp .timeago").first&.attr("title")
|
|
47
|
+
|
|
48
|
+
puts "## 📊 SimpleCov Coverage Report"
|
|
49
|
+
puts "Generated: #{timestamp}"
|
|
50
|
+
puts ""
|
|
51
|
+
|
|
52
|
+
puts "### 📈 Line Coverage: #{line_percent}"
|
|
53
|
+
puts " ✅ #{covered_lines}/#{total_lines} lines covered"
|
|
54
|
+
puts " ❌ #{missed_lines} lines missed"
|
|
55
|
+
puts ""
|
|
56
|
+
|
|
57
|
+
if branch_percent
|
|
58
|
+
puts "### 🌳 Branch Coverage: #{branch_percent}"
|
|
59
|
+
puts " ✅ #{overall_covered_branches}/#{overall_total_branches} branches covered"
|
|
60
|
+
puts " ❌ #{overall_missed_branches} branches missed"
|
|
61
|
+
puts ""
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Show file-by-file breakdown if there are missed lines or branches
|
|
65
|
+
if missed_lines.to_i.positive? || overall_missed_branches&.to_i&.positive?
|
|
66
|
+
# First, collect all files with missing coverage
|
|
67
|
+
files_with_issues = []
|
|
68
|
+
files_shown = Set.new
|
|
69
|
+
|
|
70
|
+
doc.css("tbody .t-file").each do |row|
|
|
71
|
+
file_name = row.css(".t-file__name a").first&.text&.strip
|
|
72
|
+
line_coverage = row.css(".t-file__coverage").first&.text&.strip
|
|
73
|
+
branch_coverage = row.css(".t-file__branch-coverage").first&.text&.strip
|
|
74
|
+
|
|
75
|
+
# Only include files that aren't 100% covered and haven't been seen yet
|
|
76
|
+
should_skip = files_shown.include?(file_name) ||
|
|
77
|
+
(line_coverage == "100.00 %" && (!branch_coverage || branch_coverage == "100.00 %"))
|
|
78
|
+
next if should_skip
|
|
79
|
+
|
|
80
|
+
files_shown.add(file_name)
|
|
81
|
+
files_with_issues << row
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
total_files_with_issues = files_with_issues.length
|
|
85
|
+
files_to_show = files_with_issues.take(50)
|
|
86
|
+
|
|
87
|
+
puts "### 📋 Files with missing coverage:"
|
|
88
|
+
puts ""
|
|
89
|
+
if total_files_with_issues > 50
|
|
90
|
+
puts "Showing top 50 of #{total_files_with_issues} files with missing coverage:"
|
|
91
|
+
puts ""
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
files_to_show.each do |row|
|
|
95
|
+
file_name = row.css(".t-file__name a").first&.text&.strip
|
|
96
|
+
line_coverage = row.css(".t-file__coverage").first&.text&.strip
|
|
97
|
+
branch_coverage = row.css(".t-file__branch-coverage").first&.text&.strip
|
|
98
|
+
file_link = row.css(".t-file__name a").first&.attr("href")
|
|
99
|
+
|
|
100
|
+
# Extract detailed line information for this file
|
|
101
|
+
missed_lines = []
|
|
102
|
+
missed_branches = []
|
|
103
|
+
total_file_lines = 0
|
|
104
|
+
covered_file_lines = 0
|
|
105
|
+
|
|
106
|
+
if file_link
|
|
107
|
+
file_id = file_link.gsub("#", "")
|
|
108
|
+
file_section = doc.css("##{file_id}")
|
|
109
|
+
|
|
110
|
+
if file_section.any?
|
|
111
|
+
# Get the actual counts from SimpleCov's summary
|
|
112
|
+
line_summary = file_section.css(".t-line-summary")
|
|
113
|
+
if line_summary.any?
|
|
114
|
+
summary_text = line_summary.text
|
|
115
|
+
# Extract numbers from text like "13 relevant lines. 12 lines covered and 1 lines missed."
|
|
116
|
+
total_file_lines = Regexp.last_match(1).to_i if summary_text.match(/(\d+)\s+relevant\s+lines/)
|
|
117
|
+
covered_file_lines = Regexp.last_match(1).to_i if summary_text.match(/(\d+)\s+lines\s+covered/)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Find missed lines and branches
|
|
121
|
+
file_section.css("li").each do |line_item|
|
|
122
|
+
line_number = line_item.attr("data-linenumber")
|
|
123
|
+
line_class = line_item.attr("class")
|
|
124
|
+
|
|
125
|
+
if line_class&.include?("missed") && !line_class.include?("missed-branch")
|
|
126
|
+
missed_lines << line_number
|
|
127
|
+
elsif line_class&.include?("missed-branch")
|
|
128
|
+
missed_branches << line_number
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Format the line ranges more clearly
|
|
135
|
+
def format_line_ranges(lines)
|
|
136
|
+
return "" if lines.empty?
|
|
137
|
+
|
|
138
|
+
ranges = []
|
|
139
|
+
current_range = [lines.first.to_i]
|
|
140
|
+
|
|
141
|
+
lines.map(&:to_i).sort[1..]&.each do |line|
|
|
142
|
+
if line == current_range.last + 1
|
|
143
|
+
current_range << line
|
|
144
|
+
else
|
|
145
|
+
ranges << format_range(current_range)
|
|
146
|
+
current_range = [line]
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
ranges << format_range(current_range)
|
|
150
|
+
|
|
151
|
+
"L#{ranges.join(", L")}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def format_range(range)
|
|
155
|
+
if range.length == 1
|
|
156
|
+
range.first.to_s
|
|
157
|
+
else
|
|
158
|
+
"#{range.first}-#{range.last}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Get branch counts from the file section
|
|
163
|
+
covered_branches = 0
|
|
164
|
+
total_branches = 0
|
|
165
|
+
|
|
166
|
+
if file_link
|
|
167
|
+
file_id = file_link.gsub("#", "")
|
|
168
|
+
file_section = doc.css("##{file_id}")
|
|
169
|
+
branch_summary = file_section.css(".t-branch-summary")
|
|
170
|
+
|
|
171
|
+
if branch_summary.any?
|
|
172
|
+
branch_spans = branch_summary.css("span b")
|
|
173
|
+
total_branches = branch_spans[0]&.text.to_i
|
|
174
|
+
covered_branches = branch_spans[1]&.text.to_i
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
branch_display = branch_coverage ? "Branch: #{branch_coverage}" : "Branch: N/A"
|
|
179
|
+
puts " #{file_name} (Line: #{line_coverage}, #{branch_display}):"
|
|
180
|
+
|
|
181
|
+
line_info = "📍 Lines: #{covered_file_lines}/#{total_file_lines}"
|
|
182
|
+
line_info += " (missed: #{format_line_ranges(missed_lines)})" unless missed_lines.empty?
|
|
183
|
+
puts " #{line_info}"
|
|
184
|
+
|
|
185
|
+
if total_branches.positive?
|
|
186
|
+
branch_info = "🌿 Branches: #{covered_branches}/#{total_branches}"
|
|
187
|
+
branch_info += " (missed: #{format_line_ranges(missed_branches)})" unless missed_branches.empty?
|
|
188
|
+
puts " #{branch_info}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
puts ""
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Write to GitHub Actions job summary if available
|
|
196
|
+
if ENV["GITHUB_STEP_SUMMARY"]
|
|
197
|
+
File.open(ENV["GITHUB_STEP_SUMMARY"], "a") do |f|
|
|
198
|
+
f.puts ""
|
|
199
|
+
f.puts "## 📊 Test Coverage Report"
|
|
200
|
+
f.puts ""
|
|
201
|
+
f.puts "**Line Coverage:** #{line_percent} (#{covered_lines}/#{total_lines} lines)"
|
|
202
|
+
f.puts ""
|
|
203
|
+
|
|
204
|
+
if branch_percent
|
|
205
|
+
f.puts "**Branch Coverage:** #{branch_percent} (#{overall_covered_branches}/#{overall_total_branches} branches)"
|
|
206
|
+
f.puts ""
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Add visual indicators
|
|
210
|
+
line_pct_value = line_percent.gsub("%", "").to_f
|
|
211
|
+
branch_pct_value = branch_percent&.gsub("%", "")&.to_f
|
|
212
|
+
|
|
213
|
+
if line_pct_value >= 95 && (!branch_pct_value || branch_pct_value >= 95)
|
|
214
|
+
f.puts "✅ Coverage meets quality thresholds"
|
|
215
|
+
elsif line_pct_value >= 80 && (!branch_pct_value || branch_pct_value >= 80)
|
|
216
|
+
f.puts "⚠️ Coverage is acceptable but could be improved"
|
|
217
|
+
else
|
|
218
|
+
f.puts "❌ Coverage is below recommended thresholds"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
data/bin/rubocop
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "rubygems"
|
|
5
|
+
require "bundler/setup"
|
|
6
|
+
|
|
7
|
+
# Explicit RuboCop config increases performance slightly while avoiding config confusion.
|
|
8
|
+
ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
|
|
9
|
+
|
|
10
|
+
load Gem.bin_path("rubocop", "rubocop")
|