A Claude subscription signs in and answers through the local `claude`, so
on a server or in the Docker image, which has none, it couldn't be used
(Jorben on Discord: could it be downloaded on first use, as Bun is).
- internal/claudecode: Anthropic's native build, as claude.ai/install.sh
takes it: downloads.claude.ai/claude-code-releases/latest names the
version, its manifest.json the platform's SHA-256 and size (linux
glibc/musl, darwin, win32). A build that doesn't match both is not kept.
It goes to <cache>/claude-code/<version>/claude, one version kept. Find
takes the machine's own claude first, then the download.
- magpie claude-code [install [--yes]|remove]: says the version, platform,
size, the URL it comes from, where it goes, that its checksum is checked
and that it is Anthropic's software under Anthropic's terms, then asks
[y/N]. magpie accounts add claude offers it when there is no Claude Code
(not on Windows, where one in WSL may be the one).
- the gateway's claudeBinary and the sign-in's claudeExecutable take
claudecode.Find; their "not installed" errors name the command.
- Docker docs (en, zh, ja): Claude sign-in needs `docker exec -it magpie
magpie claude-code install` first; kept in /config/cache.
The Dockerfile is unchanged: distroless cc-debian12 has glibc.
Run:
- go test -tags nogui ./... under a temp HOME with pinned caches: all ok
- gofmt -l: none; go vet (darwin), GOOS=linux and GOOS=windows go vet
-tags nogui ./...: clean; darwin, linux, windows builds
- TestClaudeSubscriptionRunsTheDownloadedClaudeCode fails on the old
proc.FindTool lookup ("Claude Code is not installed"); taking the
checksum compare out fails TestInstallRefusesABuildTheManifestDoesntName
- real: magpie claude-code install --yes downloaded Claude Code 2.1.296
darwin-arm64 (230 MB) into a sandbox HOME, checked; it ran --version
- real, in gcr.io/distroless/cc-debian12:nonroot with the image's /config
layout: install downloaded 2.1.296 linux-arm64 (245 MB) to
/config/cache/magpie/claude-code, it ran `2.1.296 (Claude Code)` there,
and magpie accounts add claude went on to print the claude.ai sign-in
link and ask for the pasted address
- the claudecode tests built for linux/arm64 pass in debian:12-slim
629 lines
23 KiB
Go
629 lines
23 KiB
Go
// magpie — one place to pick every agent's model.
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/charmbracelet/lipgloss"
|
||
|
||
"github.com/yetone/magpie/internal/agent"
|
||
"github.com/yetone/magpie/internal/appdir"
|
||
"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/imagemcp"
|
||
"github.com/yetone/magpie/internal/netproxy"
|
||
"github.com/yetone/magpie/internal/proc"
|
||
"github.com/yetone/magpie/internal/profile"
|
||
"github.com/yetone/magpie/internal/provider"
|
||
"github.com/yetone/magpie/internal/sessions"
|
||
"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 panel open the menu bar icon's quick panel, or close it
|
||
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 (serves the gateway while open when no magpie does)
|
||
magpie web [--addr host:port] [--lan] [--no-open] [--gateway]
|
||
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
|
||
--gateway: gateway mode, no Agents, Sessions or Library (on by itself with no agents here; Settings › General turns it off)
|
||
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> default take magpie out: the agent back on what it had before
|
||
magpie <agent> <field> default that field back to the agent's own default
|
||
|
||
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 webdav [on <address>|set k=v…|now|off] the same, kept the same on every computer through a WebDAV folder (magpie webdav help)
|
||
magpie s3 [on s3://<bucket>[/<prefix>]|set k=v…|now|off] the same through an S3-compatible bucket: AWS, R2, B2, MinIO… (magpie s3 help)
|
||
|
||
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 | --only-picked | --show-new]
|
||
which models an agent is shown: families (magpie provider/group set <id> family=…),
|
||
or only the models ticked for it, a new one off until it is ticked
|
||
magpie search [add <api> <key>|rm <api>] Tavily, Brave, Exa, Firecrawl or SearXNG for web search when no provider can search
|
||
magpie groups routing groups: several models agents pick as one, group/<id>
|
||
magpie group add <name> models=<m1>,<m2> [routing=smart|order|rotate|usage|pace] [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 add copilot [--host <name>.ghe.com] one more Copilot account, on github.com or an enterprise's GHE.com
|
||
magpie accounts import <codex|claude|antigravity|factory> <file>... [--yes] bring in accounts from other tools' files (Codex's auth.json, Cockpit Tools, CLIProxyAPI, Sub2API); the tool a ChatGPT or Claude file came from is signed out of it
|
||
magpie accounts switch <agent> <email> sign the agent in to another of them
|
||
magpie accounts refresh renew the saved ChatGPT sign-ins now (the gateway does it daily)
|
||
magpie accounts checkin WorkBuddy's daily check-in (签到) for each WorkBuddy account, now (Settings can do it daily)
|
||
magpie accounts project <gemini|antigravity> <email> <project> the Google Cloud project a Google account's requests go to
|
||
magpie claude-code [install [--yes]|remove] the Claude Code a Claude subscription runs; install downloads Anthropic's own build, checked against its manifest, for a server or container without one
|
||
magpie plugin [add <package>|rm|update|on|off|login <provider>|logout <provider>]
|
||
OpenCode provider plugins and pi packages: subscriptions signed in to, and served, through a plugin
|
||
magpie plugin move|migrate <subscription> run a built-in subscription's accounts on its community plugin
|
||
magpie plugin move-back|unmigrate <subscription> go back to the built-in, with its accounts
|
||
|
||
magpie serve run the gateway alone (the app runs it too)
|
||
magpie healthcheck exit 0 when the gateway answers (a container's HEALTHCHECK)
|
||
magpie gateway-key list|add <name>|rotate <id>|remove <id> manage the keys clients use to call a shared gateway
|
||
magpie gateway-key limit <id> [off|day|week|month --tokens N --cost USD --cache-reads] a key's own limit, and what it used
|
||
magpie gateway-key models <id> [all|<provider>/<model>|<provider>/* ...] the models a key may use, every one unless it names some
|
||
magpie mcp image the image and video generation MCP server an agent is given from the library (stdio)
|
||
magpie usage [today|7d|30d|all] tokens and cost per agent, model and subscription account (30d)
|
||
magpie usage --csv [--account <name>] [today|7d|30d|all] every request as CSV (or one account's): the model asked for, sent and served, tokens, cost, time, status, account
|
||
magpie sessions [--model <m>] [--folder <f>] [--json] the latest Claude Code, Codex, OpenCode and Pi sessions, with what each cost
|
||
magpie sessions --days N|today|all [--model <m>] [--folder <f>] [--json]
|
||
what every session spent, day by day, with the top models and folders (7 days)
|
||
magpie quota [<provider>] [--json] what is left of every subscription, plan and key balance
|
||
magpie quota wait <provider|account> [--timeout <d>] [--quiet]
|
||
block until that subscription (any of its accounts) or account has allowance again
|
||
magpie quota history [<provider|account>] [--days N] [--json]
|
||
each window's readings over time, kept 45 days
|
||
magpie sync refresh the model catalog and vendor model lists
|
||
magpie agents list every supported agent
|
||
magpie update [check] [--proxy <url>] [--mirror <prefix>]
|
||
install the newest release (check: only say if there is one); --proxy: an
|
||
http(s):// or socks5:// proxy for it; --mirror: a GitHub download mirror put
|
||
before the github.com URL (none unless given; still checked against usemagpie.ai's SHA-256)
|
||
magpie update mirror [<prefix>|off] the mirror every update, the app's own too, is downloaded through
|
||
magpie update auto [on|off] [30m|1h|6h|24h] whether the app looks for updates by itself, and how often (6h)
|
||
|
||
agents: claude (cc), codex, gemini, opencode (oc), mimocode, pi, goose, cursor, zed, copilot, crush, aside
|
||
`
|
||
|
||
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() {
|
||
// before anything reads or writes a file: no home, or a relative one,
|
||
// would put the agents' configs and magpie's keys under the working
|
||
// folder. What needs no file still answers (magpie version in a
|
||
// container or a script without HOME): run makes magpie's folders first.
|
||
ignored, err := appdir.CheckEnv()
|
||
if err != nil {
|
||
if len(os.Args) > 1 {
|
||
switch os.Args[1] {
|
||
case "-v", "--version", "version":
|
||
fmt.Println("magpie", version)
|
||
return
|
||
case "-h", "--help", "help":
|
||
fmt.Print(usage)
|
||
return
|
||
}
|
||
}
|
||
fmt.Fprintln(os.Stderr, "magpie:", err)
|
||
os.Exit(1)
|
||
}
|
||
slices.Sort(ignored)
|
||
for _, v := range ignored {
|
||
fmt.Fprintf(os.Stderr, "magpie: ignoring %s: not an absolute path (the programs magpie starts still get it)\n", v)
|
||
}
|
||
if provider.TookOpenedURL(os.Args[1:]) {
|
||
// Claude Code, signing in for magpie, handed over the page to open
|
||
return
|
||
}
|
||
endProbesOnSignal()
|
||
gateway.Version = version
|
||
netproxy.Install()
|
||
update.GUI = hasGUI
|
||
err = run(os.Args[1:])
|
||
proc.EndProbes() // a CLI still being asked something isn't left to init
|
||
sessions.Saved() // the session index kept, for the next run
|
||
if err != nil {
|
||
// a command with exit codes of its own (quota wait) says which
|
||
code := 1
|
||
var e exitError
|
||
if errors.As(err, &e) {
|
||
code = e.code
|
||
}
|
||
if msg := err.Error(); msg != "" {
|
||
fmt.Fprintln(os.Stderr, "magpie:", msg)
|
||
}
|
||
os.Exit(code)
|
||
}
|
||
}
|
||
|
||
// runTUI runs the TUI, which quits on Ctrl+C and SIGTERM itself once it
|
||
// has started; it asks CLIs first, and a signal then ends those.
|
||
func runTUI() error {
|
||
return tuiRun(ownSignals)
|
||
}
|
||
|
||
// tuiRun is tui.Run; a var so tests can stand in for it.
|
||
var tuiRun = tui.Run
|
||
|
||
func run(args []string) error {
|
||
if len(args) > 0 && args[0] == "healthcheck" {
|
||
return healthcheck() // every few seconds in a container: nothing else
|
||
}
|
||
if len(args) == 2 && args[0] == agent.DryRunArg {
|
||
// an agent disconnected on a copy of its files under a temporary
|
||
// home, for the Agents page to show what disconnecting changes
|
||
// (agent.DisconnectPreview)
|
||
return agent.DryRun(args[1])
|
||
}
|
||
// started by an update, the magpie it replaces goes first: the moves
|
||
// below write the files it may still be writing
|
||
update.AwaitPredecessor()
|
||
makeDirs()
|
||
settings.Migrate()
|
||
// the providers and settings read once for every agent's fields, which
|
||
// the moves below look at (a write among them reads them again)
|
||
release := provider.Hold()
|
||
agent.RenameLegacy()
|
||
agent.MoveCursorEfforts()
|
||
agent.MoveAntigravityEfforts()
|
||
agent.MoveOffAccountIDs()
|
||
release()
|
||
// 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
|
||
// a model Claude Code names that magpie doesn't serve goes to the one
|
||
// it is set to use for that tier
|
||
gateway.StandIn = agent.StandIn
|
||
// a Codex that reaches magpie for account failover alone is handed
|
||
// only its own models (#1385)
|
||
gateway.CodexOwnOnly = agent.CodexOwnOnly
|
||
// the setup kept the same on every computer, by whichever serves
|
||
gateway.WhileServing = append(gateway.WhileServing, davsync.Run)
|
||
// and dsh's patch lists, which dsh reads live: a route left behind by
|
||
// something else writing the file fails every session there until
|
||
// magpie writes its own list again
|
||
gateway.WhileServing = append(gateway.WhileServing, agent.KeepDshWired)
|
||
// and Cursor Private Inference's variables, which the Mac's launchd
|
||
// forgets at a restart, for the gateway's address now
|
||
gateway.WhileServing = append(gateway.WhileServing, agent.KeepCursorLocalEnv)
|
||
// and the request archive, when it is on, goes to the bucket sync is to
|
||
gateway.ArchiveBucket = func() (gateway.Putter, bool) {
|
||
if b, ok := davsync.S3Bucket(); ok {
|
||
return b, true
|
||
}
|
||
return nil, false
|
||
}
|
||
if len(args) == 0 {
|
||
if hasGUI {
|
||
return runGUI(true, "")
|
||
}
|
||
return runTUI()
|
||
}
|
||
// 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 runTUI()
|
||
case "web":
|
||
return webCmd(args[1:])
|
||
case "app", "gui":
|
||
// `magpie gui settings`: the window on that tab, as a restart to
|
||
// update from it comes back (update.RelaunchArgs)
|
||
if len(args) > 1 {
|
||
return runWindow(args[1])
|
||
}
|
||
return runGUI(true, "")
|
||
case "tray":
|
||
return runGUI(false, "")
|
||
case "-Embedding":
|
||
// Windows starting magpie for a click on one of its notifications
|
||
// (a usage alert, #368) left in the Action Center after it quit:
|
||
// the window, on the Usage page
|
||
return runWindow("usage")
|
||
case "panel":
|
||
return runPanel()
|
||
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 "search":
|
||
return searchCmd(args[1:])
|
||
case "groups":
|
||
return groups()
|
||
case "group":
|
||
return groupCmd(args)
|
||
case "serve":
|
||
return serve()
|
||
case "gateway-key":
|
||
return gatewayKeys(args)
|
||
case "accounts", "account":
|
||
return accountsCmd(args)
|
||
case "usage":
|
||
return usageCmd(args)
|
||
case "sessions":
|
||
return sessionsCmd(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 "webdav", "dav":
|
||
return webdavCmd(args[1:])
|
||
case "plugin", "plugins":
|
||
return pluginCmd(args)
|
||
case "s3":
|
||
return s3Cmd(args[1:])
|
||
case "mcp":
|
||
return imagemcp.Run(args[1:])
|
||
case "claude-code":
|
||
return claudeCodeCmd(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
|
||
}
|
||
if len(a.Fields) == 0 && a.Import != nil {
|
||
// `magpie cindy`: it takes magpie through its own link, confirmed there
|
||
if len(args) > 1 {
|
||
link := a.Import()
|
||
openInBrowser(link)
|
||
fmt.Println(green.Render("✓"), bold.Render(a.Name), muted.Render("opened to add magpie — confirm it there"))
|
||
fmt.Println(muted.Render(" " + link))
|
||
return nil
|
||
}
|
||
}
|
||
switch len(args) {
|
||
case 1:
|
||
return list([]*agent.Agent{a}, true, -1)
|
||
case 2:
|
||
if args[1] == "default" {
|
||
if a.Wired() {
|
||
return disconnect(a)
|
||
}
|
||
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 == "" || now == "" {
|
||
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
|
||
}
|
||
|
||
// disconnect is `magpie <agent> default` on an agent magpie is wired into:
|
||
// the Agents page's Disconnect, which puts back what the user had before
|
||
// magpie — Claude Code's own model, its endpoint — where a field's default
|
||
// leaves the agent as installed (__jingling on X: magpie claude default
|
||
// took the model they had set away with magpie's)
|
||
func disconnect(a *agent.Agent) error {
|
||
before := a.Values()
|
||
if err := a.Disconnect(); err != nil {
|
||
return err
|
||
}
|
||
now := a.Values()
|
||
for _, f := range a.Fields {
|
||
if before[f.Key] == now[f.Key] {
|
||
continue
|
||
}
|
||
shown := now[f.Key]
|
||
if shown == "" {
|
||
shown = muted.Render("default")
|
||
}
|
||
fmt.Println(green.Render("✓"), bold.Render(a.Name), muted.Render(f.Label), shown)
|
||
}
|
||
fmt.Println(muted.Render(" disconnected from magpie, back to what it had before"))
|
||
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()
|
||
// the agent's suffix after a model (omp's ":max") aside: a role on the
|
||
// same model at that level offers it as typed, and would take it. A list
|
||
// of models no picker offers: it is for the model
|
||
if a.SplitSuffix != nil {
|
||
m, _, one := a.SplitSuffix(v)
|
||
if !one {
|
||
return nil
|
||
}
|
||
v = m
|
||
}
|
||
// 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(" · "))
|
||
// through magpie for account failover while not connected (#1385)
|
||
if said := a.FailoverSaid(); said != "" {
|
||
r.vals += label.Render(" · " + said)
|
||
}
|
||
if a.Import != nil {
|
||
if a.Added != nil && a.Added() {
|
||
r.vals = value.Render("magpie added")
|
||
} else {
|
||
r.vals = label.Render("magpie "+a.ID+" add") + faint.Render(" to add magpie")
|
||
}
|
||
}
|
||
}
|
||
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
|
||
}
|