Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Go 1.26, stdlib + fatih/color, GoReleaser for cross-platform builds
├── pricing.go # Per-model pricing table, cost calculation, model name resolution
├── format.go # Terminal and JSON output formatting
├── statusline.go # Claude Code statusline mode (reads stdin JSON, outputs formatted cost line)
├── currency.go # Currency config (~/.goccc.json), exchange rate fetching/caching, symbol table
├── mcp.go # MCP server detection, per-project disable filtering, plugin walk
├── *_test.go # Table-driven tests for each module
├── fixture_test.go # Integration test against realistic JSONL fixture
Expand Down Expand Up @@ -114,6 +115,10 @@ Independently verified against a Python parser on 272 requests across 11 files (
- **MCP project resolution from slug** — when transcript has no `cwd` yet (fresh session), the project path is derived by matching the transcript directory slug against `~/.claude.json` project keys (slug = path with `/` replaced by `-`)
- **Single-read config files** — `settings.json` and `~/.claude.json` are each read once and their parsed data shared across detection and filtering
- **Plugin walk is layout-agnostic** — `parseEnabledPluginMCPs` walks the plugins dir for `.mcp.json` files and matches ancestor directory names to enabled plugin names, supporting any directory structure
- **Local currency via config file** — `~/.goccc.json` stores `currency` (ISO 4217 code), `cached_rate`, and `rate_updated`; exchange rates auto-fetched from open.er-api.com and cached for 24h
- **Currency-aware fmtCost** — `fmtCost()` checks `activeCurrency.Rate`; when > 0, multiplies USD cost by rate and uses the resolved symbol. Color thresholds stay in USD
- **CLI currency overrides** — `-currency-symbol` and `-currency-rate` flags override config; both required together
- **JSON output backward-compatible** — cost fields always in USD; `currency` metadata object added only when a non-USD currency is active

## Don't

Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,29 @@ goccc -projects -top 5 # Top 5 most expensive projects
goccc -cache-5m # Use 5-minute cache pricing instead of 1-hour
goccc -days 30 -all -json # JSON output for scripting
goccc -json | jq '.summary.total_cost' # Pipe to jq for custom analysis
goccc -currency-symbol "€" -currency-rate 0.92 # One-off currency override
```

### Local Currency

To display costs in your local currency, create `~/.goccc.json`:

```json
{
"currency": "ZAR"
}
```

goccc will auto-fetch the exchange rate from USD and cache it for 24 hours. If the API is unreachable, the last cached rate is used. Set `currency` to any [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) code (e.g., `EUR`, `GBP`, `ZAR`, `JPY`).

For one-off overrides without a config file, use both flags together:

```bash
goccc -currency-symbol "€" -currency-rate 0.92
```

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

## Claude Code Statusline

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.
Expand Down Expand Up @@ -122,6 +143,8 @@ To hide the MCP indicator, add `-no-mcp`.
| `-base-dir` | | `~/.claude` | Base directory for Claude Code data |
| `-statusline` | | `false` | Statusline mode for Claude Code (reads session JSON from stdin) |
| `-no-mcp` | | `false` | Hide MCP servers from statusline output |
| `-currency-symbol` | | | Override currency symbol (requires `-currency-rate`) |
| `-currency-rate` | | `0` | Override exchange rate from USD (requires `-currency-symbol`) |
| `-version` | `-V` | | Print version and exit |

## How It Works
Expand Down
184 changes: 184 additions & 0 deletions currency.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package main

import (
"encoding/json"
"fmt"
"math"
"net/http"
"os"
"path/filepath"
"time"
)

// activeCurrency holds the resolved currency code, symbol, and exchange rate.
// When Rate is 0, costs are displayed in USD.
var activeCurrency struct {
Code string
Symbol string
Rate float64
}

// CurrencyConfig represents the persistent config in ~/.goccc.json.
type CurrencyConfig struct {
Currency string `json:"currency"`
CachedRate float64 `json:"cached_rate,omitempty"`
RateUpdated string `json:"rate_updated,omitempty"`
}

var currencySymbols = map[string]string{
"USD": "$",
"EUR": "€",
"GBP": "£",
"JPY": "¥",
"CNY": "¥",
"KRW": "₩",
"INR": "₹",
"RUB": "₽",
"BRL": "R$",
"ZAR": "R",
"CAD": "CA$",
"AUD": "A$",
"CHF": "CHF",
"SEK": "kr",
"NOK": "kr",
"DKK": "kr",
"PLN": "zł",
"TRY": "₺",
"MXN": "MX$",
"NZD": "NZ$",
}

func symbolForCurrency(code string) string {
if s, ok := currencySymbols[code]; ok {
return s
}
return code
}

func configPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".goccc.json")
}

func loadCurrencyConfig(path string) CurrencyConfig {
if path == "" {
return CurrencyConfig{}
}
data, err := os.ReadFile(path)
if err != nil {
return CurrencyConfig{}
}
var cfg CurrencyConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return CurrencyConfig{}
}
return cfg
}

func saveCurrencyConfig(path string, cfg CurrencyConfig) {
if path == "" {
return
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return
}
if err := os.WriteFile(path, append(data, '\n'), 0644); err != nil {
fmt.Fprintf(os.Stderr, "goccc: warning: failed to save currency config: %v\n", err)
}
}

type erAPIResponse struct {
Result string `json:"result"`
Rates map[string]float64 `json:"rates"`
}

var httpClient = &http.Client{Timeout: 2 * time.Second}

func fetchExchangeRate(currency string) (float64, error) {
resp, err := httpClient.Get("https://open.er-api.com/v6/latest/USD")
if err != nil {
return 0, fmt.Errorf("fetching exchange rate: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("exchange rate API returned status %d", resp.StatusCode)
}

var apiResp erAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return 0, fmt.Errorf("decoding exchange rate response: %w", err)
}

rate, ok := apiResp.Rates[currency]
if !ok {
return 0, fmt.Errorf("currency %q not found in exchange rate data", currency)
}
return rate, nil
}

// initCurrency resolves currency from CLI flags or config file and sets activeCurrency.
func initCurrency(symbolFlag string, rateFlag float64) error {
if (symbolFlag != "") != (rateFlag != 0) {
return fmt.Errorf("-currency-symbol and -currency-rate must be used together")
}
if symbolFlag != "" && rateFlag != 0 {
if math.IsNaN(rateFlag) || math.IsInf(rateFlag, 0) || rateFlag <= 0 {
return fmt.Errorf("-currency-rate must be a positive number")
}
activeCurrency.Symbol = symbolFlag
activeCurrency.Rate = rateFlag
return nil
}
cfgPath := configPath()
cfg := loadCurrencyConfig(cfgPath)
if cfg.Currency != "" {
sym, rate := resolveCurrencyRate(&cfg, cfgPath)
activeCurrency.Code = cfg.Currency
activeCurrency.Symbol = sym
activeCurrency.Rate = rate
}
return nil
}

const rateStaleDuration = 24 * time.Hour

func resolveCurrencyRate(cfg *CurrencyConfig, cfgPath string) (symbol string, rate float64) {
if cfg.Currency == "" || cfg.Currency == "USD" {
return "$", 0
}

symbol = symbolForCurrency(cfg.Currency)

// Check if cached rate is fresh enough
if cfg.CachedRate > 0 && cfg.RateUpdated != "" {
if updated, err := time.Parse(time.RFC3339, cfg.RateUpdated); err == nil {
if time.Since(updated) < rateStaleDuration {
return symbol, cfg.CachedRate
}
}
}

// Fetch fresh rate
newRate, err := fetchExchangeRate(cfg.Currency)
if err != nil {
fmt.Fprintf(os.Stderr, "goccc: warning: %v\n", err)
// Fall back to cached rate if available
if cfg.CachedRate > 0 {
return symbol, cfg.CachedRate
}
fmt.Fprintf(os.Stderr, "goccc: warning: no cached rate available, displaying in USD\n")
return "$", 0
}

// Save updated rate
cfg.CachedRate = newRate
cfg.RateUpdated = time.Now().UTC().Format(time.RFC3339)
saveCurrencyConfig(cfgPath, *cfg)

return symbol, newRate
}
Loading
Loading