claude-matrix 1.0.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/CHANGELOG.md +21 -0
- data/LICENSE +21 -0
- data/README.md +183 -0
- data/bin/claude-matrix +7 -0
- data/lib/claude_matrix/analyzers/metrics.rb +206 -0
- data/lib/claude_matrix/cli.rb +177 -0
- data/lib/claude_matrix/readers/history_reader.rb +38 -0
- data/lib/claude_matrix/readers/session_parser.rb +111 -0
- data/lib/claude_matrix/readers/stats_reader.rb +20 -0
- data/lib/claude_matrix/version.rb +3 -0
- data/lib/claude_matrix/visualizers/dashboard.rb +453 -0
- data/lib/claude_matrix.rb +34 -0
- metadata +132 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f223740ea18dd305956e0d1a21b8424dec3a19bd467e9a78310fb40702f7697d
|
|
4
|
+
data.tar.gz: 5136193a040c79ba2b55d7de2720a89db388acfa78b5f825c19783ab7878e0b8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 50c596495620a83d34d38c604ddd1f6f3d951f4c624c788ad056a0f47a9b10e63e352c178d94a00a918b8c041c4ac8817cb4ac41e00f7b8258181894910e1449
|
|
7
|
+
data.tar.gz: f016892c5c0e9d120fa2bba9be0004d3e823dc39729db5e3060e7eba9fc579f413ddea8e19bdd722be3166897f7be51e7687e569d37d24ee7dba9796fb07bc24
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented here.
|
|
4
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
5
|
+
|
|
6
|
+
## [1.0.0] - 2026-03-10
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- Full-screen btop-style TUI dashboard
|
|
10
|
+
- Streaks: current, longest, active days
|
|
11
|
+
- Personal bests: longest session, most productive day, busiest hour, favorite tool
|
|
12
|
+
- Work mode breakdown: Explore / Build / Test
|
|
13
|
+
- Top tools ranked bar chart with gradient coloring
|
|
14
|
+
- Tokens & cost panel with per-model cost estimation
|
|
15
|
+
- Activity chart (last 30 days)
|
|
16
|
+
- Hourly heatmap (day × hour, shown on taller terminals)
|
|
17
|
+
- Time filters: Today / Week / Month / All Time (`t`/`w`/`m`/`a`)
|
|
18
|
+
- Per-project breakdown with git branch tracking
|
|
19
|
+
- `stats` command with `--today`, `--week`, `--month` flags
|
|
20
|
+
- `doctor` command to verify data sources
|
|
21
|
+
- Zero configuration — reads `~/.claude/` directly
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kapil Bhosale
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Claude Matrix
|
|
2
|
+
|
|
3
|
+
A privacy-first, terminal analytics dashboard for [Claude Code](https://claude.ai/code). It reads your local Claude Code session data (reads ~/.claude/ directly, never touches the network) — sessions, tools, tokens, cost, streaks — and renders a live TUI right in your terminal. No cloud, no API calls, no configuration. Just run it and see your usage, coding patterns.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- **Full-screen TUI** — btop-inspired two-column layout that fills your terminal exactly, no scrolling
|
|
13
|
+
- **Streaks** — current and longest coding streaks, active days
|
|
14
|
+
- **Personal bests** — longest session, most productive day, busiest hour, favorite tool
|
|
15
|
+
- **Work mode** — Explore / Build / Test breakdown based on tool usage patterns
|
|
16
|
+
- **Top tools** — ranked bar chart with gradient coloring
|
|
17
|
+
- **Tokens & cost** — input/output token bars with estimated cost (color-coded green → yellow → red)
|
|
18
|
+
- **Activity chart** — last 30-day bar chart
|
|
19
|
+
- **Heatmap** — sessions by day-of-week × hour (on taller terminals)
|
|
20
|
+
- **Time filters** — Today / Week / Month / All Time, switch with a single key
|
|
21
|
+
- **100% local** — reads `~/.claude/` directly, never touches the network
|
|
22
|
+
- **Git branch tracking** — shows the branch you were on per project
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
- Ruby 3.0+
|
|
29
|
+
- macOS or Linux
|
|
30
|
+
- Claude Code installed and used at least once
|
|
31
|
+
- A terminal with Unicode and color support (iTerm2, Warp, Kitty, Alacritty, etc.)
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
gem install claude-matrix
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**From source:**
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
git clone https://github.com/kapilbhosale/claude-matrix
|
|
45
|
+
cd claude-matrix
|
|
46
|
+
bundle install
|
|
47
|
+
bundle exec bin/claude-matrix
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
### Launch the dashboard
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
claude-matrix
|
|
58
|
+
# or
|
|
59
|
+
claude-matrix dashboard
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Dashboard keyboard controls
|
|
63
|
+
|
|
64
|
+
| Key | Action |
|
|
65
|
+
|-----|--------|
|
|
66
|
+
| `t` | Filter: Today |
|
|
67
|
+
| `w` | Filter: This Week |
|
|
68
|
+
| `m` | Filter: This Month |
|
|
69
|
+
| `a` | Filter: All Time |
|
|
70
|
+
| `r` | Refresh data |
|
|
71
|
+
| `q` | Quit |
|
|
72
|
+
|
|
73
|
+
### Quick stats (no TUI)
|
|
74
|
+
|
|
75
|
+
### Check data sources
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
claude-matrix doctor
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## How it works
|
|
84
|
+
|
|
85
|
+
Claude Code writes session transcripts to `~/.claude/projects/`. Claude Matrix reads those JSONL files directly on each refresh — no background daemon, no database, no setup.
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
~/.claude/
|
|
89
|
+
├── stats-cache.json # pre-computed stats (optional bonus)
|
|
90
|
+
├── history.jsonl # all prompts ever typed
|
|
91
|
+
└── projects/
|
|
92
|
+
└── {encoded-path}/
|
|
93
|
+
└── {session-uuid}.jsonl ← parsed on every run
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Each session file is parsed for:
|
|
97
|
+
- Tool calls (`Bash`, `Read`, `Write`, `Edit`, …)
|
|
98
|
+
- Token usage and model name (for cost estimation)
|
|
99
|
+
- Timestamps (for streaks and heatmap)
|
|
100
|
+
- Git branch (shown in Projects panel)
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Cost estimation
|
|
105
|
+
|
|
106
|
+
Estimated costs use published Claude API pricing (per 1M tokens):
|
|
107
|
+
|
|
108
|
+
| Model | Input | Output | Cache read | Cache write |
|
|
109
|
+
|-------|------:|-------:|-----------:|------------:|
|
|
110
|
+
| claude-opus-4.x | $15 | $75 | $1.50 | $18.75 |
|
|
111
|
+
| claude-sonnet-4.x | $3 | $15 | $0.30 | $3.75 |
|
|
112
|
+
| claude-haiku-4.x | $0.80 | $4 | $0.08 | $1.00 |
|
|
113
|
+
|
|
114
|
+
> These are estimates. Actual billed amounts may differ.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Project structure
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
claude-matrix/
|
|
122
|
+
├── bin/
|
|
123
|
+
│ └── claude-matrix # executable
|
|
124
|
+
├── lib/
|
|
125
|
+
│ ├── claude_matrix.rb # entry point
|
|
126
|
+
│ └── claude_matrix/
|
|
127
|
+
│ ├── cli.rb # command router + interactive loop
|
|
128
|
+
│ ├── readers/
|
|
129
|
+
│ │ ├── session_parser.rb # JSONL parser
|
|
130
|
+
│ │ ├── stats_reader.rb # stats-cache.json reader
|
|
131
|
+
│ │ └── history_reader.rb # history.jsonl reader
|
|
132
|
+
│ ├── analyzers/
|
|
133
|
+
│ │ └── metrics.rb # streaks, work modes, cost, heatmap grid
|
|
134
|
+
│ └── visualizers/
|
|
135
|
+
│ └── dashboard.rb # full-screen TUI renderer
|
|
136
|
+
├── Gemfile
|
|
137
|
+
├── claude-matrix.gemspec
|
|
138
|
+
└── README.md
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
## Releasing a new version
|
|
146
|
+
|
|
147
|
+
1. Bump `VERSION` in `lib/claude_matrix/version.rb`
|
|
148
|
+
2. Add an entry to `CHANGELOG.md`
|
|
149
|
+
3. Commit, tag, and push:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
git add -A
|
|
153
|
+
git commit -m "Release v1.x.x"
|
|
154
|
+
git tag v1.x.x
|
|
155
|
+
git push && git push --tags
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
GitHub Actions picks up the tag and publishes to RubyGems automatically.
|
|
159
|
+
|
|
160
|
+
> **One-time setup:** add your RubyGems API key as a repository secret named `RUBYGEMS_API_KEY`
|
|
161
|
+
> (GitHub repo → Settings → Secrets and variables → Actions → New repository secret).
|
|
162
|
+
> Get your key from [rubygems.org/profile/edit](https://rubygems.org/profile/edit).
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Contributing
|
|
167
|
+
|
|
168
|
+
1. Fork the repo
|
|
169
|
+
2. Create a branch: `git checkout -b my-feature`
|
|
170
|
+
3. Make your changes
|
|
171
|
+
4. Open a pull request
|
|
172
|
+
|
|
173
|
+
Bug reports and feature requests are welcome via [GitHub Issues](https://github.com/kapilbhosale/claude-matrix/issues).
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT — see [LICENSE](LICENSE).
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
*Built with Ruby, [TTY toolkit](https://ttytoolkit.org), and [Pastel](https://github.com/piotrmurach/pastel).*
|
data/bin/claude-matrix
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
require "date"
|
|
2
|
+
|
|
3
|
+
module ClaudeMatrix
|
|
4
|
+
module Analyzers
|
|
5
|
+
class Metrics
|
|
6
|
+
# Claude pricing per 1M tokens (as of early 2026)
|
|
7
|
+
MODEL_PRICING = {
|
|
8
|
+
"claude-opus-4-6" => { input: 15.0, output: 75.0, cache_read: 1.5, cache_write: 18.75 },
|
|
9
|
+
"claude-opus-4-5" => { input: 15.0, output: 75.0, cache_read: 1.5, cache_write: 18.75 },
|
|
10
|
+
"claude-sonnet-4-6" => { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
|
|
11
|
+
"claude-sonnet-4-5" => { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
|
|
12
|
+
"claude-haiku-4-5-20251001" => { input: 0.8, output: 4.0, cache_read: 0.08, cache_write: 1.0 },
|
|
13
|
+
"claude-haiku-4-5" => { input: 0.8, output: 4.0, cache_read: 0.08, cache_write: 1.0 },
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
DEFAULT_PRICE = { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 }.freeze
|
|
17
|
+
|
|
18
|
+
WORK_MODE_TOOLS = {
|
|
19
|
+
exploration: %w[Read Glob Grep WebSearch WebFetch read_file list_files search_files grep],
|
|
20
|
+
building: %w[Write Edit NotebookEdit write_to_file edit_file new_file],
|
|
21
|
+
testing: %w[Bash execute_command bash]
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def self.compute(sessions)
|
|
25
|
+
return empty_metrics if sessions.empty?
|
|
26
|
+
|
|
27
|
+
total_input = sessions.sum { |s| s[:input_tokens] }
|
|
28
|
+
total_output = sessions.sum { |s| s[:output_tokens] }
|
|
29
|
+
total_cache_r = sessions.sum { |s| s[:cache_read] }
|
|
30
|
+
total_cache_w = sessions.sum { |s| s[:cache_create] }
|
|
31
|
+
|
|
32
|
+
all_tools = sessions.flat_map { |s| s[:tools] }
|
|
33
|
+
tool_counts = all_tools.tally.sort_by { |_, c| -c }.to_h
|
|
34
|
+
|
|
35
|
+
daily = build_daily_activity(sessions)
|
|
36
|
+
streaks = calculate_streaks(daily)
|
|
37
|
+
hour_counts = build_hour_counts(sessions)
|
|
38
|
+
|
|
39
|
+
longest = sessions.max_by { |s| s[:duration_secs] }
|
|
40
|
+
most_messages = sessions.max_by { |s| s[:message_count] }
|
|
41
|
+
|
|
42
|
+
projects = sessions.group_by { |s| s[:project] }
|
|
43
|
+
most_active_project = projects.max_by { |_, ss| ss.sum { |s| s[:message_count] } }&.first
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
total_sessions: sessions.size,
|
|
47
|
+
total_messages: sessions.sum { |s| s[:message_count] },
|
|
48
|
+
total_tool_calls: all_tools.size,
|
|
49
|
+
total_input_tokens: total_input,
|
|
50
|
+
total_output_tokens: total_output,
|
|
51
|
+
total_cache_read: total_cache_r,
|
|
52
|
+
total_cache_write: total_cache_w,
|
|
53
|
+
estimated_cost: estimate_cost(sessions),
|
|
54
|
+
first_session: sessions.map { |s| s[:started_at] }.min,
|
|
55
|
+
projects: projects.keys.size,
|
|
56
|
+
project_names: projects.keys,
|
|
57
|
+
most_active_project: most_active_project,
|
|
58
|
+
tool_counts: tool_counts,
|
|
59
|
+
top_tools: tool_counts.first(10).to_h,
|
|
60
|
+
favorite_tool: tool_counts.first&.first,
|
|
61
|
+
daily_activity: daily,
|
|
62
|
+
streaks: streaks,
|
|
63
|
+
hour_counts: hour_counts,
|
|
64
|
+
hour_day_grid: build_hour_day_grid(sessions),
|
|
65
|
+
work_modes: calculate_work_modes(all_tools),
|
|
66
|
+
longest_session: longest,
|
|
67
|
+
most_messages_session: most_messages,
|
|
68
|
+
most_productive_day: most_productive_day(daily),
|
|
69
|
+
busiest_hour: hour_counts.max_by { |_, c| c }&.first,
|
|
70
|
+
models_used: sessions.map { |s| s[:model] }.compact.uniq,
|
|
71
|
+
per_project: build_per_project(sessions)
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.estimate_cost(sessions)
|
|
76
|
+
total = 0.0
|
|
77
|
+
sessions.each do |s|
|
|
78
|
+
price = find_price(s[:model])
|
|
79
|
+
total += (s[:input_tokens].to_f / 1_000_000) * price[:input]
|
|
80
|
+
total += (s[:output_tokens].to_f / 1_000_000) * price[:output]
|
|
81
|
+
total += (s[:cache_read].to_f / 1_000_000) * price[:cache_read]
|
|
82
|
+
total += (s[:cache_create].to_f / 1_000_000) * price[:cache_write]
|
|
83
|
+
end
|
|
84
|
+
total.round(4)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def self.find_price(model)
|
|
90
|
+
return DEFAULT_PRICE unless model
|
|
91
|
+
MODEL_PRICING.find { |k, _| model.start_with?(k) }&.last || DEFAULT_PRICE
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.build_daily_activity(sessions)
|
|
95
|
+
daily = Hash.new { |h, k| h[k] = { sessions: 0, messages: 0, tools: 0 } }
|
|
96
|
+
|
|
97
|
+
sessions.each do |s|
|
|
98
|
+
date = s[:started_at].to_date.to_s
|
|
99
|
+
daily[date][:sessions] += 1
|
|
100
|
+
daily[date][:messages] += s[:message_count]
|
|
101
|
+
daily[date][:tools] += s[:tool_count]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
daily.sort.to_h
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.calculate_streaks(daily)
|
|
108
|
+
return { current: 0, longest: 0, active_days: 0 } if daily.empty?
|
|
109
|
+
|
|
110
|
+
dates = daily.keys.map { |d| Date.parse(d) }.sort
|
|
111
|
+
active_days = dates.size
|
|
112
|
+
|
|
113
|
+
# Current streak
|
|
114
|
+
current = 0
|
|
115
|
+
expected = Date.today
|
|
116
|
+
dates.sort.reverse.each do |date|
|
|
117
|
+
break if date < expected - 1
|
|
118
|
+
if date == expected || date == expected - 1
|
|
119
|
+
current += 1
|
|
120
|
+
expected = date - 1
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Also allow today to count if used today
|
|
125
|
+
current = 1 if current == 0 && dates.include?(Date.today)
|
|
126
|
+
|
|
127
|
+
# Longest streak
|
|
128
|
+
longest = 1
|
|
129
|
+
temp = 1
|
|
130
|
+
dates.each_cons(2) do |d1, d2|
|
|
131
|
+
if (d2 - d1).to_i == 1
|
|
132
|
+
temp += 1
|
|
133
|
+
longest = [longest, temp].max
|
|
134
|
+
else
|
|
135
|
+
temp = 1
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
{ current: current, longest: longest, active_days: active_days }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.build_hour_counts(sessions)
|
|
143
|
+
counts = Hash.new(0)
|
|
144
|
+
sessions.each do |s|
|
|
145
|
+
counts[s[:started_at].hour] += 1
|
|
146
|
+
end
|
|
147
|
+
counts
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.calculate_work_modes(tools)
|
|
151
|
+
total = tools.size.to_f
|
|
152
|
+
return { exploration: 0, building: 0, testing: 0 } if total.zero?
|
|
153
|
+
|
|
154
|
+
result = {}
|
|
155
|
+
WORK_MODE_TOOLS.each do |mode, mode_tools|
|
|
156
|
+
count = tools.count { |t| mode_tools.include?(t) }
|
|
157
|
+
result[mode] = (count / total * 100).round
|
|
158
|
+
end
|
|
159
|
+
result
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def self.most_productive_day(daily)
|
|
163
|
+
return nil if daily.empty?
|
|
164
|
+
date, data = daily.max_by { |_, d| d[:sessions] }
|
|
165
|
+
{ date: date, sessions: data[:sessions], messages: data[:messages] }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.build_hour_day_grid(sessions)
|
|
169
|
+
# 7 rows (wday: 0=Sun..6=Sat) × 24 cols (hours)
|
|
170
|
+
grid = Array.new(7) { Array.new(24, 0) }
|
|
171
|
+
sessions.each do |s|
|
|
172
|
+
grid[s[:started_at].wday][s[:started_at].hour] += 1
|
|
173
|
+
end
|
|
174
|
+
grid
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def self.build_per_project(sessions)
|
|
178
|
+
sessions.group_by { |s| s[:project] }.transform_values do |ss|
|
|
179
|
+
{
|
|
180
|
+
sessions: ss.size,
|
|
181
|
+
messages: ss.sum { |s| s[:message_count] },
|
|
182
|
+
tools: ss.sum { |s| s[:tool_count] },
|
|
183
|
+
last_active: ss.map { |s| s[:ended_at] }.max,
|
|
184
|
+
branches: ss.map { |s| s[:git_branch] }.compact.uniq
|
|
185
|
+
}
|
|
186
|
+
end.sort_by { |_, v| -v[:sessions] }.to_h
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def self.empty_metrics
|
|
190
|
+
{
|
|
191
|
+
total_sessions: 0, total_messages: 0, total_tool_calls: 0,
|
|
192
|
+
total_input_tokens: 0, total_output_tokens: 0,
|
|
193
|
+
total_cache_read: 0, total_cache_write: 0, hour_day_grid: Array.new(7) { Array.new(24, 0) },
|
|
194
|
+
estimated_cost: 0.0, first_session: nil, projects: 0,
|
|
195
|
+
project_names: [], most_active_project: nil,
|
|
196
|
+
tool_counts: {}, top_tools: {}, favorite_tool: nil,
|
|
197
|
+
daily_activity: {}, streaks: { current: 0, longest: 0, active_days: 0 },
|
|
198
|
+
hour_counts: {}, work_modes: { exploration: 0, building: 0, testing: 0 },
|
|
199
|
+
longest_session: nil, most_messages_session: nil,
|
|
200
|
+
most_productive_day: nil, busiest_hour: nil,
|
|
201
|
+
models_used: [], per_project: {}
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
require_relative "../claude_matrix"
|
|
2
|
+
require "tty-reader"
|
|
3
|
+
require "tty-cursor"
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
module ClaudeMatrix
|
|
7
|
+
class CLI
|
|
8
|
+
def self.run(args)
|
|
9
|
+
case (args.first || "dashboard")
|
|
10
|
+
when "dashboard", "d", nil then DashboardCommand.new.run
|
|
11
|
+
when "stats" then StatsCommand.new(args[1..]).run
|
|
12
|
+
when "sync" then SyncCommand.new.run
|
|
13
|
+
when "doctor" then DoctorCommand.new.run
|
|
14
|
+
when "version", "--version", "-v" then puts "claude-matrix #{VERSION}"
|
|
15
|
+
when "help", "--help", "-h" then puts help_text
|
|
16
|
+
else
|
|
17
|
+
warn "Unknown command: #{args.first}"
|
|
18
|
+
warn help_text
|
|
19
|
+
exit 1
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.help_text
|
|
24
|
+
<<~HELP
|
|
25
|
+
Claude Matrix #{VERSION} — Claude Code Analytics Dashboard
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
claude-matrix Launch interactive dashboard
|
|
29
|
+
claude-matrix stats Print stats to stdout
|
|
30
|
+
claude-matrix stats --today / --week / --month
|
|
31
|
+
claude-matrix doctor Check data sources
|
|
32
|
+
claude-matrix version
|
|
33
|
+
|
|
34
|
+
Dashboard keys:
|
|
35
|
+
t/w/m/a Filter: Today / Week / Month / All Time
|
|
36
|
+
r Refresh
|
|
37
|
+
q Quit
|
|
38
|
+
HELP
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# ─── Interactive dashboard ────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
class DashboardCommand
|
|
44
|
+
FILTERS = %i[today week month all].freeze
|
|
45
|
+
|
|
46
|
+
def run
|
|
47
|
+
cursor = TTY::Cursor
|
|
48
|
+
reader = TTY::Reader.new(interrupt: :exit)
|
|
49
|
+
filter = :all
|
|
50
|
+
all_sessions = nil # lazy-load once, refresh on 'r'
|
|
51
|
+
|
|
52
|
+
loop do
|
|
53
|
+
all_sessions ||= ClaudeMatrix.load_sessions
|
|
54
|
+
metrics = Analyzers::Metrics.compute(filter_sessions(all_sessions, filter))
|
|
55
|
+
|
|
56
|
+
print cursor.clear_screen
|
|
57
|
+
print cursor.move_to(0, 0)
|
|
58
|
+
Visualizers::Dashboard.new(metrics, filter: filter).render
|
|
59
|
+
|
|
60
|
+
char = reader.read_char
|
|
61
|
+
case char&.downcase
|
|
62
|
+
when "q", "\e" then break
|
|
63
|
+
when "r" then all_sessions = nil # force reload
|
|
64
|
+
when "t" then filter = :today
|
|
65
|
+
when "w" then filter = :week
|
|
66
|
+
when "m" then filter = :month
|
|
67
|
+
when "a" then filter = :all
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
rescue TTY::Reader::InputInterrupt
|
|
71
|
+
# clean exit on Ctrl-C
|
|
72
|
+
ensure
|
|
73
|
+
puts TTY::Cursor.show
|
|
74
|
+
puts # move off last dashboard line
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def filter_sessions(sessions, filter)
|
|
80
|
+
case filter
|
|
81
|
+
when :today
|
|
82
|
+
d = Date.today
|
|
83
|
+
sessions.select { |s| s[:started_at].to_date == d }
|
|
84
|
+
when :week
|
|
85
|
+
start = Date.today - Date.today.wday
|
|
86
|
+
sessions.select { |s| s[:started_at].to_date >= start }
|
|
87
|
+
when :month
|
|
88
|
+
sessions.select { |s|
|
|
89
|
+
s[:started_at].year == Date.today.year &&
|
|
90
|
+
s[:started_at].month == Date.today.month
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
sessions
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# ─── Stats command ────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
class StatsCommand
|
|
101
|
+
def initialize(flags)
|
|
102
|
+
@filter = if flags.include?("--today") then :today
|
|
103
|
+
elsif flags.include?("--week") then :week
|
|
104
|
+
elsif flags.include?("--month") then :month
|
|
105
|
+
else :all
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def run
|
|
110
|
+
all = ClaudeMatrix.load_sessions
|
|
111
|
+
sess = filter_sessions(all, @filter)
|
|
112
|
+
m = Analyzers::Metrics.compute(sess)
|
|
113
|
+
|
|
114
|
+
label = { today: "Today", week: "This Week", month: "This Month", all: "All Time" }[@filter]
|
|
115
|
+
puts "\n Claude Matrix — #{label}"
|
|
116
|
+
puts " " + "─" * 40
|
|
117
|
+
puts " Sessions: #{m[:total_sessions]}"
|
|
118
|
+
puts " Messages: #{m[:total_messages]}"
|
|
119
|
+
puts " Tools: #{m[:total_tool_calls]}"
|
|
120
|
+
puts " Tokens In: #{fmt(m[:total_input_tokens])}"
|
|
121
|
+
puts " Tokens Out: #{fmt(m[:total_output_tokens])}"
|
|
122
|
+
puts " Est. Cost: $#{"%.4f" % m[:estimated_cost]}"
|
|
123
|
+
puts " Streak: #{m[:streaks][:current]} days (best: #{m[:streaks][:longest]})"
|
|
124
|
+
puts " Top Tool: #{m[:favorite_tool]} (#{m[:tool_counts][m[:favorite_tool]]})" if m[:favorite_tool]
|
|
125
|
+
puts
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def filter_sessions(sessions, filter)
|
|
131
|
+
DashboardCommand.new.send(:filter_sessions, sessions, filter)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def fmt(n)
|
|
135
|
+
n >= 1_000_000 ? "%.1fM" % (n / 1_000_000.0) :
|
|
136
|
+
n >= 1_000 ? "%.1fK" % (n / 1_000.0) : n.to_s
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# ─── Sync command ─────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
class SyncCommand
|
|
143
|
+
def run
|
|
144
|
+
files = Readers::SessionParser.jsonl_files
|
|
145
|
+
puts "\nScanning #{files.size} session files..."
|
|
146
|
+
count = 0
|
|
147
|
+
files.each_with_index do |f, i|
|
|
148
|
+
print "\rParsing #{i + 1}/#{files.size}..."
|
|
149
|
+
count += 1 if Readers::SessionParser.parse_file(f)
|
|
150
|
+
end
|
|
151
|
+
puts "\rDone! #{count}/#{files.size} sessions parsed. "
|
|
152
|
+
puts "Run 'claude-matrix' to view the dashboard."
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# ─── Doctor command ───────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
class DoctorCommand
|
|
159
|
+
def run
|
|
160
|
+
puts "\n Claude Matrix — Doctor\n " + "─" * 36
|
|
161
|
+
check "~/.claude/ exists", Dir.exist?(File.expand_path("~/.claude"))
|
|
162
|
+
check "~/.claude/projects/ exists", Dir.exist?(File.expand_path("~/.claude/projects"))
|
|
163
|
+
check "~/.claude/history.jsonl", File.exist?(File.expand_path("~/.claude/history.jsonl"))
|
|
164
|
+
check "stats-cache.json", Readers::StatsReader.available?
|
|
165
|
+
files = Readers::SessionParser.jsonl_files
|
|
166
|
+
check "Session files found (#{files.size})", files.any?
|
|
167
|
+
puts
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def check(label, ok)
|
|
173
|
+
puts " #{ok ? "\e[32m✓\e[0m" : "\e[31m✗\e[0m"} #{label}"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module ClaudeMatrix
|
|
4
|
+
module Readers
|
|
5
|
+
class HistoryReader
|
|
6
|
+
HISTORY_PATH = File.expand_path("~/.claude/history.jsonl")
|
|
7
|
+
|
|
8
|
+
def self.read
|
|
9
|
+
return [] unless File.exist?(HISTORY_PATH)
|
|
10
|
+
|
|
11
|
+
File.readlines(HISTORY_PATH, chomp: true).filter_map do |line|
|
|
12
|
+
JSON.parse(line)
|
|
13
|
+
rescue JSON::ParserError
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.top_words(limit: 10)
|
|
19
|
+
stop_words = %w[i a the an is it in on at to for of and or but
|
|
20
|
+
this that with from can you what how do be have
|
|
21
|
+
are was were will would could should may might
|
|
22
|
+
its your my we our let me now please just also
|
|
23
|
+
if so there not no yes then when they he she]
|
|
24
|
+
|
|
25
|
+
all_words = read.flat_map do |entry|
|
|
26
|
+
text = entry["display"].to_s.downcase
|
|
27
|
+
text.scan(/[a-z]{3,}/)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
all_words
|
|
31
|
+
.reject { |w| stop_words.include?(w) }
|
|
32
|
+
.tally
|
|
33
|
+
.sort_by { |_, count| -count }
|
|
34
|
+
.first(limit)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|