Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,8 +415,9 @@ Personal and team ChatGPT workspaces can use the same email. Add each workspace
with a separate `sr add codex` login and select the workspace in the browser.
New accounts use a stable key derived from the provider user ID and workspace
ID. Email is display data. Existing identifiers stay valid, including after an
email change. Use the full identifier from `sr list` to switch or remove one
workspace. Adding, refreshing, or repairing one workspace does not
email change. Use `sr switch` to choose interactively, or `sr list --ids` when
an exact stable identifier is needed for a script or removal. Adding,
refreshing, or repairing one workspace does not
replace another workspace's credentials.

Subrouter has a native Go implementation of the Codex account manager. It reads and writes its account store under Subrouter's data directory:
Expand Down
18 changes: 17 additions & 1 deletion cmd/subrouter/cloud_mode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,15 @@ func TestUsageRowsFromHostedStatusesPreservesQuotaWindows(t *testing.T) {
UsedPercent: 25,
}},
}})
if len(rows) != 1 || rows[0].email != "user@example.com" ||
if len(rows) != 1 || rows[0].email != "account-1" ||
len(rows[0].windows) != 1 || rows[0].windows[0].UsedPercent != 25 ||
rows[0].providerHealth != "unreachable" || rows[0].providerModels != 12 ||
len(rows[0].providerEndpoints) != 2 {
t.Fatalf("usage rows = %#v", rows)
}
if got := displayUsageAccountName(rows[0]); got != "user@example.com" {
t.Fatalf("display account = %q, want hosted login email", got)
}
}

func TestCloudCodexAlwaysUsesLocalProxy(t *testing.T) {
Expand Down Expand Up @@ -286,6 +289,19 @@ func TestBareSRUsesSelectedTeamInsteadOfLegacyRemote(t *testing.T) {
if strings.Contains(got, "Server: team") || remoteRequests.Load() != 0 {
t.Fatalf("bare sr contacted the stale legacy server (%d requests):\n%s", remoteRequests.Load(), got)
}
if strings.Contains(got, "codex-cloud") {
t.Fatalf("normal team status exposed the record ID:\n%s", got)
}
for _, args := range [][]string{{"list"}, {"list", "--ids"}} {
out.Reset()
if err := runner.run(context.Background(), args); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "shared@example.com") ||
strings.Contains(out.String(), "codex-cloud") != (len(args) == 2) {
t.Fatalf("%v output = %q; only --ids should show record IDs", args, out.String())
}
}
}

func TestStorageLocalUsesLocalAccountsInsteadOfCloudOrLegacyRemote(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion cmd/subrouter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1739,7 +1739,7 @@ Usage:
%[1]s add claude Add a Claude account (opens OAuth login)
%[1]s add-key Add an API key account
%[1]s import Import current ~/.codex/auth.json account
%[1]s list List all Codex accounts
%[1]s list [--ids] List accounts; --ids adds exact identifiers
%[1]s switch [email] Switch active Codex account and sync OpenCode/pi
%[1]s g [email] Switch active account, sync OpenCode/pi, and restart Codex.app
%[1]s gui [email] Switch active account, sync OpenCode/pi, and restart Codex.app
Expand Down
109 changes: 80 additions & 29 deletions cmd/subrouter/sr.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Usage:
opencode-zen, grok, qwen, qwen-token,
qwen-anthropic, claude)
sr import Import current ~/.codex/auth.json account
sr list List all Codex accounts
sr list [--ids] List accounts; --ids adds exact identifiers
sr switch [email] Switch active Codex account and sync OpenCode/pi
sr g [email] Switch active account, sync OpenCode/pi, and restart Codex.app
sr gui [email] Switch active account, sync OpenCode/pi, and restart Codex.app
Expand Down Expand Up @@ -245,11 +245,12 @@ type srUsageRow struct {
authValid bool
// providerModels counts the models the key is entitled to, from that same
// probe. Negative means unknown.
providerModels int
providerEndpoints []string
keyFingerprint string
assignedSessions int
sessionsKnown bool
providerModels int
providerEndpoints []string
keyFingerprint string
assignedSessions int
sessionsKnown bool
// email retains the saved selector; displayAccount is the human account name.
email string
active bool
planType string
Expand Down Expand Up @@ -462,7 +463,7 @@ func (r srRunner) run(ctx context.Context, args []string) error {
case "import":
return r.importActive(ctx)
case "list", "ls":
return r.list()
return r.list(args[1:])
case "switch", "use":
selector, opts, err := parseSRSwitchArgs(args[1:], srSwitchOptions{})
if err != nil {
Expand Down Expand Up @@ -658,7 +659,16 @@ func (r srRunner) runTeamCredentialCommand(
args []string,
) (bool, error) {
switch args[0] {
case "list", "ls", "status", "usage":
case "list", "ls":
showIDs, err := r.accountListIDs(args[1:])
if err != nil {
return true, err
}
if showIDs {
return true, r.cloudAccount(ctx, []string{"list"})
}
return true, r.cloudStatus(ctx)
case "status", "usage":
return true, r.cloudStatus(ctx)
case "add":
_, _, client, err := loadCloudClient(true)
Expand Down Expand Up @@ -735,7 +745,7 @@ func (r srRunner) runRemoteAccountCommand(ctx context.Context, server srServerCo
case "add-key", "add-api-key":
return r.addKeyToServer(ctx, server, args[1:])
case "list", "ls":
return r.listServerAccounts(ctx, server)
return r.listServerAccounts(ctx, server, args[1:])
case "status":
return r.serverStatusFor(ctx, server)
case "usage":
Expand Down Expand Up @@ -907,9 +917,9 @@ func (r srRunner) addCodex(ctx context.Context, deviceAuth bool) error {
return err
}
if existed {
fmt.Fprintf(r.out, "\nUpdated account: %s\n", account.Email)
fmt.Fprintf(r.out, "\nUpdated account: %s\n", account.LoginEmail())
} else {
fmt.Fprintf(r.out, "\nAdded account: %s\n", account.Email)
fmt.Fprintf(r.out, "\nAdded account: %s\n", account.LoginEmail())
}
fmt.Fprintln(r.out, "Local Codex auth was left unchanged.")
return nil
Expand Down Expand Up @@ -1001,9 +1011,9 @@ func (r srRunner) importActive(ctx context.Context) error {
return err
}
if existed {
fmt.Fprintf(r.out, "Updated existing account: %s\n", account.Email)
fmt.Fprintf(r.out, "Updated existing account: %s\n", account.LoginEmail())
} else {
fmt.Fprintf(r.out, "Imported account: %s\n", account.Email)
fmt.Fprintf(r.out, "Imported account: %s\n", account.LoginEmail())
}
return nil
}
Expand Down Expand Up @@ -1049,7 +1059,7 @@ func (r srRunner) autoImportIfEmpty(ctx context.Context) error {
return imported, importErr
})
if err == nil && imported {
fmt.Fprintf(r.out, "Auto-imported active account: %s\n\n", account.Email)
fmt.Fprintf(r.out, "Auto-imported active account: %s\n\n", account.LoginEmail())
}
return nil
}
Expand Down Expand Up @@ -1084,7 +1094,21 @@ func (r srRunner) publishActiveSync(ctx context.Context) error {
})
}

func (r srRunner) list() error {
func (r srRunner) accountListIDs(args []string) (bool, error) {
if len(args) == 0 {
return false, nil
}
if len(args) == 1 && args[0] == "--ids" {
return true, nil
}
return false, fmt.Errorf("usage: %s list [--ids]", r.programOrSubrouter())
}

func (r srRunner) list(args []string) error {
showIDs, err := r.accountListIDs(args)
if err != nil {
return err
}
all, err := r.store.ListStored()
if err != nil {
return err
Expand All @@ -1097,23 +1121,44 @@ func (r srRunner) list() error {
fmt.Fprintln(r.out, "No accounts configured. Run 'subrouter add' to add one.")
return nil
}
duplicateNames := map[string]int{}
for _, account := range all {
key := string(account.ProviderOrDefault()) + "\x00" + strings.ToLower(strings.TrimSpace(account.LoginEmail()))
duplicateNames[key]++
}
needsIDsHint := false
fmt.Fprintln(r.out)
for _, account := range all {
marker := ""
if account.Email == active {
marker = " *"
}
name := displayAccountName(account.Email)
if email := account.LoginEmail(); email != account.Email {
name = email + " [" + account.Email + "]"
name := localAccountDisplayName(account, showIDs)
key := string(account.ProviderOrDefault()) + "\x00" + strings.ToLower(strings.TrimSpace(account.LoginEmail()))
if duplicateNames[key] > 1 && !showIDs {
needsIDsHint = true
}
fmt.Fprintf(r.out, " %s%s (added %s)\n", name, marker, formatDate(account.AddedAt))
}
fmt.Fprintln(r.out)
if needsIDsHint {
fmt.Fprintf(r.out, "Some accounts share a display email. Use `%s list --ids` to select one.\n", r.programOrSubrouter())
}
fmt.Fprintln(r.out, "* = currently active in ~/.codex/auth.json")
return nil
}

func localAccountDisplayName(account accounts.StoredCodexAccount, showID bool) string {
name := displayAccountName(account.LoginEmail())
if name == "" {
name = displayAccountName(account.Email)
}
if showID && account.Email != name && account.Email != "" {
name += " [" + account.Email + "]"
}
return name
}

func (r srRunner) trace(selector string) error {
account, ok, err := r.store.FindStored(selector)
if err != nil {
Expand All @@ -1122,7 +1167,7 @@ func (r srRunner) trace(selector string) error {
if !ok {
return fmt.Errorf("no account found matching %q", selector)
}
fmt.Fprintf(r.out, "\nOAuth breadcrumbs for %s\n\n", displayAccountName(account.Email))
fmt.Fprintf(r.out, "\nOAuth breadcrumbs for %s\n\n", displayAccountName(account.LoginEmail()))
if len(account.Breadcrumbs) == 0 {
fmt.Fprintln(r.out, " none")
return nil
Expand Down Expand Up @@ -1299,7 +1344,8 @@ func (r srRunner) statusOne(ctx context.Context, selector string) error {
var matches []srUsageRow
lower := strings.ToLower(selector)
for _, row := range all {
if strings.Contains(strings.ToLower(row.email), lower) {
if strings.Contains(strings.ToLower(row.email), lower) ||
strings.Contains(strings.ToLower(displayUsageAccountName(row)), lower) {
matches = append(matches, row)
}
}
Expand Down Expand Up @@ -1328,7 +1374,7 @@ func (r srRunner) pick(ctx context.Context, opts srSwitchOptions) error {
}
if target.active {
displayUsageRows(r.out, []srUsageRow{*target}, false)
fmt.Fprintf(r.out, "Already using recommended account: %s\n", target.email)
fmt.Fprintf(r.out, "Already using recommended account: %s\n", displayUsageAccountName(*target))
return nil
}
if err := ensureUsageRowSwitchable(*target); err != nil {
Expand All @@ -1338,7 +1384,7 @@ func (r srRunner) pick(ctx context.Context, opts srSwitchOptions) error {
if err := r.switchAccount(ctx, target.email, opts); err != nil {
return err
}
fmt.Fprintf(r.out, "Picked recommended account: %s\n", target.email)
fmt.Fprintf(r.out, "Picked recommended account: %s\n", displayUsageAccountName(*target))
return nil
}

Expand Down Expand Up @@ -1441,7 +1487,7 @@ func (r srRunner) autoSwitchExhaustedActive(ctx context.Context, rows []srUsageR
if err := r.switchAccount(ctx, target.email, opts); err != nil {
return false, err
}
fmt.Fprintf(r.out, "Auto-switched to %s because active account %s is exhausted.\n", target.email, active.email)
fmt.Fprintf(r.out, "Auto-switched to %s because active account %s is exhausted.\n", displayUsageAccountName(*target), displayUsageAccountName(*active))
return true, nil
}

Expand Down Expand Up @@ -1494,7 +1540,11 @@ func (r srRunner) fetchUsageRows(ctx context.Context) ([]srUsageRow, error) {
var wg sync.WaitGroup
for i, account := range all {
i, account := i, account
rows[i] = srUsageRow{email: account.Email, active: account.Email == active, provider: account.ProviderOrDefault()}
display := ""
if !account.IsAPIKey() {
display = strings.TrimSpace(account.LoginEmail())
}
rows[i] = srUsageRow{email: account.Email, displayAccount: display, active: account.Email == active, provider: account.ProviderOrDefault()}
if account.IsAPIKey() {
rowProvider := rows[i].provider
rows[i].authMode = accounts.AuthModeAPIKey
Expand Down Expand Up @@ -1584,6 +1634,7 @@ func (r srRunner) fetchUsageRows(ctx context.Context) ([]srUsageRow, error) {
rows[i].score = selectacct.Score{AccountID: account.Email, Headroom: 0, ShortHeadroom: 0}
return
}
rows[i].displayAccount = refreshed.LoginEmail()
acct := accountFromStored(refreshed)
details, err := accounts.FetchCodexUsageDetails(ctx, r.client, acct)
if err != nil {
Expand Down Expand Up @@ -1895,7 +1946,7 @@ func (r srRunner) switchAccount(ctx context.Context, selector string, opts srSwi
if err != nil {
return err
}
fmt.Fprintf(r.out, "Switched to %s\n", activated.Email)
fmt.Fprintf(r.out, "Switched to %s\n", activated.LoginEmail())
for _, result := range syncCodexCompatibleAuth(activated) {
if result.Err != nil {
fmt.Fprintf(r.errOut, "Warning: %s auth sync failed: %s\n", result.Tool, result.Err)
Expand Down Expand Up @@ -2016,7 +2067,7 @@ func (r srRunner) remove(ctx context.Context, selector string) error {
if !ok {
return fmt.Errorf("account %q changed while it was being removed", accountID)
}
fmt.Fprintf(r.out, "Removed account: %s\n", account.Email)
fmt.Fprintf(r.out, "Removed account: %s\n", account.LoginEmail())
return nil
}

Expand Down Expand Up @@ -2573,11 +2624,11 @@ func (r srRunner) ensureSwitchableForFreshUsage(ctx context.Context, account acc
}
cooked, reason := cookedFromWindows(details.Windows)
if cooked {
return fmt.Errorf("cannot switch to %s: account is cooked (%s)", account.Email, reason)
return fmt.Errorf("cannot switch to %s: account is cooked (%s)", account.LoginEmail(), reason)
}
tempCooked, reason := tempCookedFromWindows(details.Windows)
if tempCooked {
return fmt.Errorf("cannot switch to %s: account is temporarily cooked (%s)", account.Email, reason)
return fmt.Errorf("cannot switch to %s: account is temporarily cooked (%s)", account.LoginEmail(), reason)
}
return nil
}
Expand Down Expand Up @@ -2909,7 +2960,7 @@ func displayUsageRowsGrid(out io.Writer, rows []srUsageRow, numbered, perGroupNu
for _, row := range rows {
if row.err != nil {
fmt.Fprintf(out, " %s %s: %s%s\n",
style(colored, ansiBold+ansiWhite, displayAccountName(row.email)),
style(colored, ansiBold+ansiWhite, displayUsageAccountName(row)),
style(colored, ansiDim, "["+string(usageProvider(row))+"]"),
style(colored, ansiRed, row.err.Error()),
style(colored, ansiDim, usageRowErrorHint(row)))
Expand Down
3 changes: 1 addition & 2 deletions cmd/subrouter/sr_cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -794,11 +794,10 @@ func (r srRunner) cloudStatus(ctx context.Context) error {
}
fmt.Fprintf(
r.out,
"%-20s %-32s %-14s %s\n",
"%-20s %-32s %s\n",
item.Kind,
item.Label,
status,
item.ID,
)
}
return nil
Expand Down
Loading