Skip to content
15 changes: 12 additions & 3 deletions internal/cli/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,18 +215,19 @@ func renderUsageQuota(ctx context.Context, deps *Deps, out, errOut io.Writer, pr
usd30d := costByProfile(costRows)

tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(tw, "PROFILE\tPLAN\t5H WINDOW\tWEEKLY WINDOW\tTOKENS 24H\tUSD 30D")
_, _ = fmt.Fprintln(tw, "PROFILE\tPLAN\t5H WINDOW\tWEEKLY WINDOW\tTOKENS 24H\tCACHE 24H\tUSD 30D")
for i := range profiles {
p := &profiles[i]
q := quotaByProfile[p.Name]
_, _ = fmt.Fprintf(
tw,
"%s\t%s\t%s\t%s\t%s\t$%.2f\n",
"%s\t%s\t%s\t%s\t%s\t%s\t$%.2f\n",
p.Name,
planTierLabel(q.PlanTier),
formatQuotaWindow(q.Window5h),
formatQuotaWindow(q.WindowWeekly),
humanCount(tokens24h[p.Name].TotalTokens()),
humanCount(billableTokens(tokens24h[p.Name])),
humanCount(cacheTokens(tokens24h[p.Name])),
usd30d[p.Name],
)
}
Expand Down Expand Up @@ -294,6 +295,14 @@ func humanTokens(u contracts.Usage) string {
)
}

// billableTokens is the input+output total shown as the primary "tokens used"
// figure; it excludes cache read/create which Anthropic bills at a fraction of
// the input rate and which accumulate every turn.
func billableTokens(u contracts.Usage) int { return u.InputTokens + u.OutputTokens }

// cacheTokens is the combined cache read+create total, reported separately.
func cacheTokens(u contracts.Usage) int { return u.CacheReadTokens + u.CacheCreateTokens }

func humanCount(n int) string {
switch {
case n >= 1_000_000:
Expand Down
15 changes: 15 additions & 0 deletions internal/cli/usage_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ func TestRenderUsageTableSortsProfiles(t *testing.T) {
}
}

func TestBillableAndCacheSplit(t *testing.T) {
u := contracts.Usage{
InputTokens: 1_000_000,
OutputTokens: 4_000_000,
CacheReadTokens: 600_000_000,
CacheCreateTokens: 50_000_000,
}
if got := billableTokens(u); got != 5_000_000 {
t.Fatalf("billableTokens = %d, want 5_000_000", got)
}
if got := cacheTokens(u); got != 650_000_000 {
t.Fatalf("cacheTokens = %d, want 650_000_000", got)
}
}

type usagePricing struct {
cost float64
err error
Expand Down
44 changes: 44 additions & 0 deletions internal/plandetect/detect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package plandetect

import (
"encoding/json"
"os"
"path/filepath"
"strings"
)

type claudeConfig struct {
OAuthAccount struct {
OrganizationType string `json:"organizationType"`
OrganizationRateLimit string `json:"organizationRateLimitTier"`
} `json:"oauthAccount"`
}

// Detect reads <configDir>/.claude.json and returns the ccx plan tier ("pro")
// inferred from oauthAccount.organizationType. ok is false when the file is
// missing/unreadable or the type is unrecognized, in which case the caller
// should fall back to manually configured limits.
func Detect(configDir string) (tier string, ok bool) {
if configDir == "" {
return "", false
}
// #nosec G304 -- configDir is a local Claude profile path selected by the user.
b, err := os.ReadFile(filepath.Join(configDir, ".claude.json"))
if err != nil {
return "", false
}
var cfg claudeConfig
if err := json.Unmarshal(b, &cfg); err != nil {
return "", false
}
return mapOrgType(cfg.OAuthAccount.OrganizationType, cfg.OAuthAccount.OrganizationRateLimit)
}
Comment on lines +21 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Detect performs file I/O but does not accept a context.Context.

Detect reads .claude.json via os.ReadFile, so per the project convention it should take ctx context.Context as its first parameter (and can short-circuit on ctx.Err() before the read). This also lets the quotawire caller propagate its request context.

♻️ Add ctx to the signature and propagate from the caller
-func Detect(configDir string) (tier string, ok bool) {
+func Detect(ctx context.Context, configDir string) (tier string, ok bool) {
 	if configDir == "" {
 		return "", false
 	}
+	if err := ctx.Err(); err != nil {
+		return "", false
+	}
 	// `#nosec` G304 -- configDir is a local Claude profile path selected by the user.
 	b, err := os.ReadFile(filepath.Join(configDir, ".claude.json"))

Add "context" to imports. Downstream caller in internal/quotawire/adapter.go (Line 48) becomes:

-		if tier, ok := plandetect.Detect(profiles[i].ConfigDir); ok {
+		if tier, ok := plandetect.Detect(ctx, profiles[i].ConfigDir); ok {

and the tests in detect_test.go need the ctx argument as well.

As per coding guidelines: "Every I/O or blocking public function must take ctx context.Context as its first parameter".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func Detect(configDir string) (tier string, ok bool) {
if configDir == "" {
return "", false
}
// #nosec G304 -- configDir is a local Claude profile path selected by the user.
b, err := os.ReadFile(filepath.Join(configDir, ".claude.json"))
if err != nil {
return "", false
}
var cfg claudeConfig
if err := json.Unmarshal(b, &cfg); err != nil {
return "", false
}
return mapOrgType(cfg.OAuthAccount.OrganizationType, cfg.OAuthAccount.OrganizationRateLimit)
}
func Detect(ctx context.Context, configDir string) (tier string, ok bool) {
if configDir == "" {
return "", false
}
if err := ctx.Err(); err != nil {
return "", false
}
// `#nosec` G304 -- configDir is a local Claude profile path selected by the user.
b, err := os.ReadFile(filepath.Join(configDir, ".claude.json"))
if err != nil {
return "", false
}
var cfg claudeConfig
if err := json.Unmarshal(b, &cfg); err != nil {
return "", false
}
return mapOrgType(cfg.OAuthAccount.OrganizationType, cfg.OAuthAccount.OrganizationRateLimit)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/plandetect/detect.go` around lines 21 - 35, Change Detect to accept
ctx context.Context as its first parameter and add "context" to imports; inside
Detect, check ctx.Err() and return early if canceled before performing
os.ReadFile, then proceed to read and unmarshal as before and return mapOrgType
result. Update the caller in internal/quotawire/adapter.go to pass the request
context into Detect and adjust tests in detect_test.go to provide a
context.Context argument to Detect. Ensure the function name Detect and the
mapping call to mapOrgType remain unchanged.


func mapOrgType(orgType, _ string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(orgType)) {
case "claude_pro":
return "pro", true
default:
return "", false
}
}
37 changes: 37 additions & 0 deletions internal/plandetect/detect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package plandetect

import (
"os"
"path/filepath"
"testing"
)

func writeClaudeJSON(t *testing.T, dir, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, ".claude.json"), []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
}

func TestDetectPro(t *testing.T) {
dir := t.TempDir()
writeClaudeJSON(t, dir, `{"oauthAccount":{"organizationType":"claude_pro","organizationRateLimitTier":"default_claude_ai"}}`)
tier, ok := Detect(dir)
if !ok || tier != "pro" {
t.Fatalf("want pro,true; got %q,%v", tier, ok)
}
}

func TestDetectUnknownFallsBack(t *testing.T) {
dir := t.TempDir()
writeClaudeJSON(t, dir, `{"oauthAccount":{"organizationType":"something_new"}}`)
if tier, ok := Detect(dir); ok {
t.Fatalf("unknown type must return ok=false; got %q,%v", tier, ok)
}
}

func TestDetectMissingFile(t *testing.T) {
if tier, ok := Detect(t.TempDir()); ok {
t.Fatalf("missing file must return ok=false; got %q,%v", tier, ok)
}
}
5 changes: 5 additions & 0 deletions internal/plandetect/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Package plandetect resolves a profile's Anthropic plan tier from the local
// Claude Code config (<config_dir>/.claude.json) instead of manual setup. It
// is a leaf package: it reads a file and maps a string, with no sibling
// imports.
package plandetect
10 changes: 10 additions & 0 deletions internal/quotawire/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"

"github.com/arafa-dev/ccx/internal/contracts"
"github.com/arafa-dev/ccx/internal/plandetect"
"github.com/arafa-dev/ccx/internal/profile"
"github.com/arafa-dev/ccx/internal/quota"
"github.com/arafa-dev/ccx/internal/storage"
Expand Down Expand Up @@ -39,6 +40,15 @@ func (a *Adapter) Quota(ctx context.Context, profileFilter string) ([]contracts.
}
profiles = filtered
}
// Prefer the plan tier detected from each profile's Claude config over any
// manually configured value; fall back to manual config when detection
// declines (missing/unknown). This keeps the displayed plan accurate
// without requiring `ccx profile --plan-tier`.
for i := range profiles {
if tier, ok := plandetect.Detect(profiles[i].ConfigDir); ok {
profiles[i].Limits.PlanTier = tier
}
}
computer := quota.Computer{Store: a.Store}
rows, failures, err := computer.All(ctx, profiles)
if err != nil {
Expand Down
30 changes: 30 additions & 0 deletions internal/quotawire/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package quotawire

import (
"context"
"os"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -65,6 +66,35 @@ func TestAdapterQuotaProfileFilterReturnsOnlyMatchingProfile(t *testing.T) {
}
}

func TestAdapterPrefersDetectedPlanTier(t *testing.T) {
ctx := context.Background()

// config dir reporting Pro
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".claude.json"),
[]byte(`{"oauthAccount":{"organizationType":"claude_pro"}}`), 0o600); err != nil {
t.Fatalf("write claude.json: %v", err)
}

adapter := newTestAdapter(t)
// Seed a profile with WRONG manual tier + the Pro config dir.
if err := adapter.Profiles.Add(ctx, contracts.Profile{
Name: "work",
ConfigDir: dir,
Limits: contracts.ProfileLimits{PlanTier: "max20"},
}); err != nil {
t.Fatalf("Add(work): %v", err)
}

rows, err := adapter.Quota(ctx, "work")
if err != nil {
t.Fatalf("quota: %v", err)
}
if len(rows) != 1 || rows[0].PlanTier != "pro" {
t.Fatalf("detected tier must win; got %+v", rows)
}
}

func TestAdapterQuotaReturnsPerProfileComputeFailures(t *testing.T) {
ctx := context.Background()
adapter := newTestAdapter(t)
Expand Down
7 changes: 5 additions & 2 deletions internal/run/process_launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,18 @@ type OSChildLauncher struct{}

// Start launches spec.BinaryPath and returns a process handle the supervisor
// can terminate between turns.
func (OSChildLauncher) Start(ctx context.Context, spec LaunchSpec) (StartedProcess, error) { //nolint:gocritic // ChildLauncher interface uses value specs.
//
//nolint:gocritic // ChildLauncher interface uses value specs.
func (OSChildLauncher) Start(ctx context.Context, spec LaunchSpec) (StartedProcess, error) {
if spec.BinaryPath == "" {
return nil, errors.New("launching claude: empty binary path")
}
if err := ctx.Err(); err != nil {
return nil, err
}

cmd := exec.Command(spec.BinaryPath, spec.Args...) //nolint:gosec // Launching the selected claude binary is this package's purpose.
//nolint:gosec // Launching the selected claude binary is this package's purpose.
cmd := exec.Command(spec.BinaryPath, spec.Args...)
cmd.Env = spec.Env
applyStdio(cmd, &spec)
if err := cmd.Start(); err != nil {
Expand Down
8 changes: 8 additions & 0 deletions internal/scanner/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type rawLine struct {
}

type rawMsg struct {
ID string `json:"id,omitempty"`
Model string `json:"model,omitempty"`
Usage *rawUsage `json:"usage,omitempty"`
}
Expand Down Expand Up @@ -105,6 +106,13 @@ func parseLine(b []byte, project string) (contracts.Event, parseOutcome) {
CacheReadTokens: r.Message.Usage.CacheReadInputToks,
CacheCreateTokens: r.Message.Usage.CacheCreationInputToks,
}
// Claude Code writes one JSONL line per assistant content block,
// each with a unique line uuid but the same message.id and usage.
// Use message.id as the dedup identity so the response is counted
// once. Storage keeps the line with the largest output_tokens.
if r.Message.ID != "" {
ev.UUID = r.Message.ID
}
}
}
return ev, parseEvent
Expand Down
29 changes: 29 additions & 0 deletions internal/scanner/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,35 @@ func TestParseLineAssistantUsage(t *testing.T) {
}
}

func TestParseLineUsesMessageIDAsUUID(t *testing.T) {
// Two JSONL lines from the SAME assistant response: same message.id,
// different line uuids, growing output_tokens. They must collapse to one
// identity (message.id) so downstream dedup counts the response once.
line1 := []byte(`{"type":"assistant","uuid":"line-aaa","sessionId":"s1","timestamp":"2026-05-29T10:00:00Z","message":{"id":"msg_123","model":"claude-opus-4-8","usage":{"input_tokens":3,"output_tokens":8,"cache_read_input_tokens":0,"cache_creation_input_tokens":31695}}}`)
line2 := []byte(`{"type":"assistant","uuid":"line-bbb","sessionId":"s1","timestamp":"2026-05-29T10:00:00Z","message":{"id":"msg_123","model":"claude-opus-4-8","usage":{"input_tokens":3,"output_tokens":326,"cache_read_input_tokens":0,"cache_creation_input_tokens":31695}}}`)

ev1, outcome1 := parseLine(line1, "proj")
ev2, outcome2 := parseLine(line2, "proj")
if outcome1 != parseEvent || outcome2 != parseEvent {
t.Fatalf("expected both lines to parse: outcome1=%v outcome2=%v", outcome1, outcome2)
}
if ev1.UUID != "msg_123" || ev2.UUID != "msg_123" {
t.Fatalf("usage lines must use message.id as UUID; got %q and %q", ev1.UUID, ev2.UUID)
}
}

func TestParseLineKeepsLineUUIDWhenNoUsage(t *testing.T) {
// A user line with no usage keeps its own line uuid as identity.
line := []byte(`{"type":"user","uuid":"line-ccc","sessionId":"s1","timestamp":"2026-05-29T10:00:00Z","message":{"id":"msg_999"}}`)
ev, outcome := parseLine(line, "proj")
if outcome != parseEvent {
t.Fatalf("expected line to parse")
}
if ev.UUID != "line-ccc" {
t.Fatalf("non-usage line must keep line uuid; got %q", ev.UUID)
}
}

func TestParseLineUserNoUsage(t *testing.T) {
line := []byte(`{"type":"user","uuid":"u-2","sessionId":"s-1","timestamp":"2026-05-19T12:00:00Z","message":{"content":[{"type":"text","text":"hi"}]}}`)

Expand Down
18 changes: 14 additions & 4 deletions internal/storage/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import (
"github.com/arafa-dev/ccx/internal/contracts"
)

// InsertEvents writes a batch of events under a single transaction. Duplicate
// (profile_name, event_uuid) pairs are silently skipped via ON CONFLICT DO
// NOTHING, which makes re-scanning safe and idempotent.
// InsertEvents writes a batch of events under a single transaction. Rows that
// collide on (profile_name, event_uuid) are merged: the larger output_tokens
// wins (Claude Code logs a response's output incrementally across duplicate
// lines), and other usage fields are taken from the latest row. This keeps
// re-scanning idempotent and counts each API response exactly once.
func (s *Store) InsertEvents(ctx context.Context, profileName string, events []contracts.Event) (retErr error) {
if len(events) == 0 {
return nil
Expand All @@ -33,7 +35,15 @@ INSERT INTO events (
profile_name, session_id, event_uuid, ts, project, model,
input_tokens, output_tokens, cache_read_tokens, cache_create_tokens
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(profile_name, event_uuid) DO NOTHING
ON CONFLICT(profile_name, event_uuid) DO UPDATE SET
session_id = excluded.session_id,
ts = excluded.ts,
project = excluded.project,
model = excluded.model,
input_tokens = excluded.input_tokens,
output_tokens = MAX(events.output_tokens, excluded.output_tokens),
cache_read_tokens = excluded.cache_read_tokens,
cache_create_tokens = excluded.cache_create_tokens
`
stmt, err := tx.PrepareContext(ctx, q)
if err != nil {
Expand Down
39 changes: 39 additions & 0 deletions internal/storage/events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,45 @@ func TestInsertEventsDuplicateUUIDIgnored(t *testing.T) {
}
}

func TestInsertEventsDedupsByUUIDKeepingMaxOutput(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
mustSaveProfile(t, s, "p")

ts := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
mk := func(out int) contracts.Event {
return contracts.Event{
UUID: "msg_123", // same identity (post-Task-1 parser behavior)
SessionID: "s1",
Timestamp: ts,
Type: "assistant",
Project: "proj",
Model: "claude-opus-4-8",
Usage: &contracts.Usage{InputTokens: 3, OutputTokens: out, CacheCreateTokens: 31695},
}
}
// Insert partial-output rows first, then the final complete one.
if err := s.InsertEvents(ctx, "p", []contracts.Event{mk(8), mk(8), mk(326)}); err != nil {
t.Fatalf("insert: %v", err)
}

rows, err := s.QueryUsage(ctx, contracts.UsageQuery{Profile: "p"})
if err != nil {
t.Fatalf("query: %v", err)
}
var totalOut, totalIn int
for _, r := range rows {
totalOut += r.Usage.OutputTokens
totalIn += r.Usage.InputTokens
}
if totalOut != 326 {
t.Fatalf("want output 326 (one response, max output), got %d", totalOut)
}
if totalIn != 3 {
t.Fatalf("want input 3 (counted once), got %d", totalIn)
}
}

func TestInsertEventsRescanIsSafe(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
Expand Down
Loading