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,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()
|