From 51ac3f1f6d1480c67674342de6c192fb921d4b92 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Thu, 5 Mar 2026 18:49:28 +1000 Subject: [PATCH 1/2] feat: add local currency display support with auto-fetched exchange rates --- CLAUDE.md | 5 ++ README.md | 23 ++++++ currency.go | 178 +++++++++++++++++++++++++++++++++++++++++++++++ currency_test.go | 164 +++++++++++++++++++++++++++++++++++++++++++ format.go | 26 +++++-- main.go | 7 ++ 6 files changed, 399 insertions(+), 4 deletions(-) create mode 100644 currency.go create mode 100644 currency_test.go diff --git a/CLAUDE.md b/CLAUDE.md index dc8551a..529502d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 259cf28..a569aa9 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/currency.go b/currency.go new file mode 100644 index 0000000..671bffe --- /dev/null +++ b/currency.go @@ -0,0 +1,178 @@ +package main + +import ( + "encoding/json" + "fmt" + "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 + } + _ = os.WriteFile(path, append(data, '\n'), 0644) +} + +type erAPIResponse struct { + Result string `json:"result"` + Rates map[string]float64 `json:"rates"` +} + +func fetchExchangeRate(currency string) (float64, error) { + resp, err := http.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. +// Returns an error message if flags are invalid, or "" on success. +func initCurrency(symbolFlag string, rateFlag float64) string { + if (symbolFlag != "") != (rateFlag != 0) { + return "Error: -currency-symbol and -currency-rate must be used together" + } + if symbolFlag != "" && rateFlag != 0 { + activeCurrency.Code = symbolFlag + activeCurrency.Symbol = symbolFlag + activeCurrency.Rate = rateFlag + return "" + } + cfgPath := configPath() + cfg := loadCurrencyConfig(cfgPath) + if cfg.Currency != "" { + sym, rate := resolveCurrencyRate(&cfg, cfgPath) + activeCurrency.Code = cfg.Currency + activeCurrency.Symbol = sym + activeCurrency.Rate = rate + } + return "" +} + +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 +} diff --git a/currency_test.go b/currency_test.go new file mode 100644 index 0000000..cc0a20d --- /dev/null +++ b/currency_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadCurrencyConfig(t *testing.T) { + tests := []struct { + name string + content string + wantCurr string + wantRate float64 + }{ + {"valid config", `{"currency":"ZAR","cached_rate":18.5,"rate_updated":"2026-03-05T12:00:00Z"}`, "ZAR", 18.5}, + {"empty currency", `{"currency":""}`, "", 0}, + {"invalid json", `{not json}`, "", 0}, + {"currency only", `{"currency":"EUR"}`, "EUR", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil { + t.Fatal(err) + } + cfg := loadCurrencyConfig(path) + if cfg.Currency != tt.wantCurr { + t.Errorf("Currency = %q, want %q", cfg.Currency, tt.wantCurr) + } + if cfg.CachedRate != tt.wantRate { + t.Errorf("CachedRate = %f, want %f", cfg.CachedRate, tt.wantRate) + } + }) + } +} + +func TestLoadCurrencyConfigMissing(t *testing.T) { + cfg := loadCurrencyConfig("/nonexistent/path/config.json") + if cfg.Currency != "" { + t.Errorf("expected empty currency for missing file, got %q", cfg.Currency) + } +} + +func TestLoadCurrencyConfigEmptyPath(t *testing.T) { + cfg := loadCurrencyConfig("") + if cfg.Currency != "" { + t.Errorf("expected empty currency for empty path, got %q", cfg.Currency) + } +} + +func TestSymbolForCurrency(t *testing.T) { + tests := []struct { + code string + want string + }{ + {"USD", "$"}, + {"EUR", "€"}, + {"GBP", "£"}, + {"ZAR", "R"}, + {"BRL", "R$"}, + {"UNKNOWN", "UNKNOWN"}, + } + + for _, tt := range tests { + t.Run(tt.code, func(t *testing.T) { + got := symbolForCurrency(tt.code) + if got != tt.want { + t.Errorf("symbolForCurrency(%q) = %q, want %q", tt.code, got, tt.want) + } + }) + } +} + +func TestFmtCostWithCurrency(t *testing.T) { + // Save and restore activeCurrency + origCode := activeCurrency.Code + origSym := activeCurrency.Symbol + origRate := activeCurrency.Rate + defer func() { + activeCurrency.Code = origCode + activeCurrency.Symbol = origSym + activeCurrency.Rate = origRate + }() + + tests := []struct { + name string + symbol string + rate float64 + cost float64 + want string + }{ + {"USD default", "", 0, 1.50, "$1.50"}, + {"USD small", "", 0, 0.005, "$0.0050"}, + {"ZAR large", "R", 18.5, 1.00, "R18.50"}, + {"ZAR small", "R", 18.5, 0.01, "R0.1850"}, + {"EUR large", "€", 0.92, 10.0, "€9.20"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + activeCurrency.Symbol = tt.symbol + activeCurrency.Rate = tt.rate + got := fmtCost(tt.cost) + if got != tt.want { + t.Errorf("fmtCost(%f) with rate=%f symbol=%q = %q, want %q", + tt.cost, tt.rate, tt.symbol, got, tt.want) + } + }) + } +} + +func TestSaveCurrencyConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + cfg := CurrencyConfig{ + Currency: "ZAR", + CachedRate: 18.5, + RateUpdated: "2026-03-05T12:00:00Z", + } + saveCurrencyConfig(path, cfg) + + loaded := loadCurrencyConfig(path) + if loaded.Currency != "ZAR" { + t.Errorf("Currency = %q, want ZAR", loaded.Currency) + } + if loaded.CachedRate != 18.5 { + t.Errorf("CachedRate = %f, want 18.5", loaded.CachedRate) + } +} + +func TestResolveCurrencyRateUSD(t *testing.T) { + cfg := &CurrencyConfig{Currency: "USD"} + sym, rate := resolveCurrencyRate(cfg, "") + if sym != "$" || rate != 0 { + t.Errorf("USD should return $, 0; got %q, %f", sym, rate) + } +} + +func TestResolveCurrencyRateEmpty(t *testing.T) { + cfg := &CurrencyConfig{Currency: ""} + sym, rate := resolveCurrencyRate(cfg, "") + if sym != "$" || rate != 0 { + t.Errorf("empty currency should return $, 0; got %q, %f", sym, rate) + } +} + +func TestResolveCurrencyRateCached(t *testing.T) { + cfg := &CurrencyConfig{ + Currency: "ZAR", + CachedRate: 18.5, + RateUpdated: "2099-01-01T00:00:00Z", // far future = not stale + } + sym, rate := resolveCurrencyRate(cfg, "") + if sym != "R" { + t.Errorf("symbol = %q, want R", sym) + } + if rate != 18.5 { + t.Errorf("rate = %f, want 18.5", rate) + } +} diff --git a/format.go b/format.go index b97897a..248512e 100644 --- a/format.go +++ b/format.go @@ -35,10 +35,16 @@ func fmtDuration(d time.Duration) string { } func fmtCost(c float64) string { - if c >= 1.0 { - return fmt.Sprintf("$%.2f", c) + sym := "$" + v := c + if activeCurrency.Rate > 0 { + sym = activeCurrency.Symbol + v = c * activeCurrency.Rate } - return fmt.Sprintf("$%.4f", c) + if v >= 1.0 { + return fmt.Sprintf("%s%.2f", sym, v) + } + return fmt.Sprintf("%s%.4f", sym, v) } func colorize(s string, cost float64) string { @@ -208,6 +214,7 @@ func printJSON(data *ParseResult, opts OutputOptions) { sort.Slice(models, func(i, j int) bool { return models[i].Cost > models[j].Cost }) out := struct { + Currency interface{} `json:"currency,omitempty"` Summary interface{} `json:"summary"` Models interface{} `json:"models"` Daily interface{} `json:"daily,omitempty"` @@ -234,6 +241,14 @@ func printJSON(data *ParseResult, opts OutputOptions) { Models: models, } + if activeCurrency.Rate > 0 { + out.Currency = struct { + Code string `json:"code"` + Symbol string `json:"symbol"` + Rate float64 `json:"rate"` + }{activeCurrency.Code, activeCurrency.Symbol, activeCurrency.Rate} + } + if opts.ShowDaily { out.Daily = buildJSONDaily(data) } @@ -311,7 +326,7 @@ func printSummary(data *ParseResult, opts OutputOptions) { fmtTokens(totals.CacheR), fmtTokens(totals.CacheW), totals.Requests, colorCost(totals.Cost, 10)) if totals.WebSearches > 0 { - dim.Printf(" Web searches: %d ($%.2f)\n", totals.WebSearches, float64(totals.WebSearches)*webSearchCostPerSearch) + dim.Printf(" Web searches: %d (%s)\n", totals.WebSearches, fmtCost(float64(totals.WebSearches)*webSearchCostPerSearch)) } if totals.LongCtxRequests > 0 { dim.Printf(" Long-context requests (>200K): %d (premium pricing applied)\n", totals.LongCtxRequests) @@ -319,6 +334,9 @@ func printSummary(data *ParseResult, opts OutputOptions) { if cacheWriteAs1h { dim.Println(" Cache writes priced at 1h tier (2x input); use -cache-5m for 1.25x") } + if activeCurrency.Rate > 0 { + dim.Printf(" Costs in %s (1 USD = %.4f %s)\n", activeCurrency.Code, activeCurrency.Rate, activeCurrency.Code) + } fmt.Println() if opts.ShowDaily { diff --git a/main.go b/main.go index 5237ab3..4fcfecd 100644 --- a/main.go +++ b/main.go @@ -56,6 +56,8 @@ func main() { statusline := flag.Bool("statusline", false, "Statusline mode: read session JSON from stdin, output formatted cost line") noMCP := flag.Bool("no-mcp", false, "Hide MCP servers from statusline output") cache5m := flag.Bool("cache-5m", false, "Price cache writes at 5-minute tier (1.25x) instead of 1-hour (2x)") + currencySymbolFlag := flag.String("currency-symbol", "", "Override currency symbol (requires -currency-rate)") + currencyRateFlag := flag.Float64("currency-rate", 0, "Override exchange rate from USD (requires -currency-symbol)") flag.IntVar(days, "d", 0, "Short for -days") flag.StringVar(project, "p", "", "Short for -project") @@ -94,6 +96,11 @@ func main() { cacheWriteAs1h = false } + if errMsg := initCurrency(*currencySymbolFlag, *currencyRateFlag); errMsg != "" { + fmt.Fprintf(os.Stderr, "%s\n", errMsg) + os.Exit(1) + } + if *statusline { runStatusline(*baseDir, *noMCP) return From 830860fabb1bf633a54ebeaf6e94661d7a6a52c1 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Thu, 5 Mar 2026 20:05:01 +1000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20PR=20feedback=20=E2=80=94?= =?UTF-8?q?=20HTTP=20timeout,=20error=20return,=20rate=20validation,=20CLI?= =?UTF-8?q?=20override=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- currency.go | 22 ++++++++++++++-------- format.go | 8 ++++++-- main.go | 4 ++-- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/currency.go b/currency.go index 671bffe..25a700c 100644 --- a/currency.go +++ b/currency.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "math" "net/http" "os" "path/filepath" @@ -85,7 +86,9 @@ func saveCurrencyConfig(path string, cfg CurrencyConfig) { if err != nil { return } - _ = os.WriteFile(path, append(data, '\n'), 0644) + 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 { @@ -93,8 +96,10 @@ type erAPIResponse struct { Rates map[string]float64 `json:"rates"` } +var httpClient = &http.Client{Timeout: 2 * time.Second} + func fetchExchangeRate(currency string) (float64, error) { - resp, err := http.Get("https://open.er-api.com/v6/latest/USD") + resp, err := httpClient.Get("https://open.er-api.com/v6/latest/USD") if err != nil { return 0, fmt.Errorf("fetching exchange rate: %w", err) } @@ -117,16 +122,17 @@ func fetchExchangeRate(currency string) (float64, error) { } // initCurrency resolves currency from CLI flags or config file and sets activeCurrency. -// Returns an error message if flags are invalid, or "" on success. -func initCurrency(symbolFlag string, rateFlag float64) string { +func initCurrency(symbolFlag string, rateFlag float64) error { if (symbolFlag != "") != (rateFlag != 0) { - return "Error: -currency-symbol and -currency-rate must be used together" + return fmt.Errorf("-currency-symbol and -currency-rate must be used together") } if symbolFlag != "" && rateFlag != 0 { - activeCurrency.Code = symbolFlag + 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 "" + return nil } cfgPath := configPath() cfg := loadCurrencyConfig(cfgPath) @@ -136,7 +142,7 @@ func initCurrency(symbolFlag string, rateFlag float64) string { activeCurrency.Symbol = sym activeCurrency.Rate = rate } - return "" + return nil } const rateStaleDuration = 24 * time.Hour diff --git a/format.go b/format.go index 248512e..93f9438 100644 --- a/format.go +++ b/format.go @@ -241,7 +241,7 @@ func printJSON(data *ParseResult, opts OutputOptions) { Models: models, } - if activeCurrency.Rate > 0 { + if activeCurrency.Rate > 0 && activeCurrency.Code != "" { out.Currency = struct { Code string `json:"code"` Symbol string `json:"symbol"` @@ -335,7 +335,11 @@ func printSummary(data *ParseResult, opts OutputOptions) { dim.Println(" Cache writes priced at 1h tier (2x input); use -cache-5m for 1.25x") } if activeCurrency.Rate > 0 { - dim.Printf(" Costs in %s (1 USD = %.4f %s)\n", activeCurrency.Code, activeCurrency.Rate, activeCurrency.Code) + if activeCurrency.Code != "" { + dim.Printf(" Costs in %s (1 USD = %.4f %s)\n", activeCurrency.Code, activeCurrency.Rate, activeCurrency.Code) + } else { + dim.Printf(" Costs converted at 1 USD = %.4f %s\n", activeCurrency.Rate, activeCurrency.Symbol) + } } fmt.Println() diff --git a/main.go b/main.go index 4fcfecd..7c92b6c 100644 --- a/main.go +++ b/main.go @@ -96,8 +96,8 @@ func main() { cacheWriteAs1h = false } - if errMsg := initCurrency(*currencySymbolFlag, *currencyRateFlag); errMsg != "" { - fmt.Fprintf(os.Stderr, "%s\n", errMsg) + if err := initCurrency(*currencySymbolFlag, *currencyRateFlag); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) }