diff --git a/README.md b/README.md
index 6ac4900f..77e6a726 100644
--- a/README.md
+++ b/README.md
@@ -177,6 +177,40 @@ the API key goes to `~/.gemini/.env`. Picking a catalog model points
`auth` to API key with the gateway token, and names the model in
`settings.json`; a native model puts the previous auth back.
+### Import links
+
+A vendor or relay can hand its users a ready-made provider as a link:
+
+```
+magpie://import?preset=deepseek&key=sk-…
+magpie://import?name=Acme%20Relay&chat=https://api.acme.example/v1&anthropic=https://api.acme.example&key=sk-…&models=gpt-5.5,claude-sonnet-5
+```
+
+Opening one brings up magpie with what the link would add: the name, the
+hosts your prompts and key would go to, the models. Nothing is saved until
+you press *Add*. `magpie import ` does the same in a terminal.
+
+| Parameter | Meaning |
+| ----------- | ------------------------------------------------------------------ |
+| `preset` | a preset id (`magpie presets`); its endpoints are used |
+| `region` | with a preset that has regions, which one |
+| `name` | the provider's name; required without a preset |
+| `id` | its id; derived from the name when absent |
+| `key` | the API key; the user pastes one when absent |
+| `chat` | OpenAI Chat Completions base URL (`…/v1`) |
+| `responses` | OpenAI Responses base URL (`…/v1`) |
+| `anthropic` | Anthropic Messages base URL (the root, without `/v1`) |
+| `models` | model ids to expose, comma separated |
+| `catalog` | models.dev provider id, for model names and reasoning levels |
+| `website`, `keys` | the vendor's site and its API-key page (https) |
+
+Base URLs must be https (plain http only to this machine or the local
+network). Web pages and GitHub don't link custom schemes reliably, so link
+to `https://usemagpie.ai/import#` instead: it opens
+magpie, and offers the download when it is not installed. The parameters
+stay in the fragment, which browsers never send to a server. The full guide,
+with a link builder: .
+
## Install
Download the app for macOS, Windows or Linux from
diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist
index 0dbc2d68..f9730d4e 100644
--- a/build/darwin/Info.plist
+++ b/build/darwin/Info.plist
@@ -13,6 +13,13 @@
LSMinimumSystemVersion11.0
LSUIElement
NSHighResolutionCapable
+ CFBundleURLTypes
+
+
+ CFBundleURLNamecom.yetone.magpie.import
+ CFBundleURLSchemesmagpie
+
+
NSHumanReadableCopyrightyetone
diff --git a/go.mod b/go.mod
index 53bafe9f..2866c6b5 100644
--- a/go.mod
+++ b/go.mod
@@ -6,9 +6,11 @@ require (
github.com/charmbracelet/bubbles v0.21.1
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
+ github.com/godbus/dbus/v5 v5.2.2
github.com/tidwall/gjson v1.19.0
github.com/tidwall/jsonc v0.3.3
github.com/wailsapp/wails/v3 v3.0.0-beta.24
+ golang.org/x/sys v0.46.0
)
require (
@@ -25,7 +27,6 @@ require (
github.com/coder/websocket v1.8.14 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
- github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -38,6 +39,5 @@ require (
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
- golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.39.0 // indirect
)
diff --git a/gui_off.go b/gui_off.go
index 97d6b3dd..e403f7f0 100644
--- a/gui_off.go
+++ b/gui_off.go
@@ -6,4 +6,4 @@ import "errors"
const hasGUI = false
-func runGUI(bool) error { return errors.New("this build has no GUI; run `magpie tui`") }
+func runGUI(bool, string) error { return errors.New("this build has no GUI; run `magpie tui`") }
diff --git a/gui_on.go b/gui_on.go
index a0d5dfab..647d3615 100644
--- a/gui_on.go
+++ b/gui_on.go
@@ -6,4 +6,4 @@ import "github.com/yetone/magpie/internal/gui"
const hasGUI = true
-func runGUI(showMain bool) error { return gui.Run(version, showMain) }
+func runGUI(showMain bool, link string) error { return gui.Run(version, showMain, link) }
diff --git a/internal/gui/api.go b/internal/gui/api.go
index 3951cbc1..cb305af5 100644
--- a/internal/gui/api.go
+++ b/internal/gui/api.go
@@ -163,6 +163,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
writeJSON(rw, state())
})
providerRoutes(mux, w, gw)
+ importRoutes(mux)
usageRoutes(mux)
updateRoutes(mux, w)
mux.HandleFunc("GET /api/settings", func(rw http.ResponseWriter, r *http.Request) {
diff --git a/internal/gui/app.go b/internal/gui/app.go
index f33208a5..9171cc7a 100644
--- a/internal/gui/app.go
+++ b/internal/gui/app.go
@@ -2,6 +2,8 @@ package gui
import (
"context"
+ "crypto/sha256"
+ "encoding/hex"
_ "embed"
"log"
"os"
@@ -15,6 +17,7 @@ import (
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/provider"
+ "github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/update"
)
@@ -32,6 +35,17 @@ type host struct {
panelHeight int
query string // what the windows' URLs carry (a forced theme)
+
+ ready chan struct{} // closed once the main window can be shown
+ readyOnce sync.Once
+}
+
+// whenReady runs fn once the main window can be shown safely.
+func (h *host) whenReady(fn func()) {
+ go func() {
+ <-h.ready
+ fn()
+ }()
}
func (h *host) HidePanel() { h.panel.Hide() }
@@ -43,6 +57,16 @@ func (h *host) ShowMain(view string) {
h.main.Show()
h.main.Focus()
}
+// Import opens the window on an import link, for the user to confirm.
+func (h *host) Import(link string) {
+ id := stashImport(link)
+ h.whenReady(func() {
+ h.panel.Hide()
+ h.main.SetURL("/?view=providers&import=" + id + h.query)
+ h.main.Show()
+ h.main.Focus()
+ })
+}
func (h *host) Quit() { h.app.Quit() }
func (h *host) OpenURL(url string) { _ = h.app.Browser.OpenURL(url) }
@@ -65,7 +89,8 @@ func (h *host) FitPanel(height int) {
// Run starts the desktop app: a menu bar icon whose click drops down a compact
// panel, plus a regular window for when you want it to stay around.
// showMain opens the window immediately; otherwise only the tray icon appears.
-func Run(version string, showMain bool) error {
+// link is a magpie:// link the app was started with, to confirm and import.
+func Run(version string, showMain bool, link string) error {
// After an update off the Mac, the old process starts this one and then
// quits; let it go before looking for the gateway.
update.AwaitPredecessor()
@@ -107,14 +132,23 @@ func Run(version string, showMain bool) error {
cancel()
}
}()
+ go func() {
+ if err := registerScheme(); err != nil {
+ log.Println("magpie:// links:", err)
+ }
+ }()
// MAGPIE_THEME=light|dark forces the palette; handy for screenshots.
theme := ""
if t := os.Getenv("MAGPIE_THEME"); t != "" {
theme = "&theme=" + t
}
Version = version
- h := &host{query: theme}
+ h := &host{query: theme, ready: make(chan struct{})}
h.app = application.New(application.Options{
+ // Windows and Linux start a new process for a magpie:// link (or a
+ // second launch); it hands its arguments to the running one and quits.
+ // The Mac sends the link to the running app itself.
+ SingleInstance: singleInstance(h),
Name: "magpie",
Description: "one place to pick every agent's model",
Icon: appIcon,
@@ -197,18 +231,59 @@ func Run(version string, showMain bool) error {
h.tray.SetMenu(menu)
h.tray.AttachWindow(h.panel).WindowOffset(6)
- if showMain {
- if runtime.GOOS == "windows" {
- // Wails shows a Windows webview 3s after Show whether or not
- // WebView2 has made its controller yet, and a slow first start
- // then crashes on the nil controller; wait for the first page.
- var once sync.Once
- h.main.OnWindowEvent(events.Windows.WebViewNavigationCompleted, func(*application.WindowEvent) {
- once.Do(func() { h.ShowMain("") })
- })
- } else {
- h.app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { h.ShowMain("") })
- }
+ // Wails shows a Windows webview 3s after Show whether or not WebView2
+ // has made its controller yet, and a slow first start then crashes on
+ // the nil controller; there, wait for the first page.
+ markReady := func() { h.readyOnce.Do(func() { close(h.ready) }) }
+ if runtime.GOOS == "windows" {
+ h.main.OnWindowEvent(events.Windows.WebViewNavigationCompleted, func(*application.WindowEvent) { markReady() })
+ } else {
+ h.app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { markReady() })
}
+ if showMain {
+ h.whenReady(func() { h.ShowMain("") })
+ }
+ if link != "" {
+ h.Import(link)
+ }
+ // Windows and Linux also report the start's own link as an event.
+ var skip sync.Once
+ h.app.Event.OnApplicationEvent(events.Common.ApplicationLaunchedWithUrl, func(e *application.ApplicationEvent) {
+ u := ImportLink([]string{e.Context().URL()})
+ dup := false
+ if u == link {
+ skip.Do(func() { dup = true })
+ }
+ if u != "" && !dup {
+ h.Import(u)
+ }
+ })
return h.app.Run()
}
+
+// singleInstance makes a second launch hand over to this one, off the Mac.
+// The id covers the executable and the config dir, so a build elsewhere or
+// a sandboxed HOME runs on its own.
+func singleInstance(h *host) *application.SingleInstanceOptions {
+ if runtime.GOOS == "darwin" || !sessionBus() {
+ return nil
+ }
+ exe, _ := os.Executable()
+ sum := sha256.Sum256([]byte(exe + "\x00" + settings.Dir()))
+ return &application.SingleInstanceOptions{
+ UniqueID: "ai.usemagpie.app.i" + hex.EncodeToString(sum[:6]),
+ OnSecondInstanceLaunch: func(d application.SecondInstanceData) {
+ args := d.Args
+ if len(args) > 0 {
+ args = args[1:]
+ }
+ switch {
+ case ImportLink(args) != "":
+ h.Import(ImportLink(args))
+ case len(args) == 1 && args[0] == "tray":
+ default:
+ h.whenReady(func() { h.ShowMain("") })
+ }
+ },
+ }
+}
diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css
index 503b8db1..9db32ff3 100644
--- a/internal/gui/assets/app.css
+++ b/internal/gui/assets/app.css
@@ -582,6 +582,9 @@ body.window .foot { padding-left: 18px; }
.editor .more[open] summary::before { content: "▾"; }
.editor .more summary:hover { color: var(--fg); }
.editor .more .inner { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 10px 14px; align-items: start; padding-top: 8px; }
+.editor .warnbox { grid-column: 1 / -1; font-size: 12px; line-height: 1.5; color: var(--fg); background: color-mix(in srgb, var(--amber) 12%, transparent); border: 1px solid color-mix(in srgb, var(--amber) 35%, transparent); border-radius: 8px; padding: 8px 10px; }
+.editor .warnbox.soft { background: none; color: var(--amber); border: 0; padding: 0 0 0 86px; }
+.editor.import .mchips .mchip { cursor: default; }
.editor .bar { grid-column: 1 / -1; display: flex; align-items: center; gap: 4px; padding-top: 10px; border-top: 1px solid var(--line-2); margin-top: 2px; }
.editor .bar .danger { margin-left: -7px; }
.busy { opacity: .55; pointer-events: none; }
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index afd98549..fc1c8777 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -18,6 +18,7 @@ let pick = null; // { agent, field, options, items, cursor, anchor }
let editing = null; // provider id being edited; { preset } or { custom: true } for a new one
let draft = null; // the editor's working copy
let adding = false; // the preset sheet is open
+let importing = null; // a magpie://import link waiting for a yes: { provider, error, replaces }
// the gateway tab's choices, kept per machine
let flavor = params.get("flavor") || localStorage.getItem("magpie.flavor") || "openai"; // which API the snippets speak
let lang = params.get("lang") || localStorage.getItem("magpie.lang") || "shell"; // which snippet
@@ -563,6 +564,7 @@ function renderProviders() {
}
renderExcluded();
dialog = renderAdd() || dialog;
+ if (importing) dialog = renderImport(importing);
if (dialog) openModal(dialog); else closeModal();
}
@@ -1091,7 +1093,7 @@ function input(value, placeholder, type = "text") {
i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Escape") cancelEdit(); };
return i;
}
-function cancelEdit() { editing = null; draft = null; renderProviders(); }
+function cancelEdit() { editing = null; draft = null; importing = null; renderProviders(); }
// ---------- modal ----------
// The provider editor opens as a dialog over the page; Escape, the backdrop
@@ -1324,6 +1326,68 @@ function renderEditor(p, presetID) {
return ed;
}
+// renderImport: what a magpie://import link would add, for the user to
+// check. Nothing is saved until they press Add; the key stays hidden unless
+// they ask to see it.
+function renderImport(im) {
+ const ed = el("div", "editor new import");
+ ed.onclick = (e) => e.stopPropagation();
+ const p = im.provider || {};
+ const h = el("div", "ehead");
+ h.append(icon(p.icon || "generic"), el("b", "", im.error ? t("Import link") : p.name));
+ if (!im.error) h.append(el("span", "note", t("from a link")));
+ ed.append(h);
+ const bar = el("div", "bar");
+ bar.append(el("span", "grow"));
+ const cancel = el("button", "text", t(im.error ? "Close" : "Cancel"));
+ cancel.onclick = cancelEdit;
+ bar.append(cancel);
+ if (im.error) {
+ ed.append(el("div", "warnbox", t("This link can't be imported: {e}", { e: im.error })), bar);
+ return ed;
+ }
+
+ const hosts = [...new Set([p.chat, p.responses, p.anthropic].filter(Boolean).map(hostOf))];
+ ed.append(el("div", "warnbox", t("Added from a link. Your prompts and this key will go to {hosts}; add it only if you trust the site that sent you here.", { hosts: hosts.join(", ") })));
+
+ const name = input(im.name ?? p.name, t("e.g. My Relay"));
+ name.oninput = () => { im.name = name.value; };
+ ed.append(...field(t("Name"), name));
+
+ const key = input(im.key ?? p.key ?? "", t(p.key ? "" : "paste an API key"), "password");
+ key.oninput = () => { im.key = key.value; };
+ key.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") add(); else if (e.key === "Escape") cancelEdit(); };
+ const side = el("div", "side");
+ const eye = el("button", "text", t("Show"));
+ eye.onclick = () => { const on = key.type === "password"; key.type = on ? "text" : "password"; eye.textContent = t(on ? "Hide" : "Show"); };
+ side.append(eye);
+ if (p.keysUrl && !p.key) { const b = el("button", "link", t("Get a key ↗")); b.onclick = () => api("open", { url: p.keysUrl }); side.append(b); }
+ const keyWrap = el("div", "pair");
+ keyWrap.append(key, side);
+ ed.append(...field(t("API key"), keyWrap, p.key ? t("From the link. Kept in ~/.config/magpie/providers.json, readable by you alone.") : ""));
+
+ ed.append(...field(t("Endpoints"), renderEndpoints(null, p), ""));
+ if (p.models?.length) {
+ const chips = el("div", "mchips");
+ for (const m of p.models) chips.append(el("span", "mchip on", m));
+ ed.append(...field(t("Models"), chips, ""));
+ }
+ if (im.replaces) ed.append(el("div", "warnbox soft", t("Replaces your {name}, key and all.", { name: im.replaces })));
+
+ const addBtn = el("button", "text primary", t(im.replaces ? "Replace" : "Add"));
+ const add = () => {
+ const n = (im.name ?? p.name).trim();
+ if (!n) { name.focus(); return status(t("Give it a name"), "warn"); }
+ addBtn.classList.add("busy");
+ providerAction("save", { ...p, name: n, key: (im.key ?? p.key ?? "").trim() }, t("{name} added", { name: n }));
+ };
+ addBtn.onclick = add;
+ bar.append(addBtn);
+ ed.append(bar);
+ setTimeout(() => (p.key ? addBtn : key).focus(), 0);
+ return ed;
+}
+
// Which of the vendor's models the agents get to see: click to toggle, type
// to add one the vendor's list lacks, Refresh to ask the vendor again.
// The endpoints a provider serves, with a Test that reports against each one.
@@ -1428,6 +1492,7 @@ async function providerAction(action, body, okMsg) {
providers = await api("provider/" + action, body);
editing = null;
draft = null;
+ importing = null;
adding = false;
presetQuery = "";
renderProviders();
@@ -1807,6 +1872,17 @@ else { $("#nav").remove(); }
// the panel comes back into view.
document.addEventListener("visibilitychange", () => { if (!document.hidden) load(); });
window.addEventListener("focus", load);
+// Opened on a magpie://import link: fetch what it describes (once — the
+// id is spent) and ask before adding it.
+if (mode === "window" && params.get("import")) {
+ const id = params.get("import");
+ params.delete("import");
+ history.replaceState(null, "", "?" + params);
+ api("import/" + encodeURIComponent(id)).then((im) => {
+ importing = im;
+ if (providers && view === "providers") renderProviders();
+ }).catch(() => {});
+}
if (mode === "window" && ["providers", "gateway", "usage", "settings"].includes(params.get("view"))) show(params.get("view"));
else if (mode === "window") slide($("#nav"), "nav");
load();
diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js
index 39ad7bbd..ed935bcf 100644
--- a/internal/gui/assets/i18n.js
+++ b/internal/gui/assets/i18n.js
@@ -110,6 +110,14 @@ const I18N = {
"Show": "显示",
"Hide": "隐藏",
"Get a key ↗": "获取密钥 ↗",
+ "Import link": "导入链接",
+ "from a link": "来自链接",
+ "Close": "关闭",
+ "This link can't be imported: {e}": "这个链接无法导入:{e}",
+ "Added from a link. Your prompts and this key will go to {hosts}; add it only if you trust the site that sent you here.": "这是从链接导入的。你的提示词和这个密钥会发往 {hosts};只有信任发来链接的网站时才添加。",
+ "From the link. Kept in ~/.config/magpie/providers.json, readable by you alone.": "来自链接。保存在 ~/.config/magpie/providers.json,仅你自己可读。",
+ "Replaces your {name}, key and all.": "将替换你现有的 {name},包括密钥。",
+ "Replace": "替换",
"API key": "API 密钥",
"Kept in ~/.config/magpie/providers.json, readable by you alone. Nothing is read from your shell.": "保存在 ~/.config/magpie/providers.json,仅你本人可读。不会从你的 shell 读取任何内容。",
"model ids, comma separated · e.g. gpt-5.5, claude-sonnet-5": "模型 ID,逗号分隔 · 例如 gpt-5.5, claude-sonnet-5",
diff --git a/internal/gui/bus_linux.go b/internal/gui/bus_linux.go
new file mode 100644
index 00000000..60f41fca
--- /dev/null
+++ b/internal/gui/bus_linux.go
@@ -0,0 +1,10 @@
+package gui
+
+import "github.com/godbus/dbus/v5"
+
+// sessionBus reports whether a D-Bus session bus is reachable; Wails'
+// single-instance lock needs one and gives up on the whole app without it.
+func sessionBus() bool {
+ _, err := dbus.SessionBus()
+ return err == nil
+}
diff --git a/internal/gui/bus_other.go b/internal/gui/bus_other.go
new file mode 100644
index 00000000..d46373eb
--- /dev/null
+++ b/internal/gui/bus_other.go
@@ -0,0 +1,5 @@
+//go:build !linux
+
+package gui
+
+func sessionBus() bool { return true }
diff --git a/internal/gui/importlink.go b/internal/gui/importlink.go
new file mode 100644
index 00000000..e3ffd9dd
--- /dev/null
+++ b/internal/gui/importlink.go
@@ -0,0 +1,74 @@
+package gui
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "net/http"
+ "strings"
+ "sync"
+
+ "github.com/yetone/magpie/internal/provider"
+)
+
+// An import link (magpie://import?…, see provider.ParseImport) is parsed
+// here, parked under a random id, and the window opens on the Providers tab
+// with that id; the page fetches it once and asks the user before saving
+// anything.
+
+type importJSON struct {
+ Provider provider.Provider `json:"provider"`
+ Error string `json:"error,omitempty"`
+ Replaces string `json:"replaces,omitempty"` // the name of the provider it would replace
+}
+
+var imports = struct {
+ sync.Mutex
+ m map[string]importJSON
+}{m: map[string]importJSON{}}
+
+func stashImport(link string) string {
+ var in importJSON
+ p, err := provider.ParseImport(link)
+ if err != nil {
+ in.Error = err.Error()
+ } else {
+ in.Provider = p
+ }
+ b := make([]byte, 8)
+ _, _ = rand.Read(b)
+ id := hex.EncodeToString(b)
+ imports.Lock()
+ imports.m[id] = in
+ imports.Unlock()
+ return id
+}
+
+// ImportLink is the link among a launch's arguments, if any.
+func ImportLink(args []string) string {
+ for _, a := range args {
+ if strings.HasPrefix(strings.ToLower(a), provider.Scheme+":") {
+ return a
+ }
+ }
+ return ""
+}
+
+func importRoutes(mux *http.ServeMux) {
+ // Read once: the key is in there, and a reload should not ask again.
+ mux.HandleFunc("GET /api/import/{id}", func(rw http.ResponseWriter, r *http.Request) {
+ imports.Lock()
+ in, ok := imports.m[r.PathValue("id")]
+ delete(imports.m, r.PathValue("id"))
+ imports.Unlock()
+ if !ok {
+ http.NotFound(rw, r)
+ return
+ }
+ if in.Error == "" {
+ if old, err := provider.Find(in.Provider.ID); err == nil {
+ in.Replaces = old.Name
+ }
+ }
+ writeJSON(rw, in)
+ })
+}
diff --git a/internal/gui/importlink_test.go b/internal/gui/importlink_test.go
new file mode 100644
index 00000000..0a530ab2
--- /dev/null
+++ b/internal/gui/importlink_test.go
@@ -0,0 +1,44 @@
+package gui
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestImportIsReadOnce(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("XDG_CONFIG_HOME", home)
+ mux := http.NewServeMux()
+ importRoutes(mux)
+ get := func(id string) (*httptest.ResponseRecorder, importJSON) {
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/import/"+id, nil))
+ var in importJSON
+ _ = json.Unmarshal(rec.Body.Bytes(), &in)
+ return rec, in
+ }
+
+ id := stashImport("magpie://import?preset=deepseek&key=sk-test")
+ rec, in := get(id)
+ if rec.Code != 200 || in.Provider.ID != "deepseek" || in.Provider.Key != "sk-test" || in.Error != "" {
+ t.Fatalf("%d %+v", rec.Code, in)
+ }
+ if rec, _ := get(id); rec.Code != 404 {
+ t.Fatalf("second read: %d", rec.Code)
+ }
+ if _, in := get(stashImport("magpie://import?name=x")); in.Error == "" {
+ t.Fatal("a bad link reported no error")
+ }
+}
+
+func TestImportLink(t *testing.T) {
+ if ImportLink([]string{"/usr/bin/magpie", "tray"}) != "" {
+ t.Fatal("found a link in plain args")
+ }
+ if ImportLink([]string{"MAGPIE://import?preset=x"}) == "" {
+ t.Fatal("missed an upper-case link")
+ }
+}
diff --git a/internal/gui/scheme_linux.go b/internal/gui/scheme_linux.go
new file mode 100644
index 00000000..1d932efa
--- /dev/null
+++ b/internal/gui/scheme_linux.go
@@ -0,0 +1,46 @@
+package gui
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// registerScheme makes magpie:// links open this executable: a desktop
+// entry that claims the scheme, then the desktop's default for it. An
+// entry that already runs this executable and claims it is left alone.
+func registerScheme() error {
+ exe, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ data := os.Getenv("XDG_DATA_HOME")
+ if data == "" {
+ home, _ := os.UserHomeDir()
+ data = filepath.Join(home, ".local", "share")
+ }
+ dir := filepath.Join(data, "applications")
+ path := filepath.Join(dir, "magpie.desktop")
+ entry := `[Desktop Entry]
+Type=Application
+Name=magpie
+Comment=Every agent's model. One place.
+Exec=` + exe + ` %u
+Icon=magpie
+Categories=Development;Utility;
+MimeType=x-scheme-handler/magpie;
+Terminal=false
+`
+ if old, err := os.ReadFile(path); err == nil && strings.Contains(string(old), "Exec="+exe+" %u") && strings.Contains(string(old), "x-scheme-handler/magpie") {
+ return nil
+ }
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ if err := os.WriteFile(path, []byte(entry), 0o644); err != nil {
+ return err
+ }
+ _ = exec.Command("update-desktop-database", dir).Run()
+ return exec.Command("xdg-mime", "default", "magpie.desktop", "x-scheme-handler/magpie").Run()
+}
diff --git a/internal/gui/scheme_other.go b/internal/gui/scheme_other.go
new file mode 100644
index 00000000..2ff53bb1
--- /dev/null
+++ b/internal/gui/scheme_other.go
@@ -0,0 +1,6 @@
+//go:build !windows && !linux
+
+package gui
+
+// registerScheme: the Mac reads the scheme from the bundle's Info.plist.
+func registerScheme() error { return nil }
diff --git a/internal/gui/scheme_windows.go b/internal/gui/scheme_windows.go
new file mode 100644
index 00000000..edc6fa22
--- /dev/null
+++ b/internal/gui/scheme_windows.go
@@ -0,0 +1,34 @@
+package gui
+
+import (
+ "os"
+
+ "golang.org/x/sys/windows/registry"
+)
+
+// registerScheme makes magpie:// links open this executable. There is no
+// installer to do it, so every start points the per-user handler here.
+func registerScheme() error {
+ exe, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ k, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Classes\magpie`, registry.SET_VALUE)
+ if err != nil {
+ return err
+ }
+ defer k.Close()
+ _ = k.SetStringValue("", "URL:magpie")
+ _ = k.SetStringValue("URL Protocol", "")
+ icon, _, err := registry.CreateKey(k, `DefaultIcon`, registry.SET_VALUE)
+ if err == nil {
+ _ = icon.SetStringValue("", `"`+exe+`",0`)
+ icon.Close()
+ }
+ cmd, _, err := registry.CreateKey(k, `shell\open\command`, registry.SET_VALUE)
+ if err != nil {
+ return err
+ }
+ defer cmd.Close()
+ return cmd.SetStringValue("", `"`+exe+`" "%1"`)
+}
diff --git a/internal/provider/importurl.go b/internal/provider/importurl.go
new file mode 100644
index 00000000..13b32b14
--- /dev/null
+++ b/internal/provider/importurl.go
@@ -0,0 +1,156 @@
+package provider
+
+import (
+ "errors"
+ "net"
+ "net/url"
+ "strings"
+)
+
+// Import links let a vendor or a relay hand its users a ready-made provider:
+//
+// magpie://import?preset=deepseek&key=sk-…
+// magpie://import?name=My%20Relay&chat=https://relay.example/v1&key=sk-…
+//
+// Parameters (every value URL-encoded):
+//
+// preset a preset id (magpie presets); the preset's endpoints are used
+// region with a preset that has regions, which one
+// name the provider's name; required without a preset
+// id its id; derived from the name when absent
+// key the API key; the user can paste one when absent
+// chat OpenAI Chat Completions base URL (…/v1)
+// responses OpenAI Responses base URL (…/v1)
+// anthropic Anthropic Messages base URL (the root)
+// models model ids to show agents, comma separated
+// catalog models.dev provider id, for names and reasoning levels
+// website the vendor's site; keys: where keys are made
+//
+// A link never saves anything by itself: the app shows what it would add
+// and the user says yes.
+
+// Scheme is the URL scheme magpie registers with the system.
+const Scheme = "magpie"
+
+// ParseImport reads an import link into the provider it describes.
+func ParseImport(link string) (Provider, error) {
+ u, err := url.Parse(strings.TrimSpace(link))
+ if err != nil || !strings.EqualFold(u.Scheme, Scheme) {
+ return Provider{}, errors.New("not a magpie:// link")
+ }
+ action := u.Host
+ if action == "" { // magpie:import?… or magpie:///import?…
+ action = strings.Trim(u.Opaque+u.Path, "/")
+ }
+ if action != "import" {
+ return Provider{}, errorf("magpie://%s is not something magpie knows; links start magpie://import?", action)
+ }
+ q := u.Query()
+ get := func(k string) string { return strings.TrimSpace(q.Get(k)) }
+
+ var p Provider
+ if id := get("preset"); id != "" {
+ if p, err = FromPreset(strings.ToLower(id)); err != nil {
+ return Provider{}, err
+ }
+ if r := get("region"); r != "" {
+ found := false
+ for _, reg := range Preset(p.Preset).Regions {
+ if reg.ID == r {
+ p.Chat, p.Responses, p.Anthropic, found = reg.Chat, reg.Responses, reg.Anthropic, true
+ }
+ }
+ if !found {
+ return Provider{}, errorf("%s has no region %q", p.Name, r)
+ }
+ }
+ if n := get("name"); n != "" {
+ p.Name = n
+ }
+ } else {
+ p = Provider{Name: get("name"), Catalog: strings.ToLower(get("catalog"))}
+ if p.Name == "" {
+ return Provider{}, errors.New("the link names no provider: it needs preset= or name=")
+ }
+ for k, dst := range map[string]*string{"chat": &p.Chat, "responses": &p.Responses, "anthropic": &p.Anthropic} {
+ if *dst, err = endpoint(k, get(k)); err != nil {
+ return Provider{}, err
+ }
+ }
+ if p.Chat == "" && p.Responses == "" && p.Anthropic == "" {
+ return Provider{}, errors.New("the link gives no base URL: it needs chat=, responses= or anthropic=")
+ }
+ if p.Catalog != "" && p.Catalog != Slug(p.Catalog) {
+ p.Catalog = ""
+ }
+ if p.Catalog != "" {
+ p.Icon = IconForCatalog(p.Catalog)
+ }
+ p.Website = page(get("website"))
+ p.KeysURL = page(get("keys"))
+ }
+
+ if id := get("id"); id != "" {
+ p.ID = Slug(id)
+ } else if get("preset") == "" {
+ p.ID = Slug(p.Name)
+ }
+ if p.ID == "" {
+ return Provider{}, errors.New("the link's name has no letters or digits to make an id from")
+ }
+ if p.ID == "magpie" {
+ return Provider{}, errors.New(`"magpie" is what agents call the gateway itself; the link needs another id`)
+ }
+ if len(p.Name) > 80 {
+ p.Name = p.Name[:80]
+ }
+
+ p.Key = get("key")
+ if len(p.Key) > 4096 || strings.ContainsFunc(p.Key, func(r rune) bool { return r < ' ' || r == 0x7f }) {
+ return Provider{}, errors.New("the link's key is not a key")
+ }
+ for _, m := range strings.Split(q.Get("models"), ",") {
+ if m = strings.TrimSpace(m); m != "" && len(p.Models) < 200 {
+ p.Models = append(p.Models, m)
+ }
+ }
+ return p, nil
+}
+
+// endpoint accepts an https base URL, or plain http to this machine or the
+// local network, where model servers usually run without TLS.
+func endpoint(name, raw string) (string, error) {
+ if raw == "" {
+ return "", nil
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
+ return "", errorf("the link's %s= is not a base URL: %q", name, raw)
+ }
+ switch u.Scheme {
+ case "https":
+ case "http":
+ if !local(u.Hostname()) {
+ return "", errorf("the link's %s= must use https: %q", name, raw)
+ }
+ default:
+ return "", errorf("the link's %s= must use https: %q", name, raw)
+ }
+ return strings.TrimRight(u.String(), "/"), nil
+}
+
+func local(host string) bool {
+ if host == "localhost" || strings.HasSuffix(host, ".local") {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && (ip.IsLoopback() || ip.IsPrivate())
+}
+
+// page keeps a web page link only when it is https.
+func page(raw string) string {
+ if u, err := url.Parse(raw); err == nil && u.Scheme == "https" && u.Host != "" && u.User == nil {
+ return u.String()
+ }
+ return ""
+}
diff --git a/internal/provider/importurl_test.go b/internal/provider/importurl_test.go
new file mode 100644
index 00000000..3dd4336c
--- /dev/null
+++ b/internal/provider/importurl_test.go
@@ -0,0 +1,102 @@
+package provider
+
+import (
+ "net/url"
+ "slices"
+ "testing"
+)
+
+func TestParseImportPreset(t *testing.T) {
+ p, err := ParseImport("magpie://import?preset=deepseek&key=sk-abc123&models=deepseek-v4-pro,%20deepseek-v4-flash")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p.ID != "deepseek" || p.Preset != "deepseek" || p.Key != "sk-abc123" || p.Chat == "" {
+ t.Fatalf("got %+v", p)
+ }
+ if !slices.Equal(p.Models, []string{"deepseek-v4-pro", "deepseek-v4-flash"}) {
+ t.Fatalf("models %q", p.Models)
+ }
+}
+
+func TestParseImportCustom(t *testing.T) {
+ q := url.Values{
+ "name": {"My Relay"},
+ "chat": {"https://relay.example/v1/"},
+ "anthropic": {"https://relay.example"},
+ "key": {"sk-relay"},
+ "website": {"https://relay.example"},
+ "keys": {"javascript:alert(1)"},
+ "catalog": {"openai"},
+ }
+ p, err := ParseImport("magpie://import?" + q.Encode())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p.ID != "my-relay" || p.Name != "My Relay" || p.Chat != "https://relay.example/v1" || p.Anthropic != "https://relay.example" || p.Preset != "" {
+ t.Fatalf("got %+v", p)
+ }
+ if p.Website != "https://relay.example" || p.KeysURL != "" {
+ t.Fatalf("pages: %q %q", p.Website, p.KeysURL)
+ }
+}
+
+func TestParseImportRegion(t *testing.T) {
+ var pr *PresetDef
+ for i := range presets {
+ if len(presets[i].Regions) > 1 {
+ pr = &presets[i]
+ break
+ }
+ }
+ if pr == nil {
+ t.Skip("no preset with regions")
+ }
+ r := pr.Regions[1]
+ p, err := ParseImport("magpie://import?preset=" + pr.ID + "®ion=" + r.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p.Chat != r.Chat || p.Anthropic != r.Anthropic || p.Preset != pr.ID {
+ t.Fatalf("got %+v, want region %+v", p, r)
+ }
+ if _, err := ParseImport("magpie://import?preset=" + pr.ID + "®ion=nowhere"); err == nil {
+ t.Fatal("unknown region accepted")
+ }
+}
+
+func TestParseImportRejects(t *testing.T) {
+ for _, link := range []string{
+ "https://usemagpie.ai/import?preset=deepseek",
+ "magpie://delete?id=deepseek",
+ "magpie://import",
+ "magpie://import?preset=nope",
+ "magpie://import?name=X",
+ "magpie://import?name=X&chat=http://relay.example/v1",
+ "magpie://import?name=X&chat=https://user:pw@relay.example/v1",
+ "magpie://import?name=X&chat=file:///etc/passwd",
+ "magpie://import?name=magpie&chat=https://x.example/v1",
+ "magpie://import?name=%E2%80%94&chat=https://x.example/v1",
+ "magpie://import?preset=deepseek&key=sk%0Aevil",
+ } {
+ if p, err := ParseImport(link); err == nil {
+ t.Errorf("%s: accepted as %+v", link, p)
+ }
+ }
+}
+
+func TestParseImportLocalHTTP(t *testing.T) {
+ for _, host := range []string{"localhost:11434", "127.0.0.1:8080", "192.168.1.20:8000", "box.local"} {
+ if _, err := ParseImport("magpie://import?name=Local&chat=http://" + host + "/v1"); err != nil {
+ t.Errorf("%s: %v", host, err)
+ }
+ }
+}
+
+func TestParseImportForms(t *testing.T) {
+ for _, link := range []string{"magpie:import?preset=deepseek", "magpie:///import?preset=deepseek", "MAGPIE://import/?preset=deepseek"} {
+ if _, err := ParseImport(link); err != nil {
+ t.Errorf("%s: %v", link, err)
+ }
+ }
+}
diff --git a/main.go b/main.go
index 91d5e950..5d26e627 100644
--- a/main.go
+++ b/main.go
@@ -43,6 +43,7 @@ const usage = `magpie — one place to pick every agent's model
magpie provider add e.g. magpie provider add deepseek sk-…
magpie provider add k=v… a custom vendor (magpie provider for the fields)
magpie provider key|models|test|rm
+ magpie import [-y] add the provider a magpie://import?… link describes
magpie models every model agents can pick, as provider/model
magpie serve run the gateway alone (the app runs it too)
@@ -75,17 +76,25 @@ func run(args []string) error {
agent.RenameLegacy()
if len(args) == 0 {
if hasGUI {
- return runGUI(true)
+ return runGUI(true, "")
}
return tui.Run()
}
+ // a magpie:// link the system handed over (Windows, Linux): the app
+ // opens it for the user to confirm
+ if strings.HasPrefix(strings.ToLower(args[0]), "magpie:") {
+ if !hasGUI {
+ return importCmd(args)
+ }
+ return runGUI(false, args[0])
+ }
switch args[0] {
case "tui":
return tui.Run()
case "app", "gui":
- return runGUI(true)
+ return runGUI(true, "")
case "tray":
- return runGUI(false)
+ return runGUI(false, "")
case "-h", "--help", "help":
fmt.Print(usage)
return nil
@@ -107,6 +116,8 @@ func run(args []string) error {
return nil
case "save", "use", "rm", "profiles":
return profiles(args)
+ case "import":
+ return importCmd(args[1:])
case "providers":
return providers()
case "presets":
diff --git a/providers_cli.go b/providers_cli.go
index 6b1edeaa..11cb1ccd 100644
--- a/providers_cli.go
+++ b/providers_cli.go
@@ -2,6 +2,7 @@ package main
import (
"context"
+ "errors"
"fmt"
"os"
"strings"
@@ -277,6 +278,12 @@ func addProvider(rest []string) error {
if err := applyPairs(&p, rest); err != nil {
return err
}
+ return saveNew(p)
+}
+
+// saveNew saves a provider the user just added, then asks the vendor for
+// its models.
+func saveNew(p provider.Provider) error {
if err := provider.Save(p); err != nil {
return err
}
@@ -441,3 +448,46 @@ func serve() error {
}
return s.ListenAndServe(context.Background())
}
+
+// importCmd adds the provider a magpie://import link describes, after
+// showing it: magpie import [-y] .
+func importCmd(args []string) error {
+ yes := false
+ var link string
+ for _, a := range args {
+ switch a {
+ case "-y", "--yes":
+ yes = true
+ default:
+ link = a
+ }
+ }
+ if link == "" {
+ return errors.New("magpie import [-y] 'magpie://import?…'")
+ }
+ p, err := provider.ParseImport(link)
+ if err != nil {
+ return err
+ }
+ if err := showProvider(p); err != nil {
+ return err
+ }
+ if old, err := provider.Find(p.ID); err == nil {
+ fmt.Println(amber.Render("!"), "replaces your", old.Name)
+ }
+ if !yes {
+ if st, err := os.Stdin.Stat(); err != nil || st.Mode()&os.ModeCharDevice == 0 {
+ return errors.New("not a terminal: add -y to import without asking")
+ }
+ fmt.Print("Add it? [y/N] ")
+ var answer string
+ _, _ = fmt.Scanln(&answer)
+ if a := strings.ToLower(strings.TrimSpace(answer)); a != "y" && a != "yes" {
+ return errors.New("nothing added")
+ }
+ }
+ if p.Key == "" && !p.Ready() {
+ fmt.Println(amber.Render("!"), "the link has no key · magpie provider key", p.ID, "")
+ }
+ return saveNew(p)
+}