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:
6 files changed
+172
-4
No files matched your search
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user