subscriptions: a Claude account is its email within an organization — a Team seat on the email of a personal Pro or Max is added beside it, as "email · Team name", instead of replacing it; signing in again brings back a Claude, ChatGPT or other subscription that had been removed from magpie, which is why a sign-in could finish and nothing show up; and a new provider can no longer take a subscription's id (claude, codex, …), which hid that subscription once signed in (#31)

This commit is contained in:
yetone committed 2026-09-24 22:20:04 +08:00
1 parent be1b3967f4
commit 440e535b29
6 files changed
+172 -4

No files matched your search

+7
View File
@@ -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 != "" {
+3
View File
@@ -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
}
+46 -2
View File
@@ -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)
}
}
+30
View File
@@ -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 {
+6 -2
View File
@@ -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
+80
View File
@@ -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")
}
}