From 440e535b29bae7a0f310369a812a76c2add245c0 Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 24 Sep 2026 22:20:04 +0800 Subject: [PATCH] =?UTF-8?q?subscriptions:=20a=20Claude=20account=20is=20it?= =?UTF-8?q?s=20email=20within=20an=20organization=20=E2=80=94=20a=20Team?= =?UTF-8?q?=20seat=20on=20the=20email=20of=20a=20personal=20Pro=20or=20Max?= =?UTF-8?q?=20is=20added=20beside=20it,=20as=20"email=20=C2=B7=20Team=20na?= =?UTF-8?q?me",=20instead=20of=20replacing=20it;=20signing=20in=20again=20?= =?UTF-8?q?brings=20back=20a=20Claude,=20ChatGPT=20or=20other=20subscripti?= =?UTF-8?q?on=20that=20had=20been=20removed=20from=20magpie,=20which=20is?= =?UTF-8?q?=20why=20a=20sign-in=20could=20finish=20and=20nothing=20show=20?= =?UTF-8?q?up;=20and=20a=20new=20provider=20can=20no=20longer=20take=20a?= =?UTF-8?q?=20subscription's=20id=20(claude,=20codex,=20=E2=80=A6),=20whic?= =?UTF-8?q?h=20hid=20that=20subscription=20once=20signed=20in=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/account.go | 7 +++ internal/provider/importapps.go | 3 + internal/provider/logins.go | 48 +++++++++++++++- internal/provider/provider.go | 30 ++++++++++ internal/provider/signin.go | 8 ++- internal/provider/signin_team_test.go | 80 +++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 internal/provider/signin_team_test.go diff --git a/internal/provider/account.go b/internal/provider/account.go index be160dfd..c9aeaa5c 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -354,6 +354,13 @@ func claudeAccount() (Provider, bool) { if statusPlan != "" { plan = statusPlan } + if m, err := readClaudeProfile(); err == nil && user != "" { + if acct, ok := m["oauthAccount"].(map[string]any); ok { + if email, _ := acct["emailAddress"].(string); strings.EqualFold(email, user) { + user = claudeUser(user, plan, acct) + } + } + } if user == "" { user = "Claude account" if plan != "" { diff --git a/internal/provider/importapps.go b/internal/provider/importapps.go index a59d66a8..6cb620e5 100644 --- a/internal/provider/importapps.go +++ b/internal/provider/importapps.go @@ -141,6 +141,9 @@ func ImportFromApps(picks []AppPick) ([]string, error) { // freeID is id, or id-2, id-3… whichever no provider has. func freeID(id string) string { taken := map[string]bool{"magpie": true} + for _, id := range accountIDs { + taken[id] = true + } for _, p := range All() { taken[p.ID] = true } diff --git a/internal/provider/logins.go b/internal/provider/logins.go index 35b59de2..4c97eeb1 100644 --- a/internal/provider/logins.go +++ b/internal/provider/logins.go @@ -112,7 +112,7 @@ func writePrivate(path string, b []byte) error { func upsertLogin(ls []savedLogin, l savedLogin) []savedLogin { for i := range ls { - if ls[i].Agent == l.Agent && strings.EqualFold(ls[i].User, l.User) { + if sameLogin(ls[i], l) { l.On = l.On || ls[i].On ls[i] = l return ls @@ -121,6 +121,49 @@ func upsertLogin(ls []savedLogin, l savedLogin) []savedLogin { return append(ls, l) } +// sameLogin says whether two saved logins are one account. A Claude +// account is its email within an organization: one email can be a personal +// Pro or Max and a seat on a Team, two subscriptions side by side. +func sameLogin(a, b savedLogin) bool { + if a.Agent != b.Agent { + return false + } + if a.Agent == "claude" { + ea, oa := claudeWho(a.Profile) + eb, ob := claudeWho(b.Profile) + if ea != "" && eb != "" && oa != "" && ob != "" { + return strings.EqualFold(ea, eb) && oa == ob + } + } + return strings.EqualFold(a.User, b.User) +} + +// claudeWho reads the email and organization of Claude Code's oauthAccount. +func claudeWho(profile json.RawMessage) (email, org string) { + var acct struct { + Email string `json:"emailAddress"` + Org string `json:"organizationUuid"` + } + if json.Unmarshal(profile, &acct) != nil { + return "", "" + } + return acct.Email, acct.Org +} + +// claudeUser names a Claude account: its email, and for a seat on a Team +// or Enterprise the organization too, so it reads apart from a personal +// subscription of the same email. +func claudeUser(email, plan string, acct map[string]any) string { + if email == "" || (plan != "team" && plan != "enterprise") { + return email + } + org, _ := acct["organizationName"].(string) + if org = strings.TrimSpace(org); org == "" || strings.Contains(org, email) { + org = strings.ToUpper(plan[:1]) + plan[1:] + } + return email + " · " + org +} + func codexAuthPath() string { home, _ := os.UserHomeDir() return filepath.Join(home, ".codex", "auth.json") @@ -184,7 +227,8 @@ func liveLogin(agent string) (savedLogin, bool) { l := savedLogin{Agent: agent, Plan: c.OAuth.SubscriptionType, Auth: b} if m, err := readClaudeProfile(); err == nil { if acct, ok := m["oauthAccount"].(map[string]any); ok { - l.User, _ = acct["emailAddress"].(string) + email, _ := acct["emailAddress"].(string) + l.User = claudeUser(email, l.Plan, acct) l.Profile, _ = json.Marshal(acct) } } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 163f0ca0..ffa6e294 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" ) @@ -220,6 +221,10 @@ func Save(p Provider) error { // agent's own sign-in. Saving it again brings a removed one back. p = Provider{ID: a.ID, Models: p.Models, Fallback: p.Fallback, Routing: p.Routing} } else { + if slices.Contains(accountIDs, p.ID) && !stored(p.ID) { + // taken, it would hide that subscription once signed in + return fmt.Errorf("%q is the id of the %s subscription; pick another name", p.ID, p.ID) + } if p.Chat == "" && p.Responses == "" && p.Anthropic == "" { return errors.New("a provider needs a base URL") } @@ -238,6 +243,31 @@ func Save(p Provider) error { return store(f) } +// accountIDs are the ids of the subscriptions magpie can list (account.go). +var accountIDs = []string{"claude", "codex", "copilot", "cursor", "grok"} + +func stored(id string) bool { + for _, p := range load().Providers { + if p.ID == id { + return true + } + } + return false +} + +// ShowAccount brings back the signed-in account of an agent the user had +// removed from magpie. +func ShowAccount(id string) error { + f := load() + for i := range f.Providers { + if f.Providers[i].ID == id && f.Providers[i].Hidden { + f.Providers[i].Hidden = false + return store(f) + } + } + return nil +} + // Delete removes a provider. An account is only hidden from magpie (its // model picks kept); signing out is the agent's job. func Delete(id string) error { diff --git a/internal/provider/signin.go b/internal/provider/signin.go index e2a7caf0..4bd01faa 100644 --- a/internal/provider/signin.go +++ b/internal/provider/signin.go @@ -240,6 +240,10 @@ func (s *signInFlow) finish(out SignInState) bool { out.ID, out.Agent, out.URL, out.Code = s.st.ID, s.st.Agent, s.st.URL, s.st.Code s.st = out s.mu.Unlock() + if out.State == "done" { + // signing in again brings back an account removed from magpie + _ = ShowAccount(out.Agent) + } close(s.done) if s.stop != nil { s.stop() @@ -402,7 +406,7 @@ func claudeExchange(ctx context.Context, code, verifier, redirect, state string) return savedLogin{}, err } profile, _ := json.Marshal(acct) - return savedLogin{Agent: "claude", User: email, Plan: c.OAuth.SubscriptionType, Auth: auth, Profile: profile}, nil + return savedLogin{Agent: "claude", User: claudeUser(email, c.OAuth.SubscriptionType, acct), Plan: c.OAuth.SubscriptionType, Auth: auth, Profile: profile}, nil } // claudePlans names Claude's organization types the way Claude Code does. @@ -481,7 +485,7 @@ func addLogin(l savedLogin) (using bool, err error) { defer loginsMu.Unlock() l.Seen = time.Now().UTC().Truncate(time.Second) live, signedIn := liveLogin(l.Agent) - using = !signedIn || strings.EqualFold(live.User, l.User) + using = !signedIn || sameLogin(live, l) ls := readLogins() if signedIn && !using { // the current account, as fresh as the agent has it diff --git a/internal/provider/signin_team_test.go b/internal/provider/signin_team_test.go new file mode 100644 index 00000000..56d42ace --- /dev/null +++ b/internal/provider/signin_team_test.go @@ -0,0 +1,80 @@ +package provider + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" +) + +// A Team seat on the email of a personal subscription is an account of its +// own: added beside the personal one, which stays signed in; and signing in +// brings back a Claude account that had been removed from magpie. +func TestClaudeTeamSeatBesidePersonal(t *testing.T) { + home := claudeHome(t) + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/oauth/token": + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "sk-ant-oat01-team", "refresh_token": "sk-ant-ort01-team", "expires_in": 3600, + "account": map[string]any{"uuid": "u", "email_address": "same@example.com"}, + "organization": map[string]any{"uuid": "o-team", "name": "Acme"}, + }) + case "/api/oauth/profile": + json.NewEncoder(w).Encode(map[string]any{ + "account": map[string]any{"email": "same@example.com"}, + "organization": map[string]any{"organization_type": "claude_team"}, + }) + default: + w.WriteHeader(404) + } + })) + defer fake.Close() + claudeTokenURL, claudeBase = fake.URL+"/v1/oauth/token", fake.URL + + cred := claudeSignIn(t, home, time.Now().Add(time.Hour)) + writeFile(t, filepath.Join(home, ".claude.json"), map[string]any{"oauthAccount": map[string]any{ + "emailAddress": "same@example.com", "organizationUuid": "o-personal", "organizationName": "same@example.com's Organization"}}) + if err := Delete("claude"); err != nil { + t.Fatal(err) + } + if _, ok := find(All(), "claude"); ok { + t.Fatal("removed account still listed") + } + + st, err := StartSignIn("claude") + if err != nil { + t.Fatal(err) + } + finishInBrowser(t, st, "the-code") + st = waitDone(t, st.ID) + if st.State != "done" || st.User != "same@example.com · Acme" || st.Plan != "team" || st.Using { + t.Fatalf("state %+v", st) + } + users, active := loginUsers(Logins("claude")) + if strings.Join(users, ",") != "same@example.com,same@example.com · Acme" || active != "same@example.com" { + t.Fatalf("logins %v, active %q", users, active) + } + var c map[string]map[string]any + readJSON(cred, &c) + if c["claudeAiOauth"]["refreshToken"] == "sk-ant-ort01-team" { + t.Fatal("the personal sign-in was replaced") + } + p, ok := find(All(), "claude") + if !ok || len(p.AlsoOn()) != 1 || p.AlsoOn()[0].Account.User != "same@example.com · Acme" { + t.Fatalf("account after sign-in: %v %+v", ok, p.AlsoOn()) + } +} + +func TestSaveKeepsAccountIDs(t *testing.T) { + claudeHome(t) + if err := Save(Provider{Name: "Claude", Anthropic: "https://relay.example.com", Key: "k"}); err == nil { + t.Fatal("a relay took the Claude subscription's id") + } + if id := freeID("claude"); id == "claude" { + t.Fatal("import took the Claude subscription's id") + } +}