plugin: OpenCode's provider plugins sign in to subscriptions in magpie — an npm package OpenCode users install for a plan (its auth hook: loader, methods, oauth authorize/callback, api keys, chat.headers) runs in a Bun host magpie downloads the first time a plugin needs it, speaking JSON-RPC over stdio; its sign-ins are kept in plugin-auth.json (0600). A provider a plugin signs in to is a provider like the others: its models the plugin lists, each spoken to on the API its AI SDK package speaks (anthropic → Messages, openai/azure → Responses, google → Gemini, Copilot's GPT-5+ → Responses, else chat completions), its requests carried by the plugin's fetch to the base URL its loader gives; an id magpie already has is <id>-plugin. CLI: magpie plugin [list|add|rm|update|on|off|login|logout]. App: Settings → Plugins adds, updates, switches off and removes them and says why one didn't load; Add provider → From plugins signs in with the plugin's methods, its questions (picks and checked text), the browser and a pasted code or a key, followed as the built-in sign-ins are; removing the account signs out. The built-in subscriptions stay. TestFakePlugin, TestPluginProvider (chat, Anthropic and Gemini models through the gateway) and TestPluginSignIn (a wrong code fails, the right one signs in, removing signs out, a key) run a fake plugin on Bun; plugin-signin.test.cjs (Chromium, WebKit, en/zh) covers the add sheet and Settings. signin-callback, add-sheet, zed-sub, account-proxy, own-signin-remove, routing-signin, click-scroll GUI tests pass; go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestUserPath, TestClaudeSignedOut, TestClaudeAccountNamedAsItsLogin, TestClaudeIdentityServedWhileAsked, known flakes under load, pass alone). Not yet tried with a real plugin and account.
This commit is contained in:
27 files changed
+3887
-31
No files matched your search
@@ -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
|
||||
`<id>-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/<provider>.json` — model lists fetched from vendors
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
+269
-23
@@ -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.
|
||||
|
||||
@@ -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 的模型和资源库保持一致",
|
||||
|
||||
@@ -252,6 +252,8 @@
|
||||
<div class="list prefs" id="imagesList"></div>
|
||||
<div class="row-head"><span class="label" data-t>Privacy</span></div>
|
||||
<div class="list prefs" id="redactList"></div>
|
||||
<div class="row-head"><span class="label" data-t>Plugins</span></div>
|
||||
<div class="list prefs" id="pluginsList"></div>
|
||||
<div class="row-head"><span class="label" data-t>Sync and backup</span></div>
|
||||
<div class="list prefs" id="syncList"></div>
|
||||
<div class="row-head"><span class="label" data-t>About</span></div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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()) }
|
||||
+58
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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+"\": ")
|
||||
|
||||
@@ -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 <gemini|antigravity> <email> <project> the Google Cloud project a Google account's requests go to
|
||||
magpie plugin [add <package>|rm|update|on|off|login <provider>|logout <provider>]
|
||||
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
|
||||
|
||||
+318
@@ -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 <npm package | path> install an OpenCode provider plugin (opencode-gemini-auth, ./my-plugin.js)
|
||||
magpie plugin rm <name> remove one
|
||||
magpie plugin update install the newest version of each
|
||||
magpie plugin on|off <name> turn one on or off
|
||||
magpie plugin login <provider> [<method>] sign in to a provider a plugin adds
|
||||
magpie plugin logout <provider> 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 <npm package | path>")
|
||||
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 <npm package>", 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)+"/<model>"))
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user