diff --git a/README.md b/README.md index 4aff9f56..44cde96f 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,21 @@ the API key goes to `~/.gemini/.env`. Picking a catalog model points ## Install +Download the Mac app from [usemagpie.ai](https://usemagpie.ai), or from a +terminal (macOS installs the app, Linux the command): + +```sh +curl -fsSL https://usemagpie.ai/install.sh | sh +``` + +Releases are signed and notarised, and keep themselves current: the app +downloads a new version in the background and installs it when you restart +(*Restart to Update* in the menu) or quit; `magpie update` does the same from +a terminal. Every release is on +[yetone/magpie-releases](https://github.com/yetone/magpie-releases/releases). + +From source: + ```sh go install github.com/yetone/magpie@latest ``` diff --git a/internal/gui/api.go b/internal/gui/api.go index 36525721..3951cbc1 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -164,6 +164,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { }) providerRoutes(mux, w, gw) usageRoutes(mux) + updateRoutes(mux, w) mux.HandleFunc("GET /api/settings", func(rw http.ResponseWriter, r *http.Request) { writeJSON(rw, settingsState()) }) diff --git a/internal/gui/app.go b/internal/gui/app.go index d754271e..09b958b0 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -116,6 +116,9 @@ func Run(version string, showMain bool) error { Assets: application.AssetOptions{Handler: Handler(h, gw)}, Mac: application.MacOptions{ActivationPolicy: application.ActivationPolicyAccessory}, Windows: application.WindowsOptions{DisableQuitOnLastWindowClosed: true}, + // A version downloaded but not restarted into is installed on the + // way out, so the next launch is the new one. + OnShutdown: func() { updates.install() }, }) h.panel = h.app.Window.NewWithOptions(application.WebviewWindowOptions{ @@ -162,7 +165,20 @@ func Run(version string, showMain bool) error { menu.Add("Open magpie").OnClick(func(*application.Context) { h.ShowMain("") }) menu.AddSeparator() menu.Add("Version " + version).SetEnabled(false) + restart := menu.Add("Restart to Update").SetHidden(true) + restart.OnClick(func(*application.Context) { + if restartToUpdate() { + h.app.Quit() + } + }) menu.Add("Quit magpie").OnClick(func(*application.Context) { h.app.Quit() }) + updates.onReady = func(v string) { + application.InvokeSync(func() { + restart.SetLabel("Restart to Update to " + v).SetHidden(false) + menu.Update() + }) + } + updates.start() h.tray = h.app.SystemTray.New() h.tray.SetTooltip("magpie") diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index b7a0734d..cf81809a 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -1644,14 +1644,61 @@ function renderSettings() { val.append(el("code", "", value), ...tools); r.append(who, val); about.append(r); + return r; }; - row(t("Version"), "", s.version); + renderUpdate(row(t("Version"), "", s.version)); const open = el("button", "text", t("Open")); open.onclick = () => api("open", { url: "file://" + s.path }); row(t("Config folder"), t("providers, profiles and these settings"), s.dir, copyBtn(s.dir, t("Path")), open); row(t("Gateway URL"), t("the address every agent is pointed at"), s.gateway, copyBtn(s.gateway, t("Gateway URL"))); } +// renderUpdate fills in the version row: whether a newer magpie is out. +// The app checks and downloads on its own, so usually the row just offers +// the restart; a check can also be asked for. +async function renderUpdate(r, u) { + u = u || await api("update").catch(() => null); + if (!u || !r.isConnected) return; + const who = r.querySelector(".who"), val = r.querySelector(".val"); + const sub = who.querySelector(".sub") || who.appendChild(el("div", "sub")); + sub.title = ""; + for (const b of val.querySelectorAll("button")) b.remove(); + const btn = (label, fn) => { const b = el("button", "text", label); b.onclick = fn; val.append(b); }; + const check = async () => { + sub.textContent = t("Checking for updates…"); + renderUpdate(r, await api("update/check", {}).catch((e) => ({ state: "error", error: e.message }))); + }; + switch (u.state) { + case "ready": + sub.textContent = t("{v} is downloaded", { v: u.latest }); + btn(t("Restart to update"), () => api("update/install", {})); + break; + case "available": + sub.textContent = t("{v} is out", { v: u.latest }); + btn(t("Download"), () => api("update/install", {})); + break; + case "downloading": + sub.textContent = t("Downloading {v}…", { v: u.latest }); + setTimeout(() => renderUpdate(r), 2000); + break; + case "checking": + sub.textContent = t("Checking for updates…"); + setTimeout(() => renderUpdate(r), 1000); + break; + case "latest": + sub.textContent = t("Up to date"); + btn(t("Check"), check); + break; + case "error": + sub.textContent = t("Couldn't check for updates"); + sub.title = u.error || ""; + btn(t("Check"), check); + break; + default: // built from source, or not asked yet + sub.textContent = ""; + } +} + async function savePrefs(body) { try { prefs = await api("settings", body); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index bc2ae81e..397c876e 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -254,6 +254,15 @@ const I18N = { "Dark": "深色", "About": "关于", "Version": "版本", + "Checking for updates…": "正在检查更新…", + "{v} is downloaded": "{v} 已下载", + "Restart to update": "重启以更新", + "{v} is out": "{v} 已发布", + "Download": "下载", + "Downloading {v}…": "正在下载 {v}…", + "Up to date": "已是最新", + "Check": "检查", + "Couldn't check for updates": "检查更新失败", "Config folder": "配置目录", "providers, profiles and these settings": "供应商、方案与这些设置", "Open": "打开", diff --git a/internal/gui/update.go b/internal/gui/update.go new file mode 100644 index 00000000..7ea0a613 --- /dev/null +++ b/internal/gui/update.go @@ -0,0 +1,162 @@ +package gui + +import ( + "context" + "log" + "net/http" + "path/filepath" + "sync" + "time" + + "github.com/yetone/magpie/internal/update" +) + +// updater keeps the app current. It asks the feed at start-up and every six +// hours; when a newer release is out and the app may replace its own +// bundle, the new one is downloaded and unpacked straight away, so all that +// is left is a restart. Quitting installs it too, and the next launch is +// the new version. +type updater struct { + mu sync.Mutex + state string // checking | latest | downloading | ready | available | source | error + latest *update.Release + err string + bundle string // the .app to replace, "" when not in one or not writable + staged string + onReady func(version string) +} + +type updateJSON struct { + State string `json:"state"` + Current string `json:"current"` + Latest string `json:"latest,omitempty"` + Notes string `json:"notes,omitempty"` + URL string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +var updates = &updater{} + +const updateEvery = 6 * time.Hour + +func (u *updater) start() { + if b := update.Bundle(); b != "" && update.Writable(filepath.Dir(b)) { + u.bundle = b + } + go func() { + time.Sleep(5 * time.Second) // let the app settle first + for { + u.check() + time.Sleep(updateEvery) + } + }() +} + +// check asks the feed and, when it can, stages the new version. +func (u *updater) check() { + u.mu.Lock() + if u.state == "checking" || u.state == "downloading" || u.state == "ready" { + u.mu.Unlock() + return + } + u.state, u.err = "checking", "" + u.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + rel, err := update.Latest(ctx) + u.mu.Lock() + defer u.mu.Unlock() + if err != nil { + u.state, u.err = "error", err.Error() + return + } + u.latest = rel + switch { + case !update.Released(Version): + u.state = "source" + return + case !update.Newer(rel.Version, Version): + u.state = "latest" + return + case u.bundle == "": + u.state = "available" // the user fetches it from the release page + return + } + u.state = "downloading" + u.mu.Unlock() + staged, err := update.Stage(ctx, rel, u.bundle) + u.mu.Lock() + if err != nil { + log.Println("update:", err) + u.state, u.err = "error", err.Error() + return + } + u.state, u.staged = "ready", staged + if u.onReady != nil { + go u.onReady(rel.Version) + } +} + +// install swaps the staged version in; it reports whether there was one. +func (u *updater) install() bool { + u.mu.Lock() + defer u.mu.Unlock() + if u.staged == "" { + return false + } + if err := update.Install(u.staged, u.bundle); err != nil { + log.Println("update:", err) + u.state, u.err = "error", err.Error() + return false + } + u.staged = "" + return true +} + +func (u *updater) json() updateJSON { + u.mu.Lock() + defer u.mu.Unlock() + j := updateJSON{State: u.state, Current: Version, Error: u.err} + if u.latest != nil { + j.Latest, j.Notes, j.URL = u.latest.Version, u.latest.Notes, u.latest.URL + } + return j +} + +func updateRoutes(mux *http.ServeMux, w Windows) { + mux.HandleFunc("GET /api/update", func(rw http.ResponseWriter, r *http.Request) { + writeJSON(rw, updates.json()) + }) + mux.HandleFunc("POST /api/update/check", func(rw http.ResponseWriter, r *http.Request) { + updates.check() + writeJSON(rw, updates.json()) + }) + // install restarts into the staged version; without one it opens the + // release page instead. + mux.HandleFunc("POST /api/update/install", func(rw http.ResponseWriter, r *http.Request) { + j := updates.json() + if j.State == "ready" && restartToUpdate() { + rw.WriteHeader(http.StatusNoContent) + go w.Quit() + return + } + if j.URL != "" { + w.OpenURL(j.URL) + } + writeJSON(rw, updates.json()) + }) +} + +// restartToUpdate installs the staged version and arranges for it to open +// once this process is gone; the caller then quits. +func restartToUpdate() bool { + bundle := updates.bundle + if !updates.install() { + return false + } + if err := update.Relaunch(bundle); err != nil { + log.Println("update:", err) + } + return true +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 00000000..0ecff4e9 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,334 @@ +// Package update keeps magpie current. Builds are published as GitHub +// releases of yetone/magpie-releases; usemagpie.ai/api/latest describes the +// newest one: its version, notes, and every file with its SHA-256. +// +// The macOS app replaces its own bundle: the new zip is downloaded, checked +// against its hash, unpacked, and accepted only if it is signed by the same +// team as the app already installed. A bare binary (the terminal build) +// replaces itself the same way, minus the signature. +package update + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "time" +) + +// Site is magpie's home; its /api/latest is the update feed. +const Site = "https://usemagpie.ai" + +// Feed is where the newest release is described. MAGPIE_UPDATE_FEED points +// it elsewhere, for testing an update against a local server. +func Feed() string { + if f := os.Getenv("MAGPIE_UPDATE_FEED"); f != "" { + return f + } + return Site + "/api/latest" +} + +// Release is one published version. +type Release struct { + Version string `json:"version"` // "0.2.0", no v + Notes string `json:"notes"` // markdown + URL string `json:"url"` // the release page + Assets map[string]Asset `json:"assets"` // by file name +} + +// Asset is one downloadable file of a release. +type Asset struct { + URL string `json:"url"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +var client = &http.Client{Timeout: 10 * time.Minute} + +// Latest asks the feed for the newest release. +func Latest(ctx context.Context) (*Release, error) { + req, err := http.NewRequestWithContext(ctx, "GET", Feed(), nil) + if err != nil { + return nil, err + } + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != 200 { + return nil, fmt.Errorf("update feed: %s", res.Status) + } + var r Release + if err := json.NewDecoder(res.Body).Decode(&r); err != nil { + return nil, fmt.Errorf("update feed: %w", err) + } + if parse(r.Version) == nil { + return nil, fmt.Errorf("update feed: no version") + } + return &r, nil +} + +// Released reports whether v is a release version rather than a build from +// source ("dev", "0bcb2cc-dirty", git describe's "v0.1.0-3-g0bcb2cc"); +// only releases update themselves. +func Released(v string) bool { + s := parse(v) + return s != nil && !describe.MatchString(s.pre) && !strings.Contains(s.pre, "dirty") +} + +// describe matches what git describe adds after a tag: commits since, hash. +var describe = regexp.MustCompile(`^\d+-g[0-9a-f]+`) + +// Newer reports whether version a comes after b. A pre-release comes before +// the release it leads up to. +func Newer(a, b string) bool { + x, y := parse(a), parse(b) + if x == nil || y == nil { + return false + } + for i := range 3 { + if x.n[i] != y.n[i] { + return x.n[i] > y.n[i] + } + } + switch { + case x.pre == y.pre: + return false + case x.pre == "": + return true + case y.pre == "": + return false + } + return x.pre > y.pre +} + +type semver struct { + n [3]int + pre string +} + +func parse(v string) *semver { + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + v, pre, _ := strings.Cut(v, "-") + parts := strings.Split(v, ".") + if len(parts) != 3 { + return nil + } + var s semver + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return nil + } + s.n[i] = n + } + s.pre = pre + return &s +} + +// AppAsset and BinaryAsset name the files this machine would install. +func AppAsset() string { return "magpie-darwin-" + runtime.GOARCH + ".zip" } +func BinaryAsset() string { + name := "magpie-cli-" + runtime.GOOS + "-" + runtime.GOARCH + if runtime.GOOS == "windows" { + name += ".exe" + } + return name +} + +// Bundle is the .app the running binary lives in, or "" when it is not in +// one. +func Bundle() string { + if runtime.GOOS != "darwin" { + return "" + } + exe, err := executable() + if err != nil { + return "" + } + // …/magpie.app/Contents/MacOS/magpie + app := filepath.Dir(filepath.Dir(filepath.Dir(exe))) + if filepath.Ext(app) != ".app" || filepath.Base(filepath.Dir(exe)) != "MacOS" { + return "" + } + return app +} + +func executable() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + return filepath.EvalSymlinks(exe) +} + +// Writable reports whether magpie may replace what lives in dir. +func Writable(dir string) bool { + f, err := os.CreateTemp(dir, ".magpie-update-*") + if err != nil { + return false + } + f.Close() + os.Remove(f.Name()) + return true +} + +// Stage downloads the app in rel and unpacks it next to the installed +// bundle, ready for Install. It returns the unpacked app. +func Stage(ctx context.Context, rel *Release, bundle string) (string, error) { + a, ok := rel.Assets[AppAsset()] + if !ok { + return "", fmt.Errorf("release %s has no %s", rel.Version, AppAsset()) + } + // Unpacked beside the bundle so Install is a rename on one volume. + dir := filepath.Join(filepath.Dir(bundle), ".magpie-update") + os.RemoveAll(dir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + zip := filepath.Join(dir, AppAsset()) + if err := download(ctx, a, zip); err != nil { + os.RemoveAll(dir) + return "", err + } + out := filepath.Join(dir, "app") + if b, err := exec.CommandContext(ctx, "ditto", "-x", "-k", zip, out).CombinedOutput(); err != nil { + os.RemoveAll(dir) + return "", fmt.Errorf("unzip: %v: %s", err, b) + } + os.Remove(zip) + app := filepath.Join(out, "magpie.app") + if err := sameSigner(ctx, bundle, app); err != nil { + os.RemoveAll(dir) + return "", err + } + return app, nil +} + +// sameSigner accepts app only if its signature is intact and made by the +// team that signed the bundle it replaces. +func sameSigner(ctx context.Context, bundle, app string) error { + if b, err := exec.CommandContext(ctx, "codesign", "--verify", "--deep", "--strict", app).CombinedOutput(); err != nil { + return fmt.Errorf("the downloaded app's signature is broken: %s", strings.TrimSpace(string(b))) + } + want, got := team(ctx, bundle), team(ctx, app) + if want != got { + return fmt.Errorf("the downloaded app is signed by %q, not %q", got, want) + } + return nil +} + +func team(ctx context.Context, app string) string { + b, _ := exec.CommandContext(ctx, "codesign", "-dv", app).CombinedOutput() + for _, l := range strings.Split(string(b), "\n") { + if v, ok := strings.CutPrefix(l, "TeamIdentifier="); ok && v != "not set" { + return v + } + } + return "" +} + +// Install swaps the staged app in for bundle. The running copy keeps going +// until it quits; the next launch is the new one. +func Install(staged, bundle string) error { + old := filepath.Join(filepath.Dir(staged), "old.app") + os.RemoveAll(old) + if err := os.Rename(bundle, old); err != nil { + return err + } + if err := os.Rename(staged, bundle); err != nil { + os.Rename(old, bundle) // put it back + return err + } + os.RemoveAll(filepath.Dir(filepath.Dir(staged))) // .magpie-update + return nil +} + +// Relaunch opens bundle again once this process (pid) has exited. +func Relaunch(bundle string) error { + script := fmt.Sprintf(`while kill -0 %d 2>/dev/null; do sleep 0.2; done; open %q`, os.Getpid(), bundle) + cmd := exec.Command("/bin/sh", "-c", script) + cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil + return cmd.Start() +} + +// ReplaceBinary puts the release's terminal build where the running binary +// is. +func ReplaceBinary(ctx context.Context, rel *Release) error { + a, ok := rel.Assets[BinaryAsset()] + if !ok { + return fmt.Errorf("release %s has no %s", rel.Version, BinaryAsset()) + } + exe, err := executable() + if err != nil { + return err + } + tmp := exe + ".new" + if err := download(ctx, a, tmp); err != nil { + return err + } + if err := os.Chmod(tmp, 0o755); err != nil { + os.Remove(tmp) + return err + } + if runtime.GOOS == "windows" { + // A running .exe cannot be overwritten, but it can be moved aside. + os.Remove(exe + ".old") + if err := os.Rename(exe, exe+".old"); err != nil { + os.Remove(tmp) + return err + } + } + if err := os.Rename(tmp, exe); err != nil { + os.Remove(tmp) + return err + } + return nil +} + +// download fetches a to path and checks its hash. +func download(ctx context.Context, a Asset, path string) error { + if a.SHA256 == "" { + return errors.New("the release lists no checksum for " + filepath.Base(path)) + } + req, err := http.NewRequestWithContext(ctx, "GET", a.URL, nil) + if err != nil { + return err + } + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != 200 { + return fmt.Errorf("download %s: %s", filepath.Base(path), res.Status) + } + f, err := os.Create(path) + if err != nil { + return err + } + h := sha256.New() + _, err = io.Copy(io.MultiWriter(f, h), res.Body) + if cerr := f.Close(); err == nil { + err = cerr + } + if err == nil && !strings.EqualFold(hex.EncodeToString(h.Sum(nil)), a.SHA256) { + err = fmt.Errorf("%s does not match its checksum", filepath.Base(path)) + } + if err != nil { + os.Remove(path) + } + return err +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 00000000..a6834516 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,32 @@ +package update + +import "testing" + +func TestNewer(t *testing.T) { + for _, c := range []struct { + a, b string + want bool + }{ + {"0.2.0", "0.1.0", true}, + {"0.1.0", "0.2.0", false}, + {"0.1.0", "0.1.0", false}, + {"v0.10.0", "0.9.9", true}, + {"1.0.0", "1.0.0-rc.1", true}, + {"1.0.0-rc.1", "1.0.0", false}, + {"1.0.0-rc.2", "1.0.0-rc.1", true}, + {"0.2.0", "dev", false}, + {"0.2.0", "0bcb2cc-dirty", false}, + } { + if got := Newer(c.a, c.b); got != c.want { + t.Errorf("Newer(%q, %q) = %v, want %v", c.a, c.b, got, c.want) + } + } +} + +func TestReleased(t *testing.T) { + for v, want := range map[string]bool{"0.1.0": true, "v1.2.3": true, "dev": false, "0bcb2cc-dirty": false, "v0.1.0-3-g0bcb2cc": false} { + if Released(v) != want { + t.Errorf("Released(%q) != %v", v, want) + } + } +} diff --git a/main.go b/main.go index a4665dc7..dd5702dc 100644 --- a/main.go +++ b/main.go @@ -48,6 +48,7 @@ const usage = `magpie — one place to pick every coding agent's model magpie usage [today|7d|30d|all] tokens and cost per agent and model (30d) 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), pi, goose, cursor, copilot, crush ` @@ -116,6 +117,8 @@ func run(args []string) error { return serve() case "usage": return usageCmd(args) + case "update": + return updateCmd(args) case "claude-mcp-helper": // internal: stdio MCP subprocess spawned by Claude Code return claudebridge.RunMCP(args[1:]) } diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 00000000..41a25789 --- /dev/null +++ b/site/.gitignore @@ -0,0 +1,2 @@ +.wrangler/ +node_modules/ diff --git a/site/public/favicon.png b/site/public/favicon.png new file mode 100644 index 00000000..a38774f1 Binary files /dev/null and b/site/public/favicon.png differ diff --git a/site/public/icons/claudecode-color.svg b/site/public/icons/claudecode-color.svg new file mode 100644 index 00000000..ed1293c4 --- /dev/null +++ b/site/public/icons/claudecode-color.svg @@ -0,0 +1 @@ +Claude Code \ No newline at end of file diff --git a/site/public/icons/codex-color.svg b/site/public/icons/codex-color.svg new file mode 100644 index 00000000..c77ccfdd --- /dev/null +++ b/site/public/icons/codex-color.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/site/public/icons/crush.png b/site/public/icons/crush.png new file mode 100644 index 00000000..bb7d3390 Binary files /dev/null and b/site/public/icons/crush.png differ diff --git a/site/public/icons/cursor.svg b/site/public/icons/cursor.svg new file mode 100644 index 00000000..a5b2ee3b --- /dev/null +++ b/site/public/icons/cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/site/public/icons/geminicli-color.svg b/site/public/icons/geminicli-color.svg new file mode 100644 index 00000000..e15f1f7e --- /dev/null +++ b/site/public/icons/geminicli-color.svg @@ -0,0 +1 @@ +Gemini CLI \ No newline at end of file diff --git a/site/public/icons/githubcopilot.svg b/site/public/icons/githubcopilot.svg new file mode 100644 index 00000000..3cbf22a5 --- /dev/null +++ b/site/public/icons/githubcopilot.svg @@ -0,0 +1 @@ +GithubCopilot \ No newline at end of file diff --git a/site/public/icons/goose.svg b/site/public/icons/goose.svg new file mode 100644 index 00000000..10820907 --- /dev/null +++ b/site/public/icons/goose.svg @@ -0,0 +1 @@ +Goose \ No newline at end of file diff --git a/site/public/icons/opencode.svg b/site/public/icons/opencode.svg new file mode 100644 index 00000000..9c4cb89e --- /dev/null +++ b/site/public/icons/opencode.svg @@ -0,0 +1 @@ +opencode \ No newline at end of file diff --git a/site/public/icons/pi.svg b/site/public/icons/pi.svg new file mode 100644 index 00000000..5122fb5e --- /dev/null +++ b/site/public/icons/pi.svg @@ -0,0 +1 @@ +Pi \ No newline at end of file diff --git a/site/public/img/icon-256.png b/site/public/img/icon-256.png new file mode 100644 index 00000000..6531d29b Binary files /dev/null and b/site/public/img/icon-256.png differ diff --git a/site/public/img/magpie-small.svg b/site/public/img/magpie-small.svg new file mode 100644 index 00000000..64cf1bcb --- /dev/null +++ b/site/public/img/magpie-small.svg @@ -0,0 +1 @@ + diff --git a/site/public/img/magpie.svg b/site/public/img/magpie.svg new file mode 100644 index 00000000..f6b9ceb1 --- /dev/null +++ b/site/public/img/magpie.svg @@ -0,0 +1 @@ + diff --git a/site/public/img/og.png b/site/public/img/og.png new file mode 100644 index 00000000..8ae15d48 Binary files /dev/null and b/site/public/img/og.png differ diff --git a/site/public/img/panel-dark.png b/site/public/img/panel-dark.png new file mode 100644 index 00000000..c196d669 Binary files /dev/null and b/site/public/img/panel-dark.png differ diff --git a/site/public/img/panel-light.png b/site/public/img/panel-light.png new file mode 100644 index 00000000..d1e9a52a Binary files /dev/null and b/site/public/img/panel-light.png differ diff --git a/site/public/img/picker-light.png b/site/public/img/picker-light.png new file mode 100644 index 00000000..befd0a79 Binary files /dev/null and b/site/public/img/picker-light.png differ diff --git a/site/public/index.html b/site/public/index.html new file mode 100644 index 00000000..c2e01177 --- /dev/null +++ b/site/public/index.html @@ -0,0 +1,310 @@ + + + + + +magpie + + + + + + + + + + + + + + + + + +
+
+
+

Every coding agent’s model. One place.

+

magpie lists the AI coding agents on your Mac and the model each one is set to. Click a value, pick a model. Any provider, any agent, through one local gateway.

+ +
Free · macOS 11+ · Intel Mac · Linux & Windows CLI
+
+
+ + + The magpie menu bar panel: each agent with its model + +
+
+ +
+

Works with

+ +
+ +
+

Why magpie

+

Small, careful, and out of the way.

+

It lives in the menu bar. It edits the one key you changed and nothing else. It never asks your shell for a key.

+
+

One endpoint for every agent

A local gateway speaks OpenAI chat, OpenAI Responses, Anthropic Messages and Gemini, and translates between them — streaming and tool calls included.

+

Your subscriptions, shared

Signed in to Claude Code, Codex or Copilot? That login becomes a provider every other agent can use. Nothing copied, no key to paste.

+

Providers in one field

Anthropic, OpenAI, Gemini, DeepSeek, Kimi, GLM, Qwen, OpenRouter, Ollama and a dozen more. Pick a preset, paste a key, done.

+

Surgical config edits

Comments, ordering and indentation in settings.json, config.toml or config.yaml survive. Writes are atomic.

+

Real model lists

magpie asks each vendor what it serves. A model released this morning is in the picker on the next refresh — nothing is compiled in.

+

Profiles

Snapshot every agent’s settings under a name and switch all of them back in one click.

+
+
+ +
+
+
The model picker, filtering as you type
+
+

The picker

+

Click a value. Type. Done.

+

Every model from every provider you have added, filtered as you type. Anything that isn’t listed can still be typed in.

+
    +
  • Effort, small model, auth — each agent shows the fields it actually has.
  • +
  • Native or gateway — pick the agent’s own model and magpie puts back exactly what was there.
  • +
  • Always current — magpie updates itself quietly and restarts when you say so.
  • +
+
+
+
+ +
+

The gateway

+

One base URL, every model.

+

The gateway listens on 127.0.0.1:3425 and names models provider/model. Agents never hold vendor keys; anything with a base-URL setting can use it.

+ + + + + +
Tool speaksBase URLEnvironment
OpenAIhttp://127.0.0.1:3425/v1OPENAI_BASE_URL
Anthropichttp://127.0.0.1:3425ANTHROPIC_BASE_URL
Geminihttp://127.0.0.1:3425GOOGLE_GEMINI_BASE_URL
+
+ +
+

Terminal

+

Also a TUI and a CLI.

+

The same app, from a terminal. On Linux, the installer puts the magpie command in ~/.local/bin; on a Mac it installs the app too.

+
curl -fsSL https://usemagpie.ai/install.sh | sh
+

Windows: download magpie-cli-windows-amd64.exe from the latest release.

+
# pick models
+magpie ls                              # every agent and its settings
+magpie claude opus                     # agent names take prefixes: cc, oc, gem…
+magpie codex effort high
+magpie codex deepseek/deepseek-chat    # any catalog model, through the gateway
+
+# providers
+magpie provider add deepseek sk-…      # a preset needs only the key
+magpie provider add ollama             # local servers need none
+
+# profiles
+magpie save work && magpie use work
+
+magpie tui                             # the whole app, in the terminal
+magpie update                          # install the newest release
+
+ +
+ +

Pick a model. Get back to work.

+

Free, native, about 11 MB.

+ +
+
+
+ + + + + + diff --git a/site/public/install.sh b/site/public/install.sh new file mode 100644 index 00000000..355bc561 --- /dev/null +++ b/site/public/install.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# magpie installer: curl -fsSL https://usemagpie.ai/install.sh | sh +# +# macOS: puts magpie.app in /Applications (~/Applications when that is not +# writable) and links the magpie command into ~/.local/bin. +# Linux: puts the magpie command in ~/.local/bin. +# Every download is checked against the release's SHA-256. +set -eu + +site=https://usemagpie.ai +bin="${MAGPIE_BIN_DIR:-$HOME/.local/bin}" + +say() { printf ' %s\n' "$*"; } +die() { printf 'magpie: %s\n' "$*" >&2; exit 1; } + +case "$(uname -m)" in + arm64|aarch64) arch=arm64 ;; + x86_64|amd64) arch=amd64 ;; + *) die "unsupported CPU: $(uname -m)" ;; +esac +case "$(uname -s)" in + Darwin) os=darwin; file="magpie-darwin-$arch.zip" ;; + Linux) os=linux; file="magpie-cli-linux-$arch" ;; + *) die "unsupported system: $(uname -s); see $site" ;; +esac + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +feed=$(curl -fsSL "$site/api/latest") || die "could not reach $site" +# The feed is one line of JSON; pull this file's url and hash out of it. +entry=$(printf '%s' "$feed" | sed -n "s/.*\"$file\":{\([^}]*\)}.*/\1/p") +url=$(printf '%s' "$entry" | sed -n 's/.*"url":"\([^"]*\)".*/\1/p') +sum=$(printf '%s' "$entry" | sed -n 's/.*"sha256":"\([^"]*\)".*/\1/p') +version=$(printf '%s' "$feed" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p') +[ -n "$url" ] && [ -n "$sum" ] || die "the newest release has no $file" + +say "downloading magpie $version ($file)" +curl -fsSL -o "$tmp/$file" "$url" +if command -v sha256sum >/dev/null; then got=$(sha256sum "$tmp/$file" | cut -d' ' -f1) +else got=$(shasum -a 256 "$tmp/$file" | cut -d' ' -f1); fi +[ "$got" = "$sum" ] || die "$file does not match its checksum" + +mkdir -p "$bin" +if [ "$os" = darwin ]; then + apps=/Applications + [ -w "$apps" ] || { apps="$HOME/Applications"; mkdir -p "$apps"; } + ditto -x -k "$tmp/$file" "$tmp/x" + rm -rf "$apps/magpie.app" + mv "$tmp/x/magpie.app" "$apps/magpie.app" + ln -sf "$apps/magpie.app/Contents/MacOS/magpie" "$bin/magpie" + say "installed $apps/magpie.app" +else + install -m 755 "$tmp/$file" "$bin/magpie" +fi +say "installed $bin/magpie" +case ":$PATH:" in + *":$bin:"*) ;; + *) say "add $bin to your PATH to run magpie from a terminal" ;; +esac +[ "$os" = darwin ] && say "open it: open -a magpie" || say "run it: magpie" diff --git a/site/worker.js b/site/worker.js new file mode 100644 index 00000000..9efce012 --- /dev/null +++ b/site/worker.js @@ -0,0 +1,88 @@ +// usemagpie.ai. Releases live on GitHub (yetone/magpie-releases); this +// worker turns the newest one into the update feed the app reads and into +// download links that never go stale. +// +// /api/latest {version, notes, url, published, assets: {name: {url, size, sha256}}} +// /download the Apple Silicon dmg +// /download/mac-arm64 the same; /download/mac-intel the Intel dmg +// /download/ any file of the newest release, by name +// +// Everything else is the static site in public/. + +const REPO = "yetone/magpie-releases"; +const TTL = 300; // seconds the newest release is remembered + +const SHORT = { + "mac-arm64": "magpie-darwin-arm64.dmg", + "mac-intel": "magpie-darwin-amd64.dmg", + "mac-amd64": "magpie-darwin-amd64.dmg", +}; + +export default { + async fetch(req, env, ctx) { + const url = new URL(req.url); + if (url.hostname.startsWith("www.")) { + url.hostname = url.hostname.slice(4); + return Response.redirect(url.toString(), 301); + } + if (url.pathname === "/api/latest") { + const rel = await latest(ctx); + if (!rel) return json({ error: "no release yet" }, 503); + return json(rel, 200, { "Cache-Control": `public, max-age=${TTL}` }); + } + if (url.pathname === "/download" || url.pathname.startsWith("/download/")) { + const want = url.pathname.split("/")[2] || "mac-arm64"; + const rel = await latest(ctx); + const asset = rel && rel.assets[SHORT[want] || want]; + if (!asset) return new Response("not found\n", { status: 404 }); + return Response.redirect(asset.url, 302); + } + return env.ASSETS.fetch(req); + }, +}; + +// latest is the newest release, condensed, with each file's SHA-256 taken +// from the release's SHA256SUMS. +async function latest(ctx) { + const cache = caches.default; + const key = new Request("https://usemagpie.ai/__latest"); + const hit = await cache.match(key); + if (hit) return hit.json(); + + const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { + headers: { "User-Agent": "usemagpie.ai", Accept: "application/vnd.github+json" }, + }); + if (!res.ok) return null; + const gh = await res.json(); + const sums = {}; + const sumsAsset = gh.assets.find((a) => a.name === "SHA256SUMS"); + if (sumsAsset) { + const r = await fetch(sumsAsset.browser_download_url, { headers: { "User-Agent": "usemagpie.ai" } }); + if (r.ok) { + for (const line of (await r.text()).split("\n")) { + const [hash, name] = line.trim().split(/\s+\*?/); + if (hash && name) sums[name] = hash; + } + } + } + const rel = { + version: gh.tag_name.replace(/^v/, ""), + notes: gh.body || "", + url: gh.html_url, + published: gh.published_at, + assets: {}, + }; + for (const a of gh.assets) { + if (a.name === "SHA256SUMS") continue; + rel.assets[a.name] = { url: a.browser_download_url, size: a.size, sha256: sums[a.name] || "" }; + } + ctx.waitUntil(cache.put(key, json(rel, 200, { "Cache-Control": `max-age=${TTL}` }))); + return rel; +} + +function json(v, status = 200, headers = {}) { + return new Response(JSON.stringify(v), { + status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", ...headers }, + }); +} diff --git a/site/wrangler.jsonc b/site/wrangler.jsonc new file mode 100644 index 00000000..5eab5b33 --- /dev/null +++ b/site/wrangler.jsonc @@ -0,0 +1,17 @@ +// usemagpie.ai: the static site in public/, plus worker.js for the update +// feed and the download links. Deploy with `npx wrangler deploy` from here. +{ + "name": "usemagpie", + "main": "worker.js", + "compatibility_date": "2026-09-01", + "assets": { + "directory": "./public", + "binding": "ASSETS", + "run_worker_first": ["/api/*", "/download", "/download/*"] + }, + "routes": [ + { "pattern": "usemagpie.ai", "custom_domain": true }, + { "pattern": "www.usemagpie.ai", "custom_domain": true } + ], + "observability": { "enabled": true } +} diff --git a/update_cli.go b/update_cli.go new file mode 100644 index 00000000..c3b8474f --- /dev/null +++ b/update_cli.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "fmt" + "path/filepath" + "time" + + "github.com/yetone/magpie/internal/update" +) + +// updateCmd is `magpie update [check]`: the app replaces its bundle, the +// terminal build its binary. +func updateCmd(args []string) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + rel, err := update.Latest(ctx) + if err != nil { + return err + } + if !update.Newer(rel.Version, version) { + if update.Released(version) { + fmt.Println(green.Render("✓"), "magpie", version, muted.Render("is the latest")) + } else { + fmt.Println("magpie", version, muted.Render("was built from source; the latest release is "+rel.Version)) + } + return nil + } + fmt.Println("magpie", bold.Render(rel.Version), "is out", muted.Render("(you have "+version+") · "+rel.URL)) + if len(args) > 1 && args[1] == "check" { + return nil + } + if !update.Released(version) { + return fmt.Errorf("this magpie was built from source; update it the way you built it, or get the release from %s", update.Site) + } + if app := update.Bundle(); app != "" { + if !update.Writable(filepath.Dir(app)) { + return fmt.Errorf("cannot write to %s; download the new version from %s", filepath.Dir(app), update.Site) + } + fmt.Println(muted.Render(" downloading " + update.AppAsset() + " …")) + staged, err := update.Stage(ctx, rel, app) + if err != nil { + return err + } + if err := update.Install(staged, app); err != nil { + return err + } + fmt.Println(green.Render("✓"), "updated", tilde(app), "to", rel.Version, muted.Render("· quit and reopen magpie to use it")) + return nil + } + fmt.Println(muted.Render(" downloading " + update.BinaryAsset() + " …")) + if err := update.ReplaceBinary(ctx, rel); err != nil { + return err + } + fmt.Println(green.Render("✓"), "updated to", rel.Version) + return nil +}