Files
magpie/main.go
T

409 lines
13 KiB
Go

// magpie — one place to pick every agent's model.
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/yetone/magpie/internal/agent"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/claudebridge"
"github.com/yetone/magpie/internal/davsync"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/netproxy"
"github.com/yetone/magpie/internal/profile"
"github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/tui"
"github.com/yetone/magpie/internal/update"
)
var version = "dev"
const usage = `magpie — one place to pick every agent's model
magpie open the app: a window plus a menu bar icon
magpie tray start in the menu bar only
magpie autostart [on|off] open magpie (in the menu bar) when you log in, or say whether it does
magpie tui the same thing, in the terminal
magpie web [--addr host:port] [--lan] [--no-open]
the app's window in a browser, with the gateway (no desktop needed: WSL, a server over SSH)
a new key each run; MAGPIE_WEB_KEY (16+ letters, digits, - . _ ~) keeps one, signed in for 400 days
magpie ls list detected agents and their settings
magpie <agent> show one agent
magpie <agent> <model> set an agent's model e.g. magpie claude deepseek/deepseek-chat
magpie <agent> <field> <value> set another field e.g. magpie codex effort high
magpie <agent> [field] default back to the agent's own default, magpie's wiring removed
magpie save <name> snapshot every agent's settings as a profile
magpie use <name> apply a profile
magpie profiles list profiles
magpie rm <name> delete a profile
magpie backup [--no-keys] [--no-library] [file] providers, keys, settings, profiles, agent models and the library in one file, sealed with a passphrase
magpie restore [--no-agents] [--no-library] <file> put a backup in on this machine
magpie library [sync|instructions|mcp|skill] the instructions, MCP servers and skills written into every agent (magpie library help)
magpie providers list your providers: host, key, models, who uses them
magpie presets the vendors magpie knows: add one with just a key
magpie provider add <preset> <key> e.g. magpie provider add deepseek sk-…
magpie provider add <name> k=v… a custom vendor (magpie provider for the fields)
magpie provider key|models|test|rm <id>
magpie provider fallback <id> <provider/model>… use these when it's out of quota or down
magpie import [-y] <link> add the provider a magpie://import?… link describes
magpie models [<agent>] every model agents can pick, as provider/model; an agent's, and why others aren't
magpie model name <provider/model> <name>|--reset the name a model goes by, everywhere
magpie model efforts <provider/model> <l>,<l>|--reset the reasoning levels a model offers (magpie model help)
magpie visible [<agent> <family|provider|group>,… | all]
which models an agent is shown: families (magpie provider/group set <id> family=…)
magpie groups routing groups: several models agents pick as one, group/<id>
magpie group add <name> models=<m1>,<m2> [routing=smart|order|rotate|usage] [stays=auto|session|turn|off]
magpie group <id> | set <id> k=v… | rm <id> show, change or remove one (magpie group help for more)
magpie accounts [agent] [--json] every subscription magpie knows, with each one's allowance used and when it resets
magpie accounts add <agent> sign in to one more Claude, ChatGPT or Google (Gemini CLI, Antigravity) subscription
magpie accounts switch <agent> <email> sign the agent in to another of them
magpie accounts refresh renew the saved Claude and ChatGPT sign-ins now (the gateway does it daily)
magpie accounts project <gemini|antigravity> <email> <project> the Google Cloud project a Google account's requests go to
magpie serve run the gateway alone (the app runs it too)
magpie usage [today|7d|30d|all] tokens and cost per agent and model (30d)
magpie quota [<provider>] [--json] what is left of every subscription, plan and key balance
magpie sync refresh the model catalog and vendor model lists
magpie agents list every supported agent
magpie update [check] install the newest release (check: only say if there is one)
agents: claude (cc), codex, gemini, opencode (oc), mimocode, pi, goose, cursor, copilot, crush
`
var (
bold = lipgloss.NewStyle().Bold(true)
muted = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#8B8F98", Dark: "#7C8290"})
faint = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#C4C7CE", Dark: "#4A4F5A"})
green = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#0F9D58", Dark: "#7EE2A8"})
)
func main() {
gateway.Version = version
netproxy.Install()
update.GUI = hasGUI
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "magpie:", err)
os.Exit(1)
}
}
func run(args []string) error {
settings.Migrate()
agent.RenameLegacy()
// a provider added, edited or removed, or a list fetched anew, reaches
// the model lists agents keep in files of their own
catalog.Changed = agent.SyncCatalog
// the setup kept the same on every computer, by whichever serves
gateway.WhileServing = append(gateway.WhileServing, davsync.Run)
if len(args) == 0 {
if hasGUI {
return runGUI(true, "")
}
return tui.Run()
}
// a magpie:// link the system handed over (Windows, Linux): the app
// opens it for the user to confirm
if strings.HasPrefix(strings.ToLower(args[0]), "magpie:") {
if !hasGUI {
return importCmd(args)
}
return runGUI(false, args[0])
}
switch args[0] {
case "tui":
return tui.Run()
case "web":
return webCmd(args[1:])
case "app", "gui":
return runGUI(true, "")
case "tray":
return runGUI(false, "")
case "autostart":
return autostartCmd(args[1:])
case "-h", "--help", "help":
fmt.Print(usage)
return nil
case "-v", "--version", "version":
fmt.Println("magpie", version)
return nil
case "ls", "list":
// in the order the app lists them; those hidden there come last, dimmed
shown, hidden := settings.Arrange(settings.Load(), agent.Detected(), func(a *agent.Agent) string { return a.ID })
return list(append(shown, hidden...), true, len(shown))
case "agents":
return list(agent.All(), false, -1)
case "sync":
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := catalog.Sync(ctx); err != nil {
return err
}
fmt.Println(green.Render("✓"), "catalog saved to", catalog.CachePath())
refreshLive(ctx)
return nil
case "save", "use", "rm", "profiles":
return profiles(args)
case "import":
return importCmd(args[1:])
case "providers":
return providers()
case "presets":
return presets()
case "provider":
return providerCmd(args)
case "models":
return models(args[1:])
case "model":
return modelCmd(args[1:])
case "visible":
return visibleCmd(args[1:])
case "groups":
return groups()
case "group":
return groupCmd(args)
case "serve":
return serve()
case "accounts", "account":
return accountsCmd(args)
case "usage":
return usageCmd(args)
case "quota", "quotas":
return quotaCmd(args)
case "update":
return updateCmd(args)
case "library", "lib":
return libraryCmd(args)
case "backup":
return backupCmd(args[1:])
case "restore":
return restoreCmd(args[1:])
case "claude-mcp-helper": // internal: stdio MCP subprocess spawned by Claude Code
return claudebridge.RunMCP(args[1:])
}
a, err := agent.Find(args[0])
if err != nil {
return err
}
switch len(args) {
case 1:
return list([]*agent.Agent{a}, true, -1)
case 2:
if args[1] == "default" {
return set(a, a.Fields[0].Key, "")
}
// `magpie codex xhigh`: a bare value that belongs to a non-model field
// (effort levels, for instance) is routed there; anything else is a model.
if f := fieldForValue(a, args[1]); f != nil {
return set(a, f.Key, args[1])
}
return set(a, a.Fields[0].Key, args[1])
case 3:
if args[2] == "default" {
args[2] = ""
}
return set(a, args[1], args[2])
}
return fmt.Errorf("too many arguments\n\n%s", usage)
}
func set(a *agent.Agent, key, value string) error {
f := a.Field(key)
if f == nil {
var keys []string
for _, f := range a.Fields {
keys = append(keys, f.Key)
}
return fmt.Errorf("%s has no field %q (fields: %s)", a.Name, key, strings.Join(keys, ", "))
}
value, err := a.Spell(f.Key, value)
if err != nil {
return err
}
before := f.Get()
if err := a.Apply(f.Key, value); err != nil {
return err
}
// what the config reads now, not what was asked: an agent may name the
// model under a provider of its own (OpenCode's magpie-relay/…), and a
// value it had already is said to be so
now := f.Get()
shown := now
if value == "" {
shown = muted.Render("default")
}
if now == before {
shown += " " + muted.Render("(unchanged)")
}
fmt.Println(green.Render("✓"), bold.Render(a.Name), muted.Render(f.Label), shown)
if a.Notice != nil {
if n := a.Notice(); n != "" {
fmt.Println(muted.Render(" ↻ " + n))
}
}
return nil
}
func fieldForValue(a *agent.Agent, v string) *agent.Field {
vals := a.Values()
// a model stays with the model, even where other fields offer it too
// (Claude Code's opus/sonnet/haiku/fable)
for _, o := range a.Fields[0].Options(vals) {
if o.Value == v {
return nil
}
}
for i := 1; i < len(a.Fields); i++ {
for _, o := range a.Fields[i].Options(vals) {
if o.Value == v {
return &a.Fields[i]
}
}
}
return nil
}
// list prints the agents; those from dimFrom on (when not -1) are the ones
// hidden in the app, and are dimmed.
func list(agents []*agent.Agent, detectedOnly bool, dimFrom int) error {
if len(agents) == 0 {
return fmt.Errorf("no supported agents found on this machine")
}
type row struct{ name, vals, path string }
var rows []row
nameW, valW := 0, 0
for i, a := range agents {
r := row{name: a.Name, path: tilde(a.Path)}
if !detectedOnly && !a.Detected() {
r.name = faint.Render(a.Name)
r.vals = faint.Render("not detected")
r.path = ""
} else {
vals := a.Values()
dim := dimFrom >= 0 && i >= dimFrom
label, value := muted, lipgloss.NewStyle()
if dim {
label, value = faint, faint
}
var parts []string
for _, f := range a.Fields {
v := vals[f.Key]
if v == "" && f.Quiet {
continue
}
if v == "" {
v = faint.Render("—")
} else {
v = value.Render(v)
}
if f.Label == "model" {
parts = append(parts, v)
} else {
parts = append(parts, label.Render(f.Label)+" "+v)
}
}
r.name = bold.Render(a.Name)
if dim {
r.name = faint.Render(a.Name) + " " + faint.Render("hidden")
}
r.vals = strings.Join(parts, label.Render(" · "))
}
nameW = max(nameW, lipgloss.Width(r.name))
valW = max(valW, lipgloss.Width(r.vals))
rows = append(rows, r)
}
for _, r := range rows {
fmt.Printf(" %s %s %s\n", pad(r.name, nameW), pad(r.vals, valW), faint.Render(r.path))
}
return nil
}
func tilde(p string) string {
if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(p, home) {
return "~" + p[len(home):]
}
return p
}
func profiles(args []string) error {
switch args[0] {
case "profiles":
ps, err := profile.Load()
if err != nil {
return err
}
if len(ps) == 0 {
fmt.Println(muted.Render("no profiles yet · magpie save <name>"))
return nil
}
for _, n := range profile.Names(ps) {
fmt.Printf(" %s %s\n", bold.Render(n), muted.Render(profile.LongSummary(ps[n])))
}
return nil
case "save":
if len(args) < 2 {
return fmt.Errorf("usage: magpie save <name>")
}
p, err := profile.Snapshot()
if err != nil {
return err
}
if err := profile.Save(args[1], p); err != nil {
return err
}
fmt.Println(green.Render("✓"), "saved profile", bold.Render(args[1]))
if p.Library != nil {
fmt.Println(" ", muted.Render("with the "+p.Library.Summary()))
}
return nil
case "use":
if len(args) < 2 {
return fmt.Errorf("usage: magpie use <name>")
}
ps, err := profile.Load()
if err != nil {
return err
}
p, ok := ps[args[1]]
if !ok {
return fmt.Errorf("no profile named %q", args[1])
}
a, err := profile.Apply(p)
if err != nil {
return err
}
fmt.Println(green.Render("✓"), "applied", bold.Render(args[1]), muted.Render(fmt.Sprintf("(%d changed)", a.Changed)))
for _, line := range profile.Report(a) {
fmt.Println(" ", muted.Render(line))
}
return nil
case "rm":
if len(args) < 2 {
return fmt.Errorf("usage: magpie rm <name>")
}
if err := profile.Delete(args[1]); err != nil {
return err
}
fmt.Println(green.Render("✓"), "deleted profile", bold.Render(args[1]))
return nil
}
return nil
}
func pad(s string, w int) string {
if n := w - lipgloss.Width(s); n > 0 {
return s + strings.Repeat(" ", n)
}
return s
}