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:
21 files changed
+764
-22
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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`") }
|
||||
@@ -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) }
|
||||
@@ -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
@@ -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("") })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !linux
|
||||
|
||||
package gui
|
||||
|
||||
func sessionBus() bool { return true }
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"`)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user