Skip to content

Commit d13c2e2

Browse files
committed
feat: session exit hook, configurable cost thresholds, top-level MCP detection
Session exit hook (-session-end): - Shows cost summary when Claude Code session ends (SessionEnd hook) - Displays session cost, request count, duration, today's total, and models used - Uses ANSI escape + exit code 2 to render cleanly in Claude Code's hook output - Silently exits on any error to never break session teardown Configurable cost thresholds: - ~/.goccc.json now supports warn_threshold and alert_threshold fields - Thresholds auto-swap if user accidentally reverses them - Applied to terminal output, statusline, and session exit hook MCP detection: - Now detects top-level mcpServers in ~/.claude.json (6th detection path) - Replaced programmatic MCP tests with realistic fixture-based tests - testdata/mcp/ exercises all 7 source types including disabled filtering Other: - Merged initConfig + initCurrency to load ~/.goccc.json once - configPath is now a var func for testability - Added Timestamp to dedupRecord for session duration calculation - parseDateStr returns parsed time.Time alongside date string - Float epsilon for today vs session cost comparison - Collapsed README "How It Works" to essentials, kept cache write pricing - Updated CLAUDE.md structure, conventions, and MCP source count
1 parent b5c650d commit d13c2e2

File tree

23 files changed

+767
-250
lines changed

23 files changed

+767
-250
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
goccc
22
dist/
33
*.exe
4-
settings.local.json
4+
.claude/settings.local.json

CLAUDE.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ Go 1.26, stdlib only (zero external deps), GoReleaser for cross-platform builds
1616
├── pricing.json # Externalized model pricing data (embedded + fetched from repo)
1717
├── format.go # Terminal and JSON output formatting
1818
├── color.go # ANSI color helpers (custom implementation, no external deps)
19-
├── statusline.go # Claude Code statusline mode (reads stdin JSON, outputs formatted cost line)
20-
├── currency.go # Currency config (~/.goccc.json), exchange rate fetching/caching, symbol table
19+
├── statusline.go # Statusline mode + session exit hook (reads stdin JSON, outputs formatted cost)
20+
├── currency.go # Config (~/.goccc.json): currency, exchange rates, cost thresholds
2121
├── mcp.go # MCP server detection, per-project disable filtering, plugin walk
2222
├── update.go # Version update checking and remote pricing cache refresh
2323
├── *_test.go # Table-driven tests for each module
2424
├── fixture_test.go # Integration test against realistic JSONL fixture
25-
├── testdata/ # Static JSONL fixture (multi-turn convo with subagents)
25+
├── mcp_fixture_test.go # Integration test against realistic MCP fixture (all sources + disabling)
26+
├── testdata/ # Static fixtures: JSONL (multi-turn convo with subagents), MCP (home + project layout)
2627
├── .goreleaser.yml # Release config (darwin/linux/windows, amd64/arm64)
2728
└── README.md # Usage docs and supported models
2829
```
@@ -104,8 +105,9 @@ Independently verified against a Python parser on 272 requests across 11 files (
104105
- **Shared file parsing**`parseFile()` in parser.go is used by both `parseLogs` (directory walk) and `parseSession` (statusline single-session)
105106
- **Local timezone everywhere** — local midnight for cutoffs, `parsed.Local()` for date bucketing. Never use `UTC()` for user-facing date logic
106107
- **MCP detection is best-effort** — all MCP detection functions return nil/empty on error; statusline never fails due to missing config
107-
- **MCP sources** — five detection paths: `mcpServers` in settings.json, marketplace `enabledPlugins` with `.mcp.json` walk, project-level `.mcp.json` via `cwd` from transcript, `settings.local.json` in project `.claude/`, and per-project `mcpServers` in `~/.claude.json`
108-
- **Local currency**`~/.goccc.json` stores currency code, cached rate, and timestamp; exchange rates auto-fetched and cached for 24h. `-currency-symbol` and `-currency-rate` flags override config (both required together). JSON output cost fields always in USD
108+
- **MCP sources** — six detection paths: `mcpServers` in settings.json, marketplace `enabledPlugins` with `.mcp.json` walk, project-level `.mcp.json` via `cwd` from transcript, `settings.local.json` in project `.claude/`, top-level `mcpServers` in `~/.claude.json`, and per-project `mcpServers` in `~/.claude.json`
109+
- **Config file**`~/.goccc.json` stores currency code, cached rate, timestamp, and cost thresholds (`warn_threshold`/`alert_threshold`). `initConfig()` loads everything once: thresholds first (with swap-correction if misordered), then currency. Exchange rates auto-fetched and cached for 24h. `-currency-symbol` and `-currency-rate` flags override config (both required together). JSON output cost fields always in USD
110+
- **Session end hook**`-session-end` reads `SessionEnd` JSON from stdin, parses the session transcript, and outputs a one-line cost summary. Uses `os.Exit(2)` + ANSI escape to overwrite Claude Code's "hook failed" prefix. Silently exits on any error — never breaks session teardown
109111

110112
## Don't
111113

README.md

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ Parses JSONL logs from `~/.claude/projects/`, deduplicates streaming responses,
1515
- [Installation](#installation)
1616
- [Usage](#usage)
1717
- [Claude Code Statusline](#claude-code-statusline)
18+
- [Session Exit Hook](#session-exit-hook)
19+
- [Configuration](#configuration)
1820
- [Flags](#flags)
1921
- [How It Works](#how-it-works)
2022
- [Preserving Log History](#preserving-log-history)
@@ -82,6 +84,8 @@ goccc -currency-symbol "€" -currency-rate 0.92
8284

8385
JSON output always reports costs in USD for backward compatibility, with a `currency` metadata object when a non-USD currency is active.
8486

87+
> See also: [Configuration](#configuration) for threshold customization.
88+
8589
## Claude Code Statusline
8690

8791
goccc can serve as a [Claude Code statusline](https://code.claude.com/docs/en/statusline) provider — a live cost dashboard right in your terminal prompt.
@@ -126,6 +130,52 @@ Add to `~/.claude/settings.json`:
126130

127131
To hide the MCP indicator, add `-no-mcp`.
128132

133+
## Session Exit Hook
134+
135+
goccc can show a cost summary when a Claude Code session ends — the feature users miss most since Anthropic removed it.
136+
137+
```text
138+
💸 $1.87 session (14 reqs, 23m) · 💰 $12.34 today · 🤖 Opus 4.6, Haiku 4.5
139+
```
140+
141+
Add to `~/.claude/settings.json`:
142+
143+
```json
144+
{
145+
"hooks": {
146+
"SessionEnd": [
147+
{
148+
"hooks": [
149+
{
150+
"type": "command",
151+
"command": "goccc -session-end"
152+
}
153+
]
154+
}
155+
]
156+
}
157+
}
158+
```
159+
160+
The hook runs within Claude Code's 1.5-second timeout. If anything fails, it exits silently — it will never break session teardown.
161+
162+
## Configuration
163+
164+
goccc reads its config from `~/.goccc.json`.
165+
166+
### Cost Thresholds
167+
168+
Cost values are color-coded yellow (warning) and red (alert) when they exceed thresholds. The defaults are $25 and $50 per day. To customize:
169+
170+
```json
171+
{
172+
"warn_threshold": 30,
173+
"alert_threshold": 75
174+
}
175+
```
176+
177+
Thresholds are in USD (before currency conversion). They apply to the terminal output, statusline, and session exit hook.
178+
129179
## Flags
130180

131181
| Flag | Short | Default | Description |
@@ -141,6 +191,7 @@ To hide the MCP indicator, add `-no-mcp`.
141191
| `-json` | | `false` | Output as JSON |
142192
| `-no-color` | | `false` | Disable colored output (also respects `NO_COLOR` env) |
143193
| `-base-dir` | | `~/.claude` | Base directory for Claude Code data |
194+
| `-session-end` | | `false` | Session exit hook mode (reads SessionEnd JSON from stdin) |
144195
| `-statusline` | | `false` | Statusline mode for Claude Code (reads session JSON from stdin) |
145196
| `-no-mcp` | | `false` | Hide MCP servers from statusline output |
146197
| `-currency-symbol` | | | Override currency symbol (requires `-currency-rate`) |
@@ -149,15 +200,7 @@ To hide the MCP indicator, add `-no-mcp`.
149200

150201
## How It Works
151202

152-
Claude Code stores conversation logs as JSONL files under `~/.claude/projects/<project-slug>/`. Each API call produces one or more log entries — streaming responses generate duplicates with the same `requestId`.
153-
154-
goccc:
155-
156-
1. Walks `.jsonl` files under the projects directory, skipping non-matching project directories and files older than the date range (by mtime)
157-
2. Pre-filters lines with a byte scan before JSON parsing — only `"type":"assistant"` entries carry billing data (tolerates both compact and spaced JSON formatting)
158-
3. Deduplicates streaming entries by `requestId` (last entry wins)
159-
4. Calculates costs using [Anthropic's published pricing](https://platform.claude.com/docs/en/about-claude/pricing), including cache write tiers, long-context premiums (>200K input tokens), and web search costs
160-
5. Aggregates by model, date (local timezone), project, and month
203+
goccc parses Claude Code's JSONL conversation logs from `~/.claude/projects/`, deduplicates streaming responses (by `requestId`), and calculates costs using [Anthropic's published pricing](https://platform.claude.com/docs/en/about-claude/pricing) — including cache write tiers, long-context premiums (>200K input), and web search costs.
161204

162205
### Cache Write Pricing
163206

currency.go

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ var activeCurrency struct {
2121

2222
// CurrencyConfig represents the persistent config in ~/.goccc.json.
2323
type CurrencyConfig struct {
24-
Currency string `json:"currency"`
25-
CachedRate float64 `json:"cached_rate,omitempty"`
26-
RateUpdated string `json:"rate_updated,omitempty"`
24+
Currency string `json:"currency"`
25+
CachedRate float64 `json:"cached_rate,omitempty"`
26+
RateUpdated string `json:"rate_updated,omitempty"`
27+
WarnThreshold float64 `json:"warn_threshold,omitempty"`
28+
AlertThreshold float64 `json:"alert_threshold,omitempty"`
2729
}
2830

2931
type currencyInfo struct {
@@ -88,7 +90,7 @@ func symbolForCurrency(code string) (string, bool) {
8890
return code, true
8991
}
9092

91-
func configPath() string {
93+
var configPath = func() string {
9294
home, err := os.UserHomeDir()
9395
if err != nil {
9496
return ""
@@ -154,11 +156,30 @@ func fetchExchangeRate(currency string) (float64, error) {
154156
return rate, nil
155157
}
156158

159+
// initConfig loads config from ~/.goccc.json: thresholds and currency.
160+
func initConfig(symbolFlag string, rateFlag float64) error {
161+
cfgPath := configPath()
162+
cfg := loadCurrencyConfig(cfgPath)
163+
164+
if cfg.WarnThreshold > 0 {
165+
costThresholdYellow = cfg.WarnThreshold
166+
}
167+
if cfg.AlertThreshold > 0 {
168+
costThresholdRed = cfg.AlertThreshold
169+
}
170+
if costThresholdYellow > costThresholdRed {
171+
costThresholdYellow, costThresholdRed = costThresholdRed, costThresholdYellow
172+
}
173+
174+
return initCurrency(symbolFlag, rateFlag, cfgPath, &cfg)
175+
}
176+
157177
// initCurrency resolves currency from CLI flags or config file and sets activeCurrency.
158-
func initCurrency(symbolFlag string, rateFlag float64) error {
178+
func initCurrency(symbolFlag string, rateFlag float64, cfgPath string, cfg *CurrencyConfig) error {
159179
if (symbolFlag != "") != (rateFlag != 0) {
160180
return fmt.Errorf("-currency-symbol and -currency-rate must be used together")
161181
}
182+
162183
if symbolFlag != "" && rateFlag != 0 {
163184
if math.IsNaN(rateFlag) || math.IsInf(rateFlag, 0) || rateFlag <= 0 {
164185
return fmt.Errorf("-currency-rate must be a positive number")
@@ -167,10 +188,9 @@ func initCurrency(symbolFlag string, rateFlag float64) error {
167188
activeCurrency.Rate = rateFlag
168189
return nil
169190
}
170-
cfgPath := configPath()
171-
cfg := loadCurrencyConfig(cfgPath)
191+
172192
if cfg.Currency != "" {
173-
sym, suffix, rate := resolveCurrencyRate(&cfg, cfgPath)
193+
sym, suffix, rate := resolveCurrencyRate(cfg, cfgPath)
174194
activeCurrency.Code = cfg.Currency
175195
activeCurrency.Symbol = sym
176196
activeCurrency.Suffix = suffix

currency_test.go

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,18 @@ import (
88

99
func TestLoadCurrencyConfig(t *testing.T) {
1010
tests := []struct {
11-
name string
12-
content string
13-
wantCurr string
14-
wantRate float64
11+
name string
12+
content string
13+
wantCurr string
14+
wantRate float64
15+
wantWarn float64
16+
wantAlert float64
1517
}{
16-
{"valid config", `{"currency":"ZAR","cached_rate":18.5,"rate_updated":"2026-03-05T12:00:00Z"}`, "ZAR", 18.5},
17-
{"empty currency", `{"currency":""}`, "", 0},
18-
{"invalid json", `{not json}`, "", 0},
19-
{"currency only", `{"currency":"EUR"}`, "EUR", 0},
18+
{"valid config", `{"currency":"ZAR","cached_rate":18.5,"rate_updated":"2026-03-05T12:00:00Z"}`, "ZAR", 18.5, 0, 0},
19+
{"empty currency", `{"currency":""}`, "", 0, 0, 0},
20+
{"invalid json", `{not json}`, "", 0, 0, 0},
21+
{"currency only", `{"currency":"EUR"}`, "EUR", 0, 0, 0},
22+
{"with thresholds", `{"warn_threshold":30,"alert_threshold":75}`, "", 0, 30, 75},
2023
}
2124

2225
for _, tt := range tests {
@@ -33,6 +36,12 @@ func TestLoadCurrencyConfig(t *testing.T) {
3336
if cfg.CachedRate != tt.wantRate {
3437
t.Errorf("CachedRate = %f, want %f", cfg.CachedRate, tt.wantRate)
3538
}
39+
if cfg.WarnThreshold != tt.wantWarn {
40+
t.Errorf("WarnThreshold = %f, want %f", cfg.WarnThreshold, tt.wantWarn)
41+
}
42+
if cfg.AlertThreshold != tt.wantAlert {
43+
t.Errorf("AlertThreshold = %f, want %f", cfg.AlertThreshold, tt.wantAlert)
44+
}
3645
})
3746
}
3847
}
@@ -130,9 +139,11 @@ func TestSaveCurrencyConfig(t *testing.T) {
130139
path := filepath.Join(dir, "config.json")
131140

132141
cfg := CurrencyConfig{
133-
Currency: "ZAR",
134-
CachedRate: 18.5,
135-
RateUpdated: "2026-03-05T12:00:00Z",
142+
Currency: "ZAR",
143+
CachedRate: 18.5,
144+
RateUpdated: "2026-03-05T12:00:00Z",
145+
WarnThreshold: 30,
146+
AlertThreshold: 75,
136147
}
137148
saveCurrencyConfig(path, cfg)
138149

@@ -143,6 +154,12 @@ func TestSaveCurrencyConfig(t *testing.T) {
143154
if loaded.CachedRate != 18.5 {
144155
t.Errorf("CachedRate = %f, want 18.5", loaded.CachedRate)
145156
}
157+
if loaded.WarnThreshold != 30 {
158+
t.Errorf("WarnThreshold = %f, want 30", loaded.WarnThreshold)
159+
}
160+
if loaded.AlertThreshold != 75 {
161+
t.Errorf("AlertThreshold = %f, want 75", loaded.AlertThreshold)
162+
}
146163
}
147164

148165
func TestResolveCurrencyRateUSD(t *testing.T) {
@@ -161,6 +178,40 @@ func TestResolveCurrencyRateEmpty(t *testing.T) {
161178
}
162179
}
163180

181+
func TestInitConfigSwapsThresholds(t *testing.T) {
182+
origWarn := costThresholdYellow
183+
origAlert := costThresholdRed
184+
origCurrency := activeCurrency
185+
defer func() {
186+
costThresholdYellow = origWarn
187+
costThresholdRed = origAlert
188+
activeCurrency = origCurrency
189+
}()
190+
191+
dir := t.TempDir()
192+
cfgPath := filepath.Join(dir, ".goccc.json")
193+
if err := os.WriteFile(cfgPath, []byte(`{"warn_threshold":75,"alert_threshold":30}`), 0644); err != nil {
194+
t.Fatal(err)
195+
}
196+
197+
origConfigPath := configPath
198+
configPath = func() string { return cfgPath }
199+
defer func() { configPath = origConfigPath }()
200+
201+
costThresholdYellow = 25.0
202+
costThresholdRed = 50.0
203+
if err := initConfig("", 0); err != nil {
204+
t.Fatal(err)
205+
}
206+
207+
if costThresholdYellow != 30 {
208+
t.Errorf("costThresholdYellow = %f, want 30 (swapped)", costThresholdYellow)
209+
}
210+
if costThresholdRed != 75 {
211+
t.Errorf("costThresholdRed = %f, want 75 (swapped)", costThresholdRed)
212+
}
213+
}
214+
164215
func TestResolveCurrencyRateCached(t *testing.T) {
165216
cfg := &CurrencyConfig{
166217
Currency: "ZAR",

format.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
"time"
1010
)
1111

12-
const (
12+
var (
1313
costThresholdRed = 50.0
1414
costThresholdYellow = 25.0
1515
)

format_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,41 @@ func TestFmtDuration(t *testing.T) {
8888
}
8989
}
9090

91+
func TestColorizeCustomThresholds(t *testing.T) {
92+
origNoColor := noColorFlag
93+
noColorFlag = false
94+
origWarn := costThresholdYellow
95+
origAlert := costThresholdRed
96+
defer func() {
97+
noColorFlag = origNoColor
98+
costThresholdYellow = origWarn
99+
costThresholdRed = origAlert
100+
}()
101+
102+
costThresholdYellow = 10.0
103+
costThresholdRed = 20.0
104+
105+
if colorize("test", 5.0) != "test" {
106+
t.Error("below warn threshold should not colorize")
107+
}
108+
if colorize("test", 15.0) == "test" {
109+
t.Error("between warn and alert should colorize (yellow)")
110+
}
111+
if colorize("test", 25.0) == "test" {
112+
t.Error("above alert should colorize (red)")
113+
}
114+
}
115+
116+
func TestColorizeDefaultThresholds(t *testing.T) {
117+
origNoColor := noColorFlag
118+
noColorFlag = false
119+
defer func() { noColorFlag = origNoColor }()
120+
121+
if colorize("test", 20.0) != "test" {
122+
t.Error("$20 should be plain with default $25 warn threshold")
123+
}
124+
}
125+
91126
func TestTotalCacheWrite(t *testing.T) {
92127
b := &Bucket{CacheWrite5m: 100, CacheWrite1h: 200}
93128
if got := b.TotalCacheWrite(); got != 300 {

main.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ func main() {
5454
noColor := flag.Bool("no-color", false, "Disable colored output")
5555
showVersion := flag.Bool("version", false, "Show version")
5656
statusline := flag.Bool("statusline", false, "Statusline mode: read session JSON from stdin, output formatted cost line")
57+
sessionEnd := flag.Bool("session-end", false, "Session end hook mode: read SessionEnd JSON from stdin, print cost summary")
5758
noMCP := flag.Bool("no-mcp", false, "Hide MCP servers from statusline output")
5859
cache5m := flag.Bool("cache-5m", false, "Price cache writes at 5-minute tier (1.25x) instead of 1-hour (2x)")
5960
currencySymbolFlag := flag.String("currency-symbol", "", "Override currency symbol (requires -currency-rate)")
@@ -96,7 +97,7 @@ func main() {
9697
cacheWriteAs1h = false
9798
}
9899

99-
if err := initCurrency(*currencySymbolFlag, *currencyRateFlag); err != nil {
100+
if err := initConfig(*currencySymbolFlag, *currencyRateFlag); err != nil {
100101
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
101102
os.Exit(1)
102103
}
@@ -108,6 +109,11 @@ func main() {
108109
return
109110
}
110111

112+
if *sessionEnd {
113+
runSessionEnd(*baseDir)
114+
return
115+
}
116+
111117
updateCh := checkForUpdate(version)
112118

113119
if *daily && *monthly {

0 commit comments

Comments
 (0)