diff --git a/README.md b/README.md index 78e16abb..ac175880 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,27 @@ first day, `qianfan-token-plan`, is taken too. The plans serve no model list, so the preset carries their documented models; pay as you go serves its own at `/v2/models`. +### Plugins + +A subscription magpie doesn't sign in to itself can come from an +[OpenCode](https://opencode.ai) provider plugin: the npm packages OpenCode +users install to sign in to a plan (their `auth` hook) work in magpie as they +do there. magpie runs them on [Bun](https://bun.sh), downloaded the first +time a plugin needs it, and the plugin signs in, lists the models and makes +each request; magpie serves them to agents like any provider's. + +```sh +magpie plugin add opencode-gemini-auth # an npm package, or a path to a plugin of your own +magpie plugin # the plugins, what each signs in to, and whether you are +magpie plugin login google-plugin # its sign-in: the method, its questions, the browser or a key +magpie plugin logout google-plugin +magpie plugin off opencode-gemini-auth # on brings it back; rm removes it; update updates them all +``` + +A provider id magpie already has (google, openai, anthropic) is +`-plugin`. In the app, Settings → Plugins adds and removes them, and +the providers they sign in to are in Add provider → From plugins. + ### Routing groups A routing group is several models, from one provider or many, that an agent @@ -485,6 +506,9 @@ stdin. - `~/.config/magpie/profiles.json` — saved profiles - `~/.config/magpie/providers.json` — your providers, keys included (0600) - `~/.config/magpie/stash.json` — values magpie replaced, restored on switch-back +- `~/.config/magpie/plugins.json`, `plugins/` — the plugins added, and their packages +- `~/.config/magpie/plugin-auth.json` — the plugins' sign-ins (0600) +- `~/.cache/magpie/bun/` — the Bun plugins run on - `~/.cache/magpie/models.json` — models.dev catalog (OpenCode's cache at `~/.cache/opencode/models.json` is used when present) - `~/.cache/magpie/models/.json` — model lists fetched from vendors diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 890cfb58..1047a52c 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -1338,10 +1338,13 @@ func (s *Server) forwardOnce(ctx context.Context, p provider.Provider, to provid } } } + if p.IsPlugin() { + req.Header.Set(provider.ConversationHeader, conversationID(in, body)) + } if err := p.Sign(ctx, req, to, body); err != nil { return nil, err } - return s.client.Do(req) + return p.Do(s.client, req) } // fromClaudeCode is a request Claude Code sent, by the User-Agent it gives diff --git a/internal/gateway/plugin_test.go b/internal/gateway/plugin_test.go new file mode 100644 index 00000000..cabf7709 --- /dev/null +++ b/internal/gateway/plugin_test.go @@ -0,0 +1,121 @@ +package gateway + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/provider" +) + +// A plugin's provider signed in to is a provider like the others: each of +// its models is spoken to on the API its AI SDK package speaks, at the +// base URL the plugin's loader gives, and the plugin's fetch carries the +// request with the sign-in it keeps. +func TestPluginProvider(t *testing.T) { + bun, err := exec.LookPath("bun") + if err != nil { + t.Skip("no bun on PATH") + } + fresh(t) + t.Setenv("MAGPIE_BUN", bun) + t.Cleanup(plugin.Settle) + + type seen struct { + path, auth, model, body string + } + var mu sync.Mutex + var got []seen + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + got = append(got, seen{r.URL.RequestURI(), r.Header.Get("Authorization"), r.Header.Get("X-Plugin-Model"), string(b)}) + mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + switch { + case r.URL.Path == "/v1/chat/completions": + fmt.Fprint(w, `data: {"id":"c","object":"chat.completion.chunk","model":"fake-1","choices":[{"index":0,"delta":{"role":"assistant","content":"hi from chat"},"finish_reason":null}]}`+"\n\n") + fmt.Fprint(w, `data: {"id":"c","object":"chat.completion.chunk","model":"fake-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`+"\n\ndata: [DONE]\n\n") + case r.URL.Path == "/v1/messages": + for _, e := range []string{ + `{"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"fake-claude","content":[],"usage":{"input_tokens":1,"output_tokens":0}}}`, + `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi from claude"}}`, + `{"type":"content_block_stop","index":0}`, + `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}`, + `{"type":"message_stop"}`, + } { + fmt.Fprintf(w, "data: %s\n\n", e) + } + case strings.HasPrefix(r.URL.Path, "/v1/models/"): + fmt.Fprint(w, `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"hi from gemini"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2}}`+"\n\n") + default: + w.WriteHeader(404) + } + })) + defer up.Close() + t.Setenv("FAKE_BASE", up.URL+"/v1") + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + abs, _ := filepath.Abs("../plugin/testdata/fake/index.js") + if _, err := plugin.Add(ctx, abs); err != nil { + t.Fatal(err) + } + if _, err := plugin.Providers(ctx); err != nil { + t.Fatal(err) + } + if _, err := provider.Find("fakeco"); err == nil { + t.Fatal("fakeco is a provider before it is signed in to") + } + if _, err := plugin.APIKey(ctx, "fakeco", 0, nil, "k1"); err != nil { + t.Fatal(err) + } + p, err := provider.Find("fakeco") + if err != nil || !p.IsPlugin() || p.Name != "FakeCo" { + t.Fatalf("Find = %+v, %v", p, err) + } + for model, want := range map[string]provider.Protocol{"fake-1": provider.Chat, "fake-claude": provider.Anthropic, "fake-gemini": provider.CodeAssist} { + if apis := p.APIs(model); len(apis) != 1 || apis[0] != want { + t.Fatalf("APIs(%s) = %v", model, apis) + } + } + + s := New() + for _, c := range []struct{ model, text, path string }{ + {"fakeco/fake-1", "hi from chat", "/v1/chat/completions"}, + {"fakeco/fake-claude", "hi from claude", "/v1/messages"}, + {"fakeco/fake-gemini", "hi from gemini", "/v1/models/fake-gemini:streamGenerateContent?alt=sse"}, + } { + code, body := postAs(t, s, "", `{"model":"`+c.model+`","messages":[{"role":"user","content":"hello"}]}`) + if code != 200 || !strings.Contains(body, c.text) { + t.Fatalf("%s: %d %s", c.model, code, body) + } + mu.Lock() + last := got[len(got)-1] + mu.Unlock() + model := strings.TrimPrefix(c.model, "fakeco/") + if last.path != c.path || last.auth != "Bearer k1" || last.model != model || !strings.Contains(last.body, "hello") { + t.Fatalf("%s reached upstream as %+v", c.model, last) + } + if strings.Contains(last.body, `"request"`) { + t.Fatalf("%s kept Code Assist's envelope: %s", c.model, last.body) + } + } + + if err := plugin.SignOut(ctx, "fakeco"); err != nil { + t.Fatal(err) + } + if _, err := provider.Find("fakeco"); err == nil { + t.Fatal("fakeco is still a provider after signing out") + } +} diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index ff160a34..fae77af4 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -1592,6 +1592,10 @@ input.words.rule-match { flex: 1 1 auto; width: auto; min-width: 0; } :root[data-theme="dark"] .warm-at input.at { color-scheme: dark; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .warm-at input.at { color-scheme: dark; } } @media (max-width: 520px) { .proxy-segs { flex-wrap: wrap; justify-content: flex-end; } } +/* Settings → Plugins: what a plugin signs in to, or why it didn't load, said whole */ +.row.pref.plugin-row .sub, .row.pref.plugin-add .sub { white-space: normal; } +.row.pref.plugin-row.off .name { color: var(--muted); } +.row.pref.plugin-add .val { flex-wrap: wrap; justify-content: flex-end; } .prefs .val { display: inline-flex; align-items: center; gap: 2px; margin-left: auto; min-width: 0; max-width: 60%; flex: none; } .prefs .val code { font-size: 12px; color: var(--fg-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; user-select: text; -webkit-user-select: text; padding: 0 6px; } .prefs .val .text { padding: 3px 6px; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index d12206cc..5315bcad 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -2768,6 +2768,14 @@ function renderAdd() { const w = subs.find((x) => signing?.agent === x.agent); if (w) tiles.append(renderSigning(w)); } + const plugged = pluginSubs().filter((x) => !f || x.name.toLowerCase().includes(f) || x.agent.includes(f) || "plugin".includes(f)); + if (plugged.length) { + any = true; + const grid = section("From plugins", "signed in by an OpenCode plugin"); + for (const x of plugged) grid.append(subTile(x)); + const w = plugged.find((x) => signing?.agent === x.agent); + if (w) tiles.append(renderSigning(w)); + } const gone = (providers.excluded || []).filter((x) => x.quiet && x.provider && (!f || x.agentName.toLowerCase().includes(f) || x.agent.includes(f))); if (gone.length) { any = true; @@ -4626,7 +4634,17 @@ const SUBS = [ // accounts can also come from another tool's export (Antigravity Cockpit, Antigravity Manager, CLIProxyAPI) { agent: "antigravity", name: "Antigravity", icon: "antigravity-color", plans: "Google AI Pro · Ultra · free", risk: true, importable: true }, ]; -const subOf = (agent) => SUBS.find((x) => x.agent === agent); +const subOf = (agent) => SUBS.find((x) => x.agent === agent) || pluginSubs().find((x) => x.agent === agent); + +// pluginSubs: the providers OpenCode plugins sign in to (Settings → +// Plugins), as subscriptions like the built-in ones. The plugin, not +// magpie, signs in and carries the requests. +function pluginSubs() { + return (providers.plugins || []).map((x) => ({ + agent: x.id, pid: x.pid, name: x.name, icon: x.icon, plugin: x, own: true, single: true, + get plans() { return t("from the plugin {spec}", { spec: x.spec }); }, + })); +} // importSay: what the import of an app's accounts says — where its files // come from, and who they are checked with. A ChatGPT or Claude sign-in is @@ -4656,6 +4674,7 @@ let justAdded = ""; // the account that just came in, to greet it async function startSignIn(agent, risky, site) { if (signingOpen()) api("signin/" + signing.id + "/cancel", {}).catch(() => {}); + if (subOf(agent)?.plugin) return startPluginSignIn(subOf(agent)); // an account Google may suspend is added only once that is said if (subOf(agent)?.risk && !risky) { signing = { agent, state: "risk" }; @@ -4694,27 +4713,162 @@ async function followSignIn(id) { if (signing.state !== st.state || signing.url !== st.url) { signing = { ...st, site: signing.site }; renderProviders(); } continue; } - if (st.state === "done") { - signing = null; - justAdded = st.user; - delete loginUsage[st.agent]; // what was fetched before has nothing on the new account - providers = await api("providers"); - const p = providers.providers.find((x) => x.account?.agent === st.agent); - if (p) { editing = p.id; draft = null; adding = false; presetQuery = ""; } - renderProviders(); - // signed in but listed nowhere (#155): say so rather than "added" - if (!p) status(t("{user} signed in, but magpie can't list it — please report this", { user: st.user }), "err"); - else status(st.using ? t("Signed in as {user}", { user: st.user }) : t("{user} added — switch to it any time", { user: st.user }), "ok"); - state = await api("state"); - renderAgents(); - setTimeout(() => { justAdded = ""; }, 2000); - return; - } + if (st.state === "done") return signedIn(st); signing = st.state === "canceled" ? null : { ...st, site: signing.site }; renderProviders(); } } +// signedIn: a sign-in done — the account opened in the editor. +async function signedIn(st) { + signing = null; + justAdded = st.user; + delete loginUsage[st.agent]; // what was fetched before has nothing on the new account + providers = await api("providers"); + const p = providers.providers.find((x) => x.account?.agent === st.agent); + if (p) { editing = p.id; draft = null; adding = false; presetQuery = ""; } + renderProviders(); + const who = st.user || subOf(st.agent)?.name || st.agent; + // signed in but listed nowhere (#155): say so rather than "added" + if (!p) status(t("{user} signed in, but magpie can't list it — please report this", { user: who }), "err"); + else status(st.using ? t("Signed in as {user}", { user: who }) : t("{user} added — switch to it any time", { user: who }), "ok"); + state = await api("state"); + renderAgents(); + setTimeout(() => { justAdded = ""; }, 2000); +} + +// startPluginSignIn: a plugin's provider signed in to as OpenCode's +// `auth login` does — the way to sign in, the questions that way asks, +// then the browser (and the code its page shows, pasted back) or a key. +async function startPluginSignIn(sub, method, inputs = {}) { + const ms = sub.plugin.methods || []; + if (!ms.length) { + signing = { agent: sub.agent, state: "failed", error: t("The plugin has no way to sign in to {name}", { name: sub.name }) }; + return renderProviders(); + } + if (method == null) { + if (ms.length > 1) { + signing = { agent: sub.agent, state: "method" }; + return renderProviders(); + } + method = 0; + } + signing = { agent: sub.agent, method, inputs, state: "starting" }; + renderProviders(); + try { + const r = await api("plugin-signin/prompt", { provider: sub.pid, method, inputs }); + if (signing?.agent !== sub.agent) return; + if (r.prompt) { + signing = { agent: sub.agent, method, inputs, prompt: r.prompt, state: "prompt" }; + return renderProviders(); + } + if (ms[method].type === "api") { + signing = { agent: sub.agent, method, inputs, state: "key" }; + return renderProviders(); + } + signing = await api("plugin-signin", { provider: sub.pid, method, inputs }); + signing.method = method; + if (web && signing.url) api("open", { url: signing.url }); + renderProviders(); + followSignIn(signing.id); + } catch (e) { + if (signing?.agent !== sub.agent) return; + signing = { agent: sub.agent, method, state: "failed", error: e.message }; + renderProviders(); + } +} + +// pluginAnswer: one of a sign-in method's questions answered, checked by +// the plugin before the next is asked. +async function pluginAnswer(sub, value) { + const flow = signing; + try { + const r = await api("plugin-signin/prompt", { provider: sub.pid, method: flow.method, inputs: flow.inputs, key: flow.prompt.key, value }); + if (signing !== flow) return; + if (r.error) { + signing = { ...flow, error: r.error, value }; + return renderProviders(); + } + startPluginSignIn(sub, flow.method, r.inputs); + } catch (e) { + if (signing === flow) { signing = { ...flow, error: e.message, value }; renderProviders(); } + } +} + +// pluginKey: an "api" method's key, which the plugin keeps. +async function pluginKey(sub, key) { + const flow = signing; + signing = { ...flow, busy: true }; + renderProviders(); + try { + const st = await api("plugin-signin", { provider: sub.pid, method: flow.method, inputs: flow.inputs, key }); + if (signing?.agent === sub.agent) signedIn(st); + } catch (e) { + if (signing?.agent === sub.agent) { signing = { ...flow, error: e.message }; renderProviders(); } + } +} + +// renderPluginAsk: a plugin sign-in's step before the browser — the way +// to sign in, a question, or the key. +function renderPluginAsk(sub) { + const box = el("div", "signing plugin-ask"); + const tt = el("span", "tt"); + box.append(tt); + const close = el("button", "text", t("Cancel")); + close.onclick = cancelSignIn; + if (signing.state === "method") { + tt.append(el("span", "n", t("How do you sign in to {name}?", { name: sub.name })), + el("span", "s", t("The plugin {spec} signs in and sends {name}'s requests; magpie only passes them on.", { spec: sub.plugin.spec, name: sub.name }))); + box.append(close); + sub.plugin.methods.forEach((m, i) => { + const b = el("button", "text primary", m.label || t(m.type === "api" ? "API key" : "Browser")); + b.dataset.method = String(i); + b.onclick = () => startPluginSignIn(sub, i); + box.append(b); + }); + return box; + } + const q = signing.prompt; + const flow = signing; + const form = el("form", "callback-form"); + const why = el("span", "s why", flow.error || ""); + if (flow.state === "prompt" && q.type === "select") { + tt.append(el("span", "n", q.message), why); + box.append(close); + for (const o of q.options || []) { + const b = el("button", "text primary", o.label); + if (o.hint) b.title = o.hint; + b.onclick = () => pluginAnswer(sub, o.value); + box.append(b); + } + return box; + } + const key = flow.state === "key"; + const label = key ? t("{name} API key", { name: sub.name }) : q.message; + tt.append(el("span", "n", label)); + const inp = input(flow.value || "", key ? "" : (q.placeholder || ""), key ? "password" : "text"); + inp.setAttribute("aria-label", label); + inp.autocomplete = "off"; + inp.disabled = !!flow.busy; + const go = el("button", "text primary", t(key ? "Sign in" : "Next")); + go.type = "submit"; + go.disabled = !!flow.busy; + inp.onkeydown = (e) => e.stopPropagation(); + const submit = (e) => { + e.preventDefault(); + if (signing !== flow || flow.busy) return; + if (key) pluginKey(sub, inp.value); + else pluginAnswer(sub, inp.value); + }; + form.onsubmit = submit; + go.onclick = submit; + form.append(inp, go); + tt.append(form, why); + box.append(close); + setTimeout(() => { if (inp.isConnected && !inp.disabled) inp.focus({ preventScroll: true }); }); + return box; +} + // renderStepPlan: a StepFun provider's platform sign-in, which the user // makes in their own browser and brings here with magpie's bookmarklet function renderStepPlan(sp) { @@ -4818,12 +4972,13 @@ function renderSigning(sub) { return box; } if (signing.state === "import" || signing.state === "importing" || signing.state === "imported") return renderLoginImport(sub); + if (signing.state === "method" || signing.state === "prompt" || signing.state === "key") return renderPluginAsk(sub); if (signing.state === "failed") { box.append(el("span", "mark", "!")); tt.append(el("span", "n", t("Sign-in didn't finish")), el("span", "s", signing.error || "")); box.append(tt); const again = el("button", "text primary", t("Try again")); - again.onclick = () => startSignIn(sub.agent, true, signing.site); + again.onclick = () => sub.plugin ? startPluginSignIn(sub, signing.method) : startSignIn(sub.agent, true, signing.site); const close = el("button", "text", t("Cancel")); close.onclick = cancelSignIn; box.append(close, again); @@ -4840,7 +4995,9 @@ function renderSigning(sub) { return box; } tt.append(el("span", "n", t("Finish signing in to {name} in your browser", { name: sub.name })), - el("span", "s", signing.code && sub.agent === "factory" ? t("magpie opened Factory's sign-in page. Check it shows this code and confirm it; the account shows up here as soon as you're done.") + el("span", "s", signing.state === "starting" ? t("Starting the sign-in…") + : sub.plugin ? (signing.instructions || (signing.pasteCode ? t("magpie opened the sign-in page. Paste the code it shows below.") : t("magpie opened the sign-in page. The account shows up here as soon as you're done."))) + : signing.code && sub.agent === "factory" ? t("magpie opened Factory's sign-in page. Check it shows this code and confirm it; the account shows up here as soon as you're done.") : signing.code ? t("magpie opened GitHub's device page. Enter this code there; the account shows up here as soon as you're done.") : t("magpie opened the sign-in page. The account shows up here as soon as you're done."))); if (signing.code) { const code = el("span", "devcode"); @@ -4857,12 +5014,13 @@ function renderSigning(sub) { acts.append(open, cp); tt.append(acts); } - if (signing.pasteCallback) { + if (signing.pasteCallback || signing.pasteCode) { const flow = signing; - tt.append(el("span", "s", t("If the browser cannot return to magpie, paste its final callback URL here."))); + const what = flow.pasteCode ? t("Code") : t("Callback URL"); + if (!flow.pasteCode) tt.append(el("span", "s", t("If the browser cannot return to magpie, paste its final callback URL here."))); const form = el("form", "callback-form"); - const url = input(flow.callbackURL || "", t("Callback URL")); - url.setAttribute("aria-label", t("Callback URL")); + const url = input(flow.callbackURL || "", what); + url.setAttribute("aria-label", what); url.autocomplete = "off"; url.disabled = !!flow.callbackSubmitted || !!flow.callbackSubmitting; const submit = el("button", "text primary", t("Finish sign-in")); @@ -8072,6 +8230,7 @@ function renderSettings() { renderImages(s, keep); renderRedact(s, keep); renderLAN(s); + renderPlugins(); renderSync(); const about = $("#about"); @@ -8134,6 +8293,93 @@ function renderSessionTerminal(s, keep) { select.onchange = () => savePrefs({ ...keep, sessionTerminal: select.value === "system" ? "" : select.value }); } +// renderPlugins: OpenCode's provider plugins, each signing in to a +// subscription magpie doesn't (or no longer does) itself. They are other +// people's code: magpie runs them on Bun and passes their requests on. +let pluginsView = null; +let pluginsBusy = ""; +async function renderPlugins(v) { + const box = $("#pluginsList"); + if (!box) return; + if (v) pluginsView = v; + else if (!pluginsView) { + pluginsView = { plugins: [], loading: true }; + api("plugins").then((x) => renderPlugins(x), (e) => renderPlugins({ plugins: [], error: e.message })); + } + v = pluginsView; + box.replaceChildren(); + const run = async (op, body, busy) => { + pluginsBusy = busy; + renderPlugins(); + try { + const r = await api("plugins/" + op, body); + pluginsBusy = ""; + renderPlugins(r); + providers = await api("providers"); + renderProviders(); + } catch (e) { + pluginsBusy = ""; + renderPlugins(); + status(e.message, "err"); + } + }; + for (const p of v.plugins) { + const r = el("div", "row pref plugin-row" + (p.off ? " off" : "")); + const who = el("div", "who"); + who.append(el("div", "name", p.spec)); + const sub = el("div", "sub", p.off ? t("Off") + : p.error ? t("Didn't load: {error}", { error: p.error }) + : p.providers.length ? t("Signs in to {names}", { names: p.providers.join(t(", ")) }) + : v.loading ? t("Loading…") : t("Signs in to nothing magpie can use")); + if (p.error && !p.off) sub.classList.add("bad"); + who.append(sub); + const val = el("div", "val"); + const onoff = el("button", "text", t(p.off ? "Switch on" : "Switch off")); + onoff.onclick = () => run("off", { spec: p.spec, off: !p.off }, p.spec); + const rm = el("button", "text quiet", t("Remove")); + rm.title = t("Removes the plugin and what it installed; its sign-ins are kept until you sign out"); + rm.onclick = () => run("remove", { spec: p.spec }, p.spec); + onoff.disabled = rm.disabled = !!pluginsBusy; + val.append(onoff, rm); + r.append(who, val); + box.append(r); + } + // a new one: an npm package or a file on this computer + const r = el("div", "row pref plugin-add"); + const who = el("div", "who"); + who.append(el("div", "name", t("Add a plugin"))); + who.append(el("div", "sub", pluginsBusy === "+" ? (v.bun ? t("Installing…") : t("Downloading Bun {v}, which plugins run on, then installing…", { v: v.bunVersion || "" })) + : v.error ? t("Couldn't ask the plugins: {error}", { error: v.error }) + : t("An OpenCode provider plugin — its npm package or a path. Plugins are other people's code: they sign in and make the requests."))); + const val = el("div", "val"); + const form = el("form", "proxy-segs"); + const spec = input("", "opencode-gemini-auth"); + spec.className = "proxy"; + spec.setAttribute("aria-label", t("Plugin")); + spec.autocomplete = "off"; + spec.spellcheck = false; + spec.onkeydown = (e) => e.stopPropagation(); + const add = el("button", "text primary", t("Add")); + add.type = "submit"; + add.disabled = spec.disabled = !!pluginsBusy; + form.onsubmit = add.onclick = (e) => { + e.preventDefault(); + if (!spec.value.trim() || pluginsBusy) return; + run("add", { spec: spec.value.trim() }, "+"); + }; + form.append(spec, add); + val.append(form); + if (v.plugins.length) { + const up = el("button", "text", t(pluginsBusy === "update" ? "Updating…" : "Update all")); + up.title = t("Installs the newest version of each plugin"); + up.disabled = !!pluginsBusy; + up.onclick = () => run("update", {}, "update"); + val.append(up); + } + r.append(who, val); + box.append(r); +} + // renderSync: the Settings page's sync and backup — WebDAV keeping the // setup the same on every computer, and a sealed file to carry by hand. // One of the three opens a form below its row at a time. diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 30f2110e..782fe449 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -648,6 +648,33 @@ const I18N = { "Copy the gateway URL": "复制网关地址", "Gateway URL": "网关地址", // sync and backup + // OpenCode's provider plugins: Settings → Plugins, the add sheet's "From plugins" + "Plugins": "插件", + "From plugins": "来自插件", + "signed in by an OpenCode plugin": "由 OpenCode 插件登录", + "from the plugin {spec}": "来自插件 {spec}", + "The plugin has no way to sign in to {name}": "插件没有提供登录 {name} 的方式", + "How do you sign in to {name}?": "用哪种方式登录 {name}?", + "The plugin {spec} signs in and sends {name}'s requests; magpie only passes them on.": "由插件 {spec} 登录并发出 {name} 的请求,magpie 只负责转交。", + "Browser": "浏览器", + "{name} API key": "{name} API Key", + "Next": "下一步", + "Starting the sign-in…": "正在开始登录…", + "magpie opened the sign-in page. Paste the code it shows below.": "magpie 已打开登录页面,请把页面上显示的代码粘贴到下面。", + "Didn't load: {error}": "没有加载成功:{error}", + "Signs in to {names}": "可登录 {names}", + "Signs in to nothing magpie can use": "没有 magpie 能用的登录", + "Removes the plugin and what it installed; its sign-ins are kept until you sign out": "移除插件及其安装的内容;它的登录会保留,直到你退出登录", + "Add a plugin": "添加插件", + "Downloading Bun {v}, which plugins run on, then installing…": "正在下载插件运行所需的 Bun {v},然后安装…", + "Couldn't ask the plugins: {error}": "无法读取插件:{error}", + "An OpenCode provider plugin — its npm package or a path. Plugins are other people's code: they sign in and make the requests.": "OpenCode 的 provider 插件,填 npm 包名或本地路径。插件是第三方代码:由它登录并发出请求。", + "Plugin": "插件", + "Switch on": "启用", + "Switch off": "停用", + "Sign in": "登录", + "Loading…": "加载中…", + "Installs the newest version of each plugin": "把每个插件更新到最新版本", "Sync and backup": "同步与备份", "WebDAV sync": "WebDAV 同步", "Keeps providers, settings, profiles, agents' models and the library the same on every computer": "让每台电脑上的供应商、设置、Profile、各 agent 的模型和资源库保持一致", diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index 71cefa04..94a77e8f 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -252,6 +252,8 @@
Privacy
+
Plugins
+
Sync and backup
About
diff --git a/internal/gui/plugins.go b/internal/gui/plugins.go new file mode 100644 index 00000000..72f1660d --- /dev/null +++ b/internal/gui/plugins.go @@ -0,0 +1,233 @@ +package gui + +import ( + "context" + "encoding/json" + "io" + "net/http" + "time" + + "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/provider" +) + +// OpenCode's provider plugins (internal/plugin): the providers they sign +// in to are subscriptions in the add sheet, and Settings → Plugins adds, +// updates and removes them. + +// pluginSubJSON is a plugin's provider as the add sheet lists it. +type pluginSubJSON struct { + ID string `json:"id"` // magpie's + PID string `json:"pid"` // OpenCode's, which the plugin knows it by + Name string `json:"name"` + Icon string `json:"icon"` + Spec string `json:"spec"` // the plugin + Methods []plugin.Method `json:"methods"` + SignedIn bool `json:"signedIn"` + Models int `json:"models"` +} + +// pluginIcon is the vendor's icon for the providers OpenCode names, a +// plain one for the rest. +func pluginIcon(id string) string { + switch id { + case "github-copilot", "github-copilot-enterprise": + return "githubcopilot" + case "anthropic": + return "claude-color" + case "openai": + return "openai" + case "google", "google-vertex": + return "gemini-color" + case "qwen", "alibaba": + return "qwen-color" + case "moonshotai", "kimi-for-coding": + return "kimi" + } + return "generic" +} + +func pluginSubs() []pluginSubJSON { + out := []pluginSubJSON{} + for _, pp := range plugin.Cached() { + out = append(out, pluginSubJSON{ + ID: provider.PluginID(pp.ID), PID: pp.ID, Name: pp.Name, Icon: pluginIcon(pp.ID), Spec: pp.Spec, + Methods: pp.Methods, SignedIn: pp.SignedIn, Models: len(pp.Models), + }) + } + return out +} + +// pluginEntryJSON is a plugin as Settings → Plugins lists it. +type pluginEntryJSON struct { + plugin.Entry + Error string `json:"error,omitempty"` // why it didn't load + Providers []string `json:"providers"` // the names of those it signs in to +} + +type pluginsJSON struct { + Plugins []pluginEntryJSON `json:"plugins"` + Bun bool `json:"bun"` // Bun is here; adding the first plugin downloads it otherwise + BunVer string `json:"bunVersion"` + Error string `json:"error,omitempty"` // the plugins couldn't be asked +} + +func pluginsState(ctx context.Context) pluginsJSON { + s := pluginsJSON{Plugins: []pluginEntryJSON{}, Bun: plugin.HasBun(), BunVer: plugin.BunVersion} + l := plugin.Load() + errs := map[string]string{} + names := map[string][]string{} + if len(l.Plugins) > 0 && (plugin.Running() || plugin.HasBun()) { + loaded, err := plugin.Plugins(ctx) + if err != nil { + s.Error = err.Error() + } + for _, p := range loaded { + errs[p.Spec] = p.Error + } + if ps, err := plugin.Providers(ctx); err == nil { + for _, p := range ps { + names[p.Spec] = append(names[p.Spec], p.Name) + } + } + } + for _, e := range l.Plugins { + j := pluginEntryJSON{Entry: e, Error: errs[e.Spec], Providers: names[e.Spec]} + if j.Providers == nil { + j.Providers = []string{} + } + s.Plugins = append(s.Plugins, j) + } + return s +} + +func pluginRoutes(mux *http.ServeMux, w Windows) { + mux.HandleFunc("GET /api/plugins", func(rw http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), time.Minute) + defer cancel() + writeJSON(rw, pluginsState(ctx)) + }) + // add, remove, update, turn on or off: each answers with the list + mux.HandleFunc("POST /api/plugins/{op}", func(rw http.ResponseWriter, r *http.Request) { + var in struct { + Spec string + Off bool + } + if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil && err != io.EOF { + fail(rw, err) + return + } + // a first plugin downloads Bun, and npm installs it + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + var err error + switch r.PathValue("op") { + case "add": + _, err = plugin.Add(ctx, in.Spec) + case "remove": + err = plugin.Remove(ctx, in.Spec) + case "update": + err = plugin.Update(ctx) + case "off": + err = plugin.SetOff(in.Spec, in.Off) + default: + http.NotFound(rw, r) + return + } + if err != nil { + fail(rw, err) + return + } + writeJSON(rw, pluginsState(ctx)) + }) + // signing in to a plugin's provider: the method's questions one at a + // time, then a browser (OAuth) or a key + mux.HandleFunc("POST /api/plugin-signin/prompt", func(rw http.ResponseWriter, r *http.Request) { + var in struct { + Provider string + Method int + Inputs map[string]string + Key string // the question answered, with Value: checked first + Value string + } + if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil { + fail(rw, err) + return + } + if in.Inputs == nil { + in.Inputs = map[string]string{} + } + ctx, cancel := context.WithTimeout(r.Context(), time.Minute) + defer cancel() + if in.Key != "" { + msg, err := plugin.Validate(ctx, in.Provider, in.Method, in.Key, in.Value) + if err != nil { + fail(rw, err) + return + } + if msg != "" { + writeJSON(rw, map[string]any{"error": msg}) + return + } + in.Inputs[in.Key] = in.Value + } + q, err := plugin.NextPrompt(ctx, in.Provider, in.Method, in.Inputs) + if err != nil { + fail(rw, err) + return + } + writeJSON(rw, map[string]any{"prompt": q, "inputs": in.Inputs}) + }) + mux.HandleFunc("POST /api/plugin-signin", func(rw http.ResponseWriter, r *http.Request) { + var in struct { + Provider string + Method int + Inputs map[string]string + Key string // an "api" method's key + } + if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil { + fail(rw, err) + return + } + pp, ok := pluginProviderByID(in.Provider) + if !ok || in.Method < 0 || in.Method >= len(pp.Methods) { + fail(rw, errNoPluginMethod) + return + } + if pp.Methods[in.Method].Type == "api" { + ctx, cancel := context.WithTimeout(r.Context(), time.Minute) + defer cancel() + id, err := provider.PluginAPIKey(ctx, in.Provider, in.Method, in.Inputs, in.Key) + if err != nil { + fail(rw, err) + return + } + writeJSON(rw, provider.SignInState{Agent: id, State: "done", User: pp.Name}) + return + } + st, err := provider.StartPluginSignIn(in.Provider, in.Method, in.Inputs) + if err != nil { + fail(rw, err) + return + } + if st.URL != "" && w != nil { + w.OpenURL(st.URL) + } + writeJSON(rw, st) + }) +} + +type pluginErr string + +func (e pluginErr) Error() string { return string(e) } + +const errNoPluginMethod = pluginErr("the plugin has no such way to sign in; reopen the add sheet") + +func pluginProviderByID(id string) (plugin.Provider, bool) { + for _, pp := range plugin.Cached() { + if pp.ID == id { + return pp, true + } + } + return plugin.Provider{}, false +} diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 8fc94e5c..3c0132d4 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -165,6 +165,8 @@ type providersJSON struct { // signed in to after Codex was switched to another; "" when none is // left behind (provider.CodexDaemonStale). CodexDaemon string `json:"codexDaemon,omitempty"` + // Plugins are the providers the plugins sign in to, for the add sheet + Plugins []pluginSubJSON `json:"plugins"` // Moved is the agents the change moved off models it stopped serving // (agent.Reseat), for the page to say so. Moved []agent.Move `json:"moved,omitempty"` @@ -269,6 +271,12 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON { out.Account.Name, out.Account.Icon = "Command Code", "commandcode" } out.Account.Logins = provider.Logins(a.Agent) + if pp, ok := provider.PluginOf(p.ID); ok && p.IsPlugin() { + // a plugin's sign-in: named for the provider it signs in to, + // the page following it by the provider's id + out.Account.Agent, out.Account.Name, out.Account.Icon = p.ID, pp.Name, pluginIcon(pp.ID) + out.Account.Logins = nil + } } exposed := map[string]bool{} for _, m := range p.Exposed() { @@ -388,6 +396,7 @@ func providersState() providersJSON { s.Gateway.Running, s.Gateway.Window = gateway.Serving() } s.CodexDaemon = provider.CodexDaemonStale() + s.Plugins = pluginSubs() return s } @@ -407,6 +416,7 @@ func ago(t time.Time) string { func providerRoutes(mux *http.ServeMux, w Windows) { importAppsRoutes(mux) + pluginRoutes(mux, w) traceRoutes(mux) groupRoutes(mux) mux.HandleFunc("GET /api/providers", func(rw http.ResponseWriter, r *http.Request) { diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 4198b7b6..7a254c38 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -24,6 +24,13 @@ sign-in with pasteCallback shows) in a narrow Chinese dark window: invalid input remains editable, retry reaches the callback route, and a pending or accepted submission cannot be submitted twice. +`plugin-signin.test.cjs` checks a provider an OpenCode plugin signs in to: +the add sheet lists it under "From plugins"; its sign-in asks the way, the +method's questions (a pick, then a text the plugin checks), then takes the +code the browser page shows; an API-key way opens the account. Settings → +Plugins lists the plugins, why one didn't load, and adds one. English and +Chinese, Chromium and WebKit, with the API faked. + `panel-fold.test.cjs` expands and collapses on the Agents page with the list scrolled to its end, in the tray panel (one agent open) and in the window: "Show {n} more" unrolls the rest under the button, the view going down with @@ -264,7 +271,7 @@ window, whose title bar has them; shown in the Mac window. With Node.js and Playwright available: ```sh -node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs +node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs ``` If Playwright is installed outside the repository, set `NODE_PATH` to the diff --git a/internal/gui/tests/plugin-signin.test.cjs b/internal/gui/tests/plugin-signin.test.cjs new file mode 100644 index 00000000..4dabe6ec --- /dev/null +++ b/internal/gui/tests/plugin-signin.test.cjs @@ -0,0 +1,146 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// A provider an OpenCode plugin signs in to: the add sheet lists it under +// "From plugins"; its sign-in asks the way to sign in, the method's +// questions (a pick, then a text the plugin checks), then opens the +// browser and takes the code its page shows; an "api" way takes a key and +// opens the account. Settings → Plugins lists the plugins, why one didn't +// load, and adds one. English and Chinese; 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 plugin = { id: "fakeco", pid: "fakeco", name: "FakeCo", icon: "generic", spec: "opencode-fakeco-auth", signedIn: false, models: 3, + methods: [{ type: "api", label: "API key" }, { type: "oauth", label: "Browser sign-in" }] }; + +function server(lang, asked) { + let signedIn = false; + 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") { + const providers = [{ id: "openai", name: "OpenAI", icon: "openai", preset: "openai", models: [], agents: [], key: { set: true, masked: "sk-…ab12" } }]; + if (signedIn) providers.push({ id: "fakeco", name: "FakeCo", icon: "", models: [], agents: [], key: {}, account: { agent: "fakeco", agentName: "FakeCo", agentIcon: "generic", user: "API key" } }); + return json({ providers, presets: [], excluded: [], gateway: { running: true, window: true }, plugins: [{ ...plugin, signedIn }] }); + } + if (url.pathname === "/api/groups") return json({ groups: [], pools: [] }); + if (url.pathname === "/api/gateway/trace") return json({ routes: [] }); + if (url.pathname === "/api/plugin-signin/prompt") { + const b = body(); + asked.push(["prompt", b]); + const inputs = { ...(b.inputs || {}) }; + if (b.key === "team" && !b.value) return json({ error: "Required" }); + if (b.key) inputs[b.key] = b.value; + if (b.method === 1 && !inputs.where) return json({ prompt: { type: "select", key: "where", message: "Where do you work?", options: [{ label: "Home", value: "home" }, { label: "Work", value: "work" }] }, inputs }); + if (b.method === 1 && inputs.where === "work" && !inputs.team) return json({ prompt: { type: "text", key: "team", message: "Your team", placeholder: "blue" }, inputs }); + return json({ prompt: null, inputs }); + } + if (url.pathname === "/api/plugin-signin") { + const b = body(); + asked.push(["signin", b]); + if (b.key) { signedIn = true; return json({ agent: "fakeco", state: "done", user: "FakeCo" }); } + return json({ id: "p1", agent: "fakeco", state: "waiting", url: "https://fake.test/auth", pasteCode: true, instructions: "Paste the code FakeCo shows." }); + } + if (url.pathname === "/api/signin/p1/callback") { asked.push(["code", body()]); return route.fulfill({ status: 204 }); } + if (url.pathname === "/api/signin/p1") return json({ id: "p1", agent: "fakeco", state: "waiting", url: "https://fake.test/auth", pasteCode: true, instructions: "Paste the code FakeCo shows." }); + if (url.pathname === "/api/plugins") return json({ bun: true, bunVersion: "1.3.0", plugins: [ + { spec: "opencode-fakeco-auth", providers: ["FakeCo"] }, + { spec: "opencode-broken", error: "Cannot find module 'x'", providers: [] }, + ] }); + if (url.pathname === "/api/plugins/add") { asked.push(["add", body()]); return json({ bun: true, plugins: [{ spec: "opencode-fakeco-auth", providers: ["FakeCo"] }, { spec: body().spec, providers: ["Other"] }] }); } + if (url.pathname.startsWith("/api/")) return json({}); + 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: "" }); } + }; +} + +const L = { + en: { section: "From plugins", how: "How do you sign in to FakeCo?", next: "Next", code: "Code", finish: "Finish sign-in", key: "FakeCo API key", signIn: "Sign in", + plugins: "Plugins", failed: /Didn't load: Cannot find module/, signs: "Signs in to FakeCo", add: "Add" }, + zh: { section: "来自插件", how: "用哪种方式登录 FakeCo?", next: "下一步", code: "验证码", finish: "完成登录", key: "FakeCo API Key", signIn: "登录", + plugins: "插件", failed: /没有加载成功:Cannot find module/, signs: "可登录 FakeCo", add: "添加" }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": a plugin's provider", async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + for (const lang of ["en", "zh"]) { + await t.test(lang, async () => { + const w = L[lang]; + const page = await (await browser.newContext({ viewport: { width: 900, height: 800 } })).newPage(); + page.setDefaultTimeout(5000); + const errors = [], asked = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server(lang, asked)); + await page.goto("http://magpie.test/?view=providers"); + await page.locator("#addProvider").click(); + const sheet = page.locator("#addSheet"); + await sheet.locator(".kind b", { hasText: w.section }).waitFor(); + const row = sheet.locator('.tile[data-pick="FakeCo"]'); + assert.match(await row.getAttribute("title"), /opencode-fakeco-auth/); + + // the browser way: a pick, a text the plugin checks, then the code + await row.click(); + const box = sheet.locator(".signing"); + await box.locator(".n", { hasText: w.how }).waitFor(); + if (process.env.ARTIFACT_DIR) await sheet.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-method-${engine}-${lang}.png`) }); + assert.equal(asked.length, 0, "nothing asked before the way is picked"); + await box.locator("button", { hasText: "Browser sign-in" }).click(); + await box.locator(".n", { hasText: "Where do you work?" }).waitFor(); + await box.locator("button", { hasText: "Work" }).click(); + const team = box.getByRole("textbox", { name: "Your team" }); + await team.waitFor(); + if (process.env.ARTIFACT_DIR) await box.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-prompt-${engine}-${lang}.png`) }); + await box.locator("button", { hasText: w.next }).click(); + await box.locator(".why", { hasText: "Required" }).waitFor(); + await team.fill("blue"); + await team.press("Enter"); + await box.locator(".s", { hasText: "Paste the code FakeCo shows." }).waitFor(); + const signin = asked.find(([k]) => k === "signin")[1]; + assert.deepEqual(signin, { provider: "fakeco", method: 1, inputs: { where: "work", team: "blue" } }); + if (process.env.ARTIFACT_DIR) await box.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-code-${engine}-${lang}.png`) }); + const code = box.getByRole("textbox", { name: w.code }); + await code.fill("abc123"); + await box.locator("button", { hasText: w.finish }).click(); + for (let i = 0; i < 50 && !asked.some(([k]) => k === "code"); i++) await page.waitForTimeout(50); + assert.deepEqual(asked.find(([k]) => k === "code")[1], { url: "abc123" }); + + // the key way: the account opens once it's in + await box.locator("button.text:not(.primary)").last().click(); + await row.click(); + await box.locator("button", { hasText: "API key" }).click(); + const key = box.getByLabel(w.key); + await key.waitFor(); + assert.equal(await key.getAttribute("type"), "password"); + await key.fill("k1"); + await box.locator("button", { hasText: w.signIn }).click(); + await page.waitForFunction(() => editing === "fakeco"); + assert.deepEqual(asked.filter(([k]) => k === "signin").pop()[1], { provider: "fakeco", method: 0, inputs: {}, key: "k1" }); + + // Settings → Plugins + await page.goto("http://magpie.test/?view=settings"); + const list = page.locator("#pluginsList"); + await list.locator(".sub", { hasText: w.signs }).waitFor(); + await list.locator(".sub.bad", { hasText: w.failed }).waitFor(); + await list.getByRole("textbox").fill("opencode-other-auth"); + await list.locator("button", { hasText: new RegExp("^" + w.add + "$") }).click(); + await list.locator(".name", { hasText: "opencode-other-auth" }).waitFor(); + assert.deepEqual(asked.find(([k]) => k === "add")[1], { spec: "opencode-other-auth" }); + if (process.env.ARTIFACT_DIR) { + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + await list.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugins-${engine}-${lang}.png`) }); + } + assert.deepEqual(errors, []); + }); + } + }); +} diff --git a/internal/plugin/bun.go b/internal/plugin/bun.go new file mode 100644 index 00000000..e8f16d25 --- /dev/null +++ b/internal/plugin/bun.go @@ -0,0 +1,189 @@ +package plugin + +import ( + "archive/zip" + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/yetone/magpie/internal/catalog" +) + +// BunVersion is the Bun magpie downloads to run plugins with, the first +// time one is needed. +const BunVersion = "1.3.14" + +// bunRelease is where Bun's releases are; a var for tests. +var bunRelease = "https://github.com/oven-sh/bun/releases/download" + +var bunMu sync.Mutex + +// bunTarget is the name of Bun's build for this machine: bun-darwin-aarch64 +// and the like. x64 Linux and Windows get the baseline build, which runs +// on CPUs without AVX2 as well. +func bunTarget() (string, error) { + arch := map[string]string{"arm64": "aarch64", "amd64": "x64"}[runtime.GOARCH] + if arch == "" { + return "", fmt.Errorf("Bun has no build for %s/%s", runtime.GOOS, runtime.GOARCH) + } + switch runtime.GOOS { + case "darwin", "linux": + case "windows": + if arch != "x64" { + return "", fmt.Errorf("Bun has no build for windows/%s", runtime.GOARCH) + } + default: + return "", fmt.Errorf("Bun has no build for %s", runtime.GOOS) + } + t := "bun-" + runtime.GOOS + "-" + arch + if arch == "x64" && runtime.GOOS != "darwin" { + t += "-baseline" + } + return t, nil +} + +// bunDir is where the downloaded Bun is kept. +func bunDir() string { + return filepath.Join(filepath.Dir(catalog.CachePath()), "bun", BunVersion) +} + +func bunExe() string { + if runtime.GOOS == "windows" { + return "bun.exe" + } + return "bun" +} + +// Bun is the bun to run plugins with: $MAGPIE_BUN when set, else the one +// magpie downloaded, downloading it now when there is none yet. +func Bun(ctx context.Context) (string, error) { + if b := os.Getenv("MAGPIE_BUN"); b != "" { + return b, nil + } + bunMu.Lock() + defer bunMu.Unlock() + exe := filepath.Join(bunDir(), bunExe()) + if _, err := os.Stat(exe); err == nil { + return exe, nil + } + if err := downloadBun(ctx, exe); err != nil { + return "", fmt.Errorf("downloading Bun %s to run plugins: %w", BunVersion, err) + } + return exe, nil +} + +// HasBun is whether a Bun is at hand without a download. +func HasBun() bool { + if os.Getenv("MAGPIE_BUN") != "" { + return true + } + _, err := os.Stat(filepath.Join(bunDir(), bunExe())) + return err == nil +} + +func downloadBun(ctx context.Context, exe string) error { + target, err := bunTarget() + if err != nil { + return err + } + base := bunRelease + "/bun-v" + BunVersion + "/" + sums, err := getURL(ctx, base+"SHASUMS256.txt", 1<<20) + if err != nil { + return err + } + want := "" + sc := bufio.NewScanner(strings.NewReader(string(sums))) + for sc.Scan() { + f := strings.Fields(sc.Text()) + if len(f) == 2 && f[1] == target+".zip" { + want = f[0] + } + } + if want == "" { + return fmt.Errorf("%s.zip isn't in the release's checksums", target) + } + z, err := getURL(ctx, base+target+".zip", 200<<20) + if err != nil { + return err + } + sum := sha256.Sum256(z) + if hex.EncodeToString(sum[:]) != want { + return errors.New("the download's checksum doesn't match the release's") + } + zr, err := zip.NewReader(bytes.NewReader(z), int64(len(z))) + if err != nil { + return err + } + for _, f := range zr.File { + if filepath.Base(f.Name) != bunExe() || f.FileInfo().IsDir() { + continue + } + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + if err := os.MkdirAll(filepath.Dir(exe), 0o755); err != nil { + return err + } + tmp := exe + ".part" + out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) + if err != nil { + return err + } + if _, err := io.Copy(out, rc); err != nil { + out.Close() + os.Remove(tmp) + return err + } + if err := out.Close(); err != nil { + return err + } + return os.Rename(tmp, exe) + } + return fmt.Errorf("%s.zip has no %s", target, bunExe()) +} + +func getURL(ctx context.Context, url string, limit int64) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %s", url, res.Status) + } + b, err := io.ReadAll(io.LimitReader(res.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > limit { + return nil, fmt.Errorf("%s: too large", url) + } + return b, nil +} + +// bunCommand runs bun with args in dir, the environment's proxy settings +// passed on. +var bunCommand = func(ctx context.Context, bun, dir string, args ...string) *exec.Cmd { + cmd := command(ctx, bun, args...) + cmd.Dir = dir + cmd.Env = append(env(), "BUN_INSTALL_CACHE_DIR="+filepath.Join(filepath.Dir(catalog.CachePath()), "bun", "install-cache")) + return cmd +} diff --git a/internal/plugin/host.go b/internal/plugin/host.go new file mode 100644 index 00000000..44fe0752 --- /dev/null +++ b/internal/plugin/host.go @@ -0,0 +1,498 @@ +package plugin + +import ( + "bufio" + "context" + "crypto/sha256" + _ "embed" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "sync" + "sync/atomic" + "time" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/settings" +) + +//go:embed host.js +var hostJS []byte + +// message is a line the host writes. +type message struct { + ID int64 `json:"id"` + Result json.RawMessage `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + Event string `json:"event"` + Status int `json:"status"` + Headers map[string]string `json:"headers"` + Data string `json:"data"` + Provider string `json:"provider"` + Level string `json:"level"` + Message string `json:"message"` + Title string `json:"title"` + Variant string `json:"variant"` +} + +type call struct { + done chan message // the answer + events chan message // a fetch's head and chunks, before its answer + gone chan struct{} // closed when no one reads events any more + closed atomic.Bool // done has been answered +} + +// host is one Bun process running host.js. +type host struct { + cmd *exec.Cmd + in io.WriteCloser + wmu sync.Mutex + mu sync.Mutex + calls map[int64]*call + next int64 + dead chan struct{} + err error + loaded []Loaded +} + +// Loaded is how a plugin fared when the host loaded it. +type Loaded struct { + Spec string `json:"spec"` + Error string `json:"error,omitempty"` +} + +var ( + hostMu sync.Mutex + current *host + // generation counts the hosts started, so a caller can tell a + // restart happened (the provider cache is then stale) + generation atomic.Int64 + // onChange are told a sign-in or the plugins changed. + onChange []func() + onChangeMu sync.Mutex +) + +// OnChange registers f to be told when a plugin sign-in or the plugins +// change (a sign-in saved or refreshed, a plugin added). +func OnChange(f func()) { + onChangeMu.Lock() + onChange = append(onChange, f) + onChangeMu.Unlock() +} + +func changed() { + forgetProviders() + onChangeMu.Lock() + fs := append([]func(){}, onChange...) + onChangeMu.Unlock() + for _, f := range fs { + go f() + } +} + +// Restart stops the host, so the next call starts one with the plugins +// as they are now. +func Restart() { + hostMu.Lock() + h := current + current = nil + hostMu.Unlock() + if h != nil { + h.stop() + } + changed() +} + +// Running is whether a host is up. +func Running() bool { + hostMu.Lock() + defer hostMu.Unlock() + return current != nil && current.alive() +} + +func (h *host) alive() bool { + select { + case <-h.dead: + return false + default: + return true + } +} + +func (h *host) stop() { + h.in.Close() + select { + case <-h.dead: + case <-time.After(2 * time.Second): + if h.cmd.Process != nil { + h.cmd.Process.Kill() + } + } +} + +// hostFile is host.js written where Bun can run it. +func hostFile() (string, error) { + sum := sha256.Sum256(hostJS) + dir := filepath.Join(filepath.Dir(catalog.CachePath()), "plugin-host") + p := filepath.Join(dir, "host-"+hex.EncodeToString(sum[:6])+".js") + if b, err := os.ReadFile(p); err == nil && string(b) == string(hostJS) { + return p, nil + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + tmp := p + ".tmp" + if err := os.WriteFile(tmp, hostJS, 0o644); err != nil { + return "", err + } + return p, os.Rename(tmp, p) +} + +// get is the running host, started (Bun downloaded, the plugins loaded) +// when there is none. +func get(ctx context.Context) (*host, error) { + hostMu.Lock() + defer hostMu.Unlock() + if current != nil && current.alive() { + return current, nil + } + h, err := start(ctx) + if err != nil { + return nil, err + } + current = h + return h, nil +} + +func start(ctx context.Context) (*host, error) { + bun, err := Bun(ctx) + if err != nil { + return nil, err + } + js, err := hostFile() + if err != nil { + return nil, err + } + if err := os.MkdirAll(settings.Dir(), 0o700); err != nil { + return nil, err + } + // the host outlives the request that started it + cmd := bunCommand(context.Background(), bun, settings.Dir(), "run", js) + in, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + out, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + h := &host{cmd: cmd, in: in, calls: map[int64]*call{}, dead: make(chan struct{})} + go func() { + sc := bufio.NewScanner(stderr) + sc.Buffer(make([]byte, 64<<10), 4<<20) + for sc.Scan() { + log.Printf("plugin: %s", sc.Text()) + } + }() + go h.read(out) + generation.Add(1) + + l := Load() + type item struct { + Spec string `json:"spec"` + Target string `json:"target"` + Options map[string]any `json:"options,omitempty"` + } + var items []item + for _, e := range l.Plugins { + if !e.Off { + items = append(items, item{e.Spec, Target(e.Spec), e.Options}) + } + } + var res struct { + Plugins []Loaded `json:"plugins"` + } + ictx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + err = h.call(ictx, "init", map[string]any{ + "authPath": AuthPath(), + "modelsDevPath": catalog.Source(), + "directory": settings.Dir(), + "config": l.Config, + "plugins": items, + }, &res) + if err != nil { + h.stop() + return nil, fmt.Errorf("starting plugins: %w", err) + } + h.loaded = res.Plugins + for _, p := range res.Plugins { + if p.Error != "" { + log.Printf("plugin %s didn't load: %s", p.Spec, p.Error) + } + } + return h, nil +} + +func (h *host) read(out io.Reader) { + r := bufio.NewReaderSize(out, 1<<20) + for { + line, err := r.ReadBytes('\n') + if len(line) > 0 { + var m message + if json.Unmarshal(line, &m) == nil { + h.dispatch(m) + } + } + if err != nil { + break + } + } + err := h.cmd.Wait() + h.mu.Lock() + if err == nil { + err = errors.New("the plugin host quit") + } + h.err = fmt.Errorf("the plugin host quit: %v", err) + calls := h.calls + h.calls = map[int64]*call{} + h.mu.Unlock() + close(h.dead) + for _, c := range calls { + if c.closed.CompareAndSwap(false, true) { + c.done <- message{Error: &struct { + Message string `json:"message"` + }{h.err.Error()}} + } + } +} + +func (h *host) dispatch(m message) { + if m.ID == 0 { + switch m.Event { + case "auth": + changed() + case "toast": + log.Printf("plugin: %s %s", m.Title, m.Message) + case "log": + log.Printf("plugin [%s]: %s", m.Level, m.Message) + } + return + } + h.mu.Lock() + c := h.calls[m.ID] + if m.Event == "" { + delete(h.calls, m.ID) + } + h.mu.Unlock() + if c == nil { + return + } + if m.Event != "" { + if c.events != nil { + select { + case c.events <- m: + case <-c.gone: + } + } + return + } + if c.closed.CompareAndSwap(false, true) { + c.done <- m + } +} + +func (h *host) send(v any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + h.wmu.Lock() + defer h.wmu.Unlock() + _, err = h.in.Write(append(b, '\n')) + return err +} + +func (h *host) begin(events bool) (int64, *call) { + c := &call{done: make(chan message, 1), gone: make(chan struct{})} + if events { + c.events = make(chan message, 64) + } + h.mu.Lock() + h.next++ + id := h.next + h.calls[id] = c + h.mu.Unlock() + return id, c +} + +func (h *host) forget(id int64) { + h.mu.Lock() + delete(h.calls, id) + h.mu.Unlock() +} + +// call asks the host method with params and reads its answer into out. +func (h *host) call(ctx context.Context, method string, params, out any) error { + id, c := h.begin(false) + if err := h.send(map[string]any{"id": id, "method": method, "params": params}); err != nil { + h.forget(id) + return err + } + select { + case m := <-c.done: + if m.Error != nil { + return errors.New(m.Error.Message) + } + if out != nil && len(m.Result) > 0 { + return json.Unmarshal(m.Result, out) + } + return nil + case <-ctx.Done(): + h.forget(id) + return ctx.Err() + } +} + +// Call starts the host if need be and asks it method. +func Call(ctx context.Context, method string, params, out any) error { + h, err := get(ctx) + if err != nil { + return err + } + return h.call(ctx, method, params, out) +} + +// Plugins is how each plugin fared when the host loaded it, starting the +// host if need be. +func Plugins(ctx context.Context) ([]Loaded, error) { + h, err := get(ctx) + if err != nil { + return nil, err + } + return h.loaded, nil +} + +// FetchRequest is a request a provider's plugin makes. +type FetchRequest struct { + Provider string `json:"provider"` + Model string `json:"model"` + NPM string `json:"npm"` + URL string `json:"url"` + Method string `json:"method"` + Headers map[string]string `json:"headers"` + Body []byte `json:"-"` + Session string `json:"session,omitempty"` +} + +// Fetch sends r through the provider's plugin — its loader's fetch, or +// Bun's own when it gives none — and streams back the reply. +func Fetch(ctx context.Context, r FetchRequest) (*http.Response, error) { + h, err := get(ctx) + if err != nil { + return nil, err + } + id, c := h.begin(true) + params := struct { + FetchRequest + Body string `json:"body,omitempty"` + }{r, base64.StdEncoding.EncodeToString(r.Body)} + if err := h.send(map[string]any{"id": id, "method": "fetch", "params": params}); err != nil { + h.forget(id) + return nil, err + } + abort := func() { _ = h.send(map[string]any{"method": "abort", "params": map[string]any{"id": id}}) } + var head message + select { + case head = <-c.events: + case m := <-c.done: + if m.Error != nil { + return nil, errors.New(m.Error.Message) + } + return nil, errors.New("the plugin answered no response") + case <-ctx.Done(): + abort() + h.forget(id) + return nil, ctx.Err() + } + pr, pw := io.Pipe() + res := &http.Response{ + StatusCode: head.Status, + Status: fmt.Sprintf("%d %s", head.Status, http.StatusText(head.Status)), + Header: http.Header{}, + Body: pr, + Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, + } + for k, v := range head.Headers { + res.Header.Set(k, v) + } + // the body is decoded already: fetch takes the Content-Encoding off + res.Header.Del("Content-Encoding") + res.Header.Del("Content-Length") + go func() { + defer close(c.gone) + write := func(data string) error { + b, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return err + } + _, err = pw.Write(b) + return err + } + for { + select { + case m := <-c.events: + if err := write(m.Data); err != nil { + // the reader went away, or the chunk is garbled + abort() + h.forget(id) + pw.CloseWithError(err) + return + } + case m := <-c.done: + // the chunks came before the answer: any still queued + // are written first + drain: + for { + select { + case e := <-c.events: + if write(e.Data) != nil { + break drain + } + default: + break drain + } + } + if m.Error != nil { + pw.CloseWithError(errors.New(m.Error.Message)) + } else { + pw.Close() + } + return + case <-ctx.Done(): + abort() + h.forget(id) + pw.CloseWithError(ctx.Err()) + return + } + } + }() + return res, nil +} diff --git a/internal/plugin/host.js b/internal/plugin/host.js new file mode 100644 index 00000000..98543ea0 --- /dev/null +++ b/internal/plugin/host.js @@ -0,0 +1,653 @@ +// magpie's plugin host: runs OpenCode (v1) server plugins under Bun and +// answers magpie over stdin/stdout, one JSON message a line. +// +// magpie → host: {id, method, params}; the host answers {id, result} or +// {id, error}. A fetch streams its reply first: {id, event: "head", status, +// headers}, then {id, event: "chunk", data} (base64) as the body comes, then +// {id, result: null}. {method: "abort", params: {id}} cancels one. +// host → magpie, unasked: {event: "log", level, message} and {event: +// "toast", ...} (what a plugin logs or shows), {event: "auth", provider} +// (a sign-in the host saved or refreshed). +// +// The plugins get what OpenCode hands them (PluginInput): a client whose +// auth.set saves to magpie's plugin-auth.json in OpenCode's auth.json shape, +// tui.showToast and app.log, config.get; Bun's $; the folder magpie keeps +// its files in. What else a plugin asks the client for answers {data: +// undefined}. + +import fs from "node:fs" +import path from "node:path" +import readline from "node:readline" +import { pathToFileURL } from "node:url" + +const rpcWrite = process.stdout.write.bind(process.stdout) +const send = (msg) => rpcWrite(JSON.stringify(msg) + "\n") + +// A plugin writing to stdout would break the protocol: everything it +// prints goes to stderr, which magpie logs. +const toErr = (...xs) => process.stderr.write(xs.map((x) => (typeof x === "string" ? x : Bun.inspect(x))).join(" ") + "\n") +console.log = console.info = console.debug = console.warn = toErr +process.stdout.write = (chunk, enc, cb) => process.stderr.write(chunk, enc, cb) + +let authPath = "" +let modelsDevPath = "" +let directory = process.cwd() +let userConfig = {} +const hooks = [] // {spec, hooks} +const loaded = [] // {spec, id, error} +const loaders = new Map() // provider → options the auth loader returned +const sessions = new Map() // oauth sign-in in progress → its authorize result +const inflight = new Map() // fetch id → AbortController +let config = { provider: {} } // what the plugins' config hooks made of it + +// ---- auth.json --------------------------------------------------------------- + +function readAuth() { + try { + const v = JSON.parse(fs.readFileSync(authPath, "utf8")) + return v && typeof v === "object" ? v : {} + } catch { + return {} + } +} + +function writeAuth(all) { + fs.mkdirSync(path.dirname(authPath), { recursive: true }) + const tmp = authPath + ".tmp-" + process.pid + fs.writeFileSync(tmp, JSON.stringify(all, null, 2) + "\n", { mode: 0o600 }) + fs.renameSync(tmp, authPath) +} + +function setAuth(id, info) { + const all = readAuth() + all[id] = info + writeAuth(all) + loaders.delete(id) + send({ event: "auth", provider: id }) +} + +function removeAuth(id) { + const all = readAuth() + delete all[id] + writeAuth(all) + loaders.delete(id) + send({ event: "auth", provider: id }) +} + +// ---- the client plugins are given -------------------------------------------- + +function stub(name) { + return new Proxy(async () => ({ data: undefined }), { + get(_, key) { + if (key === "then") return undefined + return stub(name + "." + String(key)) + }, + apply() { + return Promise.resolve({ data: undefined }) + }, + }) +} + +function makeClient() { + const known = { + auth: { + // OpenCode's SDK: auth.set({path: {id}, body}) + set: async (opts) => { + const id = opts?.path?.id ?? opts?.id ?? opts?.providerID + const body = opts?.body ?? opts?.auth + if (typeof id === "string" && body && typeof body === "object") { + // OpenCode keeps what the plugin gives it, saving refreshed + // tokens over the old; nothing is merged + const prev = readAuth()[id] + if (JSON.stringify(prev) !== JSON.stringify(body)) setAuth(id, body) + } + return { data: true } + }, + remove: async (opts) => { + const id = opts?.path?.id ?? opts?.id + if (typeof id === "string") removeAuth(id) + return { data: true } + }, + }, + tui: { + showToast: async (opts) => { + const b = opts?.body ?? opts ?? {} + send({ event: "toast", title: b.title ?? "", message: String(b.message ?? ""), variant: b.variant ?? "info" }) + return { data: true } + }, + }, + app: { + log: async (opts) => { + const b = opts?.body ?? opts ?? {} + send({ event: "log", level: b.level ?? "info", message: `[${b.service ?? "plugin"}] ${b.message ?? ""}` }) + return { data: true } + }, + }, + config: { + get: async () => ({ data: config }), + }, + } + const wrap = (obj, name) => + new Proxy(obj, { + get(target, key) { + if (key in target) { + const v = target[key] + return v && typeof v === "object" ? wrap(v, name + "." + String(key)) : v + } + if (key === "then") return undefined + return stub(name + "." + String(key)) + }, + }) + return wrap(known, "client") +} + +// ---- loading plugins --------------------------------------------------------- + +const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"] + +function readJSON(file) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")) + } catch { + return undefined + } +} + +// entries are the files a plugin's server side may load from, as +// OpenCode looks for them: the package's exports["./server"], its main, +// exports["."], else an index file; a path to a file is that file. A +// package whose ./server is written for OpenCode's next plugin API +// (Plugin.define, opencode-gemini-auth 2) keeps its v1 plugin at main. +function entries(target) { + const stat = fs.statSync(target, { throwIfNoEntry: false }) + if (!stat) throw new Error(`no such plugin: ${target}`) + if (!stat.isDirectory()) return [target] + const out = [] + const add = (v) => typeof v === "string" && v.trim() && out.push(path.resolve(target, v.trim())) + const pick = (x) => (typeof x === "string" ? x : x?.import ?? x?.default) + const pkg = readJSON(path.join(target, "package.json")) + if (pkg) { + const ex = pkg.exports && typeof pkg.exports === "object" && !Array.isArray(pkg.exports) ? pkg.exports : undefined + if (ex) add(pick(ex["./server"])) + add(pkg.main) + if (ex) add(pick(ex["."])) + if (typeof pkg.exports === "string") add(pkg.exports) + } + for (const f of INDEX_FILES) { + const p = path.join(target, f) + if (fs.existsSync(p)) out.push(p) + } + if (out.length === 0) throw new Error(`plugin ${target} has no entry (package.json main, exports or index file)`) + return [...new Set(out)] +} + +// servers are the plugin functions a module exports: default {id?, +// server} (v1), else every function it exports, or {server} objects +// (legacy), each once. +function servers(mod) { + const d = mod.default + if (d && typeof d === "object" && ("server" in d || "id" in d || "tui" in d)) { + return typeof d.server === "function" ? [d.server] : [] + } + const seen = new Set() + const out = [] + for (const v of Object.values(mod)) { + if (seen.has(v)) continue + seen.add(v) + if (typeof v === "function") out.push(v) + else if (v && typeof v === "object" && typeof v.server === "function") out.push(v.server) + } + return out +} + +async function loadPlugins(list) { + const input = { + client: makeClient(), + project: { id: "magpie", worktree: directory, vcs: undefined, time: { created: Date.now() } }, + directory, + worktree: directory, + experimental_workspace: { register() {} }, + serverUrl: new URL("http://127.0.0.1:4096"), + $: Bun.$, + } + for (const p of list) { + try { + let fns = [] + for (const file of entries(p.target)) { + fns = servers(await import(pathToFileURL(file).href)) + if (fns.length) break + } + if (fns.length === 0) throw new Error("exports no OpenCode plugin function") + for (const fn of fns) hooks.push({ spec: p.spec, hooks: (await fn(input, p.options)) ?? {} }) + loaded.push({ spec: p.spec }) + } catch (e) { + loaded.push({ spec: p.spec, error: String(e?.stack ?? e) }) + } + } + config = { provider: {}, ...structuredClone(userConfig) } + for (const h of hooks) { + if (typeof h.hooks.config !== "function") continue + try { + await h.hooks.config(config) + } catch (e) { + send({ event: "log", level: "error", message: `${h.spec}: config hook: ${e?.message ?? e}` }) + } + } +} + +// auths are the auth hooks by provider, the last plugin to name one +// winning, as in OpenCode. +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, auth: h.hooks.auth }) + return m +} + +function authOf(provider) { + const a = auths().get(provider) + if (!a) throw new Error(`no plugin signs in to ${provider}`) + return a.auth +} + +// ---- providers and models ---------------------------------------------------- + +let modelsDev +function mdev() { + if (modelsDev === undefined) modelsDev = (modelsDevPath && readJSON(modelsDevPath)) || {} + return modelsDev +} + +function cost(c) { + return { + input: c?.input ?? 0, + output: c?.output ?? 0, + cache: { read: c?.cache_read ?? c?.cache?.read ?? 0, write: c?.cache_write ?? c?.cache?.write ?? 0 }, + } +} + +// fromModelsDev is a models.dev model as OpenCode's provider list has it. +function fromModelsDev(p, m) { + return { + id: m.id, + providerID: p.id, + name: m.name ?? m.id, + family: m.family, + api: { id: m.id, url: m.provider?.api ?? p.api ?? "", npm: m.provider?.npm ?? p.npm ?? "@ai-sdk/openai-compatible" }, + status: m.status ?? "active", + headers: {}, + options: {}, + cost: cost(m.cost), + limit: { context: m.limit?.context ?? 0, input: m.limit?.input, output: m.limit?.output ?? 0 }, + capabilities: { + temperature: m.temperature ?? false, + reasoning: m.reasoning ?? false, + attachment: m.attachment ?? false, + toolcall: m.tool_call ?? true, + input: { + text: true, + image: (m.modalities?.input ?? []).includes("image"), + audio: (m.modalities?.input ?? []).includes("audio"), + video: (m.modalities?.input ?? []).includes("video"), + pdf: (m.modalities?.input ?? []).includes("pdf"), + }, + output: { text: true, image: false, audio: false, video: false, pdf: false }, + interleaved: m.interleaved ?? false, + }, + release_date: m.release_date ?? "", + variants: {}, + } +} + +// info is the provider as OpenCode builds it: models.dev's entry, what +// the config (with the plugins' config hooks) says of it, the plugin's +// provider.models hook. +async function info(id) { + const md = mdev()[id] + const cfg = config.provider?.[id] + const out = { + id, + name: cfg?.name ?? md?.name ?? id, + source: md ? "api" : "config", + env: cfg?.env ?? md?.env ?? [], + options: { ...(cfg?.options ?? {}) }, + npm: cfg?.npm ?? md?.npm, + api: cfg?.api ?? md?.api, + models: {}, + } + if (md) for (const m of Object.values(md.models ?? {})) out.models[m.id] = fromModelsDev(md, m) + for (const [key, m] of Object.entries(cfg?.models ?? {})) { + const was = out.models[m.id ?? key] + const npm = m.provider?.npm ?? cfg.npm ?? was?.api.npm ?? md?.npm ?? "@ai-sdk/openai-compatible" + out.models[key] = { + ...(was ?? {}), + id: key, + providerID: id, + name: m.name ?? was?.name ?? key, + api: { id: m.id ?? was?.api.id ?? key, url: m.provider?.api ?? cfg.api ?? was?.api.url ?? md?.api ?? "", npm }, + cost: m.cost ? cost(m.cost) : was?.cost ?? cost(), + limit: { ...(was?.limit ?? { context: 0, output: 0 }), ...(m.limit ?? {}) }, + options: { ...(was?.options ?? {}), ...(m.options ?? {}) }, + headers: { ...(was?.headers ?? {}), ...(m.headers ?? {}) }, + capabilities: { + ...(was?.capabilities ?? { temperature: true, toolcall: true, input: { text: true }, output: { text: true } }), + ...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}), + ...(m.attachment !== undefined ? { attachment: m.attachment } : {}), + ...(m.tool_call !== undefined ? { toolcall: m.tool_call } : {}), + ...(m.modalities?.input ? { input: Object.fromEntries(["text", "image", "audio", "video", "pdf"].map((k) => [k, m.modalities.input.includes(k)])) } : {}), + }, + variants: m.variants ?? was?.variants ?? {}, + } + } + for (const h of hooks) { + const ph = h.hooks.provider + if (ph?.id !== id || typeof ph.models !== "function") continue + try { + const next = await ph.models(JSON.parse(JSON.stringify(out)), { auth: readAuth()[id] }) + out.models = Object.fromEntries(Object.entries(next ?? {}).map(([k, m]) => [k, { ...m, id: k, providerID: id }])) + } catch (e) { + send({ event: "log", level: "error", message: `${h.spec}: provider.models: ${e?.message ?? e}` }) + } + } + for (const [k, m] of Object.entries(cfg?.models ?? {})) if (m?.disabled) delete out.models[k] + return out +} + +function methods(auth) { + return (auth.methods ?? []).map((m) => ({ type: m.type, label: m.label })) +} + +async function providers() { + const stored = readAuth() + const out = [] + for (const [id, a] of auths()) { + const p = await info(id) + out.push({ + id, + spec: a.spec, + name: p.name, + npm: p.npm ?? "", + api: p.api ?? "", + methods: methods(a.auth), + signedIn: !!stored[id], + authType: stored[id]?.type ?? "", + accountId: stored[id]?.accountId ?? stored[id]?.metadata?.email ?? stored[id]?.email ?? "", + models: Object.values(p.models) + .filter((m) => m.status !== "deprecated") + .map((m) => ({ + id: m.id, + name: m.name, + npm: m.api?.npm ?? p.npm ?? "", + url: m.api?.url ?? "", + apiId: m.api?.id ?? m.id, + context: m.limit?.context ?? 0, + input: m.limit?.input ?? 0, + output: m.limit?.output ?? 0, + reasoning: !!m.capabilities?.reasoning, + image: !!m.capabilities?.input?.image, + released: m.release_date ?? "", + cost: m.cost, + variants: Object.keys(m.variants ?? {}), + })), + }) + } + return out +} + +// ---- signing in -------------------------------------------------------------- + +function applies(prompt, inputs) { + if (prompt.when) { + const v = inputs[prompt.when.key] + if (v === undefined) return false + const eq = v === prompt.when.value + if (prompt.when.op === "eq" ? !eq : eq) return false + } + if (typeof prompt.condition === "function" && !prompt.condition(inputs)) return false + return true +} + +// nextPrompt is the method's next question for inputs so far, as +// OpenCode's CLI asks them: in order, those whose when/condition hold. +function nextPrompt(provider, index, inputs) { + const m = authOf(provider).methods[index] + if (!m) throw new Error(`no sign-in method ${index} for ${provider}`) + for (const p of m.prompts ?? []) { + if (p.key in inputs) continue + if (!applies(p, inputs)) continue + return { + type: p.type, + key: p.key, + message: p.message, + placeholder: p.placeholder ?? "", + options: p.type === "select" ? p.options.map((o) => ({ label: o.label, value: o.value, hint: o.hint ?? "" })) : undefined, + } + } + return null +} + +function validate(provider, index, key, value) { + const p = (authOf(provider).methods[index]?.prompts ?? []).find((x) => x.key === key) + if (!p || typeof p.validate !== "function") return null + return p.validate(value) ?? null +} + +// save keeps a successful sign-in as OpenCode's CLI does. +function save(provider, result, inputs, apiKey) { + const id = result.provider ?? provider + if ("refresh" in result) { + const { type: _t, provider: _p, refresh, access, expires, ...extra } = result + setAuth(id, { type: "oauth", refresh, access, expires, ...extra }) + } else if ("key" in result || apiKey) { + const md = { ...(inputs && Object.keys(inputs).length ? inputs : {}), ...(result.metadata ?? {}) } + setAuth(id, { type: "api", key: result.key ?? apiKey, ...(Object.keys(md).length ? { metadata: md } : {}) }) + } + return id +} + +let nextSession = 1 + +async function authorize({ provider, method, inputs }) { + const m = authOf(provider).methods[method] + if (!m) throw new Error(`no sign-in method ${method} for ${provider}`) + if (m.type !== "oauth") throw new Error("not an oauth method") + const a = await m.authorize(inputs ?? {}) + const session = String(nextSession++) + sessions.set(session, { provider, a, inputs }) + return { session, url: a.url ?? "", instructions: a.instructions ?? "", method: a.method } +} + +async function callback({ session, code }) { + const s = sessions.get(session) + if (!s) throw new Error("no such sign-in") + sessions.delete(session) + const r = s.a.method === "code" ? await s.a.callback(code ?? "") : await s.a.callback() + if (!r || r.type !== "success") return { ok: false } + return { ok: true, provider: save(s.provider, r, undefined) } +} + +async function apiKey({ provider, method, inputs, key }) { + const m = authOf(provider).methods[method] + if (!m || m.type !== "api") throw new Error("not an API key method") + if (typeof m.authorize !== "function") { + const md = inputs && Object.keys(inputs).length ? { metadata: inputs } : {} + setAuth(provider, { type: "api", key, ...md }) + return { ok: true, provider } + } + const r = await m.authorize(inputs ?? {}) + if (!r || r.type !== "success") return { ok: false } + return { ok: true, provider: save(provider, r, inputs, key) } +} + +// ---- requests ---------------------------------------------------------------- + +// options is what the provider's auth loader returned, run once per +// sign-in as OpenCode runs it once per start. +async function options(provider) { + if (loaders.has(provider)) return loaders.get(provider) + const a = auths().get(provider)?.auth + const stored = readAuth()[provider] + let opts = {} + if (a?.loader && stored) { + const p = await info(provider) + opts = (await a.loader(async () => readAuth()[provider], JSON.parse(JSON.stringify(p)))) ?? {} + } + if (!opts.apiKey && stored?.type === "api") opts = { ...opts, apiKey: stored.key } + loaders.set(provider, opts) + return opts +} + +async function load({ provider }) { + const o = await options(provider) + return { + baseURL: typeof o.baseURL === "string" ? o.baseURL : "", + apiKey: typeof o.apiKey === "string" ? o.apiKey : "", + headers: o.headers && typeof o.headers === "object" ? o.headers : {}, + fetch: typeof o.fetch === "function", + } +} + +// sdkHeaders are what the AI SDK package the model is on sends of the key. +function sdkHeaders(npm, key) { + if (!key) return {} + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") return { "x-api-key": key } + if (npm === "@ai-sdk/google") return { "x-goog-api-key": key } + return { authorization: `Bearer ${key}` } +} + +async function doFetch(id, { provider, model, npm, url, method, headers, body, session }) { + const ctl = new AbortController() + inflight.set(id, ctl) + try { + const o = await options(provider) + const h = new Headers() + for (const [k, v] of Object.entries(sdkHeaders(npm, o.apiKey))) h.set(k, v) + for (const [k, v] of Object.entries(o.headers ?? {})) h.set(k, String(v)) + for (const [k, v] of Object.entries(headers ?? {})) h.set(k, v) + // chat.headers: what the plugins add to the request, theirs winning + const p = await info(provider) + const m = p.models[model] ?? { id: model, providerID: provider, api: { id: model, npm } } + for (const x of hooks) { + const fn = x.hooks["chat.headers"] + if (typeof fn !== "function") continue + const out = { headers: {} } + try { + await fn( + { + sessionID: session ?? "", + agent: "build", + model: m, + provider: { source: "custom", info: p, options: o }, + message: { id: "", sessionID: session ?? "", role: "user", time: { created: Date.now() }, agent: "build", model: { providerID: provider, modelID: model } }, + }, + out, + ) + } catch (e) { + send({ event: "log", level: "error", message: `${x.spec}: chat.headers: ${e?.message ?? e}` }) + } + for (const [k, v] of Object.entries(out.headers)) h.set(k, String(v)) + } + const f = typeof o.fetch === "function" ? o.fetch : fetch + const res = await f(url, { + method: method ?? "POST", + headers: h, + body: body ? Buffer.from(body, "base64") : undefined, + signal: ctl.signal, + }) + const rh = {} + res.headers.forEach((v, k) => (rh[k] = v)) + send({ id, event: "head", status: res.status, headers: rh }) + if (res.body) { + for await (const chunk of res.body) { + send({ id, event: "chunk", data: Buffer.from(chunk).toString("base64") }) + } + } + send({ id, result: null }) + } catch (e) { + send({ id, error: { message: String(e?.message ?? e) } }) + } finally { + inflight.delete(id) + } +} + +// ---- the loop ---------------------------------------------------------------- + +const handlers = { + async init(p) { + authPath = p.authPath + modelsDevPath = p.modelsDevPath ?? "" + directory = p.directory ?? directory + userConfig = p.config ?? {} + await loadPlugins(p.plugins ?? []) + return { plugins: loaded } + }, + providers: () => providers(), + prompt: (p) => ({ prompt: nextPrompt(p.provider, p.method, p.inputs ?? {}) }), + validate: (p) => ({ error: validate(p.provider, p.method, p.key, p.value) }), + authorize, + callback, + apiKey, + load, + signOut(p) { + removeAuth(p.provider) + return null + }, + reload(p) { + loaders.delete(p.provider) + return null + }, +} + +// Everything waits for init; a request is answered even when magpie has +// closed stdin behind it, the host leaving once nothing is pending. +let ready +let pending = 0 +let closed = false +const done = () => { + if (--pending === 0 && closed) process.exit(0) +} + +const rl = readline.createInterface({ input: process.stdin, terminal: false }) +rl.on("line", (line) => { + if (!line.trim()) return + let msg + try { + msg = JSON.parse(line) + } catch { + return + } + if (msg.method === "abort") { + inflight.get(msg.params?.id)?.abort() + return + } + pending++ + if (msg.method === "init") { + ready = handlers.init(msg.params ?? {}) + ready.then( + (result) => send({ id: msg.id, result }), + (e) => send({ id: msg.id, error: { message: String(e?.message ?? e) } }), + ).finally(done) + return + } + const wait = ready ?? Promise.reject(new Error("the host has not been initialised")) + if (msg.method === "fetch") { + wait.then(() => doFetch(msg.id, msg.params ?? {}), (e) => send({ id: msg.id, error: { message: String(e?.message ?? e) } })).finally(done) + return + } + const fn = handlers[msg.method] + if (!fn) { + send({ id: msg.id, error: { message: `no method ${msg.method}` } }) + done() + return + } + wait + .then(() => fn(msg.params ?? {})) + .then( + (result) => send({ id: msg.id, result: result ?? null }), + (e) => send({ id: msg.id, error: { message: String(e?.message ?? e) } }), + ) + .finally(done) +}) +rl.on("close", () => { + closed = true + if (pending === 0) process.exit(0) +}) diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go new file mode 100644 index 00000000..c561e211 --- /dev/null +++ b/internal/plugin/plugin_test.go @@ -0,0 +1,168 @@ +package plugin + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// sandbox gives the test its own magpie folders and the Bun on PATH; a +// machine without Bun skips it. +func sandbox(t *testing.T) { + t.Helper() + bun, err := exec.LookPath("bun") + if err != nil { + t.Skip("no bun on PATH") + } + dir := t.TempDir() + t.Setenv("HOME", 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.Cleanup(Settle) +} + +func TestFakePlugin(t *testing.T) { + sandbox(t) + var seen []http.Header + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.Header.Clone()) + b, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "text/event-stream") + fl := w.(http.Flusher) + fmt.Fprintf(w, "data: %s %s\n\n", r.URL.Path, b) + fl.Flush() + time.Sleep(50 * time.Millisecond) + fmt.Fprint(w, "data: [DONE]\n\n") + })) + defer srv.Close() + t.Setenv("FAKE_BASE", srv.URL+"/v1") + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + abs, _ := filepath.Abs("testdata/fake/index.js") + if _, err := Add(ctx, abs); err != nil { + t.Fatal(err) + } + ls, err := Plugins(ctx) + if err != nil || len(ls) != 1 || ls[0].Error != "" { + t.Fatalf("Plugins = %+v, %v", ls, err) + } + ps, err := Providers(ctx) + if err != nil { + t.Fatal(err) + } + if len(ps) != 1 || ps[0].ID != "fakeco" || ps[0].Name != "FakeCo" || ps[0].SignedIn || len(ps[0].Methods) != 2 { + t.Fatalf("Providers = %+v", ps) + } + npm := map[string]string{} + for _, m := range ps[0].Models { + npm[m.ID] = m.NPM + } + if npm["fake-1"] != "@ai-sdk/openai-compatible" || npm["fake-claude"] != "@ai-sdk/anthropic" || npm["fake-gemini"] != "@ai-sdk/google" { + t.Fatalf("models = %+v", ps[0].Models) + } + + // the browser method asks where, then a team only for work + p, err := NextPrompt(ctx, "fakeco", 1, map[string]string{}) + if err != nil || p == nil || p.Key != "where" || len(p.Options) != 2 { + t.Fatalf("first prompt = %+v, %v", p, err) + } + if p, _ := NextPrompt(ctx, "fakeco", 1, map[string]string{"where": "home"}); p != nil { + t.Fatalf("home asked %+v", p) + } + if p, _ := NextPrompt(ctx, "fakeco", 1, map[string]string{"where": "work"}); p == nil || p.Key != "team" { + t.Fatalf("work asked %+v", p) + } + if e, _ := Validate(ctx, "fakeco", 1, "team", ""); e != "Required" { + t.Fatalf("Validate = %q", e) + } + in := map[string]string{"where": "work", "team": "blue"} + a, err := Authorize(ctx, "fakeco", 1, in) + if err != nil || a.Method != "code" || !strings.Contains(a.URL, "where=work") { + t.Fatalf("Authorize = %+v, %v", a, err) + } + if _, err := Finish(ctx, a.Session, "bad"); err != ErrFailed { + t.Fatalf("a bad code: %v", err) + } + a, _ = Authorize(ctx, "fakeco", 1, in) + if got, err := Finish(ctx, a.Session, "good"); err != nil || got != "fakeco" { + t.Fatalf("Finish = %q, %v", got, err) + } + var saved map[string]map[string]any + b, _ := os.ReadFile(AuthPath()) + json.Unmarshal(b, &saved) + if saved["fakeco"]["type"] != "oauth" || saved["fakeco"]["refresh"] != "r-blue" || saved["fakeco"]["accountId"] != "me@fake" { + t.Fatalf("saved %v", saved) + } + if fi, _ := os.Stat(AuthPath()); fi.Mode().Perm() != 0o600 { + t.Fatalf("plugin-auth.json is %v", fi.Mode().Perm()) + } + if !SignedIn("fakeco") { + t.Fatal("not signed in") + } + + o, err := LoaderOptions(ctx, "fakeco") + if err != nil || o.BaseURL != srv.URL+"/v1" || !o.Fetch || o.APIKey != "dummy" { + t.Fatalf("LoaderOptions = %+v, %v", o, err) + } + res, err := Fetch(ctx, FetchRequest{ + Provider: "fakeco", Model: "fake-1", NPM: "@ai-sdk/openai-compatible", + URL: o.BaseURL + "/chat/completions", Method: "POST", + Headers: map[string]string{"content-type": "application/json"}, + Body: []byte(`{"model":"fake-1"}`), + }) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil || res.StatusCode != 200 || res.Header.Get("Content-Type") != "text/event-stream" { + t.Fatalf("Fetch = %d %v %v", res.StatusCode, res.Header, err) + } + if want := "data: /v1/chat/completions {\"model\":\"fake-1\"}\n\ndata: [DONE]\n\n"; string(body) != want { + t.Fatalf("body = %q", body) + } + h := seen[len(seen)-1] + // the plugin refreshed the stale token and saved it; the chat.headers + // hook added its header; the plugin saw the provider's models + if h.Get("Authorization") != "Bearer fresh" || h.Get("X-Plugin-Model") != "fake-1" || h.Get("X-Models") != "fake-1,fake-claude,fake-gemini" { + t.Fatalf("headers = %v", h) + } + b, _ = os.ReadFile(AuthPath()) + json.Unmarshal(b, &saved) + if saved["fakeco"]["access"] != "fresh" { + t.Fatalf("refresh not saved: %v", saved) + } + + // a request that is abandoned is aborted, and the host still answers + cctx, ccancel := context.WithCancel(ctx) + res, err = Fetch(cctx, FetchRequest{Provider: "fakeco", Model: "fake-1", URL: o.BaseURL + "/chat/completions", Method: "POST", Body: []byte(`{}`)}) + if err != nil { + t.Fatal(err) + } + ccancel() + io.ReadAll(res.Body) + if _, err := Providers(ctx); err != nil { + t.Fatal(err) + } + + if err := SignOut(ctx, "fakeco"); err != nil || SignedIn("fakeco") { + t.Fatalf("SignOut: %v", err) + } + if err := SetOff(abs, true); err != nil { + t.Fatal(err) + } + if ps := Cached(); len(ps) != 0 { + t.Fatalf("a plugin turned off still lists %+v", ps) + } +} diff --git a/internal/plugin/providers.go b/internal/plugin/providers.go new file mode 100644 index 00000000..706ba061 --- /dev/null +++ b/internal/plugin/providers.go @@ -0,0 +1,325 @@ +package plugin + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" + + "github.com/yetone/magpie/internal/settings" +) + +// Method is a way a plugin signs in: "oauth" (a browser, then a code +// pasted back or not) or "api" (a key). +type Method struct { + Type string `json:"type"` + Label string `json:"label"` +} + +// Model is a model a plugin's provider serves, as OpenCode lists it. +type Model struct { + ID string `json:"id"` + Name string `json:"name"` + NPM string `json:"npm"` // the AI SDK package it is spoken to with + URL string `json:"url"` + APIID string `json:"apiId"` + Context int `json:"context"` + Input int `json:"input"` + Output int `json:"output"` + Reasoning bool `json:"reasoning"` + Image bool `json:"image"` + Released string `json:"released"` + Variants []string `json:"variants"` + Cost *struct { + Input float64 `json:"input"` + Output float64 `json:"output"` + } `json:"cost"` +} + +// Provider is a provider a plugin signs in to. +type Provider struct { + ID string `json:"id"` // OpenCode's: google, github-copilot + Spec string `json:"spec"` // the plugin + Name string `json:"name"` + NPM string `json:"npm"` + API string `json:"api"` + Methods []Method `json:"methods"` + SignedIn bool `json:"signedIn"` + AuthType string `json:"authType"` + AccountID string `json:"accountId"` + Models []Model `json:"models"` +} + +var ( + provMu sync.Mutex + provCache []Provider + provGood bool +) + +func providersPath() string { return filepath.Join(settings.Dir(), "plugin-providers.json") } + +func forgetProviders() { + provMu.Lock() + provGood = false + provMu.Unlock() +} + +// Providers asks the plugins for their providers, starting the host if +// need be, and keeps the answer for Cached. +func Providers(ctx context.Context) ([]Provider, error) { + var ps []Provider + if err := Call(ctx, "providers", nil, &ps); err != nil { + return nil, err + } + provMu.Lock() + provCache, provGood = ps, true + provMu.Unlock() + if b, err := json.Marshal(ps); err == nil { + _ = os.WriteFile(providersPath(), b, 0o600) + } + return ps, nil +} + +// refreshing is Cached's refreshes in the background. +var refreshing sync.WaitGroup + +// Settle waits for Cached's refreshes to end, then stops the host: for +// tests, whose folders the host runs in go when they end. +func Settle() { + refreshing.Wait() + Restart() +} + +// Cached is the plugins' providers as last asked, without starting the +// host: what is known of them when magpie has only just started. A +// provider's sign-in is read afresh from plugin-auth.json. +func Cached() []Provider { + provMu.Lock() + ps := provCache + good := provGood + provMu.Unlock() + if ps == nil { + if b, err := os.ReadFile(providersPath()); err == nil { + _ = json.Unmarshal(b, &ps) + } + } + if len(Load().Plugins) == 0 { + return nil + } + auth := readAuth() + out := make([]Provider, 0, len(ps)) + on := map[string]bool{} + for _, e := range Load().Plugins { + if !e.Off { + on[e.Spec] = true + } + } + for _, p := range ps { + if !on[p.Spec] { + continue + } + a, ok := auth[p.ID] + p.SignedIn = ok + p.AuthType = "" + if ok { + p.AuthType = a.Type + if a.AccountID != "" { + p.AccountID = a.AccountID + } + } + out = append(out, p) + } + if !good && len(Load().Plugins) > 0 { + // refreshed in the background: a sign-in or the plugins changed + refreshing.Add(1) + go func() { + defer refreshing.Done() + if Running() || HasBun() { + _, _ = Providers(context.Background()) + } + }() + provMu.Lock() + provGood = true // one refresh at a time; a failure is retried on the next change + provMu.Unlock() + } + return out +} + +type storedAuth struct { + Type string `json:"type"` + AccountID string `json:"accountId"` +} + +func readAuth() map[string]storedAuth { + var m map[string]storedAuth + if b, err := os.ReadFile(AuthPath()); err == nil { + _ = json.Unmarshal(b, &m) + } + return m +} + +// SignedIn is whether a plugin sign-in is kept for provider. +func SignedIn(provider string) bool { + _, ok := readAuth()[provider] + return ok +} + +// Prompt is a question a sign-in method asks before it starts. +type Prompt struct { + Type string `json:"type"` // text, select + Key string `json:"key"` + Message string `json:"message"` + Placeholder string `json:"placeholder,omitempty"` + Options []struct { + Label string `json:"label"` + Value string `json:"value"` + Hint string `json:"hint,omitempty"` + } `json:"options,omitempty"` +} + +// NextPrompt is the method's next question given the answers so far; nil +// when it has asked them all. +func NextPrompt(ctx context.Context, provider string, method int, inputs map[string]string) (*Prompt, error) { + var r struct { + Prompt *Prompt `json:"prompt"` + } + err := Call(ctx, "prompt", map[string]any{"provider": provider, "method": method, "inputs": inputs}, &r) + return r.Prompt, err +} + +// Validate is what the method says is wrong with value as the answer to +// key, "" when nothing is. +func Validate(ctx context.Context, provider string, method int, key, value string) (string, error) { + var r struct { + Error *string `json:"error"` + } + if err := Call(ctx, "validate", map[string]any{"provider": provider, "method": method, "key": key, "value": value}, &r); err != nil { + return "", err + } + if r.Error == nil { + return "", nil + } + return *r.Error, nil +} + +// Authorization is an OAuth sign-in begun: the page to open, and whether +// the plugin waits for it itself ("auto") or needs the code the page +// shows pasted back ("code"). +type Authorization struct { + Session string `json:"session"` + URL string `json:"url"` + Instructions string `json:"instructions"` + Method string `json:"method"` +} + +// Authorize begins an OAuth sign-in. +func Authorize(ctx context.Context, provider string, method int, inputs map[string]string) (Authorization, error) { + var a Authorization + err := Call(ctx, "authorize", map[string]any{"provider": provider, "method": method, "inputs": inputs}, &a) + return a, err +} + +// ErrFailed is a sign-in the plugin says failed. +var ErrFailed = errors.New("the sign-in failed") + +// Finish waits for an OAuth sign-in to finish: an "auto" one on its own, +// a "code" one with the code pasted back. It gives the provider the +// sign-in was saved for. +func Finish(ctx context.Context, session, code string) (string, error) { + var r struct { + OK bool `json:"ok"` + Provider string `json:"provider"` + } + if err := Call(ctx, "callback", map[string]any{"session": session, "code": code}, &r); err != nil { + return "", err + } + if !r.OK { + return "", ErrFailed + } + return r.Provider, nil +} + +// APIKey signs in with a key, as an "api" method does. +func APIKey(ctx context.Context, provider string, method int, inputs map[string]string, key string) (string, error) { + var r struct { + OK bool `json:"ok"` + Provider string `json:"provider"` + } + if err := Call(ctx, "apiKey", map[string]any{"provider": provider, "method": method, "inputs": inputs, "key": key}, &r); err != nil { + return "", err + } + if !r.OK { + return "", ErrFailed + } + return r.Provider, nil +} + +// SignOut forgets the provider's sign-in. +func SignOut(ctx context.Context, provider string) error { + if Running() { + return Call(ctx, "signOut", map[string]any{"provider": provider}, nil) + } + var m map[string]json.RawMessage + b, err := os.ReadFile(AuthPath()) + if err != nil { + return nil + } + if err := json.Unmarshal(b, &m); err != nil { + return err + } + delete(m, provider) + b, _ = json.MarshalIndent(m, "", " ") + if err := os.WriteFile(AuthPath(), append(b, '\n'), 0o600); err != nil { + return err + } + changed() + return nil +} + +// Options is what the provider's auth loader gave: where its requests +// go, the key the AI SDK would send, and whether it carries them itself. +type Options struct { + BaseURL string `json:"baseURL"` + APIKey string `json:"apiKey"` + Headers map[string]string `json:"headers"` + Fetch bool `json:"fetch"` +} + +var ( + optMu sync.Mutex + optCache = map[string]Options{} + optGen int64 +) + +// LoaderOptions runs the provider's auth loader (once per sign-in and +// host) and gives what it returned. +func LoaderOptions(ctx context.Context, provider string) (Options, error) { + optMu.Lock() + if optGen != generation.Load() { + optCache, optGen = map[string]Options{}, generation.Load() + } + o, ok := optCache[provider] + optMu.Unlock() + if ok { + return o, nil + } + if err := Call(ctx, "load", map[string]any{"provider": provider}, &o); err != nil { + return Options{}, err + } + optMu.Lock() + if optGen == generation.Load() { + optCache[provider] = o + } + optMu.Unlock() + return o, nil +} + +func init() { + OnChange(func() { + optMu.Lock() + optCache = map[string]Options{} + optMu.Unlock() + }) +} diff --git a/internal/plugin/store.go b/internal/plugin/store.go new file mode 100644 index 00000000..118b2d6a --- /dev/null +++ b/internal/plugin/store.go @@ -0,0 +1,270 @@ +// Package plugin runs OpenCode's provider plugins for magpie. +// +// A subscription a vendor may object to being used outside its own app is +// not built into magpie: a plugin signs in to it and makes its requests, +// the way OpenCode's plugins do (opencode-gemini-auth and the like), and +// magpie runs those same plugins. They are npm packages (or local files) +// written against OpenCode's plugin API (@opencode-ai/plugin, v1): their +// auth hook gives the sign-in methods and a loader whose fetch carries the +// requests, their config and provider hooks the models. magpie runs them +// under Bun, downloaded the first time a plugin is added, in one process +// (host.js) it talks to over stdin and stdout. +// +// What magpie keeps: plugins.json, the plugins and the config handed to +// them (OpenCode's, its provider section: a plugin's options); the npm +// packages under plugins/; plugin-auth.json, the sign-ins, in OpenCode's +// auth.json shape (so one can be moved between the two). +package plugin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + + "github.com/yetone/magpie/internal/netproxy" + "github.com/yetone/magpie/internal/proc" + "github.com/yetone/magpie/internal/settings" +) + +// Entry is one plugin the user added. +type Entry struct { + // Spec is how it was added: an npm package (name, name@version) or a + // path to a file or folder. + Spec string `json:"spec"` + // Off is set on a plugin the user turned off. + Off bool `json:"off,omitempty"` + // Options are handed to the plugin as its second argument, as + // OpenCode's ["name", {options}] does. + Options map[string]any `json:"options,omitempty"` +} + +// List is plugins.json. +type List struct { + Plugins []Entry `json:"plugins"` + // Config is OpenCode's config the plugins are handed: its provider + // section says what a plugin reads of it + // (provider.google.options.projectId). + Config map[string]any `json:"config,omitempty"` +} + +var listMu sync.Mutex + +// Dir is the folder magpie installs npm plugins into. +func Dir() string { return filepath.Join(settings.Dir(), "plugins") } + +// AuthPath is the plugins' sign-ins, OpenCode's auth.json in shape. +func AuthPath() string { return filepath.Join(settings.Dir(), "plugin-auth.json") } + +func listPath() string { return filepath.Join(settings.Dir(), "plugins.json") } + +// Load reads plugins.json. +func Load() List { + var l List + if b, err := os.ReadFile(listPath()); err == nil { + _ = json.Unmarshal(b, &l) + } + return l +} + +func save(l List) error { + b, err := json.MarshalIndent(l, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(settings.Dir(), 0o700); err != nil { + return err + } + return os.WriteFile(listPath(), append(b, '\n'), 0o600) +} + +// IsPath is whether spec names a file or folder rather than a package, +// as OpenCode tells them apart. +func IsPath(spec string) bool { + return strings.HasPrefix(spec, "file://") || strings.HasPrefix(spec, ".") || filepath.IsAbs(spec) || regexp.MustCompile(`^[A-Za-z]:[\\/]`).MatchString(spec) +} + +var pkgName = regexp.MustCompile(`^(@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*$`) + +// Name is the package spec names, without its version: "@scope/x" of +// "@scope/x@1.2", or the path. +func Name(spec string) string { + if IsPath(spec) { + return spec + } + at := strings.LastIndex(spec, "@") + if at > 0 { + return spec[:at] + } + return spec +} + +// Target is where the plugin is to load from: the installed package's +// folder, or the path. +func Target(spec string) string { + if IsPath(spec) { + p := strings.TrimPrefix(spec, "file://") + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return p + } + return filepath.Join(Dir(), "node_modules", filepath.FromSlash(Name(spec))) +} + +// Add installs a plugin and adds it to the list, in place of one of the +// same package. A package is installed with its scripts left unrun. +func Add(ctx context.Context, spec string) (Entry, error) { + spec = strings.TrimSpace(spec) + if spec == "" { + return Entry{}, errors.New("no plugin given") + } + if IsPath(spec) { + t := Target(spec) + if _, err := os.Stat(t); err != nil { + return Entry{}, err + } + spec = t + } else { + if !pkgName.MatchString(Name(spec)) { + return Entry{}, fmt.Errorf("%q isn't an npm package name", spec) + } + if Name(spec) == spec { + spec += "@latest" + } + if err := install(ctx, spec); err != nil { + return Entry{}, err + } + } + listMu.Lock() + defer listMu.Unlock() + l := Load() + e := Entry{Spec: spec} + if i := slices.IndexFunc(l.Plugins, func(x Entry) bool { return Name(x.Spec) == Name(spec) }); i >= 0 { + e.Options = l.Plugins[i].Options + l.Plugins[i] = e + } else { + l.Plugins = append(l.Plugins, e) + } + if err := save(l); err != nil { + return Entry{}, err + } + Restart() + return e, nil +} + +// Update installs the version of each npm plugin its spec says now +// (latest, for the most part). +func Update(ctx context.Context) error { + var errs []error + for _, e := range Load().Plugins { + if IsPath(e.Spec) { + continue + } + if err := install(ctx, e.Spec); err != nil { + errs = append(errs, fmt.Errorf("%s: %w", e.Spec, err)) + } + } + Restart() + return errors.Join(errs...) +} + +// 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 }) + 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) + err := save(l) + listMu.Unlock() + if err != nil { + return err + } + if !IsPath(e.Spec) { + if bun, err := Bun(ctx); err == nil { + _ = bunCommand(ctx, bun, Dir(), "remove", "--ignore-scripts", Name(e.Spec)).Run() + } + } + Restart() + return nil +} + +// SetOff turns a plugin off or back on. +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 }) + if i < 0 { + return fmt.Errorf("no plugin %q", name) + } + l.Plugins[i].Off = off + if err := save(l); err != nil { + return err + } + Restart() + return nil +} + +// SetConfig sets the OpenCode config the plugins are handed. +func SetConfig(cfg map[string]any) error { + listMu.Lock() + defer listMu.Unlock() + l := Load() + l.Config = cfg + if err := save(l); err != nil { + return err + } + Restart() + return nil +} + +// install puts an npm package into plugins/ with bun add, its scripts +// left unrun as OpenCode leaves them. +func install(ctx context.Context, spec string) error { + bun, err := Bun(ctx) + if err != nil { + return err + } + if err := os.MkdirAll(Dir(), 0o755); err != nil { + return err + } + pj := filepath.Join(Dir(), "package.json") + if _, err := os.Stat(pj); err != nil { + if err := os.WriteFile(pj, []byte("{\n \"name\": \"magpie-plugins\",\n \"private\": true\n}\n"), 0o644); err != nil { + return err + } + } + out, err := bunCommand(ctx, bun, Dir(), "add", "--ignore-scripts", spec).CombinedOutput() + if err != nil { + return fmt.Errorf("bun add %s: %v: %s", spec, err, lastLines(string(out), 6)) + } + return nil +} + +func lastLines(s string, n int) string { + ls := strings.Split(strings.TrimSpace(s), "\n") + if len(ls) > n { + ls = ls[len(ls)-n:] + } + return strings.Join(ls, "\n") +} + +func command(ctx context.Context, name string, args ...string) *exec.Cmd { + return proc.CommandContext(ctx, name, args...) +} + +// env is the plugins' environment: magpie's, with its proxy. +func env() []string { return netproxy.Env(os.Environ()) } diff --git a/internal/plugin/testdata/fake/index.js b/internal/plugin/testdata/fake/index.js new file mode 100644 index 00000000..b55ad7e7 --- /dev/null +++ b/internal/plugin/testdata/fake/index.js @@ -0,0 +1,58 @@ +// A plugin as OpenCode's are written, signing in to a made-up provider +// whose requests go to $FAKE_BASE. +export const FakePlugin = async ({ client }) => ({ + config: async (cfg) => { + cfg.provider = cfg.provider ?? {} + cfg.provider.fakeco = { + name: "FakeCo", + npm: "@ai-sdk/openai-compatible", + api: "https://fake.invalid/v1", + models: { + "fake-1": { name: "Fake One", limit: { context: 1000, output: 100 } }, + "fake-claude": { name: "Fake Claude", provider: { npm: "@ai-sdk/anthropic" }, limit: { context: 2000, output: 200 } }, + "fake-gemini": { name: "Fake Gemini", provider: { npm: "@ai-sdk/google" }, reasoning: true, limit: { context: 3000, output: 300 } }, + }, + } + }, + auth: { + provider: "fakeco", + methods: [ + { type: "api", label: "API key" }, + { + type: "oauth", + label: "Browser", + prompts: [ + { type: "select", key: "where", message: "Where?", options: [{ label: "Home", value: "home" }, { label: "Work", value: "work" }] }, + { type: "text", key: "team", message: "Team?", when: { key: "where", op: "eq", value: "work" }, validate: (v) => (v ? undefined : "Required") }, + ], + authorize: async (inputs) => ({ + url: "https://fake.invalid/auth?where=" + inputs.where, + instructions: "Paste the code", + method: "code", + callback: async (code) => + code === "good" + ? { type: "success", refresh: "r-" + (inputs.team ?? "none"), access: "stale", expires: 0, accountId: "me@fake" } + : { type: "failed" }, + }), + }, + ], + loader: async (getAuth, provider) => ({ + apiKey: "dummy", + baseURL: process.env.FAKE_BASE, + async fetch(url, init) { + let a = await getAuth() + if (a.type === "oauth" && a.expires < Date.now()) { + a = { ...a, access: "fresh", expires: Date.now() + 3600e3 } + await client.auth.set({ path: { id: "fakeco" }, body: a }) + } + const h = new Headers(init.headers) + h.set("authorization", "Bearer " + (a.type === "oauth" ? a.access : a.key)) + h.set("x-models", Object.keys(provider.models).sort().join(",")) + return fetch(url, { ...init, headers: h }) + }, + }), + }, + "chat.headers": async (input, output) => { + if (input.model.providerID === "fakeco") output.headers["x-plugin-model"] = input.model.id + }, +}) diff --git a/internal/provider/account.go b/internal/provider/account.go index c0544b56..174419ae 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -31,6 +31,7 @@ import ( "time" "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/plugin" "github.com/yetone/magpie/internal/proc" ) @@ -78,6 +79,11 @@ type Account struct { // explain adds what the user can do about a refusal the account's // backend answered, "" when there is nothing to add (factory.go). explain func(status int, body []byte) string + + // plugin is set on a plugin's provider (plugins.go), and transport + // carries its requests: the plugin's fetch. + plugin *plugin.Provider + transport func(req *http.Request) (*http.Response, error) } // APIs lists the APIs model is served on, as the provider's last model @@ -85,6 +91,9 @@ type Account struct { // Claude models on Chat and Anthropic's. nil is not known, and every API // the provider speaks may be tried. func (p Provider) APIs(model string) []Protocol { + if p.IsPlugin() { + return p.pluginAPIs(model) + } ms, _, _ := catalog.Live(p.ID) for _, m := range ms { if m.ID == model && len(m.APIs) > 0 { @@ -829,7 +838,7 @@ func Accounts() []Provider { out = append(out, p) } } - return out + return append(out, pluginAccounts()...) } func readJSON(path string, v any) bool { diff --git a/internal/provider/plugins.go b/internal/provider/plugins.go new file mode 100644 index 00000000..c159c7d5 --- /dev/null +++ b/internal/provider/plugins.go @@ -0,0 +1,317 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/plugin" +) + +// A plugin's provider (see internal/plugin) is a subscription like the +// built-in ones: signed in to through the plugin, its models the ones the +// plugin lists, its requests made by the plugin's fetch. It speaks what +// OpenCode would speak to each model, by the AI SDK package the model is +// spoken to with. + +// pluginBase is where a plugin provider's requests seem to go, for the +// gateway: its transport takes them to where the plugin says instead. +const pluginBase = "plugin://" + +// ConversationHeader carries the conversation a request belongs to, to a +// plugin's chat.headers hook as its session; it goes no further. +const ConversationHeader = "X-Magpie-Conversation" + +// PluginID is the id magpie gives the provider OpenCode calls id: the same, +// unless a preset or a built-in subscription has it (google, openai, +// anthropic), when it is id-plugin. +func PluginID(id string) string { + if slices.Contains(accountIDs, id) || Preset(id) != nil || id == "magpie" { + return id + "-plugin" + } + return id +} + +// IsPlugin is whether the provider is a plugin's. +func (p Provider) IsPlugin() bool { return p.Account != nil && p.Account.plugin != nil } + +// PluginOf is the plugin provider magpie's provider id is, when it is one. +func PluginOf(id string) (plugin.Provider, bool) { + for _, pp := range plugin.Cached() { + if PluginID(pp.ID) == id { + return pp, true + } + } + return plugin.Provider{}, false +} + +// pluginAccounts are the plugins' providers signed in to. +func pluginAccounts() []Provider { + var out []Provider + for _, pp := range plugin.Cached() { + if pp.SignedIn { + out = append(out, pluginProvider(pp)) + } + } + return out +} + +// pluginProtocol is the API model is spoken to on, as OpenCode's AI SDK +// package for it speaks: Anthropic's, OpenAI's Responses (and Copilot's +// for GPT-5 and after), Gemini's (as Code Assist's inner request), else +// chat completions. +func pluginProtocol(provider string, m plugin.Model) Protocol { + switch m.NPM { + case "@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic": + return Anthropic + case "@ai-sdk/openai", "@ai-sdk/azure": + return Responses + case "@ai-sdk/google": + return CodeAssist + case "@ai-sdk/github-copilot": + return copilotAPI(modelAPIID(m)) + } + if strings.HasPrefix(provider, "github-copilot") { + return copilotAPI(modelAPIID(m)) + } + return Chat +} + +var gptN = regexp.MustCompile(`^gpt-(\d+)`) + +// copilotAPI is OpenCode's shouldUseCopilotResponsesApi: GPT-5 and after, +// but for gpt-5-mini, on Responses. +func copilotAPI(model string) Protocol { + if m := gptN.FindStringSubmatch(model); m != nil { + if n, _ := strconv.Atoi(m[1]); n >= 5 && !strings.HasPrefix(model, "gpt-5-mini") { + return Responses + } + } + return Chat +} + +// npmBase is where the AI SDK package sends a model by default. +var npmBase = map[string]string{ + "@ai-sdk/anthropic": "https://api.anthropic.com/v1", + "@ai-sdk/openai": "https://api.openai.com/v1", + "@ai-sdk/google": "https://generativelanguage.googleapis.com/v1beta", + "@ai-sdk/github-copilot": "https://api.githubcopilot.com", + "@ai-sdk/xai": "https://api.x.ai/v1", + "@ai-sdk/mistral": "https://api.mistral.ai/v1", + "@ai-sdk/groq": "https://api.groq.com/openai/v1", + "@ai-sdk/deepseek": "https://api.deepseek.com/v1", +} + +func pluginModel(pp plugin.Provider, id string) (plugin.Model, bool) { + for _, m := range pp.Models { + if m.ID == id { + return m, true + } + } + return plugin.Model{}, false +} + +func pluginCatalog(pp plugin.Provider) []catalog.Model { + out := make([]catalog.Model, 0, len(pp.Models)) + for _, m := range pp.Models { + c := catalog.Model{ + ID: m.ID, Name: m.Name, Provider: pp.ID, Released: m.Released, + APIs: []string{string(pluginProtocol(pp.ID, m))}, Images: m.Image, + Context: m.Input, Output: m.Output, + } + if c.Context == 0 { + c.Context = m.Context + } + if c.Name == "" { + c.Name = m.ID + } + if m.Reasoning { + c.Efforts = m.Variants + } + if m.Cost != nil { + c.Price = &catalog.Price{Input: m.Cost.Input, Output: m.Cost.Output} + } + out = append(out, c) + } + return out +} + +func pluginProvider(pp plugin.Provider) Provider { + id := PluginID(pp.ID) + name := pp.Name + if name == "" { + name = pp.ID + } + user := pp.AccountID + if user == "" { + user = map[string]string{"api": "API key", "oauth": "Signed in"}[pp.AuthType] + } + a := &Account{Agent: "plugin", User: user, Stream: true, plugin: &pp} + a.models = func() []catalog.Model { + if cur, ok := PluginOf(id); ok { + return pluginCatalog(cur) + } + return pluginCatalog(pp) + } + a.fetch = func(ctx context.Context) ([]catalog.Model, error) { + ps, err := plugin.Providers(ctx) + if err != nil { + return nil, err + } + for _, cur := range ps { + if cur.ID == pp.ID { + return catalog.Chat(pluginCatalog(cur)), nil + } + } + return nil, fmt.Errorf("%s's plugin no longer lists it", name) + } + a.sign = func(ctx context.Context, req *http.Request, body []byte) error { return nil } + a.transport = func(req *http.Request) (*http.Response, error) { return pluginFetch(pp, req) } + p := Provider{ID: id, Name: name, Account: a} + for _, m := range pp.Models { + switch pluginProtocol(pp.ID, m) { + case Chat: + p.Chat = pluginBase + pp.ID + "/v1" + case Responses: + p.Responses = pluginBase + pp.ID + "/v1" + case Anthropic: + p.Anthropic = pluginBase + pp.ID + case CodeAssist: + a.codeAssist = pluginBase + pp.ID + } + } + return p +} + +// pluginAPIs is the one API a plugin provider's model is spoken to on. +func (p Provider) pluginAPIs(model string) []Protocol { + pp := p.Account.plugin + if cur, ok := PluginOf(p.ID); ok { + pp = &cur + } + if m, ok := pluginModel(*pp, model); ok { + return []Protocol{pluginProtocol(pp.ID, m)} + } + return nil +} + +// pluginFetch sends a request the gateway made for a plugin's provider +// through the plugin: to the base URL its loader gave (else the model's, +// the provider's, the AI SDK package's), with what the loader adds. +func pluginFetch(pp plugin.Provider, req *http.Request) (*http.Response, error) { + ctx := req.Context() + var body []byte + if req.Body != nil { + b, err := io.ReadAll(req.Body) + req.Body.Close() + if err != nil { + return nil, err + } + body = b + } + rest := strings.TrimPrefix(req.URL.String(), pluginBase+pp.ID) + model := bodyModel(body) + codeAssist := strings.HasPrefix(rest, "/v1internal:") + if codeAssist { + // Code Assist's envelope off: Gemini's own request, the model in + // the path, as @ai-sdk/google sends it + var env struct { + Model string `json:"model"` + Request json.RawMessage `json:"request"` + } + if err := json.Unmarshal(body, &env); err != nil { + return nil, err + } + model, body = env.Model, env.Request + } + m, ok := pluginModel(pp, model) + if !ok { + if cur, found := PluginOf(PluginID(pp.ID)); found { + pp = cur + m, ok = pluginModel(pp, model) + } + } + if !ok { + m = plugin.Model{ID: model, NPM: pp.NPM} + } + api := modelAPIID(m) + if api != model && !codeAssist { + body = withModel(body, api) + } + o, err := plugin.LoaderOptions(ctx, pp.ID) + if err != nil { + return nil, err + } + base := firstOf(o.BaseURL, m.URL, pp.API, npmBase[m.NPM]) + if base == "" { + return nil, errors.New(pp.Name + "'s plugin says nowhere to send its requests") + } + base = strings.TrimRight(base, "/") + var url string + switch { + case codeAssist: + url = base + "/models/" + api + ":streamGenerateContent?alt=sse" + case strings.HasPrefix(rest, "/v1/"): + // chat, responses and Anthropic's messages: the AI SDK's base + // ends where magpie's /v1 does + url = base + strings.TrimPrefix(rest, "/v1") + default: + url = base + rest + } + h := map[string]string{} + for k, vs := range req.Header { + if len(vs) > 0 && !strings.EqualFold(k, ConversationHeader) { + h[strings.ToLower(k)] = vs[0] + } + } + return plugin.Fetch(ctx, plugin.FetchRequest{ + Provider: pp.ID, Model: api, NPM: m.NPM, URL: url, Method: req.Method, + Headers: h, Body: body, Session: req.Header.Get(ConversationHeader), + }) +} + +func modelAPIID(m plugin.Model) string { + if m.APIID != "" { + return m.APIID + } + return m.ID +} + +func withModel(body []byte, model string) []byte { + var v map[string]json.RawMessage + if json.Unmarshal(body, &v) != nil { + return body + } + v["model"], _ = json.Marshal(model) + if b, err := json.Marshal(v); err == nil { + return b + } + return body +} + +func firstOf(ss ...string) string { + for _, s := range ss { + if s != "" { + return s + } + } + return "" +} + +// Do sends req, through the account's own transport when it has one (a +// plugin's) and client otherwise. +func (p Provider) Do(client *http.Client, req *http.Request) (*http.Response, error) { + if p.Account != nil && p.Account.transport != nil { + return p.Account.transport(req) + } + req.Header.Del(ConversationHeader) + return client.Do(req) +} diff --git a/internal/provider/pluginsignin.go b/internal/provider/pluginsignin.go new file mode 100644 index 00000000..9df42a3f --- /dev/null +++ b/internal/provider/pluginsignin.go @@ -0,0 +1,109 @@ +package provider + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/yetone/magpie/internal/plugin" +) + +// StartPluginSignIn begins signing in to a plugin's provider (OpenCode's +// id) with its OAuth method, the method's questions answered: the page to +// open, then either the plugin notices on its own or the code the page +// shows is pasted back (SubmitSignInCallback). SignInStatus follows it, +// as it does a built-in sign-in; its Agent is the provider's magpie id. +func StartPluginSignIn(id string, method int, inputs map[string]string) (SignInState, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + a, err := plugin.Authorize(ctx, id, method, inputs) + cancel() + if err != nil { + return SignInState{}, err + } + s := &signInFlow{done: make(chan struct{})} + s.st = SignInState{ID: randomToken(9), Agent: PluginID(id), State: "waiting", URL: a.URL, Instructions: a.Instructions} + wait, stop := context.WithTimeout(context.Background(), signInTimeout) + s.stop = stop + if a.Method == "code" { + s.st.PasteCode, s.plugin = true, a.Session + go func() { + <-wait.Done() + if errors.Is(wait.Err(), context.DeadlineExceeded) { + s.finish(SignInState{State: "failed", Error: "the sign-in timed out; start it again"}) + } + }() + } else { + go func() { + saved, err := plugin.Finish(wait, a.Session, "") + s.pluginDone(saved, err) + }() + } + signIns.Lock() + for sid, o := range signIns.m { + if o.st.Agent == s.st.Agent { + o.finish(SignInState{State: "canceled"}) + delete(signIns.m, sid) + } + } + signIns.m[s.st.ID] = s + signIns.Unlock() + return s.status(), nil +} + +// pluginCode finishes a plugin's sign-in with the code its page showed. +func (s *signInFlow) pluginCode(code string) error { + code = strings.TrimSpace(code) + if code == "" { + return errors.New("paste the code the sign-in page showed") + } + s.mu.Lock() + session := s.plugin + s.plugin = "" + waiting := s.st.State == "waiting" + s.mu.Unlock() + if session == "" || !waiting { + return errors.New("this sign-in is over; start it again") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + saved, err := plugin.Finish(ctx, session, code) + s.pluginDone(saved, err) + return err +} + +func (s *signInFlow) pluginDone(saved string, err error) { + switch { + case errors.Is(err, context.Canceled): + s.finish(SignInState{State: "canceled"}) + case errors.Is(err, context.DeadlineExceeded): + s.finish(SignInState{State: "failed", Error: "the sign-in timed out; start it again"}) + case err != nil: + s.finish(SignInState{State: "failed", Error: err.Error()}) + default: + s.finish(SignInState{State: "done", User: pluginUser(saved)}) + } +} + +// PluginAPIKey signs in to a plugin's provider with a key, as its "api" +// method does; it gives the provider's magpie id. +func PluginAPIKey(ctx context.Context, id string, method int, inputs map[string]string, key string) (string, error) { + if strings.TrimSpace(key) == "" { + return "", errors.New("enter the key") + } + saved, err := plugin.APIKey(ctx, id, method, inputs, strings.TrimSpace(key)) + if err != nil { + return "", err + } + _ = ShowAccount(PluginID(saved)) + return PluginID(saved), nil +} + +func pluginUser(id string) string { + for _, pp := range plugin.Cached() { + if pp.ID == id { + return pp.AccountID + } + } + return "" +} diff --git a/internal/provider/pluginsignin_test.go b/internal/provider/pluginsignin_test.go new file mode 100644 index 00000000..2c12c412 --- /dev/null +++ b/internal/provider/pluginsignin_test.go @@ -0,0 +1,94 @@ +package provider + +import ( + "context" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/yetone/magpie/internal/plugin" +) + +// A plugin's sign-in is followed like a built-in one: the page, then the +// code pasted back (a wrong one fails the sign-in, which starts again); +// done, its provider is there, and removing it signs out. A key signs in +// at once. +func TestPluginSignIn(t *testing.T) { + bun, err := exec.LookPath("bun") + if err != nil { + t.Skip("no bun on PATH") + } + claudeHome(t) + t.Setenv("MAGPIE_BUN", bun) + t.Cleanup(plugin.Settle) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + abs, _ := filepath.Abs("../plugin/testdata/fake/index.js") + if _, err := plugin.Add(ctx, abs); err != nil { + t.Fatal(err) + } + if _, err := plugin.Providers(ctx); err != nil { + t.Fatal(err) + } + + in := map[string]string{"where": "work", "team": "blue"} + wait := func(id string) SignInState { + t.Helper() + for range 100 { + if st, _ := SignInStatus(id); st.State != "waiting" { + return st + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("the sign-in is still waiting") + return SignInState{} + } + st, err := StartPluginSignIn("fakeco", 1, in) + if err != nil || st.Agent != "fakeco" || st.State != "waiting" || !st.PasteCode || !strings.Contains(st.URL, "where=work") || st.Instructions != "Paste the code" { + t.Fatalf("StartPluginSignIn = %+v, %v", st, err) + } + if err := SubmitSignInCallback(st.ID, " "); err == nil { + t.Fatal("an empty code was taken") + } + if err := SubmitSignInCallback(st.ID, "bad"); err == nil { + t.Fatal("a wrong code was taken") + } + if got := wait(st.ID); got.State != "failed" { + t.Fatalf("after a wrong code: %+v", got) + } + if err := SubmitSignInCallback(st.ID, "good"); err == nil { + t.Fatal("a failed sign-in took another code") + } + + st, _ = StartPluginSignIn("fakeco", 1, in) + if err := SubmitSignInCallback(st.ID, "good"); err != nil { + t.Fatal(err) + } + if got := wait(st.ID); got.State != "done" || got.User != "me@fake" { + t.Fatalf("after the code: %+v", got) + } + if p, err := Find("fakeco"); err != nil || !p.IsPlugin() { + t.Fatalf("Find = %+v, %v", p, err) + } + if err := Delete("fakeco"); err != nil { + t.Fatal(err) + } + if plugin.SignedIn("fakeco") { + t.Fatal("removing the account didn't sign out") + } + if _, err := Find("fakeco"); err == nil { + t.Fatal("fakeco is a provider after it was removed") + } + + if _, err := PluginAPIKey(ctx, "fakeco", 0, nil, " "); err == nil { + t.Fatal("an empty key was taken") + } + if id, err := PluginAPIKey(ctx, "fakeco", 0, nil, "k1"); err != nil || id != "fakeco" { + t.Fatalf("PluginAPIKey = %q, %v", id, err) + } + if _, err := Find("fakeco"); err != nil { + t.Fatal(err) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index aa8b37bf..f7dfb35b 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -8,6 +8,7 @@ package provider import ( + "context" "encoding/json" "errors" "fmt" @@ -17,8 +18,10 @@ import ( "regexp" "slices" "strings" + "time" "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/plugin" "github.com/yetone/magpie/internal/settings" ) @@ -536,6 +539,12 @@ func ShowAccount(id string) error { // Delete removes a provider. An account is only hidden from magpie (its // model picks kept); signing out is the agent's job. func Delete(id string) error { + if p, ok := find(Accounts(), id); ok && p.IsPlugin() { + // a plugin's sign-in is magpie's own: removing it signs out + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return plugin.SignOut(ctx, p.Account.plugin.ID) + } if _, ok := find(Accounts(), id); ok { f := load() for i := range f.Providers { @@ -709,7 +718,7 @@ func (p Provider) Base(proto Protocol) string { // Speaks lists the protocols the vendor serves natively, preferred first. func (p Provider) Speaks() []Protocol { // a Google sign-in speaks Code Assist, and only that - if p.Account != nil && p.Account.codeAssist != "" { + if p.Account != nil && p.Account.codeAssist != "" && !p.IsPlugin() { return []Protocol{CodeAssist} } var out []Protocol @@ -718,6 +727,10 @@ func (p Provider) Speaks() []Protocol { out = append(out, pr) } } + // a plugin's Gemini models, beside what else it serves + if p.IsPlugin() && p.Account.codeAssist != "" { + out = append(out, CodeAssist) + } return out } diff --git a/internal/provider/signin.go b/internal/provider/signin.go index 813f03c0..290a78a5 100644 --- a/internal/provider/signin.go +++ b/internal/provider/signin.go @@ -53,6 +53,9 @@ type SignInState struct { Code string `json:"code,omitempty"` // what to type there, for a device code State string `json:"state"` // installing, waiting, done, failed or canceled PasteCallback bool `json:"pasteCallback,omitempty"` // a callback URL can also finish this sign-in + // PasteCode is a plugin's sign-in finished by the code its page shows + PasteCode bool `json:"pasteCode,omitempty"` + Instructions string `json:"instructions,omitempty"` // a plugin's words for its page // Installing is the CLI being installed before the sign-in can start Installing string `json:"installing,omitempty"` User string `json:"user,omitempty"` // the account, once done @@ -71,6 +74,7 @@ type signInFlow struct { stop func() // ends an agent's own login command, when that is the sign-in kiro *kiroFlow site string // where to sign in, for an agent with more than one (ZCode: "zai" or "bigmodel") + plugin string // a plugin's sign-in session, finished with the code pasted back done chan struct{} } @@ -349,15 +353,19 @@ func CancelSignIn(id string) { } // SubmitSignInCallback finishes a browser sign-in whose callback could not -// reach this machine. No sign-in magpie makes now takes one (PasteCallback is -// never set); the route and the page's field stay for one that will. +// reach this machine, or a plugin's with the code its page showed. No +// built-in sign-in takes a callback now (PasteCallback is never set); the +// route and the page's field stay for one that will. func SubmitSignInCallback(id, raw string) error { signIns.Lock() - _, ok := signIns.m[id] + s, ok := signIns.m[id] signIns.Unlock() if !ok { return errors.New("no such sign-in") } + if s.plugin != "" { + return s.pluginCode(raw) + } return errors.New("this sign-in can't be finished from a pasted address") } diff --git a/internal/provider/test.go b/internal/provider/test.go index 9fa4a234..b7e3ff77 100644 --- a/internal/provider/test.go +++ b/internal/provider/test.go @@ -287,7 +287,7 @@ func probe(ctx context.Context, p Provider, proto Protocol, url string, body []b return r } start := time.Now() - res, err := http.DefaultClient.Do(req) + res, err := p.Do(http.DefaultClient, req) r.Millis = time.Since(start).Milliseconds() if err != nil { r.Error = strings.TrimPrefix(err.Error(), "Post \""+url+"\": ") diff --git a/main.go b/main.go index 6fce6c44..96a4d755 100644 --- a/main.go +++ b/main.go @@ -74,6 +74,8 @@ const usage = `magpie — one place to pick every agent's model magpie accounts refresh renew the saved Claude and ChatGPT sign-ins now (the gateway does it daily) magpie accounts checkin WorkBuddy's daily check-in (签到) for each WorkBuddy account, now (Settings can do it daily) magpie accounts project the Google Cloud project a Google account's requests go to + magpie plugin [add |rm|update|on|off|login |logout ] + OpenCode provider plugins: subscriptions signed in to, and served, through a plugin magpie serve run the gateway alone (the app runs it too) magpie mcp image the image generation MCP server an agent is given from the library (stdio) @@ -216,6 +218,8 @@ func run(args []string) error { return restoreCmd(args[1:]) case "webdav", "dav": return webdavCmd(args[1:]) + case "plugin", "plugins": + return pluginCmd(args) case "mcp": return imagemcp.Run(args[1:]) case "claude-mcp-helper": // internal: stdio MCP subprocess spawned by Claude Code diff --git a/plugins_cli.go b/plugins_cli.go new file mode 100644 index 00000000..5db25e46 --- /dev/null +++ b/plugins_cli.go @@ -0,0 +1,318 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/signal" + "strconv" + "strings" + "time" + + "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/provider" +) + +const pluginUsage = `usage: magpie plugin [list] [--json] + magpie plugin add install an OpenCode provider plugin (opencode-gemini-auth, ./my-plugin.js) + magpie plugin rm remove one + magpie plugin update install the newest version of each + magpie plugin on|off turn one on or off + magpie plugin login [] sign in to a provider a plugin adds + magpie plugin logout forget the sign-in` + +// pluginCmd: `magpie plugin …` — OpenCode's provider plugins, which sign in +// to a subscription and carry its requests (internal/plugin). +func pluginCmd(args []string) error { + sub := "list" + if len(args) > 1 { + sub = args[1] + } + rest := args[min(len(args), 2):] + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + switch sub { + case "list", "ls", "--json": + return listPlugins(ctx, sub == "--json" || len(rest) > 0 && rest[0] == "--json") + case "add", "install": + if len(rest) != 1 { + return errors.New(pluginUsage) + } + if !plugin.IsPath(rest[0]) && !plugin.HasBun() { + fmt.Println(muted.Render("Downloading Bun " + plugin.BunVersion + ", which plugins run on…")) + } + e, err := plugin.Add(ctx, rest[0]) + if err != nil { + return err + } + fmt.Println(green.Render("✓"), "added", e.Spec) + return listPlugins(ctx, false) + case "rm", "remove", "uninstall": + if len(rest) != 1 { + return errors.New(pluginUsage) + } + if err := plugin.Remove(ctx, rest[0]); err != nil { + return err + } + fmt.Println(green.Render("✓"), "removed", rest[0]) + return nil + case "update", "upgrade": + if err := plugin.Update(ctx); err != nil { + return err + } + fmt.Println(green.Render("✓"), "plugins updated") + return listPlugins(ctx, false) + case "on", "off": + if len(rest) != 1 { + return errors.New(pluginUsage) + } + if err := plugin.SetOff(rest[0], sub == "off"); err != nil { + return err + } + fmt.Println(green.Render("✓"), rest[0], "is", sub) + return nil + case "login", "signin": + if len(rest) < 1 || len(rest) > 2 { + return errors.New(pluginUsage) + } + method := "" + if len(rest) == 2 { + method = rest[1] + } + return pluginLogin(ctx, rest[0], method) + case "logout", "signout": + if len(rest) != 1 { + return errors.New(pluginUsage) + } + pp, err := pluginProvider(ctx, rest[0]) + if err != nil { + return err + } + if err := plugin.SignOut(ctx, pp.ID); err != nil { + return err + } + fmt.Println(green.Render("✓"), "signed out of", pp.Name) + return nil + case "help", "-h", "--help": + fmt.Println(pluginUsage) + return nil + } + return errors.New(pluginUsage) +} + +func listPlugins(ctx context.Context, asJSON bool) error { + l := plugin.Load() + if len(l.Plugins) == 0 { + if asJSON { + fmt.Println("[]") + return nil + } + fmt.Println("No plugins. Add one: magpie plugin add ") + return nil + } + loaded, lerr := plugin.Plugins(ctx) + errs := map[string]string{} + for _, p := range loaded { + errs[p.Spec] = p.Error + } + ps, perr := plugin.Providers(ctx) + if asJSON { + b, _ := json.MarshalIndent(map[string]any{"plugins": l.Plugins, "loaded": loaded, "providers": ps}, "", " ") + fmt.Println(string(b)) + return nil + } + for _, e := range l.Plugins { + state := green.Render("on") + switch { + case e.Off: + state = muted.Render("off") + case errs[e.Spec] != "": + state = "failed: " + errs[e.Spec] + } + fmt.Printf("%s %s\n", bold.Render(e.Spec), state) + for _, p := range ps { + if p.Spec != e.Spec { + continue + } + who := muted.Render("not signed in · magpie plugin login " + p.ID) + if p.SignedIn { + who = green.Render("signed in") + if p.AccountID != "" { + who += " as " + p.AccountID + } + } + fmt.Printf(" %s %s %s\n", provider.PluginID(p.ID), muted.Render("("+p.Name+", "+strconv.Itoa(len(p.Models))+" models)"), who) + } + } + if lerr != nil { + return lerr + } + return perr +} + +// pluginProvider is the plugins' provider named by OpenCode's id or +// magpie's. +func pluginProvider(ctx context.Context, name string) (plugin.Provider, error) { + ps, err := plugin.Providers(ctx) + if err != nil { + return plugin.Provider{}, err + } + for _, p := range ps { + if p.ID == name || provider.PluginID(p.ID) == name || strings.EqualFold(p.Name, name) { + return p, nil + } + } + var ids []string + for _, p := range ps { + ids = append(ids, p.ID) + } + if len(ids) == 0 { + return plugin.Provider{}, fmt.Errorf("no plugin signs in to %q — add one: magpie plugin add ", name) + } + return plugin.Provider{}, fmt.Errorf("no plugin signs in to %q; they sign in to %s", name, strings.Join(ids, ", ")) +} + +// pluginLogin signs in to a plugin's provider as OpenCode's `auth login` +// does: the method, its questions, then a browser (and a code pasted +// back) or a key. +func pluginLogin(ctx context.Context, name, method string) error { + pp, err := pluginProvider(ctx, name) + if err != nil { + return err + } + if len(pp.Methods) == 0 { + return fmt.Errorf("%s's plugin has no way to sign in", pp.Name) + } + m := 0 + switch { + case method != "": + m = -1 + for i, x := range pp.Methods { + if strconv.Itoa(i+1) == method || strings.EqualFold(x.Label, method) || x.Type == method { + m = i + break + } + } + if m < 0 { + return fmt.Errorf("%s has no sign-in method %q", pp.Name, method) + } + case len(pp.Methods) > 1: + fmt.Println("How do you sign in to", pp.Name+"?") + for i, x := range pp.Methods { + fmt.Printf(" %d. %s\n", i+1, x.Label) + } + n, err := askNumber(len(pp.Methods)) + if err != nil { + return err + } + m = n + } + inputs := map[string]string{} + for { + q, err := plugin.NextPrompt(ctx, pp.ID, m, inputs) + if err != nil { + return err + } + if q == nil { + break + } + for { + v, err := askPrompt(q) + if err != nil { + return err + } + msg, err := plugin.Validate(ctx, pp.ID, m, q.Key, v) + if err != nil { + return err + } + if msg == "" { + inputs[q.Key] = v + break + } + fmt.Println(msg) + } + } + var saved string + if pp.Methods[m].Type == "api" { + key, err := secret("key", pp.Name+" API key: ", false) + if err != nil { + return err + } + if saved, err = plugin.APIKey(ctx, pp.ID, m, inputs, key); err != nil { + return err + } + } else { + a, err := plugin.Authorize(ctx, pp.ID, m, inputs) + if err != nil { + return err + } + if a.URL != "" { + fmt.Println("Sign in in your browser. If it didn't open, go to:") + fmt.Println(faint.Render(a.URL)) + openInBrowser(a.URL) + } + if a.Instructions != "" { + fmt.Println(a.Instructions) + } + code := "" + if a.Method == "code" { + fmt.Print("Code: ") + line, err := stdin.ReadString('\n') + if err != nil && line == "" { + return err + } + code = strings.TrimSpace(line) + } + wait, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + if saved, err = plugin.Finish(wait, a.Session, code); err != nil { + return err + } + } + fmt.Println(green.Render("✓"), "signed in to", pp.Name, muted.Render("· its models are "+provider.PluginID(saved)+"/")) + return nil +} + +func askNumber(n int) (int, error) { + for { + fmt.Print("> ") + line, err := stdin.ReadString('\n') + if i, e := strconv.Atoi(strings.TrimSpace(line)); e == nil && i >= 1 && i <= n { + return i - 1, nil + } + if err != nil { + return 0, err + } + fmt.Printf("A number from 1 to %d\n", n) + } +} + +func askPrompt(q *plugin.Prompt) (string, error) { + if q.Type == "select" { + fmt.Println(q.Message) + for i, o := range q.Options { + hint := "" + if o.Hint != "" { + hint = muted.Render(" " + o.Hint) + } + fmt.Printf(" %d. %s%s\n", i+1, o.Label, hint) + } + i, err := askNumber(len(q.Options)) + if err != nil { + return "", err + } + return q.Options[i].Value, nil + } + p := q.Message + if q.Placeholder != "" { + p += " " + muted.Render("("+q.Placeholder+")") + } + fmt.Print(p + " ") + line, err := stdin.ReadString('\n') + if err != nil && line == "" { + return "", err + } + return strings.TrimSpace(line), nil +}