import: magpie://import links add a provider after the user confirms

A vendor or relay can hand out a ready-made provider as a link. The app
registers the magpie scheme (Info.plist on macOS, HKCU on Windows, a
.desktop handler on Linux), a second launch hands the link to the running
one, and the window shows what the link would add, with the hosts the
prompts and key would go to, before anything is saved. `magpie import`
does the same in a terminal.
This commit is contained in:
yetone committed 2026-09-24 12:17:45 +08:00
1 parent 084480beec
commit 4bd3f3a4b5
21 files changed
+764 -22

No files matched your search

+34
View File
@@ -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 <link>` 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#<same parameters>` 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: <https://usemagpie.ai/docs/import>.
## Install
Download the app for macOS, Windows or Linux from
+7
View File
@@ -13,6 +13,13 @@
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>LSUIElement</key><true/>
<key>NSHighResolutionCapable</key><true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key><string>com.yetone.magpie.import</string>
<key>CFBundleURLSchemes</key><array><string>magpie</string></array>
</dict>
</array>
<key>NSHumanReadableCopyright</key><string>yetone</string>
</dict>
</plist>
+2 -2
View File
@@ -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
)
+1 -1
View File
@@ -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`") }
+1 -1
View File
@@ -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) }
+1
View File
@@ -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) {
+89 -14
View File
@@ -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("") })
}
},
}
}
+3
View File
@@ -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; }
+77 -1
View File
@@ -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();
+8
View File
@@ -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",
+10
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !linux
package gui
func sessionBus() bool { return true }
+74
View File
@@ -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)
})
}
+44
View File
@@ -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")
}
}
+46
View File
@@ -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()
}
+6
View File
@@ -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 }
+34
View File
@@ -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"`)
}
+156
View File
@@ -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 ""
}
+102
View File
@@ -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 + "&region=" + 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 + "&region=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)
}
}
}
+14 -3
View File
@@ -43,6 +43,7 @@ const usage = `magpie — one place to pick every agent's model
magpie provider add <preset> <key> e.g. magpie provider add deepseek sk-…
magpie provider add <name> k=v… a custom vendor (magpie provider for the fields)
magpie provider key|models|test|rm <id>
magpie import [-y] <link> 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":
+50
View File
@@ -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] <link>.
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, "<key>")
}
return saveNew(p)
}