usemagpie.ai, and a magpie that updates itself
site/ is the website: a static page plus a worker that turns the newest release of yetone/magpie-releases into an update feed (/api/latest, with every file's SHA-256), stable download links and an install script. The app asks the feed at start-up and every six hours, downloads a newer version in the background, and installs it on Restart to Update or on quit. A download must match its checksum and be signed by the same team as the app it replaces. `magpie update` does the same from a terminal and replaces a bare binary in place.
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": "打开",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.wrangler/
|
||||
node_modules/
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude Code</title><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="#D97757" fill-rule="evenodd"></path></svg>
|
||||
|
After Width: | Height: | Size: 424 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-_R_0_" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"></path></svg>
|
||||
|
After Width: | Height: | Size: 547 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini CLI</title><path d="M0 4.391A4.391 4.391 0 014.391 0h15.217A4.391 4.391 0 0124 4.391v15.217A4.391 4.391 0 0119.608 24H4.391A4.391 4.391 0 010 19.608V4.391z" fill="url(#lobe-icons-gemini-cli-_R_0_)"></path><path clip-rule="evenodd" d="M19.74 1.444a2.816 2.816 0 012.816 2.816v15.48a2.816 2.816 0 01-2.816 2.816H4.26a2.816 2.816 0 01-2.816-2.816V4.26A2.816 2.816 0 014.26 1.444h15.48zM7.236 8.564l7.752 3.728-7.752 3.727v2.802l9.557-4.596v-3.866L7.236 5.763v2.801z" fill="#1E1E2E" fill-rule="evenodd"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-cli-_R_0_" x1="24" x2="0" y1="6.587" y2="16.494"><stop stop-color="#EE4D5D"></stop><stop offset=".328" stop-color="#B381DD"></stop><stop offset=".476" stop-color="#207CFE"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 921 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Goose</title><path d="M21.595 23.61c1.167-.254 2.405-.944 2.405-.944l-2.167-1.784a12.124 12.124 0 01-2.695-3.131 12.127 12.127 0 00-3.97-4.049l-.794-.462a1.115 1.115 0 01-.488-.815.844.844 0 01.154-.575c.413-.582 2.548-3.115 2.94-3.44.503-.416 1.065-.762 1.586-1.159.074-.056.148-.112.221-.17.003-.002.007-.004.009-.007.167-.131.325-.272.45-.438.453-.524.563-.988.59-1.193-.061-.197-.244-.639-.753-1.148.319.02.705.272 1.056.569.235-.376.481-.773.727-1.171.165-.266-.08-.465-.086-.471h-.001V3.22c-.007-.007-.206-.25-.471-.086-.567.35-1.134.702-1.639 1.021 0 0-.597-.012-1.305.599a2.464 2.464 0 00-.438.45l-.007.009c-.058.072-.114.147-.17.221-.397.521-.743 1.083-1.16 1.587-.323.391-2.857 2.526-3.44 2.94a.842.842 0 01-.574.153 1.115 1.115 0 01-.815-.488l-.462-.794a12.123 12.123 0 00-4.049-3.97 12.133 12.133 0 01-3.13-2.695L1.332 0S.643 1.238.39 2.405c.352.428 1.27 1.49 2.34 2.302C1.58 4.167.73 3.75.06 3.4c-.103.765-.063 1.92.043 2.816.726.317 1.961.806 3.219 1.066-1.006.236-2.11.278-2.961.262.15.554.358 1.119.64 1.688.119.263.25.52.39.77.452.125 2.222.383 3.164.171l-2.51.897a27.776 27.776 0 002.544 2.726c2.031-1.092 2.494-1.241 4.018-2.238-2.467 2.008-3.108 2.828-3.8 3.67l-.483.678c-.25.351-.469.725-.65 1.117-.61 1.31-1.47 4.1-1.47 4.1-.154.486.202.842.674.674 0 0 2.79-.861 4.1-1.47.392-.182.766-.4 1.118-.65l.677-.483c.227-.187.453-.37.701-.586 0 0 1.705 2.02 3.458 3.349l.896-2.511c-.211.942.046 2.712.17 3.163.252.142.509.272.772.392.569.28 1.134.49 1.688.64-.016-.853.026-1.956.261-2.962.26 1.258.75 2.493 1.067 3.219.895.106 2.051.146 2.816.043a73.87 73.87 0 01-1.308-2.67c.811 1.07 1.874 1.988 2.302 2.34h-.001z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>opencode</title><path d="M16 6H8v12h8V6zm4 16H4V2h16v20z"></path></svg>
|
||||
|
After Width: | Height: | Size: 235 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Pi</title><path clip-rule="evenodd" d="M1 1h16.5v11H12v5.5H6.5V23H1V1zm5.5 5.5V12H12V6.5H6.5z"></path><path d="M17.5 12H23v11h-5.5V12z"></path></svg>
|
||||
|
After Width: | Height: | Size: 313 B |
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44"><path fill-rule="evenodd" d="M1.5 5.5C1.3 5.7 1.2 6.5 1.2 6.9C1.2 6.9 1.2 7 1.2 7.1C1.1 7.4 1.3 8 1.4 8.3C1.4 8.4 1.5 8.5 1.5 8.7C1.5 8.9 1.7 9 1.8 9.3C2.2 9.9 2.2 10 2.3 10.1C2.4 10.2 2.5 10.3 2.6 10.5C2.8 10.8 3 11.1 3.2 11.3C3.3 11.3 3.4 11.6 3.6 11.8C3.8 12 4.1 12.4 4.2 12.5C4.7 13 5.2 13.7 5.4 13.9C6 14.5 6.5 15.1 6.7 15.3C6.7 15.4 6.9 15.5 6.9 15.7C7.3 15.9 7.7 16.5 7.9 16.8C7.9 16.8 8 16.9 8 16.9C8.1 16.9 8.2 17.2 8.2 17.3C8.2 17.3 8.1 17.2 8.1 17.1C7.9 16.9 7.9 16.9 7.6 16.6C7.5 16.5 7.4 16.5 7.4 16.3C7.2 16.2 6.7 15.7 6.3 15.2C6.1 15.1 6 14.9 6 14.9C5.9 14.8 5.7 14.7 5.7 14.6C5.6 14.5 5.4 14.3 5.3 14.2C5.1 14.1 4.9 13.8 4.7 13.5C4.5 13.3 4.2 13.1 4.1 12.9C4.1 12.9 3.8 12.7 3.8 12.5C3.5 12.3 3 11.8 2.8 11.4C1.8 10.5 0.9 9.6 0.7 9.6C0.6 9.7 0.6 9.8 0.5 10C0.5 10.1 0.6 10.2 0.5 10.3C0.5 10.4 0.6 10.5 0.6 10.6C0.5 10.7 0.5 10.9 0.6 11C0.5 11.1 0.6 11.4 0.6 11.5C0.9 12.9 1.3 13.8 2.8 14.9C2.9 15.1 3.3 15.3 3.4 15.4C3.4 15.5 3.6 15.6 3.7 15.7C4.2 16 6.4 17.8 7 18.4C7.4 18.8 7.4 18.8 7.5 18.9C7.6 19 7.7 19 7.7 19.1C7.7 19.2 7.8 19.2 7.9 19.2C7.9 19.3 8.1 19.4 8.1 19.5C8.2 19.6 8.2 19.7 8.3 19.7C8.3 19.7 8.3 19.7 8.2 19.7C8.2 19.6 8.1 19.6 8 19.6C7.9 19.5 7.9 19.4 7.8 19.4C7.7 19.3 7.6 19.2 7.6 19.2C7.6 19.1 7.5 19 7.3 18.9C7.2 18.9 6.9 18.6 6.7 18.4C6.1 17.8 4.5 16.6 3.5 16C3.4 15.8 3 15.6 2.9 15.5C2.6 15.3 1.5 14.6 0.9 14.3C0.7 14.1 0.6 14.1 0.6 14.2C0.5 14.5 0.8 15.9 0.9 16.1C0.9 16.2 1 16.2 0.9 16.3C1.2 16.8 1.9 17.6 2.7 18.3C2.8 18.5 4 19.2 4.7 19.5C6.2 20.5 6.6 20.7 7.4 21.5C8.2 22.2 8.7 22.7 9.3 23.6C9.5 24.1 9.9 24.4 10.3 25.3C10.4 25.5 10.5 25.8 10.5 25.8C10.7 25.8 10.8 25.7 11 25.4C11.6 25 11.9 24.6 12.3 24.3C12.8 24 13 23.8 13 23.7C13.1 23.7 13.1 23.8 13 24C12 24.8 11 26 10.2 26.9C10.1 27.1 9.9 27.5 9.7 27.7C9.5 27.8 9.4 28 9.4 28C9.4 28 9.3 28.3 9.1 28.5C8.6 29.2 7.7 30.8 7.7 30.9C7.7 31 7.8 31 8.3 30.8C9 30.4 9.5 30.2 10.4 30C11.7 29.6 12.5 29.6 11.6 30C10.5 30.2 9.7 30.6 9.2 31.2C8.8 31.7 8.8 31.7 9.3 31.7C10.1 31.8 10.9 31.9 11.6 32.2C12.8 32.7 13.5 33.1 15.5 34.8C16.4 35.5 17.3 36.2 17.8 36.4C17.9 36.5 18.1 36.7 18.2 36.8C19.1 37.3 21.2 38.1 22.3 38.3C22.4 38.3 22.7 38.3 22.8 38.3C22.8 38.5 23 38.5 23 38.5C23.1 38.5 23.2 38.5 23.3 38.5C23.6 38.6 25.9 38.6 26.1 38.5C26.2 38.5 26.3 38.5 26.4 38.5C26.5 38.5 26.6 38.5 26.7 38.3C26.7 38.3 27 38.3 27.1 38.3C28.3 38.1 30.2 37.3 31.4 36.7C31.5 36.7 31.7 36.4 31.8 36.4C32.2 36.2 33.2 35.4 33.4 35.2C34.2 34.5 35.5 33 35.8 32.6C35.8 32.5 36 32.2 36.1 32C36.4 31.8 36.6 31.4 36.6 31.2C36.7 31.1 36.8 30.9 36.9 30.8C37.2 30.4 37.5 29.6 37.7 29.2C37.7 29.1 37.8 28.7 37.9 28.7C38.1 28.5 38.4 27.1 38.5 26.8C39 25.2 39.1 23.7 39 22.4C39 22.2 38.9 21.9 38.9 21.8C38.9 21.7 38.9 21.6 38.9 21.4C38.6 20.5 38.5 19.9 38.2 19C37.9 18.5 37.5 17.5 37.3 17.2C37 16.8 36.8 15.8 36.8 15.2C36.8 13.8 37.7 12.7 39.6 12.2C40.1 12.1 41.3 11.8 42.1 11.7C43.5 11.5 43.5 11.5 43.4 11.3C42.9 10.7 40.9 9.9 39.2 9.7C37.9 9.5 38.1 9.5 37.6 9C37 8.6 36.4 8 36 7.9C36 7.8 35.8 7.8 35.7 7.7C35.1 7.3 34.2 7 33.1 6.9C32.6 6.7 31.6 6.9 31 7C29.6 7.3 28.3 8 27.3 9.2C26.7 9.8 26.2 10.8 25.7 11.7C25.5 12.1 24.8 13.4 24.7 13.7C24.6 13.9 24.5 14.1 24.1 14.7C23.5 15.7 22.1 16.9 20.6 17.8C20.5 18 20.3 18.2 20.1 18.3C19.7 18.5 19.4 18.8 19.1 19C18.9 19.1 18.7 19.3 18.5 19.4C18 19.8 17.2 20.3 17 20.5C16.9 20.5 16.8 20.6 16.7 20.7C16.3 20.8 16.1 20.9 16 20.9C15.5 20.8 14.9 20.5 14.2 20.2C13.7 19.8 12.7 18.9 12.4 18.6C12.3 18.5 12.2 18.4 12.1 18.2C11.9 18 11.2 17.1 11 16.6C10.9 16.5 10.8 16.4 10.7 16.3C10.7 16.2 10.6 16.1 10.6 15.9C10.5 15.9 10.4 15.7 10.4 15.6C10.3 15.5 10.2 15.2 10.1 15C10 14.8 9.8 14.4 9.7 14.2C9.6 14 9.5 13.7 9.4 13.6C9.4 13.4 9.3 13.2 9.2 13C9.2 12.8 9 12.6 9 12.5C8.9 12.3 8.7 12.1 8.7 12C8.7 12 8.5 11.7 8.5 11.7C8.3 11.2 8.2 10.9 7.5 10C6.4 8.3 4.2 6.4 2.7 5.8C2 5.5 1.6 5.4 1.5 5.5ZM28.4 16.7C29.2 16.9 29.7 17.2 29.9 17.7C30.1 18.2 30 18.9 29.6 19.4C29.2 19.9 28.2 20.9 27.6 21.2C26.8 21.7 25.3 22.4 24.2 22.7C23.9 22.8 23.6 22.9 23.5 22.9C22.8 23.2 20.7 23.7 19.6 24C19 24.2 18.4 24.3 18.1 24.4C17.9 24.4 16.1 24.9 15.3 25.1C14.6 25.2 14.7 25.1 15.7 23.6C16.4 22.7 18.5 20.6 19.4 19.9C20.8 18.8 21.4 18.4 23.2 17.6C23.8 17.4 25.6 16.8 25.9 16.8C25.9 16.8 26.3 16.8 26.6 16.7C26.8 16.7 28.2 16.7 28.4 16.7ZM30.4 20.2C30.5 20.3 30.6 20.6 30.6 20.7C31.7 23.1 33.4 24.6 35.6 25.3C36.1 25.5 36.7 25.7 37.2 25.8C37.7 25.8 37.8 25.9 37.8 26.1C37.7 26.2 37.7 26.3 37.7 26.5C37.7 26.6 37.7 26.8 37.6 27C37.4 28.5 36.8 29.9 36.1 31C36.1 31.2 35.9 31.4 35.9 31.6C35.7 31.9 35.2 32.6 34.7 33.3C32.6 35.6 29.7 37.3 26.6 37.8C26.5 37.8 26.3 37.9 26.2 37.9C25.8 37.9 23.7 37.9 23.3 37.9C23.2 37.9 23 37.8 22.9 37.8C21.1 37.6 19.3 36.8 18 35.7C17.7 35.4 17 34.7 16.8 34.5C16.3 33.8 16.1 33 16.3 32.5C16.5 31.8 16.8 31.6 17.9 31.1C19.1 30.5 19.4 30.4 20.2 30C21.4 29.4 23.2 28.4 23.9 27.9C23.9 27.8 24.2 27.7 24.5 27.6C26.3 26.3 28 24.6 28.9 22.9C29.5 22.2 29.7 21.6 29.9 20.7C30.1 20 30.2 19.9 30.4 20.2Z"/></svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44"><path fill-rule="evenodd" d="M1.49 5.61C1.36 5.78 1.21 6.46 1.2 6.87C1.2 6.91 1.2 7.02 1.2 7.11C1.19 7.44 1.29 8.03 1.4 8.33C1.43 8.42 1.5 8.6 1.53 8.73C1.6 8.93 1.67 9.07 1.86 9.39C2.17 9.93 2.29 10.11 2.34 10.18C2.39 10.23 2.5 10.39 2.6 10.53C2.84 10.88 3.01 11.11 3.18 11.3C3.25 11.37 3.46 11.62 3.64 11.83C3.81 12.06 4.07 12.37 4.22 12.53C4.71 13.1 5.22 13.68 5.41 13.89C5.96 14.48 6.51 15.09 6.67 15.29C6.76 15.41 6.91 15.59 7 15.67C7.28 15.98 7.71 16.51 7.9 16.79C7.95 16.85 7.99 16.91 8.01 16.91C8.05 16.93 8.26 17.24 8.23 17.27C8.21 17.28 8.17 17.25 8.02 17.08C7.94 16.99 7.89 16.93 7.59 16.66C7.52 16.6 7.41 16.48 7.35 16.39C7.22 16.24 6.77 15.75 6.29 15.25C6.17 15.12 6.02 14.96 5.96 14.9C5.9 14.84 5.77 14.7 5.67 14.6C5.56 14.5 5.39 14.31 5.28 14.19C5.16 14.08 4.91 13.8 4.71 13.59C4.52 13.38 4.26 13.1 4.16 12.98C4.05 12.86 3.88 12.68 3.78 12.57C3.54 12.34 3.03 11.8 2.75 11.51C1.89 10.56 0.87 9.65 0.71 9.68C0.63 9.7 0.57 9.82 0.56 10C0.56 10.09 0.56 10.24 0.55 10.34C0.56 10.44 0.55 10.6 0.55 10.68C0.56 10.76 0.56 10.92 0.57 11.03C0.56 11.15 0.6 11.4 0.63 11.61C0.87 12.97 1.41 13.79 2.78 14.92C3.01 15.11 3.28 15.34 3.39 15.42C3.48 15.51 3.64 15.63 3.73 15.69C4.19 16.02 6.45 17.89 6.98 18.39C7.41 18.78 7.48 18.86 7.54 18.95C7.56 19 7.65 19.08 7.72 19.12C7.79 19.16 7.88 19.23 7.92 19.29C7.95 19.33 8.03 19.45 8.11 19.53C8.18 19.62 8.26 19.7 8.26 19.72C8.29 19.74 8.27 19.74 8.21 19.73C8.17 19.71 8.1 19.65 8.06 19.58C8 19.53 7.9 19.44 7.82 19.38C7.74 19.34 7.64 19.26 7.62 19.21C7.59 19.16 7.48 19.05 7.37 18.97C7.26 18.88 6.97 18.63 6.72 18.42C6.03 17.81 4.51 16.65 3.56 16C3.34 15.86 3.03 15.64 2.87 15.52C2.61 15.34 1.55 14.67 0.98 14.33C0.7 14.15 0.6 14.15 0.56 14.29C0.5 14.56 0.76 15.94 0.9 16.13C0.92 16.16 0.97 16.24 1 16.3C1.23 16.86 1.91 17.68 2.65 18.29C2.88 18.47 4.01 19.22 4.64 19.61C6.2 20.55 6.57 20.81 7.37 21.49C8.2 22.19 8.71 22.76 9.3 23.64C9.62 24.09 9.84 24.48 10.29 25.31C10.45 25.61 10.54 25.74 10.59 25.75C10.65 25.76 10.78 25.66 11.01 25.45C11.51 24.99 11.94 24.63 12.3 24.34C12.82 23.95 12.98 23.83 13.06 23.81C13.17 23.78 13.16 23.83 13.01 23.97C12.07 24.82 10.97 26 10.26 26.93C10.09 27.15 9.84 27.49 9.69 27.67C9.55 27.86 9.42 28.03 9.42 28.04C9.42 28.07 9.29 28.27 9.13 28.5C8.61 29.22 7.76 30.72 7.76 30.91C7.76 31.03 7.86 31.01 8.31 30.78C8.97 30.45 9.54 30.24 10.46 29.99C11.63 29.67 12.5 29.63 11.52 29.93C10.52 30.25 9.79 30.67 9.23 31.22C8.84 31.62 8.85 31.65 9.36 31.7C10.15 31.75 10.84 31.92 11.6 32.22C12.79 32.68 13.49 33.13 15.56 34.82C16.45 35.54 17.32 36.19 17.73 36.43C17.89 36.52 18.1 36.65 18.21 36.73C19.13 37.34 21.14 38.12 22.31 38.33C22.46 38.35 22.64 38.38 22.73 38.41C22.81 38.42 22.95 38.43 23.05 38.43C23.12 38.43 23.26 38.45 23.33 38.47C23.51 38.53 25.9 38.53 26.08 38.47C26.14 38.45 26.27 38.43 26.36 38.43C26.45 38.43 26.6 38.42 26.68 38.41C26.77 38.38 26.95 38.35 27.1 38.33C28.26 38.13 30.25 37.37 31.32 36.7C31.48 36.6 31.71 36.46 31.82 36.39C32.11 36.21 33.13 35.44 33.4 35.2C34.17 34.51 35.47 33.05 35.77 32.55C35.81 32.49 35.98 32.23 36.15 31.99C36.3 31.75 36.52 31.43 36.6 31.28C36.69 31.12 36.81 30.91 36.88 30.79C37.09 30.45 37.49 29.63 37.64 29.23C37.72 29.03 37.83 28.78 37.88 28.69C37.98 28.5 38.41 27.18 38.51 26.78C38.88 25.28 39.02 23.77 38.91 22.43C38.88 22.17 38.86 21.89 38.85 21.82C38.84 21.75 38.82 21.57 38.79 21.44C38.64 20.5 38.49 19.93 38.13 19.01C37.93 18.55 37.41 17.48 37.25 17.23C36.99 16.83 36.76 15.87 36.76 15.26C36.76 13.74 37.71 12.75 39.63 12.25C40.13 12.11 41.36 11.88 42.07 11.77C43.42 11.58 43.5 11.56 43.31 11.31C42.9 10.79 40.9 10.01 39.22 9.7C37.92 9.47 38.02 9.51 37.57 9.09C37.01 8.56 36.34 8.05 36.02 7.88C35.94 7.85 35.78 7.76 35.66 7.68C35.09 7.34 34.21 7.06 33.07 6.88C32.64 6.81 31.62 6.88 31 7.01C29.58 7.31 28.33 8.07 27.25 9.25C26.73 9.84 26.1 10.85 25.69 11.74C25.53 12.08 24.84 13.43 24.71 13.66C24.58 13.9 24.46 14.09 24.06 14.7C23.45 15.67 22.11 16.92 20.66 17.89C20.52 17.99 20.23 18.2 19.99 18.37C19.76 18.55 19.39 18.81 19.18 18.97C18.96 19.12 18.66 19.33 18.49 19.44C17.97 19.83 17.17 20.35 17.01 20.45C16.92 20.5 16.75 20.6 16.64 20.68C16.36 20.86 16.11 20.95 15.97 20.93C15.54 20.85 14.87 20.52 14.26 20.18C13.72 19.89 12.65 18.99 12.37 18.61C12.33 18.54 12.19 18.38 12.07 18.24C11.87 18 11.23 17.08 10.95 16.62C10.89 16.53 10.81 16.38 10.76 16.31C10.72 16.24 10.64 16.09 10.59 15.98C10.52 15.88 10.44 15.7 10.39 15.6C10.34 15.51 10.21 15.25 10.11 15.03C10.01 14.82 9.85 14.45 9.75 14.22C9.64 14 9.52 13.74 9.48 13.65C9.41 13.44 9.34 13.31 9.19 12.99C9.12 12.86 9.01 12.62 8.94 12.49C8.87 12.35 8.77 12.14 8.72 12.05C8.67 11.95 8.58 11.79 8.53 11.67C8.34 11.28 8.16 10.98 7.55 10.07C6.37 8.32 4.23 6.45 2.71 5.83C1.99 5.53 1.6 5.47 1.49 5.61ZM34.38 9.65C34.68 9.93 34.44 10.37 34.03 10.31C33.67 10.27 33.58 9.69 33.92 9.55C34.04 9.49 34.27 9.53 34.38 9.65ZM33.08 10.51C33.08 10.76 33.15 10.89 33.44 11.19C33.53 11.29 33.6 11.37 33.58 11.37C33.5 11.37 33.25 11.19 33.17 11.07C33.03 10.87 32.92 10.39 33 1Line truncated
|
||||
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1,310 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>magpie</title>
|
||||
<meta name="description" content="One place to pick every coding agent's model. Claude Code, Codex, Gemini CLI, OpenCode and more, from the menu bar, with one local gateway in front of every provider.">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#0b0b0c" media="(prefers-color-scheme: dark)">
|
||||
<link rel="icon" href="/favicon.png" type="image/png">
|
||||
<link rel="apple-touch-icon" href="/img/icon-256.png">
|
||||
<meta property="og:title" content="magpie">
|
||||
<meta property="og:description" content="One place to pick every coding agent's model.">
|
||||
<meta property="og:image" content="https://usemagpie.ai/img/og.png">
|
||||
<meta property="og:url" content="https://usemagpie.ai/">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--bg2: #f1f1f2;
|
||||
--card: #ffffff;
|
||||
--ink: #111112;
|
||||
--ink2: #4a4a50;
|
||||
--ink3: #85858c;
|
||||
--line: #e4e4e7;
|
||||
--btn: #111112;
|
||||
--btn-ink: #fafafa;
|
||||
--code: #f4f4f5;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.04), 0 12px 40px -12px rgba(0,0,0,.18);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0b0b0c;
|
||||
--bg2: #121214;
|
||||
--card: #161618;
|
||||
--ink: #f2f2f3;
|
||||
--ink2: #b3b3ba;
|
||||
--ink3: #7a7a82;
|
||||
--line: #26262a;
|
||||
--btn: #f2f2f3;
|
||||
--btn-ink: #0b0b0c;
|
||||
--code: #151517;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 20px 60px -20px rgba(0,0,0,.8);
|
||||
}
|
||||
.bird { filter: invert(1); }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; }
|
||||
code, pre, kbd { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; }
|
||||
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 24px; }
|
||||
|
||||
nav { position: sticky; top: 0; z-index: 10; backdrop-filter: saturate(1.4) blur(14px); -webkit-backdrop-filter: saturate(1.4) blur(14px); background: color-mix(in srgb, var(--bg) 78%, transparent); border-bottom: 1px solid transparent; transition: border-color .2s; }
|
||||
nav.scrolled { border-bottom-color: var(--line); }
|
||||
nav .wrap { display: flex; align-items: center; gap: 28px; height: 60px; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 650; font-size: 17px; letter-spacing: -.01em; text-decoration: none; }
|
||||
.brand img { width: 24px; height: 24px; }
|
||||
nav .links { display: flex; gap: 24px; margin-left: auto; font-size: 14px; color: var(--ink2); }
|
||||
nav .links a { text-decoration: none; }
|
||||
nav .links a:hover { color: var(--ink); }
|
||||
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 0 18px; height: 42px; border-radius: 10px; background: var(--btn); color: var(--btn-ink); text-decoration: none; font-weight: 560; font-size: 15px; white-space: nowrap; border: 1px solid var(--btn); transition: transform .12s, opacity .12s; }
|
||||
.btn:hover { opacity: .88; }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn.small { height: 34px; padding: 0 14px; font-size: 14px; border-radius: 8px; }
|
||||
.btn.ghost { background: transparent; color: var(--ink); border-color: var(--line); }
|
||||
.btn svg { width: 16px; height: 16px; }
|
||||
|
||||
.hero { padding: 88px 0 40px; display: grid; grid-template-columns: 1.05fr .95fr; gap: 56px; align-items: center; }
|
||||
.hero h1 { font-size: clamp(40px, 6vw, 64px); line-height: 1.04; letter-spacing: -.035em; font-weight: 700; margin: 0 0 22px; }
|
||||
.hero h1 span { color: var(--ink3); }
|
||||
.hero p.lede { font-size: 19px; color: var(--ink2); margin: 0 0 32px; max-width: 30em; }
|
||||
.cta { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||||
.meta { margin-top: 14px; font-size: 13px; color: var(--ink3); }
|
||||
.meta a { color: var(--ink2); }
|
||||
.shot { position: relative; }
|
||||
.shot picture img { width: 100%; max-width: 440px; display: block; margin: 0 auto; border-radius: 14px; box-shadow: var(--shadow); border: 1px solid var(--line); }
|
||||
|
||||
.agents { padding: 40px 0 16px; text-align: center; }
|
||||
.agents p { font-size: 13px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink3); margin: 0 0 22px; }
|
||||
.agents ul { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; justify-content: center; gap: 14px 22px; }
|
||||
.agents li { display: flex; align-items: center; gap: 8px; font-size: 14.5px; color: var(--ink2); }
|
||||
.agents img { width: 22px; height: 22px; border-radius: 5px; }
|
||||
|
||||
section { padding: 88px 0 0; }
|
||||
.eyebrow { font-size: 13px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink3); margin: 0 0 12px; }
|
||||
h2 { font-size: clamp(30px, 4vw, 42px); line-height: 1.1; letter-spacing: -.03em; margin: 0 0 16px; font-weight: 680; }
|
||||
.sub { font-size: 18px; color: var(--ink2); max-width: 36em; margin: 0 0 40px; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 16px; overflow: hidden; }
|
||||
.grid > div { background: var(--card); padding: 28px 26px 30px; }
|
||||
.grid h3 { margin: 0 0 8px; font-size: 16.5px; letter-spacing: -.01em; font-weight: 640; }
|
||||
.grid p { margin: 0; color: var(--ink2); font-size: 15px; }
|
||||
.grid code { font-size: 13px; background: var(--code); padding: 1px 5px; border-radius: 4px; }
|
||||
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; align-items: center; }
|
||||
.split img { width: 100%; max-width: 440px; display: block; margin: 0 auto; border-radius: 14px; box-shadow: var(--shadow); border: 1px solid var(--line); }
|
||||
.split ul { padding: 0; margin: 0; list-style: none; }
|
||||
.split li { padding: 14px 0; border-top: 1px solid var(--line); color: var(--ink2); font-size: 15.5px; }
|
||||
.split li b { color: var(--ink); font-weight: 600; }
|
||||
|
||||
pre { margin: 0; background: var(--code); border: 1px solid var(--line); border-radius: 12px; padding: 20px 22px; overflow-x: auto; font-size: 13.5px; line-height: 1.75; color: var(--ink); }
|
||||
pre .c { color: var(--ink3); }
|
||||
.table { width: 100%; border-collapse: collapse; font-size: 14.5px; }
|
||||
.table th, .table td { text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--line); }
|
||||
.table th { font-weight: 560; color: var(--ink3); font-size: 13px; }
|
||||
.table td:first-child { font-weight: 560; }
|
||||
.table code { font-size: 13px; }
|
||||
|
||||
.install { display: flex; align-items: center; gap: 12px; background: var(--code); border: 1px solid var(--line); border-radius: 12px; padding: 6px 6px 6px 18px; max-width: 560px; }
|
||||
.install code { flex: 1; font-size: 14px; overflow-x: auto; white-space: nowrap; }
|
||||
.install code::before { content: "$ "; color: var(--ink3); }
|
||||
.copy { flex: none; height: 34px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--card); color: var(--ink); font: inherit; font-size: 13px; cursor: pointer; }
|
||||
.copy:hover { border-color: var(--ink3); }
|
||||
|
||||
.final { text-align: center; padding: 120px 0 100px; }
|
||||
.final img { width: 88px; height: 88px; margin-bottom: 18px; }
|
||||
.final .cta { justify-content: center; }
|
||||
footer { border-top: 1px solid var(--line); padding: 28px 0 40px; font-size: 13.5px; color: var(--ink3); }
|
||||
footer .wrap { display: flex; flex-wrap: wrap; gap: 12px 24px; align-items: center; }
|
||||
footer .links { margin-left: auto; display: flex; gap: 20px; }
|
||||
footer a { text-decoration: none; }
|
||||
footer a:hover { color: var(--ink); }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.hero { grid-template-columns: 1fr; padding-top: 56px; gap: 44px; }
|
||||
.split { grid-template-columns: 1fr; }
|
||||
.split.rev > :first-child { order: 2; }
|
||||
.grid { grid-template-columns: 1fr 1fr; }
|
||||
nav .links a.hide { display: none; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.wrap { padding: 0 16px; }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
nav .links { gap: 16px; }
|
||||
section { padding-top: 64px; }
|
||||
.table .path { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav id="nav"><div class="wrap">
|
||||
<a class="brand" href="/"><img class="bird" src="/img/magpie-small.svg" alt="">magpie</a>
|
||||
<div class="links">
|
||||
<a class="hide" href="#features">Features</a>
|
||||
<a class="hide" href="#gateway">Gateway</a>
|
||||
<a href="#cli">CLI</a>
|
||||
<a href="https://github.com/yetone/magpie-releases/releases">Releases</a>
|
||||
</div>
|
||||
<a class="btn small dl" href="/download">Download</a>
|
||||
</div></nav>
|
||||
|
||||
<main class="wrap">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<h1>Every coding agent’s model. <span>One place.</span></h1>
|
||||
<p class="lede">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.</p>
|
||||
<div class="cta">
|
||||
<a class="btn dl" href="/download">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M11.2 8.5c0-1.6 1.3-2.4 1.4-2.4-.8-1.1-2-1.3-2.4-1.3-1-.1-2 .6-2.5.6s-1.3-.6-2.2-.6C4.4 4.8 3.3 5.5 2.7 6.6c-1.2 2.1-.3 5.2.9 6.9.6.8 1.2 1.7 2.1 1.7.8 0 1.2-.5 2.2-.5s1.3.5 2.2.5 1.5-.9 2-1.7c.6-.9.9-1.8.9-1.9 0 0-1.8-.7-1.8-3.1ZM9.6 3.6c.5-.6.8-1.3.7-2.1-.7 0-1.5.5-2 1-.4.5-.8 1.3-.7 2.1.8.1 1.5-.4 2-1Z"/></svg>
|
||||
<span class="dl-label">Download for Mac</span>
|
||||
</a>
|
||||
<a class="btn ghost" href="#cli">Install from terminal</a>
|
||||
</div>
|
||||
<div class="meta"><span class="ver">Free</span> · macOS 11+ · <a class="alt" href="/download/mac-intel">Intel Mac</a> · <a href="#cli">Linux & Windows CLI</a></div>
|
||||
</div>
|
||||
<div class="shot">
|
||||
<picture>
|
||||
<source srcset="/img/panel-dark.png" media="(prefers-color-scheme: dark)">
|
||||
<img src="/img/panel-light.png" width="440" height="470" alt="The magpie menu bar panel: each agent with its model">
|
||||
</picture>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="agents">
|
||||
<p>Works with</p>
|
||||
<ul>
|
||||
<li><img src="/icons/claudecode-color.svg" alt="">Claude Code</li>
|
||||
<li><img src="/icons/codex-color.svg" alt="">Codex</li>
|
||||
<li><img src="/icons/geminicli-color.svg" alt="">Gemini CLI</li>
|
||||
<li><img class="bird" src="/icons/opencode.svg" alt="">OpenCode</li>
|
||||
<li><img class="bird" src="/icons/pi.svg" alt="">Pi</li>
|
||||
<li><img class="bird" src="/icons/goose.svg" alt="">Goose</li>
|
||||
<li><img class="bird" src="/icons/cursor.svg" alt="">Cursor CLI</li>
|
||||
<li><img class="bird" src="/icons/githubcopilot.svg" alt="">Copilot CLI</li>
|
||||
<li><img src="/icons/crush.png" alt="">Crush</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<section id="features">
|
||||
<p class="eyebrow">Why magpie</p>
|
||||
<h2>Small, careful, and out of the way.</h2>
|
||||
<p class="sub">It lives in the menu bar. It edits the one key you changed and nothing else. It never asks your shell for a key.</p>
|
||||
<div class="grid">
|
||||
<div><h3>One endpoint for every agent</h3><p>A local gateway speaks OpenAI chat, OpenAI Responses, Anthropic Messages and Gemini, and translates between them — streaming and tool calls included.</p></div>
|
||||
<div><h3>Your subscriptions, shared</h3><p>Signed in to Claude Code, Codex or Copilot? That login becomes a provider every other agent can use. Nothing copied, no key to paste.</p></div>
|
||||
<div><h3>Providers in one field</h3><p>Anthropic, OpenAI, Gemini, DeepSeek, Kimi, GLM, Qwen, OpenRouter, Ollama and a dozen more. Pick a preset, paste a key, done.</p></div>
|
||||
<div><h3>Surgical config edits</h3><p>Comments, ordering and indentation in <code>settings.json</code>, <code>config.toml</code> or <code>config.yaml</code> survive. Writes are atomic.</p></div>
|
||||
<div><h3>Real model lists</h3><p>magpie asks each vendor what it serves. A model released this morning is in the picker on the next refresh — nothing is compiled in.</p></div>
|
||||
<div><h3>Profiles</h3><p>Snapshot every agent’s settings under a name and switch all of them back in one click.</p></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="split rev">
|
||||
<div><img src="/img/picker-light.png" width="440" height="390" alt="The model picker, filtering as you type" loading="lazy"></div>
|
||||
<div>
|
||||
<p class="eyebrow">The picker</p>
|
||||
<h2>Click a value. Type. Done.</h2>
|
||||
<p class="sub">Every model from every provider you have added, filtered as you type. Anything that isn’t listed can still be typed in.</p>
|
||||
<ul>
|
||||
<li><b>Effort, small model, auth</b> — each agent shows the fields it actually has.</li>
|
||||
<li><b>Native or gateway</b> — pick the agent’s own model and magpie puts back exactly what was there.</li>
|
||||
<li><b>Always current</b> — magpie updates itself quietly and restarts when you say so.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="gateway">
|
||||
<p class="eyebrow">The gateway</p>
|
||||
<h2>One base URL, every model.</h2>
|
||||
<p class="sub">The gateway listens on <code>127.0.0.1:3425</code> and names models <code>provider/model</code>. Agents never hold vendor keys; anything with a base-URL setting can use it.</p>
|
||||
<table class="table">
|
||||
<tr><th>Tool speaks</th><th>Base URL</th><th class="path">Environment</th></tr>
|
||||
<tr><td>OpenAI</td><td><code>http://127.0.0.1:3425/v1</code></td><td class="path"><code>OPENAI_BASE_URL</code></td></tr>
|
||||
<tr><td>Anthropic</td><td><code>http://127.0.0.1:3425</code></td><td class="path"><code>ANTHROPIC_BASE_URL</code></td></tr>
|
||||
<tr><td>Gemini</td><td><code>http://127.0.0.1:3425</code></td><td class="path"><code>GOOGLE_GEMINI_BASE_URL</code></td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="cli">
|
||||
<p class="eyebrow">Terminal</p>
|
||||
<h2>Also a TUI and a CLI.</h2>
|
||||
<p class="sub">The same app, from a terminal. On Linux, the installer puts the <code>magpie</code> command in <code>~/.local/bin</code>; on a Mac it installs the app too.</p>
|
||||
<div class="install"><code id="cmd">curl -fsSL https://usemagpie.ai/install.sh | sh</code><button class="copy" data-copy="cmd">Copy</button></div>
|
||||
<p class="meta" style="margin:12px 0 28px">Windows: download <code>magpie-cli-windows-amd64.exe</code> from the <a href="https://github.com/yetone/magpie-releases/releases/latest">latest release</a>.</p>
|
||||
<pre><span class="c"># pick models</span>
|
||||
magpie ls <span class="c"># every agent and its settings</span>
|
||||
magpie claude opus <span class="c"># agent names take prefixes: cc, oc, gem…</span>
|
||||
magpie codex effort high
|
||||
magpie codex deepseek/deepseek-chat <span class="c"># any catalog model, through the gateway</span>
|
||||
|
||||
<span class="c"># providers</span>
|
||||
magpie provider add deepseek sk-… <span class="c"># a preset needs only the key</span>
|
||||
magpie provider add ollama <span class="c"># local servers need none</span>
|
||||
|
||||
<span class="c"># profiles</span>
|
||||
magpie save work && magpie use work
|
||||
|
||||
magpie tui <span class="c"># the whole app, in the terminal</span>
|
||||
magpie update <span class="c"># install the newest release</span></pre>
|
||||
</section>
|
||||
|
||||
<div class="final">
|
||||
<img class="bird" src="/img/magpie.svg" alt="">
|
||||
<h2>Pick a model. Get back to work.</h2>
|
||||
<p class="sub" style="margin:0 auto 32px">Free, native, about 11 MB.</p>
|
||||
<div class="cta"><a class="btn dl" href="/download"><span class="dl-label">Download for Mac</span></a></div>
|
||||
<div class="meta"><span class="ver"></span></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer><div class="wrap">
|
||||
<span>© 2026 magpie</span>
|
||||
<div class="links">
|
||||
<a href="https://github.com/yetone/magpie-releases/releases">Releases</a>
|
||||
<a href="/api/latest">Update feed</a>
|
||||
<a href="https://x.com/yetone">@yetone</a>
|
||||
</div>
|
||||
</div></footer>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const nav = document.getElementById("nav");
|
||||
const onScroll = () => nav.classList.toggle("scrolled", scrollY > 8);
|
||||
addEventListener("scroll", onScroll, { passive: true }); onScroll();
|
||||
|
||||
document.querySelectorAll(".copy").forEach((b) => b.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(document.getElementById(b.dataset.copy).textContent);
|
||||
b.textContent = "Copied"; setTimeout(() => (b.textContent = "Copy"), 1400);
|
||||
} catch {}
|
||||
}));
|
||||
|
||||
// Intel Macs get the Intel build; browsers that can tell say so, the rest
|
||||
// get Apple Silicon with the Intel link beside it.
|
||||
const setArch = (arch) => {
|
||||
if (arch !== "x86") return;
|
||||
document.querySelectorAll("a.dl").forEach((a) => (a.href = "/download/mac-intel"));
|
||||
document.querySelectorAll("a.alt").forEach((a) => { a.href = "/download/mac-arm64"; a.textContent = "Apple Silicon"; });
|
||||
};
|
||||
try { navigator.userAgentData?.getHighEntropyValues(["architecture"]).then((v) => setArch(v.architecture)).catch(() => {}); } catch {}
|
||||
|
||||
fetch("/api/latest").then((r) => (r.ok ? r.json() : null)).then((rel) => {
|
||||
if (!rel) return;
|
||||
document.querySelectorAll(".ver").forEach((e) => (e.textContent = "Version " + rel.version));
|
||||
}).catch(() => {});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
@@ -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/<file> 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 },
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||