plugins: two plugins that sign in to one provider are both listed, each row says which serves it, and Use for picks the other (neiko on Discord)

neiko on Discord installed a third-party plugin beside a plugin of their
own, added by its folder. Only the third-party one could be seen. Both
plugins' auth hooks name the same provider id. The host's auths() keeps
the last plugin in plugins.json for an id, as OpenCode does, so the
user's own plugin silently lost it:
- its row read "Signs in to nothing magpie can use";
- its provider was gone from the Add sheet;
- keepUnloaded could list the id a second time, from the
  plugin-providers.json kept before.
A package-name clash and a display-name clash were tried as well. Both
plugins stay visible in those cases, because a folder plugin's name is
its path.

What changed:
- host.js:
  - init gives each loaded plugin the ids it signs in to (provides) and,
    for each id another plugin serves, that plugin's spec (servedBy).
  - auths() honours plugins.json's new prefer map (id -> spec).
  - Only the serving plugin's provider.models hook runs for the id.
- plugin:
  - Clashes() turns provides and servedBy into {id, by, with} per spec.
  - Prefer() records a pick and restarts the host.
  - Remove drops the removed plugin's picks.
  - Re-adding a plugin under a new spec carries its picks over.
  - remove, off, options and prefer find a plugin by its exact spec
    before its package name (indexOf), so acting on one plugin never
    reaches another of the same package.
  - keepUnloaded no longer re-adds a provider from before when another
    plugin serves its id now.
- GUI:
  - /api/plugins rows carry clashes, with the provider's listed name.
  - POST /api/plugins/prefer {spec, provider}.
  - The serving row says "{other} signs in to {name} too; this one
    serves it".
  - The other row says, in amber, "{name} is served by {other}, which
    signs in to it too", with a "Use for {name}" button.
  - New strings in zh, zh-TW, ja and de.
- CLI: `magpie plugin` prints the same lines, with the command that
  fixes it, and `magpie plugin use <name> <provider>` makes the pick.
- Docs: docs/subsystems/provider-plugins.md, docs/reference.md and
  site/public/docs/{,zh/,ja/}plugins.html.

What was run:
- Reproduced under a sandbox HOME with magpie web on 3562: a fake npm
  plugin and a folder plugin, both on id "acme".
  - Old binary: the local row had providers [] and /api/providers
    listed acme twice.
  - New binary: both rows carry the clash; prefer moves acme to the
    folder plugin; remove clears the pick and leaves the other plugin
    serving acme.
- New Go tests:
  - TestTwoPluginsOneProvider, a real Bun host: Prefer, restart,
    off/on and Remove.
  - TestClashes, TestPluginNamedExactlyFirst and
    TestKeepUnloadedLeavesAServedID.
  - TestPluginsClashRows (gui) and TestPluginListSaysClash (CLI).
  - Each fails when its piece is broken by hand: prefer ignored in
    host.js, the keepUnloaded id check removed, name before exact spec,
    Remove keeping picks, no provides, no clashes in the rows, no
    prefer route.
- plugin-clash.test.cjs, in Chromium and WebKit, in en, zh, zh-TW, ja
  and de, at 1100 and 560px: 22/22 pass. It times out on the old
  plugins.js.
- gui-de, gui-ja, gui-zh-tw, i18n-keys and every plugin-*.test.cjs:
  248 tests.
  - 245 passed in the 4-wide run.
  - plugin-moved-signin (webkit) failed once in that run, then passed
    3/3 alone and 26/26 in a smaller 4-wide rerun.
- gofmt -l; go vet ./...; GOOS=linux and GOOS=windows go vet -tags
  nogui ./...; go build ./...
- The full go test -tags nogui ./... under a temp HOME with the caches
  pinned: 44 packages ok, none failed.
- After rebasing onto 05917100 (plugins.css conflict with a3a8e257's
  chip rules, both kept): plugin, gui and the root plugin tests, vet on
  darwin, linux and windows, and the 248 GUI tests above, all green
  (248/248 this time).

Not tried: a real third-party plugin, neiko's own plugins, and Windows
beyond vet.
This commit is contained in:
yetone committed 2026-10-10 00:55:38 +08:00
1 parent 059171006b
commit 7d0e415e62
18 files changed
+791 -11

No files matched your search

+14
View File
@@ -530,6 +530,20 @@ A provider id magpie already has (google, openai, anthropic) is
`<id>-plugin`. In the app, Settings → Plugins adds and removes them, and
the providers they sign in to are in Add provider → From plugins.
Sometimes two installed plugins sign in to the same provider id, for
example a third-party plugin and your own copy of it. One of them serves
that id: the one you picked, or else the one added last. Both plugins stay
listed, and each row in Plugins › Installed says which one serves the
provider. The other row has **Use for <provider>**, which switches the
provider to it. `magpie plugin` prints the same thing, and
```sh
magpie plugin use ~/dev/my-acme-auth acme # this plugin serves acme from now on
```
makes the pick. Each plugin can still be switched off or removed on its
own.
A package is pi's when its `package.json` has a `pi` manifest, the
`pi-package` keyword, or depends on `@earendil-works/pi-coding-agent`.
magpie installs pi beside it and loads it with pi's own loader; the
+32
View File
@@ -42,6 +42,31 @@ The switch is off by default. For a provider whose vendor magpie checked in thro
Qoder plugins without `auth.checkin` still use [`qoder_checkin.go`](../../internal/provider/qoder_checkin.go) through the plugin's fetch. The server's `CLAIMABLE` status decides whether to claim; the local `[startAt, endAt)` check only recognizes an already-claimed campaign and its expiry, including one that crosses Beijing midnight. Successful results keep the active campaign's end in the optional `until` field of `qoder-checkin.json`. Scheduled checks reuse results from the same Beijing day for at most 30 minutes and never past that end, so a long campaign cannot hide new or reset campaigns indefinitely. The existing loop looks every 30 minutes and on a Beijing day change; a midnight refresh saves today's result for the Usage card and TUI. An `inactive` result is now asked again after 30 minutes rather than settled for the day; failures retain their existing retry schedule. A manual check-in always asks immediately. Saved results without `until` remain readable and use the same 30-minute refresh, with no migration needed. Other vendors and plugins with their own check-in retain their daily settlement.
## Two plugins for one provider id
The host runs one plugin per provider id. When two installed plugins have `auth` hooks for the same id, such as a third-party plugin and the user's own copy added by its folder, `auths()` in `host.js` takes the one picked for that id in `plugins.json`'s `prefer` map (provider id → spec). Without a pick it takes the last one in the list, as OpenCode does. Only the serving plugin's `provider.models` hook runs for that id. Every plugin's `config` hook still runs, so another plugin can still add models or a name to `config.provider[id]`.
Before this, the plugin that lost the id disappeared silently: its row read "Signs in to nothing magpie can use", and `keepUnloaded` could list the id twice, once from the old `plugin-providers.json` (neiko on Discord). Now:
- `init` returns each plugin's `provides` (the ids its auth hooks name) and `servedBy` (id → the other spec, for each id it doesn't serve).
- `plugin.Clashes` turns these into `{id, by, with}` per spec.
- `/api/plugins` gives each row its `clashes`, along with the provider's listed name.
- `keepUnloaded` doesn't keep a provider from before when another plugin serves its id now.
Both rows stay visible on the Plugins page:
- The serving row says "{other} signs in to {name} too; this one serves it".
- The other row says, in amber, "{name} is served by {other}, which signs in to it too". It has a **Use for {name}** button, which calls `POST /api/plugins/prefer {spec, provider}` (`plugin.Prefer`) and restarts the host.
In the CLI, `magpie plugin list` prints the same two lines, and `magpie plugin use <name> <provider>` makes the pick.
How the store handles picks and names:
- Removing a plugin drops its picks.
- Switching a plugin off hands the id to the other plugin until it is switched on again.
- Re-adding a plugin under a new version or spec keeps its picks.
- Remove, off, options and prefer find a plugin by its exact spec first, then by package name (`indexOf` in `store.go`). This way, acting on one plugin never reaches another plugin that has the same package name.
## How ownership changes
Migration records use four states:
@@ -93,6 +118,13 @@ Gateway parity tests in [`plugin_parity_test.go`](../../internal/gateway/plugin_
[`plugin_cliproxy_test.go`](../../internal/plugin/plugin_cliproxy_test.go)'s `TestPluginHostTakesANewProxy` starts a real host without a proxy, sets one, and checks that a program the plugin starts gets it. `plugin-signed-out-account.test.cjs` covers a moved Grok account signed out while in use and as the agent's own, in Chromium and WebKit, en and zh, 420 and 1100 wide.
These tests cover two plugins on one provider id:
- [`plugin_clash_test.go`](../../internal/plugin/plugin_clash_test.go)'s `TestTwoPluginsOneProvider` loads two copies of the fake plugin in a real host. It checks `provides`/`servedBy`, `Clashes`, that the id is listed only once, and that the pick survives Prefer, a restart, off/on and Remove.
- `TestPluginNamedExactlyFirst` and `TestKeepUnloadedLeavesAServedID` cover the store and the providers cache.
- `TestPluginsClashRows` (`internal/gui`) and `TestPluginListSaysClash` (the CLI) check what the user sees.
- `plugin-clash.test.cjs` covers both rows and Use for, in Chromium and WebKit, in en, zh, zh-TW, ja and de, at 1100 and 560 wide.
[`quotas_dedupe_test.go`](../../internal/provider/quotas_dedupe_test.go) checks unrelated reset collisions and ZCode deduplication, including plugin usage window conversion for both plugin ids. [`TestPlanQuotas`](../../internal/provider/planquota_test.go) checks the custom GLM endpoint, cached cards, and fallback readings restored from disk. These fixtures do not contact the live vendors or run the community ZCode plugin.
Run the relevant package tests with an isolated home directory. Resolve GOPATH and Go's caches before changing HOME, so repeated runs reuse dependencies and downloaded toolchains. The subshell makes temporary files writable and cleans up its home on exit, including when tests fail, and returns the test command's exit status.
+32
View File
@@ -1796,6 +1796,14 @@ const I18N = {
"Didn't load: {error}": "没有加载成功:{error}",
"Signs in to {names}": "可登录 {names}",
"Signs in to nothing magpie can use": "没有 magpie 能用的登录",
"{other} signs in to {name} too; this one serves it": "{other} 也登录 {name};由这个插件提供",
"{name} is served by {other}, which signs in to it too": "{name} 由同样登录它的 {other} 提供",
"Two plugins sign in to {name}; magpie runs it on one. Use for {name} on the other one's row switches it": "有两个插件都登录 {name},magpie 只用其中一个。在另一个插件那一行点“用于 {name}”即可切换",
"Two plugins sign in to {name}; magpie runs it on one. Use for {name} switches it to this one, or remove the one you don't want": "有两个插件都登录 {name},magpie 只用其中一个。点“用于 {name}”换成这个插件,或移除不需要的那个",
"Use for {name}": "用于 {name}",
"Switching…": "正在切换…",
"{name} runs on this plugin instead of {other}; both stay installed": "{name} 改由这个插件提供,不再用 {other};两个插件都保留",
"{name} now runs on {plugin}": "{name} 现在由 {plugin} 提供",
"Removes the plugin and what it installed; its sign-ins are kept until you sign out": "移除插件及其安装的内容;它的登录会保留,直到你退出登录",
"Serves {names}": "承载 {names}",
"{names} runs on this plugin in place of the built-in": "{names} 正由此插件代替内置运行",
@@ -5651,6 +5659,14 @@ const I18N = {
"Didn't load: {error}": "沒有載入成功:{error}",
"Signs in to {names}": "可登入 {names}",
"Signs in to nothing magpie can use": "沒有 magpie 能用的登入",
"{other} signs in to {name} too; this one serves it": "{other} 也登入 {name};由這個外掛提供",
"{name} is served by {other}, which signs in to it too": "{name} 由同樣登入它的 {other} 提供",
"Two plugins sign in to {name}; magpie runs it on one. Use for {name} on the other one's row switches it": "有兩個外掛都登入 {name},magpie 只用其中一個。在另一個外掛那一列點「用於 {name}」即可切換",
"Two plugins sign in to {name}; magpie runs it on one. Use for {name} switches it to this one, or remove the one you don't want": "有兩個外掛都登入 {name},magpie 只用其中一個。點「用於 {name}」換成這個外掛,或移除不需要的那個",
"Use for {name}": "用於 {name}",
"Switching…": "正在切換…",
"{name} runs on this plugin instead of {other}; both stay installed": "{name} 改由這個外掛提供,不再用 {other};兩個外掛都保留",
"{name} now runs on {plugin}": "{name} 現在由 {plugin} 提供",
"Removes the plugin and what it installed; its sign-ins are kept until you sign out": "移除外掛及其安裝的內容;它的登入會保留,直到你退出登入",
"Serves {names}": "承載 {names}",
"{names} runs on this plugin in place of the built-in": "{names} 正由此外掛代替內建執行",
@@ -9488,6 +9504,14 @@ const I18N = {
"Didn't load: {error}": "読み込めませんでした:{error}",
"Signs in to {names}": "{names} にサインイン",
"Signs in to nothing magpie can use": "magpie で使えるサインインはありません",
"{other} signs in to {name} too; this one serves it": "{other} も {name} にサインインします。このプラグインが提供中です",
"{name} is served by {other}, which signs in to it too": "{name} は、同じくサインインする {other} が提供しています",
"Two plugins sign in to {name}; magpie runs it on one. Use for {name} on the other one's row switches it": "2 つのプラグインが {name} にサインインします。magpie が使うのは 1 つだけです。もう一方の行の「{name} に使う」で切り替えられます",
"Two plugins sign in to {name}; magpie runs it on one. Use for {name} switches it to this one, or remove the one you don't want": "2 つのプラグインが {name} にサインインします。magpie が使うのは 1 つだけです。「{name} に使う」でこのプラグインに切り替えるか、不要な方を削除してください",
"Use for {name}": "{name} に使う",
"Switching…": "切り替え中…",
"{name} runs on this plugin instead of {other}; both stay installed": "{name} を {other} ではなくこのプラグインで動かします。どちらもインストールされたままです",
"{name} now runs on {plugin}": "{name} は {plugin} で動くようになりました",
"Removes the plugin and what it installed; its sign-ins are kept until you sign out": "プラグインとそれがインストールしたものを削除します。サインインはサインアウトするまで残ります",
"Serves {names}": "{names} を提供",
"{names} runs on this plugin in place of the built-in": "{names} は組み込みの代わりにこのプラグインで動作しています",
@@ -13344,6 +13368,14 @@ const I18N = {
"Didn't load: {error}": "Nicht geladen: {error}",
"Signs in to {names}": "Meldet bei {names} an",
"Signs in to nothing magpie can use": "Meldet bei nichts an, das magpie nutzen kann",
"{other} signs in to {name} too; this one serves it": "{other} meldet sich auch bei {name} an; diese Erweiterung stellt es bereit",
"{name} is served by {other}, which signs in to it too": "{name} wird von {other} bereitgestellt, die sich ebenfalls dort anmeldet",
"Two plugins sign in to {name}; magpie runs it on one. Use for {name} on the other one's row switches it": "Zwei Erweiterungen melden sich bei {name} an; magpie nutzt nur eine. „Für {name} nutzen“ in der Zeile der anderen wechselt",
"Two plugins sign in to {name}; magpie runs it on one. Use for {name} switches it to this one, or remove the one you don't want": "Zwei Erweiterungen melden sich bei {name} an; magpie nutzt nur eine. „Für {name} nutzen“ wechselt zu dieser, oder entfernen Sie die nicht gewünschte",
"Use for {name}": "Für {name} nutzen",
"Switching…": "Wird gewechselt…",
"{name} runs on this plugin instead of {other}; both stay installed": "{name} läuft über diese Erweiterung statt über {other}; beide bleiben installiert",
"{name} now runs on {plugin}": "{name} läuft jetzt über {plugin}",
"Removes the plugin and what it installed; its sign-ins are kept until you sign out": "Entfernt die Erweiterung und alles, was sie installiert hat; ihre Anmeldungen bleiben bis zur Abmeldung erhalten",
"Serves {names}": "Stellt {names} bereit",
"{names} runs on this plugin in place of the built-in": "{names} läuft über diese Erweiterung statt über die integrierte Version",
+5
View File
@@ -84,6 +84,11 @@ button.pm-chip:disabled { cursor: default; opacity: .6; }
of the line (yetone: 这些配色有点丑,一点都不优雅精致) */
.pm-chip.warn { gap: 5px; background: none; box-shadow: inset 0 0 0 1px var(--line); color: var(--fg-2); font-weight: 400; }
.pm-chip.warn .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--amber); flex: none; }
/* a provider two installed plugins sign in to: a line that wraps, never clipped */
.pm-row .sub.pm-clash { display: block; white-space: normal; overflow: visible; overflow-wrap: anywhere; }
.pm-clash .dot { display: inline-block; width: 6px; height: 6px; margin: 0 5px 1px 0; border-radius: 50%; background: var(--faint); vertical-align: middle; }
.pm-row .sub.pm-clash.warn { color: var(--amber); }
.pm-clash.warn .dot { background: currentColor; }
.pm-foot .pm-check { display: inline-flex; align-items: center; gap: 5px; }
.pm-foot .pm-check .spin { width: 9px; height: 9px; border-radius: 50%; border: 1.5px solid currentColor; border-right-color: transparent; animation: spin .7s linear infinite; }
/* what a plugin is reads as the line's other facts do (stars, version):
+30
View File
@@ -796,6 +796,12 @@
const subs = subsOf(pkg);
const sub = el("div", "sub");
const cands = e.off ? [] : movableOf(pkg);
// a provider another installed plugin signs in to as well (neiko on
// Discord: a third-party plugin and their own one both name it). One
// of them serves it; the row says which, and the other gets a way to
// take it over, rather than the provider just going missing
const clashes = e.off || e.error ? [] : e.clashes || [];
const nameOfSpec = (spec) => { const o = (mine?.plugins || []).find((x) => x.spec === spec); return o ? shownName(o) : label(spec); };
const ask = asking?.pkg === pkg && !busy.size && (asking.op === "move" ? cands.length : moved.length) ? asking.op : "";
if (ask === "move") {
sub.textContent = t("{names} can run on it again, with the same accounts.", { names: cands.map((c) => c.name).join(t(", ")) });
@@ -818,8 +824,11 @@
}
} else if (mw && !e.providers.length) {
// only middleware: its own line says what it does
} else if (clashes.length && !e.providers.length) {
// its own line below says whose it is
} else sub.textContent = e.providers.length ? t("Signs in to {names}", { names: e.providers.join(t(", ")) }) : t("Signs in to nothing magpie can use");
if (sub.textContent) who.append(sub);
if (!ask) for (const c of clashes) who.append(clashLine(e, c, nameOfSpec));
if (mw && !e.off && !ask) who.append(mwLine(mw));
if (mw && !e.off && !ask && editing?.pkg === pkg) who.append(optionsEditor(e));
r.append(logo(l?.icon || subs[0]?.icon, false, e.middlewareOnly), who);
@@ -849,6 +858,13 @@
mv.onclick = () => move(here);
val.append(mv);
}
for (const c of ask ? [] : clashes.filter((c) => c.by !== e.spec)) {
const u = el("button", "text", b === "prefer" ? t("Switching…") : t("Use for {name}", { name: c.name }));
u.title = t("{name} runs on this plugin instead of {other}; both stay installed", { name: c.name, other: nameOfSpec(c.by) });
u.disabled = busy.size > 0;
u.onclick = () => act(pkg, "prefer", { spec: e.spec, provider: c.id }, () => status(t("{name} now runs on {plugin}", { name: c.name, plugin: shownName(e) }), "ok"));
val.append(u);
}
if (!e.off && !e.error && subs.some((x) => !x.signedIn)) {
// signed in to one of its subscriptions already (Qoder, beside Qoder
// CN; #556): a blue Sign in read as the plugin signed out, so the
@@ -959,6 +975,20 @@
// mwLine is a middleware's line in its plugin's row: its hooks, how many
// calls and how long each took, and the calls that failed (which went on
// as if it weren't there), or why it didn't load
// clashLine is one provider two installed plugins both sign in to, as
// the row of either says it: served here, or served by the other one
function clashLine(e, c, nameOfSpec) {
const d = el("div", "sub pm-clash" + (c.by === e.spec ? "" : " warn"));
const others = (c.by === e.spec ? c.with : [c.by]).map(nameOfSpec).join(t(", "));
d.append(el("span", "dot"), el("span", "", c.by === e.spec
? t("{other} signs in to {name} too; this one serves it", { name: c.name, other: others })
: t("{name} is served by {other}, which signs in to it too", { name: c.name, other: others })));
d.title = c.by === e.spec
? t("Two plugins sign in to {name}; magpie runs it on one. Use for {name} on the other one's row switches it", { name: c.name })
: t("Two plugins sign in to {name}; magpie runs it on one. Use for {name} switches it to this one, or remove the one you don't want", { name: c.name });
return d;
}
function mwLine(m) {
const d = el("div", "sub pm-mw");
if (m.error) {
+26
View File
@@ -92,6 +92,17 @@ type pluginEntryJSON struct {
// whether that is all it has, no provider to sign in to
IsMiddleware bool `json:"isMiddleware,omitempty"`
MiddlewareOnly bool `json:"middlewareOnly,omitempty"`
// Clashes are the providers it signs in to that another plugin signs
// in to as well (a plugin of the user's own beside a third party's):
// the host runs one plugin for each, and the row says which, with a
// way to pick this one
Clashes []pluginClashJSON `json:"clashes,omitempty"`
}
// pluginClashJSON is a provider two plugins sign in to, as a row says it.
type pluginClashJSON struct {
plugin.Clash
Name string `json:"name"` // the provider's, as the plugin serving it names it
}
// autoUpdatedFor is how long a plugin's row says magpie updated it.
@@ -118,6 +129,8 @@ func pluginsState(ctx context.Context, w Windows) pluginsJSON {
l := plugin.Load()
errs := map[string]string{}
names := map[string][]string{}
clashes := map[string][]plugin.Clash{}
idName := map[string]string{}
if len(l.Plugins) > 0 && (plugin.Running() || plugin.HasBun()) {
loaded, err := plugin.Plugins(ctx)
if err != nil {
@@ -126,9 +139,11 @@ func pluginsState(ctx context.Context, w Windows) pluginsJSON {
for _, p := range loaded {
errs[p.Spec] = p.Error
}
clashes = plugin.Clashes(loaded)
if ps, err := plugin.Providers(ctx); err == nil {
for _, p := range ps {
names[p.Spec] = append(names[p.Spec], p.Name)
idName[p.ID] = p.Name
}
}
}
@@ -156,6 +171,13 @@ func pluginsState(ctx context.Context, w Windows) pluginsJSON {
j.IsMiddleware, j.MiddlewareOnly = true, only
j.OptionsExample = plugin.OptionsExample(plugin.Target(e.Spec))
}
for _, c := range clashes[e.Spec] {
n := idName[c.ID]
if n == "" {
n = c.ID
}
j.Clashes = append(j.Clashes, pluginClashJSON{Clash: c, Name: n})
}
j.Moved = provider.MovedOnto(e.Spec)
if j.Moved == nil {
j.Moved = []string{}
@@ -338,6 +360,8 @@ func pluginRoutes(mux *http.ServeMux, w Windows) {
Spec string
Off bool
Options map[string]any
// prefer's: the provider id the plugin is to serve
Provider string
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil && err != io.EOF {
fail(rw, err)
@@ -360,6 +384,8 @@ func pluginRoutes(mux *http.ServeMux, w Windows) {
err = provider.SetPluginOff(ctx, in.Spec, in.Off)
case "options":
err = plugin.SetOptions(in.Spec, in.Options)
case "prefer":
err = plugin.Prefer(in.Spec, in.Provider)
default:
http.NotFound(rw, r)
return
+100
View File
@@ -0,0 +1,100 @@
package gui
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/yetone/magpie/internal/plugin"
"github.com/yetone/magpie/internal/testenv"
)
// Two installed plugins that sign in to one provider (neiko on Discord:
// a third-party plugin beside their own folder plugin): each row of
// /api/plugins carries the clash, naming the plugin that serves it and
// the provider as it is listed, and prefer hands it to the other one.
func TestPluginsClashRows(t *testing.T) {
bun, err := exec.LookPath("bun")
if err != nil {
t.Skip("no bun on PATH")
}
dir := t.TempDir()
testenv.SetHome(t, dir)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, "cache"))
t.Setenv("MAGPIE_BUN", bun)
t.Setenv("MAGPIE_PLUGIN_MARKET", "off")
t.Cleanup(plugin.Settle)
src, err := os.ReadFile("../plugin/testdata/fake/index.js")
if err != nil {
t.Fatal(err)
}
mine, theirs := filepath.Join(dir, "mine", "index.js"), filepath.Join(dir, "theirs", "index.js")
for _, f := range []string{mine, theirs} {
if err := os.MkdirAll(filepath.Dir(f), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(f, src, 0o644); err != nil {
t.Fatal(err)
}
}
mux := http.NewServeMux()
pluginRoutes(mux, nil)
type row struct {
Spec string
Providers []string
Clashes []struct {
ID, By, Name string
With []string
}
}
do := func(path string, body any) map[string]row {
t.Helper()
method, rd := "GET", &bytes.Buffer{}
if body != nil {
method = "POST"
_ = json.NewEncoder(rd).Encode(body)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(method, path, rd))
if rec.Code != 200 {
t.Fatalf("%s: %d %s", path, rec.Code, rec.Body)
}
var s struct{ Plugins []row }
if err := json.Unmarshal(rec.Body.Bytes(), &s); err != nil {
t.Fatalf("%s: %v", path, err)
}
out := map[string]row{}
for _, r := range s.Plugins {
out[r.Spec] = r
}
return out
}
do("/api/plugins/add", map[string]string{"spec": mine})
rows := do("/api/plugins/add", map[string]string{"spec": theirs})
check := func(rows map[string]row, by string) {
t.Helper()
for spec, other := range map[string]string{mine: theirs, theirs: mine} {
r := rows[spec]
if len(r.Clashes) != 1 || r.Clashes[0].ID != "fakeco" || r.Clashes[0].By != by || r.Clashes[0].Name != "FakeCo" || len(r.Clashes[0].With) != 1 || r.Clashes[0].With[0] != other {
t.Fatalf("%s's row = %+v, want fakeco served by %s", spec, r, by)
}
if want := spec == by; (len(r.Providers) == 1) != want {
t.Fatalf("%s's row lists %v; serving fakeco: %v", spec, r.Providers, want)
}
}
}
check(rows, theirs)
check(do("/api/plugins/prefer", map[string]string{"spec": mine, "provider": "fakeco"}), mine)
check(do("/api/plugins", nil), mine)
// removing theirs leaves mine alone, and no clash to say
rows = do("/api/plugins/remove", map[string]string{"spec": theirs})
if r, ok := rows[mine]; !ok || len(r.Clashes) != 0 || len(r.Providers) != 1 || len(rows) != 1 {
t.Fatalf("after removing theirs, rows = %+v", rows)
}
}
+125
View File
@@ -0,0 +1,125 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Two installed plugins that sign in to one provider (neiko on Discord: a
// third-party plugin and their own folder plugin both name it). Both rows
// stay, each says which one serves the provider, and the other one offers
// "Use for …", which tells magpie to run the provider on it. Neither row
// reads "Signs in to nothing magpie can use". Chromium and WebKit, every
// language, wide and narrow; the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const MINE = "/Users/me/dev/my-acme";
const THEIRS = "opencode-acme-auth@latest";
function server(lang, asked) {
let by = THEIRS;
const clash = (spec) => [{ id: "acme", by, with: [spec === MINE ? THEIRS : MINE], name: "Acme" }];
const installed = () => [
{ spec: MINE, providers: by === MINE ? ["Acme"] : [], version: "", clashes: clash(MINE) },
{ spec: THEIRS, providers: by === THEIRS ? ["Acme"] : [], version: "1.0.0", clashes: clash(THEIRS) },
];
const state = () => ({ bun: true, bunVersion: "1.3.0", plugins: installed() });
return async (route) => {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
const body = () => route.request().postDataJSON();
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } });
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true }, plugins: [{ id: "acme", pid: "acme", name: "Acme", spec: by, signedIn: false, models: [] }] });
if (url.pathname === "/api/groups") return json({ groups: [], pools: [] });
if (url.pathname === "/api/gateway/trace") return json({ routes: [] });
if (url.pathname === "/api/plugins/market" || url.pathname === "/api/plugins" || url.pathname === "/api/plugins/listings") return json(url.pathname === "/api/plugins" ? state() : url.pathname === "/api/plugins/listings" ? { listings: [] } : { listings: [], state: state() });
if (url.pathname === "/api/plugins/search") return json({ hits: [] });
if (url.pathname === "/api/plugins/npm") return json({});
if (url.pathname === "/api/plugins/prefer") {
const b = body();
asked.push(["prefer", b]);
by = b.spec;
return json({});
}
if (url.pathname.startsWith("/api/plugins/")) { asked.push([url.pathname, body()]); return json({}); }
if (url.pathname.startsWith("/api/")) return json({});
if (!/^\/[\w./-]*$/.test(url.pathname) || url.host !== "magpie.test") { asked.push(["fetched", url.href]); return route.fulfill({ status: 404, body: "" }); }
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
};
}
// what each row says, in each language
const L = {
en: { installed: "Installed", nothing: "Signs in to nothing magpie can use", served: "Acme is served by {other}, which signs in to it too", serves: "{other} signs in to Acme too; this one serves it", use: "Use for Acme", done: "Acme now runs on my-acme" },
zh: { installed: "已安装", nothing: "没有 magpie 能用的登录", served: "Acme 由同样登录它的 {other} 提供", serves: "{other} 也登录 Acme;由这个插件提供", use: "用于 Acme", done: "Acme 现在由 my-acme 提供" },
"zh-TW": { installed: "已安裝", nothing: "沒有 magpie 能用的登入", served: "Acme 由同樣登入它的 {other} 提供", serves: "{other} 也登入 Acme;由這個外掛提供", use: "用於 Acme", done: "Acme 現在由 my-acme 提供" },
ja: { installed: "インストール済み", nothing: "magpie で使えるサインインはありません", served: "Acme は、同じくサインインする {other} が提供しています", serves: "{other} も Acme にサインインします。このプラグインが提供中です", use: "Acme に使う", done: "Acme は my-acme で動くようになりました" },
de: { installed: "Installiert", nothing: "Meldet bei nichts an, das magpie nutzen kann", served: "Acme wird von {other} bereitgestellt, die sich ebenfalls dort anmeldet", serves: "{other} meldet sich auch bei Acme an; diese Erweiterung stellt es bereit", use: "Für Acme nutzen", done: "Acme läuft jetzt über my-acme" },
};
const say = (s, other) => s.replace("{other}", other);
const shot = async (loc, name) => {
if (!process.env.ARTIFACT_DIR) return;
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
await loc.screenshot({ path: path.join(process.env.ARTIFACT_DIR, name + ".png") });
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
test(engine + ": two plugins that sign in to one provider", async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" }));
t.after(() => browser.close());
for (const lang of Object.keys(L)) {
for (const width of [1100, 560]) {
await t.test(`${lang} ${width}px`, async () => {
const w = L[lang];
const asked = [];
const ctx = await browser.newContext({ viewport: { width, height: 820 } });
const page = await ctx.newPage();
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server(lang, asked));
await page.goto("http://magpie.test/?view=plugins");
const view = page.locator("#view-plugins");
await view.waitFor({ state: "visible" });
await view.locator(".lib-tabs .opt", { hasText: w.installed }).click();
const row = (n) => view.locator(".pm-row", { has: page.locator(".name span", { hasText: new RegExp("^" + n + "$") }) });
const mine = row("my-acme"), theirs = row("opencode-acme-auth");
await mine.waitFor();
await theirs.waitFor();
// the third-party one serves it: both rows are there, and say so
assert.equal((await mine.locator(".pm-clash").innerText()).trim(), say(w.served, "opencode-acme-auth"));
assert.match(await mine.locator(".pm-clash").getAttribute("class"), /\bwarn\b/);
assert.equal((await theirs.locator(".pm-clash").innerText()).trim(), say(w.serves, "my-acme"));
assert.doesNotMatch(await mine.innerText(), new RegExp(w.nothing), "the row doesn't say it signs in to nothing");
assert.equal(await theirs.locator("button", { hasText: w.use }).count(), 0, "the one serving it has nothing to take over");
// the row fits: nothing wider than the page
const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
assert.ok(over <= 0, `the page scrolls sideways by ${over}px`);
await shot(view, `plugin-clash-${engine}-${lang}-${width}`);
// Use for Acme on the user's own one: magpie is told to run it there
const use = mine.locator("button", { hasText: w.use });
assert.equal(await use.count(), 1);
assert.ok((await use.getAttribute("title")).includes("opencode-acme-auth"), "its title names the one it takes over from");
await use.click();
await mine.locator(".pm-clash:not(.warn)").waitFor();
assert.deepEqual(asked.filter(([k]) => k === "prefer"), [["prefer", { spec: MINE, provider: "acme" }]]);
assert.equal((await mine.locator(".pm-clash").innerText()).trim(), say(w.serves, "opencode-acme-auth"));
assert.equal((await theirs.locator(".pm-clash").innerText()).trim(), say(w.served, "my-acme"));
assert.equal(await theirs.locator("button", { hasText: w.use }).count(), 1, "and the other one can take it back");
assert.equal(await mine.locator("button", { hasText: w.use }).count(), 0);
assert.equal(await page.locator("#status").textContent(), w.done);
await shot(view, `plugin-clash-used-${engine}-${lang}-${width}`);
assert.deepEqual(errors, []);
await ctx.close();
});
}
}
});
}
+50
View File
@@ -16,6 +16,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
@@ -491,6 +492,54 @@ type writeReq struct {
type Loaded struct {
Spec string `json:"spec"`
Error string `json:"error,omitempty"`
// Provides are the provider ids its auth hooks sign in to
Provides []string `json:"provides,omitempty"`
// ServedBy is, for each of them another plugin signs in to and
// serves, that plugin's spec: the host runs one plugin per provider
// id (Prefer)
ServedBy map[string]string `json:"servedBy,omitempty"`
}
// Clash is a provider id more than one plugin signs in to. The host runs
// one of them for it: the one the user picked (Prefer), else the last in
// plugins.json, as OpenCode does. The others' sign-in to it goes unused.
type Clash struct {
ID string `json:"id"` // the provider id, OpenCode's
By string `json:"by"` // the spec of the plugin that serves it
// With are the other plugins that sign in to it
With []string `json:"with"`
}
// Clashes are the provider ids each loaded plugin shares with another,
// by the plugin's spec.
func Clashes(loaded []Loaded) map[string][]Clash {
who := map[string][]string{}
var ids []string
for _, l := range loaded {
for _, id := range l.Provides {
if who[id] == nil {
ids = append(ids, id)
}
who[id] = append(who[id], l.Spec)
}
}
out := map[string][]Clash{}
for _, id := range ids {
specs := who[id]
if len(specs) < 2 {
continue
}
by := ""
for _, l := range loaded {
if slices.Contains(specs, l.Spec) && l.ServedBy[id] == "" {
by = l.Spec
}
}
for _, s := range specs {
out[s] = append(out[s], Clash{ID: id, By: by, With: slices.DeleteFunc(slices.Clone(specs), func(x string) bool { return x == s })})
}
}
return out
}
var (
@@ -807,6 +856,7 @@ func startOn(ctx context.Context, bun string) (*host, bool, error) {
"piPath": pi,
"directory": settings.Dir(),
"config": l.Config,
"prefer": l.Prefer,
"plugins": items,
}, &res)
if err != nil {
+31 -2
View File
@@ -267,6 +267,9 @@ let modelsDevPath = ""
let piPath = "" // pi.js, which loads pi's extensions
let directory = process.cwd()
let userConfig = {}
// prefer is the plugin the user picked for a provider id more than one
// plugin signs in to: id → its spec (plugins.json's prefer)
let prefer = {}
const hooks = [] // {spec, hooks}
const loaded = [] // {spec, id, error}
const loaders = new Map() // account → options the auth loader returned
@@ -910,13 +913,34 @@ async function loadPlugins(list) {
}
// auths are the auth hooks by provider, the last plugin to name one
// winning, as in OpenCode.
// winning, as in OpenCode, unless the user picked one of them for it
// (prefer): a plugin of their own beside a third party's that signs in to
// the same provider id is then theirs to choose, not the list's order.
function auths() {
const m = new Map()
for (const h of hooks) if (h.hooks.auth?.provider) m.set(h.hooks.auth.provider, { spec: h.spec, target: h.target, auth: h.hooks.auth })
for (const h of hooks) {
const id = h.hooks.auth?.provider
if (id && prefer[id] === h.spec) m.set(id, { spec: h.spec, target: h.target, auth: h.hooks.auth })
}
return m
}
// signsIn are the provider ids the plugin spec's auth hooks name.
const signsIn = (spec) => [...new Set(hooks.filter((h) => h.spec === spec && h.hooks.auth?.provider).map((h) => h.hooks.auth.provider))]
// withProviders is a loaded plugin as init tells magpie of it: the provider
// ids it signs in to, and for each one another plugin serves (two plugins
// naming one id: the host runs one), the plugin that does. magpie says so
// on the plugin's row rather than leave its provider missing.
function withProviders(l) {
const ids = l.error ? [] : signsIn(l.spec)
if (!ids.length) return l
const a = auths()
const servedBy = Object.fromEntries(ids.filter((id) => a.get(id) && a.get(id).spec !== l.spec).map((id) => [id, a.get(id).spec]))
return { ...l, provides: ids, ...(Object.keys(servedBy).length ? { servedBy } : {}) }
}
function authOf(provider) {
const a = auths().get(provider)
if (!a) throw new Error(`no plugin signs in to ${provider}`)
@@ -1015,9 +1039,13 @@ async function info(id, key, strict) {
variants: m.variants ?? was?.variants ?? {},
}
}
const server = auths().get(id)?.spec
for (const h of hooks) {
const ph = h.hooks.provider
if (ph?.id !== id || typeof ph.models !== "function") continue
// a plugin that signs in to id too but doesn't serve it: its list
// would be asked with the serving plugin's account
if (server && h.spec !== server && signsIn(h.spec).includes(id)) continue
const k = key ?? accountsOf(readAuth(), id)[0] ?? id
await fresh(id, k)
const all = readAuth()
@@ -1653,8 +1681,9 @@ const handlers = {
piPath = p.piPath ?? ""
directory = p.directory ?? directory
userConfig = p.config ?? {}
prefer = p.prefer ?? {}
await loadPlugins(p.plugins ?? [])
return { plugins: loaded }
return { plugins: loaded.map(withProviders) }
},
providers: (p) => providers(p ?? {}),
prompt: async (p) => ({ prompt: await nextPrompt(p.provider, p.method, p.inputs ?? {}) }),
+189
View File
@@ -0,0 +1,189 @@
package plugin
import (
"context"
"os"
"path/filepath"
"reflect"
"testing"
"time"
)
// Two plugins that sign in to one provider id (neiko on Discord: a
// third-party plugin and their own one, added by its folder). The host
// runs one of them for it; each says it signs in to the id, the other
// one says who serves it, the provider is listed once, and Prefer hands
// it to the other. Removing one leaves the other's untouched.
func TestTwoPluginsOneProvider(t *testing.T) {
sandbox(t)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
src, err := os.ReadFile("testdata/fake/index.js")
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
mine, theirs := filepath.Join(dir, "mine", "index.js"), filepath.Join(dir, "theirs", "index.js")
for _, f := range []string{mine, theirs} {
if err := os.MkdirAll(filepath.Dir(f), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(f, src, 0o644); err != nil {
t.Fatal(err)
}
}
served := func(want string) {
t.Helper()
ps, err := Providers(ctx)
if err != nil {
t.Fatal(err)
}
var specs []string
for _, p := range ps {
if p.ID == "fakeco" {
specs = append(specs, p.Spec)
}
}
if !reflect.DeepEqual(specs, []string{want}) {
t.Fatalf("fakeco is served by %v, want just %s", specs, want)
}
}
// the user's own first: it serves fakeco
if _, err := Add(ctx, mine); err != nil {
t.Fatal(err)
}
served(mine)
// a third party's added after it serves it now, as in OpenCode, and
// fakeco is listed once, not again as mine's from the list kept before
if _, err := Add(ctx, theirs); err != nil {
t.Fatal(err)
}
served(theirs)
ls, err := Plugins(ctx)
if err != nil || len(ls) != 2 {
t.Fatalf("Plugins = %+v, %v", ls, err)
}
by := map[string]Loaded{}
for _, l := range ls {
by[l.Spec] = l
}
if l := by[mine]; !reflect.DeepEqual(l.Provides, []string{"fakeco"}) || !reflect.DeepEqual(l.ServedBy, map[string]string{"fakeco": theirs}) {
t.Fatalf("mine loaded as %+v: it signs in to fakeco, which theirs serves", l)
}
if l := by[theirs]; !reflect.DeepEqual(l.Provides, []string{"fakeco"}) || l.ServedBy != nil {
t.Fatalf("theirs loaded as %+v: it signs in to fakeco and serves it", l)
}
want := map[string][]Clash{
mine: {{ID: "fakeco", By: theirs, With: []string{theirs}}},
theirs: {{ID: "fakeco", By: theirs, With: []string{mine}}},
}
if c := Clashes(ls); !reflect.DeepEqual(c, want) {
t.Fatalf("Clashes = %+v, want %+v", c, want)
}
// picked for it, mine serves it again; theirs stays installed
if err := Prefer(mine, "fakeco"); err != nil {
t.Fatal(err)
}
served(mine)
if ls, err = Plugins(ctx); err != nil {
t.Fatal(err)
}
if c := Clashes(ls); len(c[mine]) != 1 || c[mine][0].By != mine || c[theirs][0].By != mine {
t.Fatalf("after Prefer, Clashes = %+v", c)
}
// ...and stays picked after a restart
Restart()
served(mine)
// switched off, it leaves fakeco to theirs; on again, it has it back
if err := SetOff(mine, true); err != nil {
t.Fatal(err)
}
served(theirs)
if err := SetOff(mine, false); err != nil {
t.Fatal(err)
}
served(mine)
// removed, its pick goes with it, and theirs serves fakeco
if err := Remove(ctx, mine); err != nil {
t.Fatal(err)
}
if p := Load().Prefer; len(p) != 0 {
t.Fatalf("after removing mine, Prefer = %v", p)
}
served(theirs)
}
func TestClashes(t *testing.T) {
ls := []Loaded{
{Spec: "a", Provides: []string{"x", "y"}, ServedBy: map[string]string{"x": "c"}},
{Spec: "b", Error: "broken"},
{Spec: "c", Provides: []string{"x"}},
{Spec: "d", Provides: []string{"z"}},
}
want := map[string][]Clash{
"a": {{ID: "x", By: "c", With: []string{"c"}}},
"c": {{ID: "x", By: "c", With: []string{"a"}}},
}
if c := Clashes(ls); !reflect.DeepEqual(c, want) {
t.Fatalf("Clashes = %+v, want %+v", c, want)
}
if c := Clashes(nil); len(c) != 0 {
t.Fatalf("Clashes(nil) = %+v", c)
}
}
// A name is the plugin added as it exactly before one whose package has
// that name: switching one off, setting its options or picking it never
// reaches another.
func TestPluginNamedExactlyFirst(t *testing.T) {
storeSandbox(t)
writeList(t, `{"plugins":[{"spec":"acme@latest"},{"spec":"acme"}]}`)
if err := SetOff("acme", true); err != nil {
t.Fatal(err)
}
if ps := Load().Plugins; ps[0].Off || !ps[1].Off {
t.Fatalf("SetOff(acme) = %+v, want acme off, not acme@latest", ps)
}
if err := SetOptions("acme", map[string]any{"k": 1.0}); err != nil {
t.Fatal(err)
}
if ps := Load().Plugins; ps[0].Options != nil || ps[1].Options == nil {
t.Fatalf("SetOptions(acme) = %+v", ps)
}
if err := Prefer("acme", "acme"); err != nil {
t.Fatal(err)
}
if p := Load().Prefer; !reflect.DeepEqual(p, map[string]string{"acme": "acme"}) {
t.Fatalf("Prefer = %v", p)
}
if err := Prefer("acme@latest", "acme"); err != nil {
t.Fatal(err)
}
if p := Load().Prefer; !reflect.DeepEqual(p, map[string]string{"acme": "acme@latest"}) {
t.Fatalf("Prefer = %v", p)
}
if err := Prefer("nosuch", "acme"); err == nil {
t.Fatal("Prefer took a plugin that isn't installed")
}
if err := Prefer("acme", ""); err == nil {
t.Fatal("Prefer took no provider")
}
}
// A provider kept from before for a plugin that told none this time isn't
// kept when another plugin serves its id now: it would be listed twice.
func TestKeepUnloadedLeavesAServedID(t *testing.T) {
storeSandbox(t)
writeList(t, `{"plugins":[{"spec":"/me/acme"},{"spec":"acme-auth"},{"spec":"/me/broken"}]}`)
now := []Provider{{ID: "acme", Spec: "acme-auth"}}
last := []Provider{{ID: "acme", Spec: "/me/acme"}, {ID: "other", Spec: "/me/broken"}}
got := keepUnloaded(now, last)
want := []Provider{{ID: "acme", Spec: "acme-auth"}, {ID: "other", Spec: "/me/broken"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("keepUnloaded = %+v, want %+v", got, want)
}
}
+6 -3
View File
@@ -357,16 +357,19 @@ func keepUnloaded(ps, last []Provider) []Provider {
_ = json.Unmarshal(b, &last)
}
}
told := map[string]bool{}
told, ids := map[string]bool{}, map[string]bool{}
for _, p := range ps {
told[p.Spec] = true
told[p.Spec], ids[p.ID] = true, true
}
installed := map[string]bool{}
for _, e := range list().Plugins {
installed[e.Spec] = true
}
for _, p := range last {
if installed[p.Spec] && !told[p.Spec] {
// a provider another plugin serves now (two plugins sign in to
// it, and the other was installed or picked for it) isn't kept as
// this one's too: it would be listed twice under one id
if installed[p.Spec] && !told[p.Spec] && !ids[p.ID] {
ps = append(ps, p)
}
}
+55 -4
View File
@@ -61,6 +61,11 @@ type List struct {
// section says what a plugin reads of it
// (provider.google.options.projectId).
Config map[string]any `json:"config,omitempty"`
// Prefer is the plugin the user picked for a provider id more than
// one plugin signs in to (a plugin of their own beside a third
// party's): provider id → that plugin's spec. Without it the last of
// them in Plugins serves the id, as in OpenCode.
Prefer map[string]string `json:"prefer,omitempty"`
}
var listMu sync.Mutex
@@ -137,7 +142,7 @@ func list() List {
// copy, so nothing they change reaches what list keeps.
func Load() List {
l := list()
return List{Plugins: clonePlugins(l.Plugins), Config: cloneMap(l.Config)}
return List{Plugins: clonePlugins(l.Plugins), Config: cloneMap(l.Config), Prefer: maps.Clone(l.Prefer)}
}
// clonePlugins copies the entries, their options with them.
@@ -407,6 +412,13 @@ func add(ctx context.Context, spec, version string) (Entry, error) {
return x.Spec == spec || n == name
}); i >= 0 {
e.Options = l.Plugins[i].Options
// a provider id the user picked it for stays its (another version,
// or its package from a git repository in place of npm's)
for id, s := range l.Prefer {
if s == l.Plugins[i].Spec {
l.Prefer[id] = spec
}
}
l.Plugins[i] = e
} else {
l.Plugins = append(l.Plugins, e)
@@ -495,17 +507,29 @@ func Update(ctx context.Context) error {
return errors.Join(errs...)
}
// indexOf is the plugin name means in l: the one added as name exactly,
// else the one of that package. A plugin of the user's own (a folder)
// beside a third party's is each told apart by its own spec, so taking
// one away or switching it off never reaches the other.
func indexOf(l List, name string) int {
if i := slices.IndexFunc(l.Plugins, func(x Entry) bool { return x.Spec == name }); i >= 0 {
return i
}
return slices.IndexFunc(l.Plugins, func(x Entry) bool { return Name(x.Spec) == name })
}
// Remove takes a plugin off the list (and out of plugins/).
func Remove(ctx context.Context, name string) error {
listMu.Lock()
l := Load()
i := slices.IndexFunc(l.Plugins, func(x Entry) bool { return Name(x.Spec) == name || x.Spec == name })
i := indexOf(l, name)
if i < 0 {
listMu.Unlock()
return fmt.Errorf("no plugin %q", name)
}
e := l.Plugins[i]
l.Plugins = slices.Delete(l.Plugins, i, i+1)
maps.DeleteFunc(l.Prefer, func(_, s string) bool { return s == e.Spec })
err := save(l)
listMu.Unlock()
if err != nil {
@@ -525,7 +549,7 @@ func SetOff(name string, off bool) error {
listMu.Lock()
defer listMu.Unlock()
l := Load()
i := slices.IndexFunc(l.Plugins, func(x Entry) bool { return Name(x.Spec) == name || x.Spec == name })
i := indexOf(l, name)
if i < 0 {
return fmt.Errorf("no plugin %q", name)
}
@@ -537,13 +561,40 @@ func SetOff(name string, off bool) error {
return nil
}
// Prefer has the plugin named serve provider id, which another plugin
// signs in to as well (Clash): the host runs it for id from now on, the
// other one keeping its other providers. A plugin removed takes its picks
// with it; one switched off leaves the id to the others until it is on
// again.
func Prefer(name, id string) error {
listMu.Lock()
defer listMu.Unlock()
l := Load()
i := indexOf(l, name)
if i < 0 {
return fmt.Errorf("no plugin %q", name)
}
if id == "" {
return errors.New("no provider given")
}
if l.Prefer == nil {
l.Prefer = map[string]string{}
}
l.Prefer[id] = l.Plugins[i].Spec
if err := save(l); err != nil {
return err
}
Restart()
return nil
}
// SetOptions sets the options a plugin is handed (a middleware's
// ctx.options); nil takes them away.
func SetOptions(name string, opts map[string]any) error {
listMu.Lock()
defer listMu.Unlock()
l := Load()
i := slices.IndexFunc(l.Plugins, func(x Entry) bool { return Name(x.Spec) == name || x.Spec == name })
i := indexOf(l, name)
if i < 0 {
// a short name, as the community's READMEs write it: param-override
// for @magpie-community/middleware-param-override
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/yetone/magpie/internal/plugin"
)
// magpie plugin lists both plugins that sign in to one provider (neiko on
// Discord: a third-party plugin and their own), says which one serves it
// and how to have the other one serve it; `magpie plugin use` does that.
func TestPluginListSaysClash(t *testing.T) {
bun, err := exec.LookPath("bun")
if err != nil {
t.Skip("no bun on PATH")
}
groupsHome(t)
t.Setenv("MAGPIE_BUN", bun)
t.Setenv("MAGPIE_PLUGIN_MARKET", "off")
t.Setenv("NO_COLOR", "1")
t.Cleanup(plugin.Settle)
src, err := os.ReadFile("internal/plugin/testdata/fake/index.js")
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
mine, theirs := filepath.Join(dir, "mine", "index.js"), filepath.Join(dir, "theirs", "index.js")
for _, f := range []string{mine, theirs} {
os.MkdirAll(filepath.Dir(f), 0o755)
if err := os.WriteFile(f, src, 0o644); err != nil {
t.Fatal(err)
}
if _, err := plugin.Add(context.Background(), f); err != nil {
t.Fatal(err)
}
}
out, err := stdoutOf(t, func() error { return listPlugins(context.Background(), false) })
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "served by "+theirs+", which signs in to it too · magpie plugin use "+mine+" fakeco") {
t.Errorf("mine's row doesn't say theirs serves fakeco:\n%s", out)
}
if !strings.Contains(out, "also signed in to by "+mine+"; this one serves it") {
t.Errorf("theirs' row doesn't say it serves fakeco:\n%s", out)
}
out, err = stdoutOf(t, func() error { return pluginCmd([]string{"plugin", "use", mine, "fakeco"}) })
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "served by "+mine+", which signs in to it too · magpie plugin use "+theirs+" fakeco") {
t.Errorf("after use, theirs' row doesn't say mine serves fakeco:\n%s", out)
}
if _, err := stdoutOf(t, func() error { return pluginCmd([]string{"plugin", "use", "nosuch", "fakeco"}) }); err == nil {
t.Error("use took a plugin that isn't installed")
}
}
+31 -2
View File
@@ -20,6 +20,7 @@ const pluginUsage = `usage: magpie plugin [list] [--json]
magpie plugin update install the newest version of each
magpie plugin on|off <name> turn one on or off
magpie plugin options <name> [<json> | off] show or set what a plugin is handed (a middleware's ctx.options)
magpie plugin use <name> <provider> have this plugin serve a provider another plugin signs in to as well
magpie plugin login <provider> [<method>] sign in to a provider a plugin adds
magpie plugin logout <provider> forget the sign-in
magpie plugin move|migrate <subscription> run a built-in subscription's accounts on its community plugin
@@ -97,6 +98,16 @@ func pluginCmd(args []string) error {
return errors.New(pluginUsage)
}
return pluginOptions(rest[0], rest[1:])
case "use", "prefer":
if len(rest) != 2 {
return errors.New(pluginUsage)
}
rest[0] = installedName(rest[0])
if err := plugin.Prefer(rest[0], rest[1]); err != nil {
return err
}
fmt.Println(green.Render("✓"), rest[0], "serves", rest[1])
return listPlugins(ctx, false)
case "login", "signin":
if len(rest) < 1 || len(rest) > 2 {
return errors.New(pluginUsage)
@@ -168,6 +179,7 @@ func listPlugins(ctx context.Context, asJSON bool) error {
for _, p := range loaded {
errs[p.Spec] = p.Error
}
clashes := plugin.Clashes(loaded)
ps, perr := plugin.Providers(ctx)
mws := middleware.States()
if asJSON {
@@ -219,6 +231,18 @@ func listPlugins(ctx context.Context, asJSON bool) error {
}
fmt.Printf(" %s %s %s\n", provider.PluginID(p.ID), muted.Render("("+p.Name+", "+strconv.Itoa(len(p.Models))+" models)"), who)
}
// a provider another plugin signs in to as well: one of them runs
// it, and the other's is said, not left out
for _, c := range clashes[e.Spec] {
if e.Off || errs[e.Spec] != "" {
continue
}
if c.By == e.Spec {
fmt.Printf(" %s %s\n", provider.PluginID(c.ID), muted.Render("also signed in to by "+strings.Join(c.With, ", ")+"; this one serves it"))
continue
}
fmt.Printf(" %s %s\n", provider.PluginID(c.ID), amber.Render("served by "+c.By+", which signs in to it too · magpie plugin use "+e.Spec+" "+c.ID))
}
}
if lerr != nil {
return lerr
@@ -251,10 +275,15 @@ func installedName(name string) string {
func pluginOptions(name string, set []string) error {
var e *plugin.Entry
ps := plugin.Load().Plugins
// the one added as name exactly, else the one of that package
for i, x := range ps {
if plugin.Name(x.Spec) == name || x.Spec == name {
if e == nil && x.Spec == name {
e = &ps[i]
}
}
for i, x := range ps {
if e == nil && plugin.Name(x.Spec) == name {
e = &ps[i]
break
}
}
// a short name, as the community's READMEs write it
+1
View File
@@ -540,6 +540,7 @@ magpie quota <span class="c"># acme · Pro · 5h 37%
<li>エージェントはプラグインのモデルを <code>&lt;id&gt;/&lt;model&gt;</code> と呼びます。id はプラグインの <code>auth.provider</code> です。</li>
<li>その id が magpie ですでに使われている場合(組み込みサブスクリプション、プリセット(<code>google</code>、<code>openai</code>、<code>anthropic</code>、…)、<code>magpie</code> 自身)、プラグインのプロバイダは <code>&lt;id&gt;-plugin</code> という名前になります。自分だけの id を選んでください。</li>
<li>例外は、magpie の非推奨の組み込みサブスクリプション向けのコミュニティプラグインです。これらは組み込みの id(<code>cursor</code>、<code>zed</code>、…)を使い、ユーザーがプラグインに移行するとそれを引き継ぐので、エージェントは変更なしで動き続けます。</li>
<li>インストール済みの 2 つのプラグインが同じ id にサインインする場合(公開プラグインと自分のコピーなど)、提供するのは 1 つだけです。選んだ方、選んでいなければ後から追加した方です。どちらも <span class="ui">プラグイン → インストール済み</span> に残り、各行にどちらが提供しているかが表示されます。もう一方の行の <span class="ui">… に使う</span>、または <code>magpie plugin use &lt;名前&gt; &lt;id&gt;</code> で切り替えられます。</li>
</ul>
<h2 id="test"><a class="anchor" href="#test">テスト</a></h2>
+1
View File
@@ -540,6 +540,7 @@ magpie quota <span class="c"># acme · Pro · 5h 37%
<li>Agents name a plugin’s model as <code>&lt;id&gt;/&lt;model&gt;</code>, where the id is the plugin’s <code>auth.provider</code>.</li>
<li>When the id is taken in magpie, by a built-in subscription, a preset (<code>google</code>, <code>openai</code>, <code>anthropic</code>, …) or <code>magpie</code> itself, the plugin’s provider is named <code>&lt;id&gt;-plugin</code>. Pick an id that is only yours.</li>
<li>The community’s plugins for magpie’s deprecated built-in subscriptions are the exception. They use the built-in’s id (<code>cursor</code>, <code>zed</code>, …) and take it over once the user moves onto the plugin, so agents keep working unchanged.</li>
<li>When two installed plugins sign in to the same id, such as a published plugin and your own copy of it, one of them serves it: the one picked for it, otherwise the one added last. Both stay in <span class="ui">Plugins → Installed</span>, and each row says which one serves the provider. <span class="ui">Use for …</span> on the other row, or <code>magpie plugin use &lt;name&gt; &lt;id&gt;</code>, makes the pick.</li>
</ul>
<h2 id="test"><a class="anchor" href="#test">Testing</a></h2>
+1
View File
@@ -540,6 +540,7 @@ magpie quota <span class="c"># acme · Pro · 5h 37%
<li>agent 用 <code>&lt;id&gt;/&lt;model&gt;</code> 指代插件的模型,id 就是插件的 <code>auth.provider</code>。</li>
<li>这个 id 在 magpie 里已被占用时,比如内置订阅、预设(<code>google</code>、<code>openai</code>、<code>anthropic</code> 等)或 <code>magpie</code> 本身,插件的供应商会叫 <code>&lt;id&gt;-plugin</code>。请选一个只属于你的 id。</li>
<li>社区为 magpie 已弃用的内置订阅做的插件是例外。它们用内置订阅的 id(<code>cursor</code>、<code>zed</code> 等),用户迁移到插件后就接管这个 id,agent 无需任何改动。</li>
<li>两个已安装的插件登录同一个 id 时(比如已发布的插件和你自己的副本),只由其中一个提供:选定的那个,否则是后添加的那个。两个插件都留在<span class="ui">插件 → 已安装</span>里,各自那一行会写明由哪个提供。在另一行点<span class="ui">用于 …</span>,或运行 <code>magpie plugin use &lt;名字&gt; &lt;id&gt;</code>,即可改选。</li>
</ul>
<h2 id="test"><a class="anchor" href="#test">测试</a></h2>