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) +}