diff --git a/internal/provider/claude_dirs.go b/internal/provider/claude_dirs.go index 2dd81a20..34123f30 100644 --- a/internal/provider/claude_dirs.go +++ b/internal/provider/claude_dirs.go @@ -302,19 +302,30 @@ func (a *Account) AgentsOwn() bool { return a != nil && a.token == nil } // ClaudeCodeMovedOff says Claude Code itself is signed in to another // account than user now: what a Claude Code run in its own home says of // the account it is on is no longer user's. false when it can't be told. +// Named from its profile, the account is told by its whole name: a Team +// seat and a personal plan of one email ("me@x.com · Acme", "me@x.com") +// are two accounts, and one's usage isn't the other's (netfishx on X). func ClaudeCodeMovedOff(user string) bool { - on := claudeCodeOn() - return on != "" && !sameClaudeUser(on, user) + on, named := claudeCodeOn() + if on == "" { + return false + } + if named { + return !strings.EqualFold(strings.TrimSpace(on), strings.TrimSpace(user)) + } + return !sameClaudeUser(on, user) } // claudeCodeOn is the account Claude Code itself is signed in to now, as -// magpie names it; "" when signed out or unknown. -func claudeCodeOn() string { +// magpie names it; "" when signed out or unknown. named: the name is from +// its profile (~/.claude.json), whole, not the email alone `claude auth +// status` gives. +func claudeCodeOn() (user string, named bool) { l, ok := liveLogin("claude") if !ok { - return "" + return "", false } - return l.User + return l.User, len(l.Profile) > 0 } // sameClaudeUser: a and b name one account, the one as magpie names a Team diff --git a/internal/provider/claude_seats_test.go b/internal/provider/claude_seats_test.go new file mode 100644 index 00000000..93466683 --- /dev/null +++ b/internal/provider/claude_seats_test.go @@ -0,0 +1,79 @@ +package provider + +import ( + "context" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +// claudeOnSeat signs Claude Code in to me@x.com's subscription of org, +// as /login leaves it: the sign-in and the profile naming its org. +func claudeOnSeat(t *testing.T, home, cred, org, orgID, plan string) { + t.Helper() + writeFile(t, filepath.Join(home, ".claude.json"), map[string]any{"oauthAccount": map[string]any{"emailAddress": "me@x.com", + "organizationName": org, "organizationUuid": orgID}}) + writeFile(t, cred, map[string]any{"claudeAiOauth": map[string]any{"accessToken": "tok-" + orgID, "refreshToken": "r-" + orgID, + "expiresAt": time.Now().Add(time.Hour).UnixMilli(), "subscriptionType": plan}}) + forgetAccountCaches() +} + +// A Team seat and a personal Max of one email are two accounts: Claude +// Code moved from one to the other is moved off it, either way (netfishx +// on X: several Claude accounts' usage and plans mixed up). +func TestClaudeSeatAndPersonalOfOneEmailAreTwo(t *testing.T) { + home := claudeHome(t) + cred := claudeSignIn(t, home, time.Now().Add(time.Hour)) + claudeOnSeat(t, home, cred, "Acme", "org-acme", "team") + if u, _ := claudeCodeOn(); u != "me@x.com · Acme" { + t.Fatalf("on %q", u) + } + if ClaudeCodeMovedOff("me@x.com · Acme") || ClaudeCodeMovedOff("ME@x.com · Acme ") { + t.Error("Claude Code on the Acme seat taken as moved off it") + } + if !ClaudeCodeMovedOff("me@x.com") { + t.Error("Claude Code on the Acme seat taken as on the personal me@x.com") + } + claudeOnSeat(t, home, cred, "me@x.com's Organization", "org-me", "max") + if u, _ := claudeCodeOn(); u != "me@x.com" { + t.Fatalf("on %q", u) + } + if !ClaudeCodeMovedOff("me@x.com · Acme") { + t.Error("Claude Code on the personal me@x.com taken as still on the Acme seat") + } + if ClaudeCodeMovedOff("me@x.com") { + t.Error("Claude Code on the personal me@x.com taken as moved off it") + } +} + +// The seat's reading isn't overwritten by /usage run once Claude Code is +// on the personal account of the same email. +func TestClaudeSeatKeepsItsOwnUsage(t *testing.T) { + home := claudeHome(t) + cred := claudeSignIn(t, home, time.Now().Add(time.Hour)) + claudeOnSeat(t, home, cred, "Acme", "org-acme", "team") + var out atomic.Value + out.Store("Current session: 5% used · resets " + soon(1) + " at 3:30pm (UTC)\n") + fakeClaudeUsage(t, &out, nil) + AskClaudeUsage() + if ws, err := claudeWindows(context.Background(), "me@x.com · Acme", true); err != nil || len(ws) == 0 || ws[0].Used != 5 { + t.Fatalf("seat: %v %+v", err, ws) + } + claudeOnSeat(t, home, cred, "me@x.com's Organization", "org-me", "max") + out.Store("Current session: 100% used · resets " + soon(1) + " at 3:30pm (UTC)\n") + claudeUsage.Lock() + for k, e := range claudeUsage.m { + e.tried = e.tried.Add(-claudeAskFloor) + claudeUsage.m[k] = e + } + claudeUsage.Unlock() + AskClaudeUsage() + // asked as the active account by a caller that looked before the switch + if ws, _ := claudeWindows(context.Background(), "me@x.com · Acme", true); len(ws) == 0 || ws[0].Used != 5 { + t.Fatalf("the seat shows the personal account's reading: %+v", ws) + } + if ws, err := claudeWindows(context.Background(), "me@x.com", true); err != nil || len(ws) == 0 || ws[0].Used != 100 { + t.Fatalf("personal: %v %+v", err, ws) + } +} diff --git a/internal/provider/subscription_usage.go b/internal/provider/subscription_usage.go index 8c31890b..7ba7d510 100644 --- a/internal/provider/subscription_usage.go +++ b/internal/provider/subscription_usage.go @@ -334,7 +334,7 @@ func fetchSubscriptionUsage(ctx context.Context) []SubscriptionQuota { if ls := accountsOf("claude"); len(ls) > 1 || p.Account.standIn { fetches = append(fetches, perLogin(via("claude"), ls, "Claude Code", "claude-color")...) } else { - fetches = append(fetches, withUser(ctx, p.Account.User, func() SubscriptionQuota { return claudeSubscriptionUsage(viaLogin("claude", p.Account.User)) })) + fetches = append(fetches, withUser(ctx, p.Account.User, func() SubscriptionQuota { return claudeSubscriptionUsage(viaLogin("claude", p.Account.User), p.Account.User) })) } } if user, plan, ok := cursorIdentity(); !moved("cursor") && ok && !hidden["cursor"] { @@ -491,13 +491,17 @@ type accountStatusError struct { func (e *accountStatusError) Error() string { return http.StatusText(e.status) } -func claudeSubscriptionUsage(ctx context.Context) SubscriptionQuota { +// claudeSubscriptionUsage is the allowance of user, the account Claude Code +// is signed in to, by the name magpie gives it (claudeAccount): its +// readings and what it said answering are kept under that name, which for +// a Team seat isn't the email alone `claude auth status` gives. +func claudeSubscriptionUsage(ctx context.Context, user string) SubscriptionQuota { q := SubscriptionQuota{Provider: "claude", Name: "Claude Code", Icon: "claude-color", Windows: []QuotaWindow{}} if _, _, ok := claudeCredential(); !ok { q.Error = "Claude Code is signed out; run claude auth login" return q } - user, plan, _ := claudeIdentity() + _, plan, _ := claudeIdentity() q.Plan = plan var err error q.Windows, err = claudeWindows(ctx, user, true)