Files
magpie/accounts_cli.go
yetone a154197bbb accounts: ChatGPT accounts come in from codexbar's config.json (碳碳双键)
碳碳双键 on Discord (#general): several Codex accounts switched with
codexbar on the old computer; after a magpie backup and restore the new
one had only the account Codex itself was signed in to. A backup carries
no sign-in by design (a refresh token rotates, so two computers can't
share one); importing is how accounts move. But codexbar's own store,
~/.codexbar/config.json, wasn't a shape the importer knew: its accounts
sit under providers[].accounts, so the whole file was read as one entry
with no token ("#1: failed").

codexbarEntries reads it as codexbar (lizhelang/codexbar 9f2e0f53)
writes it (CodexBarConfig.swift, CodexBarConfigStore): the accounts of
the provider of kind openai_oauth and openAI.remoteConnectionAccounts,
camelCase tokens, the workspace from openAIAccountId (its id is
codexbar's own). api_key accounts are said to be keys; other providers
(API endpoints of other vendors) are left alone; a sign-in in both places
is read once. codexbar signs in with Codex's own OAuth client, so the
refresh works as for Codex's auth.json. Its own export (Sub2API's shape)
was already read.

The import window's texts (en, zh, zh-TW, ja, de), the CLI's warning and
the docs name codexbar; reference.md says a backup carries no sign-in and
that importing the old computer's file is how accounts move.

Run:
- TestImportCodexbarConfig, fixture in codexbar's encoder output (pretty,
  sorted keys, ISO dates; two Team seats, a duplicate remote account, an
  access-only account, a DeepSeek api_key provider): fails on the old code
  ("got #1:failed"), passes on the new; seats kept apart by
  openAIAccountId with an id token that doesn't name the workspace.
- Built nogui binary, sandbox HOME: `magpie accounts import codex
  config.json --yes` lists the three accounts, ChatGPT refusing the fake
  tokens; no real codexbar install here to try with real accounts.
- login-import.test.cjs (chromium, webkit; en, zh) and i18n-keys.test.cjs
  pass.
- go test -tags nogui ./... (temp HOME, pinned caches): all ok.
- go vet (darwin, GOOS=linux, GOOS=windows) on . and internal/provider;
  gofmt -l clean.
2026-10-10 15:04:23 +08:00

764 lines
25 KiB
Go

package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/agent"
"github.com/yetone/magpie/internal/proc"
"github.com/yetone/magpie/internal/provider"
)
// accountsCmd: `magpie accounts [agent] [--json]`, `magpie accounts add <agent>`,
// `magpie accounts switch|forget <agent> <email>`,
// `magpie accounts project <gemini|antigravity> <email> <project>`
// — the subscriptions magpie remembers, how much of each one's allowance is
// used, and switching the agent between them.
func accountsCmd(args []string) error {
const usage = "usage: magpie accounts [claude|codex|grok|copilot|gemini|antigravity|zed|factory|mimo|<plugin>] [--json] | magpie accounts add <claude|codex|gemini|antigravity|zed|factory|mimo|<plugin>> | magpie accounts add copilot [--host <name>.ghe.com] | magpie accounts import <codex|claude|antigravity|factory> <file>... [--yes] | magpie accounts refresh [--json] | magpie accounts checkin [--json] | magpie accounts switch|forget <claude|codex|gemini|antigravity|zed|factory|mimo|<plugin>> <email> | magpie accounts switch|forget copilot <login>[@<name>.ghe.com] [--host <name>.ghe.com] | magpie accounts project <gemini|antigravity> <email> <gcp-project-id>"
agentID := func(s string) (string, error) {
switch strings.ToLower(s) {
case "claude", "cc":
return "claude", nil
case "codex":
return "codex", nil
case "gemini", "gemini-cli":
return "gemini", nil
case "antigravity", "ag":
return "antigravity", nil
case "zed":
return "zed", nil
case "factory", "droid":
return "factory", nil
case "mimo", "mimo-app", "xiaomi-mimo":
return provider.MiMoID, nil
case "copilot":
return "copilot", nil
}
// a plugin's subscription, by its provider's id or name, as a built-in's
if pp, err := pluginProvider(context.Background(), s); err == nil {
return provider.PluginID(pp.ID), nil
}
return "", fmt.Errorf("%q: only Claude Code, Codex, Copilot, Gemini CLI, Antigravity, Zed, Factory, Xiaomi MiMo and plugins' accounts can be added and switched\n%s", s, usage)
}
if len(args) > 1 && args[1] == "project" {
if len(args) != 5 {
return fmt.Errorf("%s", usage)
}
id, err := agentID(args[2])
if err != nil {
return err
}
if err := provider.SetGoogleProject(id, args[3], args[4]); err != nil {
return err
}
if strings.TrimSpace(args[4]) == "" {
fmt.Println(green.Render("✓"), args[3], "no longer names a Google Cloud project")
} else {
fmt.Println(green.Render("✓"), args[3], "now uses Google Cloud project", args[4])
}
return nil
}
if len(args) > 1 && args[1] == "import" {
return importAccounts(args[2:])
}
if len(args) > 1 && args[1] == "refresh" {
return refreshAccounts(len(args) > 2 && args[2] == "--json")
}
if len(args) > 1 && args[1] == "checkin" {
return checkinWorkBuddy(len(args) > 2 && args[2] == "--json")
}
if len(args) > 1 && args[1] == "add" {
// a Copilot account by GitHub's device code, on github.com or an
// enterprise's <name>.ghe.com (#723)
if len(args) >= 3 && strings.EqualFold(args[2], "copilot") {
var host string
switch {
case len(args) == 5 && args[3] == "--host":
host = args[4]
case len(args) == 4 && strings.HasPrefix(args[3], "--host="):
host = strings.TrimPrefix(args[3], "--host=")
case len(args) != 3:
return fmt.Errorf("usage: magpie accounts add copilot [--host <name>.ghe.com]")
}
h, err := provider.CopilotHost(host)
if err != nil {
return err
}
if h != "" {
return addAccount("copilot:" + h)
}
return addAccount("copilot")
}
if len(args) != 3 {
return fmt.Errorf("%s", usage)
}
id, err := agentID(args[2])
if err != nil {
return err
}
return addAccount(id)
}
if len(args) > 1 && (args[1] == "switch" || args[1] == "forget") {
// a Copilot account's host, for a login on more than one (#1220)
host, hasHost := "", false
for i := 2; i < len(args); i++ {
if v, ok := strings.CutPrefix(args[i], "--host="); ok {
host, hasHost = v, true
args = slices.Delete(slices.Clone(args), i, i+1)
break
}
if args[i] == "--host" && i+1 < len(args) {
host, hasHost = args[i+1], true
args = slices.Delete(slices.Clone(args), i, i+2)
break
}
}
if len(args) != 4 {
return fmt.Errorf("%s", usage)
}
id, err := agentID(args[2])
if err != nil {
return err
}
if hasHost {
if id != "copilot" {
return fmt.Errorf("--host names a Copilot account's host\n%s", usage)
}
h, err := provider.CopilotHost(host)
if err != nil {
return err
}
login, _, _ := strings.Cut(args[3], "@")
args[3] = provider.CopilotAccountName(login, h)
}
if args[3], err = accountNamed(id, args[3]); err != nil {
return err
}
if args[1] == "forget" {
moved, err := agent.Reseat(func() error { return provider.ForgetLogin(id, args[3]) })
if err != nil {
return err
}
fmt.Println(green.Render("✓"), "forgot", args[3])
printMoved(moved)
return nil
}
if err := provider.SwitchLogin(id, args[3]); err != nil {
return err
}
if _, plug := provider.PluginOf(id); plug || id == "gemini" || id == "antigravity" || id == "copilot" {
fmt.Println(green.Render("✓"), "magpie now uses", args[3], "for", id)
return nil
}
fmt.Println(green.Render("✓"), id, "is now signed in as", args[3], muted.Render("· sessions already running keep their account until restarted"))
if id == "codex" {
if was := provider.CodexDaemonStale(); was != "" {
fmt.Println(" ", "Codex's background service is still signed in as", was+"; restart it to use", args[3]+":", provider.CodexDaemonRestart)
fmt.Println(" ", muted.Render("running Codex sessions will be interrupted"))
}
if was := provider.CodexAppStale(); was != "" {
fmt.Println(" ", "The Codex app is still signed in as", was+", and shows its limits; quit it and open it again to use", args[3])
}
}
return nil
}
which, asJSON := "", false
for _, a := range args[1:] {
switch strings.ToLower(a) {
case "--json":
asJSON = true
case "grok", "copilot":
which = strings.ToLower(a)
default:
id, err := agentID(a)
if err != nil {
return err
}
which = id
}
}
ls := provider.Logins(which)
rows := accountRows(ls, time.Now())
if asJSON {
b, _ := json.MarshalIndent(rows, "", " ")
fmt.Println(string(b))
return nil
}
if len(ls) == 0 {
fmt.Println(muted.Render("no accounts yet ·"), "add one: magpie accounts add <claude|codex|gemini|antigravity>")
return nil
}
width := 0
for _, r := range rows {
width = max(width, len(r.User))
}
now := time.Now() // one instant for every window's reset time
for _, r := range rows {
mark := " "
switch {
case r.Active:
mark = green.Render("● ")
case r.On:
mark = muted.Render("○ ")
}
plan := ""
if r.Plan != "" {
plan = " · " + r.Plan
}
line := fmt.Sprintf("%s%-7s %-*s %s", mark, r.Agent, width, r.User, muted.Render(fmt.Sprintf("%-8s", plan)))
for _, w := range r.Windows {
line += " " + quotaCell(w, now)
}
if r.Balance != "" {
line += " " + balanceCell(r.Balance, r.Agent, r.User)
}
if r.Resets != nil {
line += " " + resetsCell(r.Resets, provider.AutoResets(r.Agent, r.User))
}
if r.Lapsed != "" {
line += " " + muted.Render(r.Lapsed)
} else if r.Error != "" {
line += " " + muted.Render(r.Error)
}
line += quotaReadingCell(r.AsOf, r.ReadAt, r.Windows)
fmt.Println(line)
}
fmt.Println(faint.Render(" ● signed in · ○ takes over when it runs out · add one: magpie accounts add <agent> · switch: magpie accounts switch <agent> <email>"))
return nil
}
// accountRow is one account and its allowance, as `magpie accounts` shows it.
type accountRow struct {
Agent string `json:"agent"`
User string `json:"user"`
Plan string `json:"plan,omitempty"`
Active bool `json:"active"` // the agent is signed in to it
On bool `json:"on"` // in use: the active one, or next in line
Windows []quotaSpan `json:"windows"`
Error string `json:"error,omitempty"`
Lapsed string `json:"lapsed,omitempty"` // its sign-in has to be made again
AsOf *time.Time `json:"asOf,omitempty"` // last reading when it couldn't be read now
ReadAt *time.Time `json:"readAt,omitempty"` // when what is shown was read, when known
// Resets are a Codex account's rate-limit resets, when it holds any.
Resets *provider.ResetCredits `json:"resets,omitempty"`
// Balance is what the account holds beside its windows, a ChatGPT
// account's credits, when the vendor tells it.
Balance string `json:"balance,omitempty"`
}
type quotaSpan = provider.QuotaSpan
// accountRows asks each agent's accounts for their allowance at once; what
// was asked less than a minute ago comes from magpie's cache.
func accountRows(ls []provider.Login, now time.Time) []accountRow {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
usage := map[string]map[string]provider.SubscriptionQuota{}
seen := map[string]bool{}
provider.AskClaudeUsage()
var mu sync.Mutex
var wg sync.WaitGroup
for _, l := range ls {
if seen[l.Agent] {
continue
}
seen[l.Agent] = true
wg.Add(1)
go func(agent string) {
defer wg.Done()
u := provider.LoginUsage(ctx, agent)
mu.Lock()
usage[agent] = u
mu.Unlock()
}(l.Agent)
}
wg.Wait()
rows := []accountRow{}
for _, l := range ls {
r := accountRow{Agent: l.Agent, User: l.User, Plan: l.Plan, Active: l.Active, On: l.On, Windows: []quotaSpan{}, Lapsed: l.Lapsed}
for user, q := range usage[l.Agent] {
if !strings.EqualFold(user, l.User) {
continue
}
if r.Plan == "" {
r.Plan = q.Plan
}
r.Error, r.Resets, r.AsOf, r.ReadAt, r.Balance = q.Error, q.Resets, q.AsOf, q.ReadAt, q.Balance
// a pool's own windows stand in for the models' drawing on it,
// as the usage page shows them
for _, w := range provider.PooledWindows(q.Windows) {
s := quotaSpan{Name: w.Name, Used: w.Used, Remaining: max(0, 100-w.Used), ResetsAt: w.ResetsAt, Display: w.Display, Pool: w.Pool}
if s.ResetsAt == nil && w.ResetSecs > 0 {
t := now.Add(time.Duration(w.ResetSecs) * time.Second)
s.ResetsAt = &t
}
r.Windows = append(r.Windows, s)
}
}
rows = append(rows, r)
}
return rows
}
// quotaCell is one window in a line as of now: "5h 42% ↻2h13m 14:13".
func quotaCell(w quotaSpan, now time.Time) string {
name := strings.NewReplacer(" hours", "h", " hour", "h", " days", "d", " day", "d", " · ", " ").Replace(w.Name)
cell := fmt.Sprintf("%s %.0f%%", name, w.Used)
if w.Display != "" {
cell += " (" + w.Display + ")"
}
if w.ResetsAt != nil {
if !w.ResetsAt.After(now) {
cell += muted.Render(" · reset time passed " + w.ResetsAt.Local().Format("Jan 2 15:04"))
} else {
cell += muted.Render(" ↻" + untilShort(w.ResetsAt.Sub(now)) + " " + provider.ResetClock(*w.ResetsAt, now))
}
}
return cell
}
// quotaReadingCell says when a line was read: "as of … (cached)" for a
// reading kept in place of one that failed, "read 12m ago" for one a
// cache handed back (a Claude account's waits for Claude Code to tell it
// again), nothing for one just read.
func quotaReadingCell(at, readAt *time.Time, ws []quotaSpan) string {
if at == nil {
if readAt != nil && time.Since(*readAt) >= 2*time.Minute {
return " " + muted.Render("read "+ago(*readAt))
}
return ""
}
cell := " " + muted.Render("as of "+at.Local().Format("Jan 2 15:04")+" (cached)")
if slices.ContainsFunc(ws, func(w quotaSpan) bool { return w.ResetsAt != nil && !w.ResetsAt.After(time.Now()) }) {
cell += muted.Render(" · expired window; current allowance unknown")
}
return cell
}
// untilShort is a time left as "45m", "2h13m" or "3d4h".
func untilShort(d time.Duration) string {
switch {
case d <= 0:
return "now"
case d < time.Hour:
return fmt.Sprintf("%dm", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60)
}
return fmt.Sprintf("%dd%dh", int(d.Hours())/24, int(d.Hours())%24)
}
// addAccount signs in to one more subscription in the browser, the way the
// window's "Add account" does.
func addAccount(agentID string) error {
if risk := map[string]string{"antigravity": provider.AntigravityRisk, "claude": provider.ClaudeRisk}[agentID]; risk != "" {
fmt.Println(bold.Render("!"), risk)
fmt.Print("Sign in anyway? [y/N] ")
var yes string
fmt.Scanln(&yes)
if !strings.EqualFold(strings.TrimSpace(yes), "y") && !strings.EqualFold(strings.TrimSpace(yes), "yes") {
return fmt.Errorf("sign-in canceled")
}
}
if _, plug := provider.PluginOf(agentID); plug || provider.Moved(agentID) {
return pluginLogin(context.Background(), agentID, "")
}
if p, _ := claudeCode.downloaded(); agentID == "claude" && runtime.GOOS != "windows" && claudeCode.own() == "" && p == "" {
// a server or container: the sign-in runs Claude Code, which it
// has none of; downloading it is offered, and asked (Jorben).
// Not on Windows, where one in WSL may be the one signed in with.
fmt.Println(amber.Render("!"), "Claude Code isn't installed here: magpie signs in to Claude, and answers its requests, through it")
if err := installClaudeCode(false); err != nil {
return err
}
}
st, err := provider.StartSignIn(agentID)
if err != nil {
return err
}
if st.State == "installing" {
fmt.Printf("Installing %s, which %s is used through…\n", st.Installing, agentID)
for st.State == "installing" {
time.Sleep(500 * time.Millisecond)
st, _ = provider.SignInStatus(st.ID)
}
if st.State != "waiting" {
return fmt.Errorf("sign-in didn't finish: %s", st.Error)
}
}
fmt.Println("Finish signing in in your browser. If it didn't open, go to:")
fmt.Println(faint.Render(st.URL))
if st.Code != "" {
fmt.Println("and confirm the code", st.Code)
}
openInBrowser(st.URL)
ctx, stop := interruptContext()
defer stop()
if st.PasteCallback || st.PasteCode {
if st.PasteCode {
fmt.Println("Paste the code the page shows here and press Enter:")
} else {
fmt.Println("If the page the browser ends on won't load (magpie on a server or in Docker), paste its whole address here and press Enter:")
}
id := st.ID
go func() {
lines := bufio.NewScanner(os.Stdin)
for lines.Scan() {
if current, ok := provider.SignInStatus(id); !ok || current.State != "waiting" {
return
}
if err := provider.SubmitSignInCallback(id, lines.Text()); err != nil {
fmt.Println(err)
continue
}
return
}
}()
}
st, err = provider.WaitSignIn(ctx, st.ID)
if err != nil {
return err
}
switch st.State {
case "done":
if st.Again {
fmt.Println(green.Render("✓"), st.User, "is already listed — its sign-in was renewed")
} else if st.Using {
fmt.Println(green.Render("✓"), agentID, "is signed in as", st.User)
} else if strings.HasPrefix(agentID, "copilot") {
fmt.Println(green.Render("✓"), "added", st.User, muted.Render("· with the other Copilot accounts in magpie's window"))
} else {
fmt.Println(green.Render("✓"), "added", st.User, muted.Render("· use it: magpie accounts switch "+agentID+" "+st.User))
}
return nil
case "failed":
return fmt.Errorf("sign-in didn't finish: %s", st.Error)
}
return fmt.Errorf("sign-in canceled")
}
// importUsage is how `magpie accounts import` is run.
const importUsage = "usage: magpie accounts import <codex|claude|antigravity|factory> <file>... [--yes]"
// importAccounts: `magpie accounts import <agent> <file>... [--yes]` brings
// in accounts from other tools' files, as the window's "Import accounts from
// a file…" does (#1453): Codex's auth.json, codexbar's config.json, and
// Cockpit Tools', CLIProxyAPI's and Sub2API's exports for ChatGPT; Claude
// Code's .credentials.json and
// CLIProxyAPI's for Claude; Antigravity Cockpit's, Antigravity Manager's
// and CLIProxyAPI's for Antigravity; Factory API keys. The files are only
// read. What importing does to the tool the file came from is said first,
// and nothing is imported until that is agreed to (--yes agrees).
func importAccounts(args []string) error {
yes := false
var rest []string
for _, a := range args {
switch a {
case "--yes", "-y":
yes = true
default:
rest = append(rest, a)
}
}
if len(rest) < 2 {
return fmt.Errorf("%s", importUsage)
}
var id string
switch strings.ToLower(rest[0]) {
case "codex", "chatgpt":
id = "codex"
case "claude", "cc":
id = "claude"
case "antigravity", "ag":
id = "antigravity"
case "factory", "droid":
id = "factory"
if provider.Moved(id) {
return fmt.Errorf("Factory runs on its plugin now: add the key with magpie accounts add factory")
}
default:
return fmt.Errorf("%q: accounts can be imported for codex, claude, antigravity and factory\n%s", rest[0], importUsage)
}
paths := rest[1:]
files := make([]string, len(paths))
for i, p := range paths {
// a file that can't be read stops the import: what it holds isn't
// known, so it isn't taken for a file with nothing in it
st, err := os.Stat(p)
if err != nil {
return err
}
if st.Size() > 4<<20 {
return fmt.Errorf("%s is too big to be an export", p)
}
b, err := os.ReadFile(p)
if err != nil {
return err
}
files[i] = string(b)
}
var say []string
switch id {
case "codex":
say = []string{"Each account's sign-in is refreshed with ChatGPT before it is added, as signing in does.",
"That spends the file's sign-in: the tool it came from (Codex CLI on another computer, codexbar, Cockpit Tools, CLIProxyAPI) is signed out of that account and has to sign in again. From then on the account is magpie's.",
"The files are only read, never changed. An account magpie holds already is left as it is."}
case "claude":
say = []string{provider.ClaudeRisk,
"Each account is kept as the file has it. Claude Code renews its sign-in the first time it uses it, and the file's copy then stops working: the tool it came from has to sign in again.",
"The files are only read, never changed."}
case "antigravity":
say = []string{provider.AntigravityRisk, "Each account's sign-in is refreshed with Google before it is added."}
case "factory":
say = []string{"Each key is asked whose Factory account it is before it is added."}
}
for _, s := range say {
fmt.Println(bold.Render("!"), s)
}
if !yes {
fmt.Print("Import? [y/N] ")
var answer string
fmt.Scanln(&answer)
if a := strings.ToLower(strings.TrimSpace(answer)); a != "y" && a != "yes" {
return fmt.Errorf("import canceled")
}
}
ctx, stop := interruptContext()
defer stop()
ctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
var res []provider.ImportedAccount
var err error
switch id {
case "codex", "claude":
res, err = provider.ImportLogins(ctx, id, files)
case "antigravity":
res, err = provider.ImportGoogleAccounts(ctx, id, files)
case "factory":
res, err = provider.ImportFactoryKeys(ctx, files)
}
if err != nil {
if len(paths) == 1 {
return fmt.Errorf("%s: %w", paths[0], err)
}
return err
}
took := 0
for _, r := range res {
who := r.User
if r.File > 0 && r.File <= len(paths) {
who = paths[r.File-1]
}
switch r.Status {
case "added":
took++
plan := ""
if r.Plan != "" {
plan = " · " + r.Plan
}
fmt.Println(green.Render("✓"), "added", who+plan)
case "updated":
took++
fmt.Println(green.Render("✓"), who, muted.Render("· in magpie already, now with this sign-in"))
case "exists":
took++
fmt.Println(muted.Render("="), who, muted.Render("· in magpie already with this sign-in, left as it is"))
default:
fmt.Println(bold.Render("✗"), who+":", r.Error)
}
}
if took == 0 {
return fmt.Errorf("nothing imported")
}
fmt.Println(faint.Render(" see them: magpie accounts " + id))
return nil
}
// openInBrowser opens url in the user's browser. It is a variable so the
// tests can have it go nowhere without starting a program (noBrowser).
var openInBrowser = openURLInBrowser
func openURLInBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = proc.Command("open", url)
case "windows":
cmd = proc.Command("rundll32", "url.dll,FileProtocolHandler", url)
case "android":
cmd = proc.Command("termux-open-url", url)
default:
cmd = proc.Command("xdg-open", url)
}
_ = cmd.Start()
}
// refreshAccounts: `magpie accounts refresh` renews the sign-in of every
// saved Claude and ChatGPT account the agent isn't signed in to now — what
// the gateway does once a day, for a magpie that isn't left running (cron,
// launchd). A sign-in the vendor refuses is marked as needing a new one.
func refreshAccounts(asJSON bool) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
rs := provider.RenewLogins(ctx, 0)
if asJSON {
b, _ := json.MarshalIndent(rs, "", " ")
fmt.Println(string(b))
}
failed := 0
for _, r := range rs {
if r.Err != "" {
failed++
}
if asJSON {
continue
}
switch {
case r.Renewed:
fmt.Println(green.Render("✓"), r.Agent, r.User, muted.Render("renewed"))
default:
fmt.Println(muted.Render("✗"), r.Agent, r.User, muted.Render(r.Err))
}
}
if !asJSON && len(rs) == 0 {
fmt.Println(muted.Render("no saved Claude or ChatGPT accounts besides the ones the agents are signed in to"))
}
if !asJSON {
for _, a := range []string{"claude", "codex"} {
for _, l := range provider.Logins(a) {
if l.Active {
fmt.Println(muted.Render("·"), a, l.User, muted.Render("signed in now · "+a+" renews it itself"))
}
}
}
}
if failed > 0 {
return fmt.Errorf("%d of %d accounts couldn't be renewed", failed, len(rs))
}
return nil
}
// checkinWorkBuddy: `magpie accounts checkin` — WorkBuddy's daily check-in
// (签到) for each WorkBuddy (China) account not in yet today, and Trae CN's
// (每日签到) for each Trae CN account, and MiniMax Code's for each MiniMax
// Code account, and Qoder's daily credits for each Qoder account, and each
// plugin's own check-in (auth.checkin) for its accounts, now, and how each
// stands. The
// settings do it on their own once a day.
func checkinWorkBuddy(asJSON bool) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
rs := append(provider.CheckInWorkBuddy(ctx), provider.CheckInTrae(ctx)...)
rs = append(rs, provider.CheckInMiniMax(ctx)...)
rs = append(rs, provider.CheckInQoder(ctx)...)
rs = append(rs, provider.CheckInPlugins(ctx)...)
if rs == nil {
rs = []provider.WorkBuddyCheckin{}
}
if asJSON {
b, _ := json.MarshalIndent(rs, "", " ")
fmt.Println(string(b))
}
failed := 0
for _, r := range rs {
if r.Outcome == provider.CheckinFailed {
failed++
}
if asJSON {
continue
}
switch r.By {
case "trae":
r.User = "Trae CN " + r.User
case "minimax":
r.User = "MiniMax Code " + r.User
case "qoder":
r.User = "Qoder " + r.User
default:
if r.Vendor != "" {
r.User = strings.TrimSpace(r.Vendor + " " + r.User)
}
}
switch r.Outcome {
case provider.CheckinClaimed, provider.CheckinDone:
line := fmt.Sprint(green.Render("✓"), " ", r.User, " checked in today")
if r.Credit > 0 {
line += fmt.Sprintf(" +%g", r.Credit)
}
if r.Streak > 0 {
line += muted.Render(fmt.Sprintf(" · a %d-day streak", r.Streak))
}
if r.Outcome == provider.CheckinDone || !r.Asked {
line += muted.Render(" · already")
}
fmt.Println(line)
case provider.CheckinIneligible:
fmt.Println(muted.Render("·"), r.User, muted.Render("not eligible for the check-in"))
case provider.CheckinInactive:
fmt.Println(muted.Render("·"), r.User, muted.Render("no check-in event now"))
case provider.CheckinCaptcha:
fmt.Println(muted.Render("·"), r.User, muted.Render(strings.TrimSpace("asks for a captcha: check in in its own app "+r.Msg)))
default:
fmt.Println(muted.Render("✗"), r.User, muted.Render(r.Msg))
}
}
if !asJSON && len(rs) == 0 {
fmt.Println(muted.Render("no WorkBuddy (China), Trae CN, MiniMax Code or Qoder account is signed in"))
}
if failed > 0 {
return fmt.Errorf("%d of %d accounts couldn't check in", failed, len(rs))
}
return nil
}
// accountNamed is the saved account of agent that user names: its name as
// magpie lists it ("a@b.com · Team"), or the email alone when only one of
// the agent's accounts has it.
func accountNamed(agent, user string) (string, error) {
user = strings.TrimSpace(user)
if agent == "copilot" {
// "mona@github.com" is github.com's mona, named "mona"
if login, host, ok := strings.Cut(user, "@"); ok {
if h, err := provider.CopilotHost(host); err == nil {
user = provider.CopilotAccountName(login, h)
}
}
}
ls := provider.Logins(agent)
var names, same []string
for _, l := range ls {
if strings.EqualFold(l.User, user) {
return l.User, nil
}
names = append(names, l.User)
if email, _, _ := strings.Cut(l.User, " · "); strings.EqualFold(strings.TrimSpace(email), user) {
same = append(same, l.User)
} else if login, _, _ := strings.Cut(l.User, "@"); agent == "copilot" && strings.EqualFold(login, user) {
// a Copilot login alone names its one account on an
// enterprise's host (#1220)
same = append(same, l.User)
}
}
switch {
case len(same) == 1:
return same[0], nil
case len(same) > 1 && agent == "copilot":
return "", fmt.Errorf("copilot has %d accounts of %s: %q — name one in full (<login>@<name>.ghe.com) or add --host <name>.ghe.com", len(same), user, same)
case len(same) > 1:
return "", fmt.Errorf("%s has %d accounts of %s: %q — name one in full", agent, len(same), user, same)
case len(names) == 0:
return "", fmt.Errorf("no saved %s accounts · add one: magpie accounts add %s", agent, agent)
}
return "", fmt.Errorf("no saved %s account %q · its accounts: %q", agent, user, names)
}