codestrain 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: codestrain
3
+ Version: 0.1.0
4
+ Summary: Your AI coding recovery score, from the terminal.
5
+ Project-URL: Homepage, https://codestrain.dev
6
+ Project-URL: Repository, https://github.com/codestrain/codestrain-cli
7
+ Author: Ivan Kononov
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: burnout,claude-code,developer-tools,drs,wellness
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Software Development
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+
21
+ <p align="center"><img src="https://raw.githubusercontent.com/codestrain/codestrain-cli/main/.assets/logo.png" alt="CodeStrain" width="200"/></p>
22
+
23
+ # CodeStrain CLI
24
+
25
+ *Your AI coding recovery score, from the terminal.*
26
+
27
+ <p align="center">
28
+ <a href="https://pypi.org/project/codestrain/"><img src="https://img.shields.io/pypi/v/codestrain.svg" alt="PyPI version"/></a>
29
+ <a href="https://pypi.org/project/codestrain/"><img src="https://img.shields.io/pypi/pyversions/codestrain.svg" alt="Python versions"/></a>
30
+ <a href="https://github.com/codestrain/codestrain-cli/blob/main/LICENSE"><img src="https://img.shields.io/pypi/l/codestrain.svg" alt="License: MIT"/></a>
31
+ <a href="https://github.com/codestrain/codestrain-cli/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/codestrain/codestrain-cli/ci.yml?branch=main" alt="CI status"/></a>
32
+ </p>
33
+
34
+ ## What is this
35
+
36
+ CodeStrain parses the Claude Code JSONL session logs already on your disk (`~/.claude/projects/`) and prints cost, token usage, a Developer Recovery Score (DRS) estimate, and a per-project breakdown. Zero dependencies — Python stdlib only. Read-only — your JSONL never leaves the machine.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ # one-liner (recommended)
42
+ curl -fsSL codestrain.dev/install | sh
43
+ ```
44
+
45
+ ```bash
46
+ # pipx
47
+ pipx install codestrain
48
+ ```
49
+
50
+ ```bash
51
+ # uv
52
+ uv tool install codestrain
53
+ ```
54
+
55
+ ## Quick start
56
+
57
+ ```bash
58
+ # today's stats (default)
59
+ codestrain
60
+ ```
61
+
62
+ ```bash
63
+ # all-time, every session ever logged
64
+ codestrain --all
65
+ ```
66
+
67
+ ```bash
68
+ # all-time, with project names hashed
69
+ codestrain --all --anonymize
70
+ ```
71
+
72
+ ## Example output
73
+
74
+ ```
75
+ ______ __ _____ __
76
+ / ____/___ ____/ /__ / ___// /__________ _( )___
77
+ / / / __ \/ __ / _ \ \__ \/ __/ ___/ __ `/ / __ \
78
+ / /___/ /_/ / /_/ / __/___/ / /_/ / / /_/ / / / / /
79
+ \____/\____/\__._/\___//____/\__/_/ \__._/_/_/ /_/
80
+
81
+ Your AI coding recovery score.
82
+
83
+ --- All Time ------------------------------------------
84
+
85
+ Sessions: 1454
86
+ Duration: 137h 21m (span 15352h 27m)
87
+ Turns: 61007
88
+ Tokens: 2.0M in / 25.4M out
89
+ Cost: $21948.61
90
+ Models: claude-haiku-4-5, claude-opus-4-5, claude-opus-4-7 +5 more
91
+
92
+ DRS Estimate (avg per active day · 52 days · 2.6h/day)
93
+ Strain: 9.0/21
94
+ Recovery: 82%
95
+ Readiness: GREEN — Recovered. Good to go.
96
+
97
+ --- Per-Project Breakdown -----------------------------
98
+
99
+ project-1 31h 2m 13638 turns $7193.92
100
+ project-2 21h 40m 8684 turns $3652.80
101
+ project-3 15h 32m 4789 turns $1212.63
102
+ ...
103
+ ```
104
+
105
+ ## Flags reference
106
+
107
+ | Flag | Purpose |
108
+ |------|---------|
109
+ | `--all` | Aggregate every session ever logged instead of just today. |
110
+ | `--project NAME` | Only include sessions whose project basename matches `NAME`. |
111
+ | `--path DIR` | Read JSONL from `DIR` instead of `~/.claude/projects/`. |
112
+ | `--detect` | Scan common locations and print where Claude Code data lives. |
113
+ | `--anonymize` | Hash project names before printing the breakdown. |
114
+ | `--no-breakdown` | Suppress the per-project breakdown table. |
115
+ | `--no-color` | Disable ANSI colors (also honors `NO_COLOR`). |
116
+ | `--logo {auto,big,small,none}` | Control the ASCII logo: `big` always, `small` one-liner, `none` off, `auto` picks based on terminal width. |
117
+
118
+ ## DRS — what it actually measures
119
+
120
+ **Strain (0-21, per active day).** The CLI sums the gaps between consecutive turns that are ≤ 5 minutes — that's the "active coding" duration. Each hour contributes `2.1` strain points, capped at 21. The 5-minute threshold matches the ccusage / Claude Code Usage Monitor convention and is configurable via `CODESTRAIN_GAP_MIN`. Debug-heavy sessions (high error ratio), late-night work (after 22:00), and weekend coding add small penalties.
121
+
122
+ **Recovery (0-100%).** Recovery moves inversely to strain and is modulated by hours since the last session (sleep proxy). Eight hours off lifts the baseline; high recent strain pulls it down. The local heuristic doesn't have biometric input — it's purely behavioral.
123
+
124
+ **Readiness.** A traffic-light derived from recovery: **GREEN** at ≥ 67%, **YELLOW** between 34% and 66%, **RED** below 34%. The thresholds match the macOS app and the WHOOP-inspired DRS spec.
125
+
126
+ This is a heuristic estimate from JSONL logs, not medical advice. The full CodeStrain app refines DRS with ML models, wearable data (HealthKit / WHOOP / Oura), and per-user calibration.
127
+
128
+ ## Why this is privacy-first
129
+
130
+ - All parsing runs locally. No data ever leaves your machine.
131
+ - No telemetry, no opt-in pings, no usage analytics — not even crash reports.
132
+ - Your JSONL files are read-only. They are never uploaded, copied, or modified.
133
+ - Respects `NO_COLOR` and `FORCE_COLOR` / `CLICOLOR_FORCE` conventions for piping and CI.
134
+
135
+ ## Related projects
136
+
137
+ - [ccusage](https://github.com/ryoppippi/ccusage) — the npm reference for parsing Claude Code JSONL. Friend, not foe. We follow its session model so numbers line up.
138
+ - [Claude-Code-Usage-Monitor](https://github.com/Maciek-roboblog/Claude-Code-Usage-Monitor) — Python alternative with ML burn-rate prediction and a live dashboard.
139
+
140
+ ## Roadmap (v0.1)
141
+
142
+ - CreatureView — a tiny macOS menu-bar companion that surfaces DRS without opening a terminal (private beta).
143
+ - Souls Studio — paid persona pack and custom-character marketplace (Drill Sergeant, Gentle Princess, Sarcastic AI...).
144
+ - Magenta-key sprite pipeline v1.2 — clean alpha extraction for community-created creatures.
145
+ - Wearable integration — Apple HealthKit, WHOOP, Oura → unified `HealthSnapshot`.
146
+
147
+ More at [codestrain.dev](https://codestrain.dev).
148
+
149
+ ## Contributing
150
+
151
+ PRs welcome. Sign your commits with `git commit -s` (DCO) and run the suite before opening one:
152
+
153
+ ```bash
154
+ python -m pytest tests/
155
+ tests/smoke.sh
156
+ ```
157
+
158
+ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the contribution workflow and [`TESTING.md`](TESTING.md) for the full test matrix.
159
+
160
+ ## License
161
+
162
+ CodeStrain CLI is MIT-licensed and free for everyone — individuals, teams, companies, and forks — forever for this and every prior release. The CodeStrain hosted service (DRS predictions, ML models, encrypted sync) is a separate paid product; the CLI works fully offline without it.
163
+
164
+ If we ever introduce a commercial license for a future major version, we will give at least 90 days' notice, keep individuals and small organizations free, and never apply new terms retroactively. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the maintainer's relicensing posture and the DCO sign-off contributors use.
165
+
166
+ Copyright (c) 2026 LLP HubLab (codestrain.dev).
167
+
168
+ ---
169
+
170
+ Star this repo if codestrain told you something you didn't know about your last week of AI coding. → [codestrain.dev](https://codestrain.dev)
@@ -0,0 +1,6 @@
1
+ codestrain_cli.py,sha256=u5-6psGBCwZrVDbJSP3YlWBe5ZfOJc3V1APCVBbanzo,32499
2
+ codestrain-0.1.0.dist-info/METADATA,sha256=E1FxeeYjjH1D5Xbp1_3yacvQSKQuSvAptBoHgaqTrBQ,7385
3
+ codestrain-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
4
+ codestrain-0.1.0.dist-info/entry_points.txt,sha256=kuAjicekRXrcDm9nBY0YQMGkvd21IqdmSz9BDGssqQE,51
5
+ codestrain-0.1.0.dist-info/licenses/LICENSE,sha256=bkvNGLbNc2u-D3plxHIdZQ3jA01YNLi7pvB9hbNYwnc,1084
6
+ codestrain-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ codestrain = codestrain_cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LLP HubLab (codestrain.dev)
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.
codestrain_cli.py ADDED
@@ -0,0 +1,875 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CodeStrain CLI -- Your AI coding recovery score.
4
+ Parses Claude Code JSONL sessions and shows stats.
5
+
6
+ Usage:
7
+ python codestrain_cli.py # Show today's stats
8
+ python codestrain_cli.py --all # Show all-time stats
9
+ python codestrain_cli.py --project X # Filter by project
10
+ python codestrain_cli.py --help # Show help
11
+ """
12
+
13
+ import argparse
14
+ import datetime
15
+ import json
16
+ import os
17
+ import sys
18
+ from pathlib import Path
19
+
20
+
21
+ # ── ANSI Colors ──────────────────────────────────────────────────────────────
22
+
23
+ def _enable_windows_vt():
24
+ """Enable ANSI virtual-terminal processing on Windows conhost/cmd.
25
+
26
+ On non-Windows platforms this is a no-op and returns True.
27
+ On Windows, calls SetConsoleMode with ENABLE_VIRTUAL_TERMINAL_PROCESSING
28
+ (0x0004) so escape sequences render as colors instead of raw `\\033[...m`
29
+ text. Returns False if the call fails — caller should disable colors.
30
+ Stdlib only (ctypes); no `colorama` dep.
31
+ """
32
+ if sys.platform != "win32":
33
+ return True
34
+ try:
35
+ import ctypes
36
+ kernel32 = ctypes.windll.kernel32
37
+ handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
38
+ mode = ctypes.c_ulong()
39
+ if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
40
+ return False
41
+ ENABLE_VT = 0x0004
42
+ return bool(kernel32.SetConsoleMode(handle, mode.value | ENABLE_VT))
43
+ except Exception:
44
+ return False
45
+
46
+
47
+ class Colors:
48
+ RESET = "\033[0m"
49
+ BOLD = "\033[1m"
50
+ DIM = "\033[2m"
51
+ GREEN = "\033[32m"
52
+ YELLOW = "\033[33m"
53
+ RED = "\033[31m"
54
+ CYAN = "\033[36m"
55
+ WHITE = "\033[37m"
56
+ AMBER = "\033[38;5;214m"
57
+
58
+ @staticmethod
59
+ def enabled():
60
+ """Detect whether ANSI color output should be emitted.
61
+
62
+ Precedence (high → low):
63
+ 1. NO_COLOR env var → off (per no-color.org)
64
+ 2. TERM == "dumb" → off
65
+ 3. FORCE_COLOR / CLICOLOR_FORCE env var → on
66
+ (per force-color.org + bixense.com/clicolors, lets users pipe to
67
+ `less -R` or capture colored CI logs despite isatty()==False)
68
+ 4. sys.stdout.isatty() → on if attached to a real terminal
69
+
70
+ On Windows, ANSI VT processing is enabled via SetConsoleMode; if that
71
+ call fails, colors are silently disabled regardless of the above.
72
+ """
73
+ if os.environ.get("NO_COLOR"):
74
+ return False
75
+ if os.environ.get("TERM") == "dumb":
76
+ return False
77
+ if os.environ.get("FORCE_COLOR") or os.environ.get("CLICOLOR_FORCE"):
78
+ return _enable_windows_vt()
79
+ if not sys.stdout.isatty():
80
+ return False
81
+ return _enable_windows_vt()
82
+
83
+
84
+ _colors_on = Colors.enabled()
85
+
86
+
87
+ def c(color, text):
88
+ """Wrap text in ANSI color if output is a terminal."""
89
+ if _colors_on:
90
+ return f"{color}{text}{Colors.RESET}"
91
+ return str(text)
92
+
93
+
94
+ def bold(text):
95
+ return c(Colors.BOLD, text)
96
+
97
+
98
+ # ── DRS Color ────────────────────────────────────────────────────────────────
99
+
100
+ def drs_color(recovery):
101
+ """Return green/yellow/red color based on recovery percentage."""
102
+ if recovery >= 67:
103
+ return Colors.GREEN
104
+ elif recovery >= 34:
105
+ return Colors.YELLOW
106
+ return Colors.RED
107
+
108
+
109
+ def readiness_label(recovery):
110
+ """Return readiness traffic-light label."""
111
+ if recovery >= 67:
112
+ return c(Colors.GREEN, "GREEN -- Recovered. Good to go.")
113
+ elif recovery >= 34:
114
+ return c(Colors.YELLOW, "YELLOW -- Moderate strain. Take more breaks.")
115
+ return c(Colors.RED, "RED -- High strain. Consider a lighter day.")
116
+
117
+
118
+ # ── Path auto-detect ─────────────────────────────────────────────────────────
119
+
120
+ # Candidate locations where Claude Code might store JSONL. First hit wins.
121
+ DEFAULT_JSONL_CANDIDATES = (
122
+ "~/.claude/projects",
123
+ "~/Library/Application Support/Claude/projects",
124
+ "~/Library/Application Support/ClaudeBar-Probe",
125
+ "~/Library/Application Support/CodexBar-ClaudeProbe",
126
+ "~/.config/claude/projects", # Linux fallback
127
+ "~/AppData/Roaming/Claude/projects", # Windows fallback
128
+ )
129
+
130
+
131
+ def detect_jsonl_path():
132
+ """Return the first existing default location, or None.
133
+
134
+ Used when --path is not given. Walks DEFAULT_JSONL_CANDIDATES and returns
135
+ the first path that has ANY *.jsonl file inside (depth-2 max).
136
+ """
137
+ for cand in DEFAULT_JSONL_CANDIDATES:
138
+ p = Path(os.path.expanduser(cand))
139
+ if not p.exists():
140
+ continue
141
+ # Cheap probe: any *.jsonl two levels down?
142
+ for jsonl in p.rglob("*.jsonl"):
143
+ return p
144
+ return None
145
+
146
+
147
+ def suggest_jsonl_paths():
148
+ """Return a list of (path, jsonl_count) for every candidate that exists."""
149
+ found = []
150
+ for cand in DEFAULT_JSONL_CANDIDATES:
151
+ p = Path(os.path.expanduser(cand))
152
+ if not p.exists():
153
+ continue
154
+ n = sum(1 for _ in p.rglob("*.jsonl"))
155
+ if n > 0:
156
+ found.append((p, n))
157
+ return found
158
+
159
+
160
+ def decode_project_name(encoded):
161
+ """Convert `-Users-konn4-workplace-codestrain` → `codestrain` (basename only).
162
+
163
+ Claude Code stores each project's JSONL under a directory whose name is the
164
+ cwd with `/` → `-`. The last segment is the project folder name. Falls back
165
+ to the raw encoded string if it doesn't look like a `-Users-` prefix.
166
+ """
167
+ if not encoded.startswith("-Users-") and not encoded.startswith("-home-"):
168
+ return encoded
169
+ parts = encoded.lstrip("-").split("-")
170
+ return parts[-1] if parts else encoded
171
+
172
+
173
+ # ── JSONL Parsing ────────────────────────────────────────────────────────────
174
+
175
+ def find_jsonl_files(base_dir, project_filter=None):
176
+ """Walk the JSONL root and return list of (project_name, file_path).
177
+
178
+ Project name preference:
179
+ 1. The first `cwd` field seen inside the first event of the file
180
+ (decoded to a clean basename, e.g. "codestrain").
181
+ 2. Fallback: decoded directory name (-Users-foo-bar-baz → baz).
182
+ """
183
+ base = Path(base_dir)
184
+ if not base.exists():
185
+ return []
186
+
187
+ results = []
188
+ for jsonl in base.rglob("*.jsonl"):
189
+ rel = jsonl.relative_to(base)
190
+ parts = list(rel.parts)
191
+ encoded_dir = parts[0] if len(parts) >= 2 else "unknown"
192
+
193
+ # Try to read `cwd` from the first parseable event in the file
194
+ project_name = None
195
+ try:
196
+ with jsonl.open() as f:
197
+ for line in f:
198
+ line = line.strip()
199
+ if not line:
200
+ continue
201
+ try:
202
+ d = json.loads(line)
203
+ except json.JSONDecodeError:
204
+ continue
205
+ cwd = d.get("cwd")
206
+ if isinstance(cwd, str) and cwd:
207
+ project_name = Path(cwd).name or Path(cwd).parent.name
208
+ break
209
+ except OSError:
210
+ pass
211
+
212
+ if not project_name:
213
+ project_name = decode_project_name(encoded_dir)
214
+
215
+ if project_filter and project_filter.lower() not in project_name.lower():
216
+ continue
217
+
218
+ results.append((project_name, jsonl))
219
+
220
+ return results
221
+
222
+
223
+ def parse_jsonl(path):
224
+ """Parse a single JSONL file and return a list of event dicts."""
225
+ events = []
226
+ try:
227
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
228
+ for line in f:
229
+ line = line.strip()
230
+ if not line:
231
+ continue
232
+ try:
233
+ events.append(json.loads(line))
234
+ except json.JSONDecodeError:
235
+ continue
236
+ except (OSError, IOError):
237
+ return []
238
+ return events
239
+
240
+
241
+ def extract_session_stats(events):
242
+ """Extract stats from parsed JSONL events.
243
+
244
+ Token + model + cost are read from `event["message"]["usage"]` / `event["message"]["model"]`
245
+ — that's where Claude Code actually writes them. The older top-level layout is kept as
246
+ a fallback for any other JSONL flavor a user might point us at.
247
+
248
+ Cost is COMPUTED from token counts × `MODEL_PRICING_USD_PER_MTOK` (Claude Code does not
249
+ write a costUSD field). Includes cache-creation + cache-read tokens, priced separately.
250
+ """
251
+ timestamps = []
252
+ total_input_tokens = 0
253
+ total_output_tokens = 0
254
+ total_cache_creation_tokens = 0
255
+ total_cache_read_tokens = 0
256
+ total_cost = 0.0
257
+ turn_count = 0
258
+ error_turns = 0
259
+ models_used = set()
260
+
261
+ for event in events:
262
+ # Extract timestamp
263
+ ts = event.get("timestamp")
264
+ if ts:
265
+ try:
266
+ if isinstance(ts, str):
267
+ dt = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
268
+ timestamps.append(dt)
269
+ elif isinstance(ts, (int, float)):
270
+ dt = datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc)
271
+ timestamps.append(dt)
272
+ except (ValueError, OSError):
273
+ pass
274
+
275
+ # Tokens + model live inside event["message"] for Claude Code; fall back to
276
+ # top-level for synthetic fixtures or other tools.
277
+ msg = event.get("message") if isinstance(event.get("message"), dict) else {}
278
+ usage = msg.get("usage") if isinstance(msg.get("usage"), dict) else event.get("usage", {})
279
+
280
+ # Cost preference: explicit costUSD on the event wins (some forks of
281
+ # ccusage / Claude Code variants write it pre-computed). Otherwise
282
+ # compute from tokens × MODEL_PRICING_USD_PER_MTOK below.
283
+ explicit_cost = event.get("costUSD", event.get("cost_usd"))
284
+ if isinstance(explicit_cost, (int, float)):
285
+ total_cost += explicit_cost
286
+
287
+ if isinstance(usage, dict):
288
+ inp = int(usage.get("input_tokens") or 0)
289
+ out = int(usage.get("output_tokens") or 0)
290
+ cache_w = int(usage.get("cache_creation_input_tokens") or 0)
291
+ cache_r = int(usage.get("cache_read_input_tokens") or 0)
292
+ total_input_tokens += inp
293
+ total_output_tokens += out
294
+ total_cache_creation_tokens += cache_w
295
+ total_cache_read_tokens += cache_r
296
+
297
+ model = msg.get("model") or event.get("model") or ""
298
+ if model:
299
+ models_used.add(model)
300
+ # Only compute pricing-based cost when no explicit costUSD was
301
+ # provided — avoids double-counting in fixture data.
302
+ if not isinstance(explicit_cost, (int, float)):
303
+ pricing = price_per_mtok_for_model(model)
304
+ if pricing:
305
+ p_in, p_out, p_cw, p_cr = pricing
306
+ total_cost += (
307
+ inp / 1_000_000 * p_in
308
+ + out / 1_000_000 * p_out
309
+ + cache_w / 1_000_000 * p_cw
310
+ + cache_r / 1_000_000 * p_cr
311
+ )
312
+
313
+ # Count turns
314
+ role = event.get("role", event.get("type", ""))
315
+ if role in ("assistant", "user", "tool"):
316
+ turn_count += 1
317
+
318
+ # Detect errors — search BOTH a plain string `message` and a structured one
319
+ message = event.get("message", event.get("content", ""))
320
+ text_blob = ""
321
+ if isinstance(message, str):
322
+ text_blob = message
323
+ elif isinstance(message, dict):
324
+ content = message.get("content")
325
+ if isinstance(content, str):
326
+ text_blob = content
327
+ elif isinstance(content, list):
328
+ # Claude Code structured content (text + thinking + tool_use blocks)
329
+ pieces = []
330
+ for block in content:
331
+ if isinstance(block, dict):
332
+ for k in ("text", "thinking", "content"):
333
+ v = block.get(k)
334
+ if isinstance(v, str):
335
+ pieces.append(v)
336
+ text_blob = " ".join(pieces)
337
+ if text_blob:
338
+ lower = text_blob.lower()
339
+ if any(kw in lower for kw in ("error", "exception", "failed", "traceback")):
340
+ error_turns += 1
341
+
342
+ # Compute "active" duration vs wall-clock "span".
343
+ #
344
+ # `duration_seconds` (default reported as "Duration:") is ACTIVE time —
345
+ # sum of gaps between consecutive turns that are ≤ ACTIVE_GAP_THRESHOLD
346
+ # (5 min). This matches the ccusage / Claude Code Usage Monitor convention
347
+ # and reflects real coding time, not the calendar span of a session that
348
+ # may stay open for days.
349
+ #
350
+ # `span_seconds` is kept for advanced views — end_time − start_time of
351
+ # the whole session, idle minutes included.
352
+ duration_seconds = 0.0
353
+ span_seconds = 0.0
354
+ start_time = None
355
+ end_time = None
356
+ if timestamps:
357
+ timestamps.sort()
358
+ start_time = timestamps[0]
359
+ end_time = timestamps[-1]
360
+ span_seconds = (end_time - start_time).total_seconds()
361
+ # Active-time threshold: gap above this between turns ⇒ user went idle.
362
+ # 5 minutes by default; override via CODESTRAIN_GAP_MIN (minutes).
363
+ gap_threshold = max(1, int(os.environ.get("CODESTRAIN_GAP_MIN") or "5")) * 60
364
+ for prev, curr in zip(timestamps, timestamps[1:]):
365
+ gap = (curr - prev).total_seconds()
366
+ if 0 < gap <= gap_threshold:
367
+ duration_seconds += gap
368
+
369
+ return {
370
+ "turn_count": turn_count,
371
+ "duration_seconds": duration_seconds,
372
+ "span_seconds": span_seconds,
373
+ "total_input_tokens": total_input_tokens,
374
+ "total_output_tokens": total_output_tokens,
375
+ "total_cache_creation_tokens": total_cache_creation_tokens,
376
+ "total_cache_read_tokens": total_cache_read_tokens,
377
+ "total_cost": total_cost,
378
+ "error_turns": error_turns,
379
+ "models": models_used,
380
+ "start_time": start_time,
381
+ "end_time": end_time,
382
+ }
383
+
384
+
385
+
386
+
387
+ # ── Model pricing (USD per 1M tokens) ────────────────────────────────────────
388
+ #
389
+ # Tuple order: (input, output, cache_creation_write, cache_read).
390
+ # Source: Anthropic pricing page snapshot, May 2026. Keep in sync with
391
+ # server/ml/training/pricing or ccusage if pricing drifts.
392
+
393
+ MODEL_PRICING_USD_PER_MTOK = {
394
+ # Claude 4.x family
395
+ "claude-opus-4-7": (15.00, 75.00, 18.75, 1.50),
396
+ "claude-opus-4-6": (15.00, 75.00, 18.75, 1.50),
397
+ "claude-opus-4-5": (15.00, 75.00, 18.75, 1.50),
398
+ "claude-opus-4": (15.00, 75.00, 18.75, 1.50),
399
+ "claude-sonnet-4-6": ( 3.00, 15.00, 3.75, 0.30),
400
+ "claude-sonnet-4-5": ( 3.00, 15.00, 3.75, 0.30),
401
+ "claude-sonnet-4": ( 3.00, 15.00, 3.75, 0.30),
402
+ "claude-haiku-4-5": ( 0.80, 4.00, 1.00, 0.08),
403
+ "claude-haiku-4": ( 0.80, 4.00, 1.00, 0.08),
404
+ # Claude 3.x legacy
405
+ "claude-3-7-sonnet": ( 3.00, 15.00, 3.75, 0.30),
406
+ "claude-3-5-sonnet": ( 3.00, 15.00, 3.75, 0.30),
407
+ "claude-3-5-haiku": ( 0.80, 4.00, 1.00, 0.08),
408
+ "claude-3-opus": (15.00, 75.00, 18.75, 1.50),
409
+ "claude-3-sonnet": ( 3.00, 15.00, 3.75, 0.30),
410
+ "claude-3-haiku": ( 0.25, 1.25, 0.30, 0.03),
411
+ }
412
+
413
+
414
+ def price_per_mtok_for_model(model):
415
+ """Return (in, out, cache_w, cache_r) USD per Mtok for a model id, or None.
416
+
417
+ Strips a trailing date suffix (e.g. `claude-opus-4-7-20260101`) and falls
418
+ back to family-only prefix matches so we still get a useful price for the
419
+ next minor revision of a model line before this table is updated.
420
+ """
421
+ if not model:
422
+ return None
423
+ if model in MODEL_PRICING_USD_PER_MTOK:
424
+ return MODEL_PRICING_USD_PER_MTOK[model]
425
+ # Strip date suffix (YYYYMMDD at the end)
426
+ parts = model.rsplit("-", 1)
427
+ if len(parts) == 2 and parts[1].isdigit() and len(parts[1]) == 8:
428
+ if parts[0] in MODEL_PRICING_USD_PER_MTOK:
429
+ return MODEL_PRICING_USD_PER_MTOK[parts[0]]
430
+ # Prefix fallback: longest matching key
431
+ for key in sorted(MODEL_PRICING_USD_PER_MTOK, key=len, reverse=True):
432
+ if model.startswith(key):
433
+ return MODEL_PRICING_USD_PER_MTOK[key]
434
+ return None
435
+
436
+
437
+ # ── DRS Estimation ───────────────────────────────────────────────────────────
438
+
439
+ def estimate_strain(total_hours, debug_ratio, is_late_night=False, is_weekend=False):
440
+ """
441
+ Simplified strain estimate (0-21 scale).
442
+
443
+ Based on the DRS formula from ARCHITECTURE.md:
444
+ - Base: coding hours * 2.1 (so 10h = max 21)
445
+ - Debug spiral: +3 if error ratio > 30%
446
+ - Late night: +2 if coding after 10pm
447
+ - Weekend: +1.5 if coding on Sat/Sun
448
+ """
449
+ base = min(21.0, total_hours * 2.1)
450
+ debug_penalty = 3.0 if debug_ratio > 0.3 else (1.5 if debug_ratio > 0.15 else 0.0)
451
+ late = 2.0 if is_late_night else 0.0
452
+ weekend = 1.5 if is_weekend else 0.0
453
+ return min(21.0, base + debug_penalty + late + weekend)
454
+
455
+
456
+ def estimate_recovery(strain, hours_since_last):
457
+ """
458
+ Simplified recovery estimate (0-100).
459
+
460
+ More hours since last session = more recovery.
461
+ Higher strain = harder to recover.
462
+ """
463
+ # Base recovery from time off (8h sleep = 60% recovery)
464
+ time_recovery = min(80.0, hours_since_last * 7.5)
465
+ # Strain penalty
466
+ strain_penalty = strain * 2.0
467
+ return max(0.0, min(100.0, time_recovery - strain_penalty + 40.0))
468
+
469
+
470
+ # ── Display ──────────────────────────────────────────────────────────────────
471
+
472
+ def format_duration(seconds):
473
+ """Format seconds into a human-readable string."""
474
+ if seconds < 60:
475
+ return f"{int(seconds)}s"
476
+ elif seconds < 3600:
477
+ m = int(seconds // 60)
478
+ s = int(seconds % 60)
479
+ return f"{m}m {s}s"
480
+ else:
481
+ h = int(seconds // 3600)
482
+ m = int((seconds % 3600) // 60)
483
+ return f"{h}h {m}m"
484
+
485
+
486
+ def format_cost(cost):
487
+ """Format USD cost."""
488
+ if cost < 0.01:
489
+ return "$0.00"
490
+ return f"${cost:.2f}"
491
+
492
+
493
+ def format_tokens(count):
494
+ """Format token count with K/M suffix."""
495
+ if count >= 1_000_000:
496
+ return f"{count / 1_000_000:.1f}M"
497
+ elif count >= 1_000:
498
+ return f"{count / 1_000:.1f}K"
499
+ return str(count)
500
+
501
+
502
+ def _terminal_cols(default=80):
503
+ """Return terminal width in columns; falls back to `default` if unknown.
504
+
505
+ Uses `os.get_terminal_size()` which checks the controlling TTY then
506
+ COLUMNS env var. Returns the default on OSError (no TTY, e.g. piped).
507
+ """
508
+ try:
509
+ return os.get_terminal_size().columns
510
+ except (OSError, ValueError):
511
+ return default
512
+
513
+
514
+ def _print_logo_big():
515
+ """Print the full 5-line ASCII logo (needs ≥ 56 cols to render cleanly)."""
516
+ print(c(Colors.AMBER, " ______ __ _____ __ "))
517
+ print(c(Colors.AMBER, " / ____/___ ____/ /__ / ___// /__________ _( )___"))
518
+ print(c(Colors.AMBER, " / / / __ \\/ __ / _ \\ \\__ \\/ __/ ___/ __ `/ / __ \\"))
519
+ print(c(Colors.AMBER, "/ /___/ /_/ / /_/ / __/___/ / /_/ / / /_/ / / / / /"))
520
+ print(c(Colors.AMBER, "\\____/\\____/\\__._/\\___//____/\\__/_/ \\__._/_/_/ /_/"))
521
+
522
+
523
+ def _print_logo_small():
524
+ """Print a one-line compact logo for 40-55 col terminals (tmux splits, etc.)."""
525
+ print(c(Colors.AMBER, "[ codestrain ]"))
526
+
527
+
528
+ def print_header_adaptive(mode="auto"):
529
+ """Print the header with a logo sized for the terminal.
530
+
531
+ Modes:
532
+ auto → terminal width: ≥56 = big, 40-55 = small, <40 = no logo
533
+ big → force 5-line ASCII logo
534
+ small → force one-line `[ codestrain ]`
535
+ none → skip the logo block entirely (tagline still prints)
536
+ """
537
+ print()
538
+ if mode == "big":
539
+ _print_logo_big()
540
+ elif mode == "small":
541
+ _print_logo_small()
542
+ elif mode == "none":
543
+ pass
544
+ else: # auto
545
+ cols = _terminal_cols(default=80)
546
+ if cols >= 56:
547
+ _print_logo_big()
548
+ elif cols >= 40:
549
+ _print_logo_small()
550
+ # else: skip logo entirely on cramped terminals
551
+ if mode != "none":
552
+ print()
553
+ print(c(Colors.DIM, " Your AI coding recovery score."))
554
+ print()
555
+
556
+
557
+ def print_header():
558
+ """Backwards-compatible wrapper — defaults to auto-detected logo size."""
559
+ print_header_adaptive("auto")
560
+
561
+
562
+ def print_divider(label=""):
563
+ """Print a section divider that fits the terminal width.
564
+
565
+ Width = min(56, terminal_cols - 2) so dividers don't wrap on narrow
566
+ terminals (tmux splits, ≤ 56-col windows).
567
+ """
568
+ width = max(10, min(56, _terminal_cols(default=80) - 2))
569
+ if label:
570
+ # Room for "--- LABEL " then fill the rest with dashes.
571
+ fill = max(1, width - 4 - len(label) - 1)
572
+ print(f"\n{c(Colors.DIM, '---')} {bold(label)} {c(Colors.DIM, '-' * fill)}")
573
+ else:
574
+ print(c(Colors.DIM, "-" * width))
575
+
576
+
577
+ def print_session_summary(stats_list, label=""):
578
+ """Print aggregated stats for a list of session stats."""
579
+ if not stats_list:
580
+ print(f" {c(Colors.DIM, 'No sessions found.')}")
581
+ return
582
+
583
+ total_turns = sum(s["turn_count"] for s in stats_list)
584
+ total_duration = sum(s["duration_seconds"] for s in stats_list)
585
+ total_cost = sum(s["total_cost"] for s in stats_list)
586
+ total_input = sum(s["total_input_tokens"] for s in stats_list)
587
+ total_output = sum(s["total_output_tokens"] for s in stats_list)
588
+ total_errors = sum(s["error_turns"] for s in stats_list)
589
+ all_models = set()
590
+ for s in stats_list:
591
+ all_models.update(s["models"])
592
+
593
+ # Estimate DRS.
594
+ #
595
+ # The strain formula is per-day ("how strained are you today"). If we
596
+ # naively feed it the SUM of all hours across many days, the result is
597
+ # always 21/21 (the cap), which is meaningless for `--all` mode.
598
+ # Solution: compute average hours-per-active-day and feed that instead.
599
+ total_hours = total_duration / 3600.0
600
+ debug_ratio = total_errors / max(1, total_turns)
601
+
602
+ # Count distinct calendar days with at least one session.
603
+ active_days = {
604
+ s["start_time"].astimezone().date()
605
+ for s in stats_list
606
+ if s.get("start_time") is not None
607
+ }
608
+ num_days = max(1, len(active_days))
609
+ hours_per_day = total_hours / num_days
610
+
611
+ # Late-night / weekend flags only fire if at least ~10% of the active
612
+ # days hit that pattern — otherwise a single weekend session a year ago
613
+ # would flip the flag forever in --all mode.
614
+ late_night_days = 0
615
+ weekend_days = 0
616
+ for s in stats_list:
617
+ if s.get("end_time"):
618
+ local = s["end_time"].astimezone()
619
+ if local.hour >= 22 or local.hour < 6:
620
+ late_night_days += 1
621
+ if local.weekday() >= 5:
622
+ weekend_days += 1
623
+ flag_threshold = max(1, num_days // 10)
624
+ is_late_night = late_night_days >= flag_threshold
625
+ is_weekend = weekend_days >= flag_threshold
626
+
627
+ strain = estimate_strain(hours_per_day, debug_ratio, is_late_night, is_weekend)
628
+ recovery = estimate_recovery(strain, 8.0) # assume 8h since last session
629
+
630
+ drs_col = drs_color(recovery)
631
+
632
+ if label:
633
+ print(f" {bold(label)}")
634
+ print()
635
+
636
+ total_span = sum(s.get("span_seconds", 0) for s in stats_list)
637
+
638
+ print(f" Sessions: {bold(str(len(stats_list)))}")
639
+ # Duration = active coding time (sum of inter-turn gaps ≤ 5 min).
640
+ # Span = calendar wall-clock from first to last turn — usually MUCH larger
641
+ # because Claude Code sessions can stay open across days. We show
642
+ # both so the user can tell active work apart from idle drift.
643
+ print(f" Duration: {bold(format_duration(total_duration))} "
644
+ f"{c(Colors.DIM, f'(span {format_duration(total_span)})')}")
645
+ print(f" Turns: {bold(str(total_turns))}")
646
+ print(f" Tokens: {c(Colors.CYAN, format_tokens(total_input))} in / {c(Colors.CYAN, format_tokens(total_output))} out")
647
+ print(f" Cost: {c(Colors.AMBER, format_cost(total_cost))}")
648
+
649
+ if all_models:
650
+ models_str = ", ".join(sorted(all_models)[:3])
651
+ if len(all_models) > 3:
652
+ models_str += f" +{len(all_models) - 3} more"
653
+ print(f" Models: {c(Colors.DIM, models_str)}")
654
+
655
+ print()
656
+ if num_days > 1:
657
+ print(f" {bold('DRS Estimate')} "
658
+ f"{c(Colors.DIM, f'(avg per active day · {num_days} days · {hours_per_day:.1f}h/day)')}")
659
+ else:
660
+ print(f" {bold('DRS Estimate')}")
661
+ print(f" Strain: {c(drs_col, f'{strain:.1f}')}/21")
662
+ print(f" Recovery: {c(drs_col, f'{recovery:.0f}%')}")
663
+ print(f" Readiness: {readiness_label(recovery)}")
664
+
665
+ if is_late_night:
666
+ print(f"\n {c(Colors.YELLOW, 'Late-night coding detected (+2 strain)')}")
667
+ if is_weekend:
668
+ print(f" {c(Colors.YELLOW, 'Weekend coding detected (+1.5 strain)')}")
669
+
670
+
671
+ def print_project_breakdown(project_stats, anonymize=False):
672
+ """Print per-project breakdown.
673
+
674
+ `anonymize` replaces real project names with `project-1` / `project-2`...
675
+ (preserving the duration-sorted order) so the breakdown can be safely
676
+ shared in screenshots / social media without leaking client names.
677
+ """
678
+ if not project_stats:
679
+ return
680
+
681
+ print_divider("Per-Project Breakdown")
682
+ print()
683
+
684
+ # Sort by total duration descending
685
+ sorted_projects = sorted(
686
+ project_stats.items(),
687
+ key=lambda x: sum(s["duration_seconds"] for s in x[1]),
688
+ reverse=True,
689
+ )
690
+
691
+ for i, (project, stats_list) in enumerate(sorted_projects, start=1):
692
+ total_duration = sum(s["duration_seconds"] for s in stats_list)
693
+ total_cost = sum(s["total_cost"] for s in stats_list)
694
+ total_turns = sum(s["turn_count"] for s in stats_list)
695
+
696
+ if anonymize:
697
+ project_display = f"project-{i}"
698
+ else:
699
+ project_display = project[:30] + "..." if len(project) > 30 else project
700
+ print(
701
+ f" {c(Colors.WHITE, project_display):<36}"
702
+ f"{bold(format_duration(total_duration)):>10} "
703
+ f"{c(Colors.CYAN, str(total_turns)):>6} turns "
704
+ f"{c(Colors.AMBER, format_cost(total_cost)):>8}"
705
+ )
706
+
707
+ print()
708
+
709
+
710
+ # ── Main ─────────────────────────────────────────────────────────────────────
711
+
712
+ def main():
713
+ parser = argparse.ArgumentParser(
714
+ description="CodeStrain CLI -- Your AI coding recovery score.",
715
+ formatter_class=argparse.RawDescriptionHelpFormatter,
716
+ epilog="""
717
+ examples:
718
+ codestrain Show today's stats
719
+ codestrain --all Show all-time stats
720
+ codestrain --project myapp Filter by project name
721
+ codestrain --path ~/custom Use custom JSONL directory
722
+ """,
723
+ )
724
+ parser.add_argument(
725
+ "--all",
726
+ action="store_true",
727
+ help="Show all-time stats instead of just today",
728
+ )
729
+ parser.add_argument(
730
+ "--project",
731
+ type=str,
732
+ default=None,
733
+ help="Filter by project name (substring match)",
734
+ )
735
+ parser.add_argument(
736
+ "--path",
737
+ type=str,
738
+ default=None,
739
+ help="Custom path to JSONL directory (auto-detected if omitted)",
740
+ )
741
+ parser.add_argument(
742
+ "--detect",
743
+ action="store_true",
744
+ help="List all detected JSONL locations and exit (no stats shown)",
745
+ )
746
+ parser.add_argument(
747
+ "--no-color",
748
+ action="store_true",
749
+ help="Disable colored output",
750
+ )
751
+ parser.add_argument(
752
+ "--anonymize",
753
+ action="store_true",
754
+ help="Replace real project names with project-1/project-2/... "
755
+ "(safe for screenshots & social posts)",
756
+ )
757
+ parser.add_argument(
758
+ "--no-breakdown",
759
+ action="store_true",
760
+ help="Skip the per-project breakdown section entirely",
761
+ )
762
+ parser.add_argument(
763
+ "--logo",
764
+ choices=("auto", "big", "small", "none"),
765
+ default="auto",
766
+ help="Logo variant: auto (default, adapts to terminal width), "
767
+ "big (5-line ASCII), small (one-line), or none (skip logo)",
768
+ )
769
+
770
+ args = parser.parse_args()
771
+
772
+ if args.no_color:
773
+ global _colors_on
774
+ _colors_on = False
775
+
776
+ # --detect: scan + report candidates + exit.
777
+ if args.detect:
778
+ print_header_adaptive(args.logo)
779
+ found = suggest_jsonl_paths()
780
+ if not found:
781
+ print(f" {c(Colors.RED, 'No Claude Code data found in any standard location.')}")
782
+ print(" Searched:")
783
+ for cand in DEFAULT_JSONL_CANDIDATES:
784
+ print(f" {c(Colors.DIM, os.path.expanduser(cand))}")
785
+ print("\n Pass --path /your/dir if your JSONL lives elsewhere.")
786
+ print()
787
+ sys.exit(1)
788
+ print(f" {c(Colors.GREEN, 'Detected JSONL locations:')}\n")
789
+ for p, n in found:
790
+ print(f" {p} {c(Colors.DIM, f'({n} files)')}")
791
+ print()
792
+ if len(found) == 1:
793
+ print(f" {c(Colors.DIM, 'Run codestrain (no flags) to use it.')}")
794
+ else:
795
+ print(f" {c(Colors.DIM, 'Multiple locations found — pass --path to pick one.')}")
796
+ print()
797
+ sys.exit(0)
798
+
799
+ # Determine base directory: --path wins; otherwise auto-detect; otherwise legacy default.
800
+ if args.path:
801
+ base_dir = os.path.expanduser(args.path)
802
+ else:
803
+ detected = detect_jsonl_path()
804
+ base_dir = str(detected) if detected else os.path.expanduser("~/.claude/projects")
805
+
806
+ if not os.path.isdir(base_dir):
807
+ print_header_adaptive(args.logo)
808
+ print(f" {c(Colors.RED, 'No Claude Code data found.')}")
809
+ print(f" Tried: {c(Colors.DIM, base_dir)}")
810
+ print()
811
+ print(f" Run {c(Colors.CYAN, 'codestrain --detect')} to scan for other locations,")
812
+ print(f" or {c(Colors.CYAN, 'codestrain --path /your/dir')} to point at a custom one.")
813
+ print()
814
+ sys.exit(1)
815
+
816
+ # Find and parse JSONL files
817
+ files = find_jsonl_files(base_dir, project_filter=args.project)
818
+
819
+ if not files:
820
+ print_header_adaptive(args.logo)
821
+ if args.project:
822
+ print(f" {c(Colors.YELLOW, f'No sessions found for project matching: {args.project}')}")
823
+ else:
824
+ print(f" {c(Colors.DIM, 'No JSONL files found in')} {base_dir}")
825
+ print()
826
+ sys.exit(0)
827
+
828
+ # Parse all files and collect stats
829
+ today = datetime.date.today()
830
+ all_stats = []
831
+ project_stats = {}
832
+
833
+ for project_name, file_path in files:
834
+ events = parse_jsonl(file_path)
835
+ if not events:
836
+ continue
837
+
838
+ stats = extract_session_stats(events)
839
+
840
+ # Filter to today if not --all
841
+ if not args.all and stats["start_time"]:
842
+ session_date = stats["start_time"].astimezone().date()
843
+ if session_date != today:
844
+ continue
845
+
846
+ if stats["turn_count"] == 0 and stats["duration_seconds"] == 0:
847
+ continue
848
+
849
+ all_stats.append(stats)
850
+
851
+ if project_name not in project_stats:
852
+ project_stats[project_name] = []
853
+ project_stats[project_name].append(stats)
854
+
855
+ # Display results
856
+ print_header_adaptive(args.logo)
857
+
858
+ time_label = "Today" if not args.all else "All Time"
859
+ if args.project and not args.anonymize:
860
+ time_label += f" (project: {args.project})"
861
+ elif args.project and args.anonymize:
862
+ time_label += " (filtered)"
863
+
864
+ print_divider(time_label)
865
+ print()
866
+ print_session_summary(all_stats)
867
+
868
+ if len(project_stats) > 1 and not args.no_breakdown:
869
+ print_project_breakdown(project_stats, anonymize=args.anonymize)
870
+
871
+ print()
872
+
873
+
874
+ if __name__ == "__main__":
875
+ main()