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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 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 speaks Base URL Environment
+ OpenAI http://127.0.0.1:3425/v1OPENAI_BASE_URL
+ Anthropic http://127.0.0.1:3425ANTHROPIC_BASE_URL
+ Gemini http://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 | shCopy
+ 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
+}