Files
magpie/internal/gui/plugins.go
T
yetone 456208945b plugins: a plugin can be installed from a git repository — github:owner/repo, owner/repo, a GitHub URL or a git+https:// one (Lemon on Discord: can plugins be installed directly from git?) — magpie plugin add and the Plugins field took only an npm name or a path, and turned a git spec away as "isn't an npm package name". A git spec now goes to bun add as given and is kept as given in plugins.json; the package it installs is named by its repository's package.json, which bun records as the dependency whose spec it is, so the plugin is known (list, on/off, rm, its version, its README) by that name or by the spec. update and the row's Update run bun update on it, which fetches the repository's commit now where bun add keeps the one bun.lock has, the spec unchanged; npm is never asked for a newer version of it, nor auto-updates it. Adding the same package from another URL, or from npm, takes its entry's place. The Plugins field says it takes a GitHub repo (en+zh); an installed git plugin shows its package's name, an Update button, and a page with its README, no npm link and a Source link to the repository. Nothing that installed before is turned away. TestGitPlugin (a fake bun fetching from local bare repositories, the real one loading the plugin when on PATH) and plugin-git.test.cjs fail without the change; TestIsGit checks what counts as a git spec. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; plugin-git, plugin-local, plugin-market, plugin-updates, click-scroll, old-webkit pass.
2026-10-02 02:24:49 +08:00

347 lines
11 KiB
Go

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: the one the plugin gives its provider,
// else the one the plugin market gives the plugin or its provider, else
// that of the providers OpenCode names, a plain one for the rest.
func pluginIcon(pp plugin.Provider) string {
if ic := provider.PluginIcon(pp); ic != "" {
return ic
}
switch pp.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), 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
Version string `json:"version,omitempty"` // installed
Latest string `json:"latest,omitempty"` // on npm, when the market asked
// Package is the package installed from a git repository, which its
// own package.json names
Package string `json:"package,omitempty"`
// Moved are the built-in subscriptions moved onto it, which go back
// to themselves when it is removed or turned off
Moved []string `json:"moved"`
// AutoUpdated is the update magpie made to it by itself lately
AutoUpdated *plugin.Updated `json:"autoUpdated,omitempty"`
}
// autoUpdatedFor is how long a plugin's row says magpie updated it.
const autoUpdatedFor = 3 * 24 * time.Hour
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
// Picker is whether this magpie can show the system's folder picker:
// `magpie web` has none, and the page then offers no button for it
Picker bool `json:"picker"`
// Movable are the built-ins with accounts a plugin could run, which
// its card and its row offer to move
Movable []provider.MoveCandidate `json:"movable"`
}
func pluginsState(ctx context.Context, w Windows) pluginsJSON {
s := pluginsJSON{Plugins: []pluginEntryJSON{}, Bun: plugin.HasBun(), BunVer: plugin.BunInUse(), Movable: provider.MoveCandidates(), Picker: w != nil && !isWeb(w)}
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], Version: plugin.Installed(e.Spec)}
if j.Providers == nil {
j.Providers = []string{}
}
if plugin.IsGit(e.Spec) {
j.Package = plugin.Name(e.Spec)
}
if u, ok := plugin.LastUpdated(plugin.Name(e.Spec), time.Now().Add(-autoUpdatedFor)); ok && !plugin.IsPath(e.Spec) {
j.AutoUpdated = &u
}
j.Moved = provider.MovedOnto(e.Spec)
if j.Moved == nil {
j.Moved = []string{}
}
s.Plugins = append(s.Plugins, j)
}
return s
}
// pluginMarketJSON is the plugin market: the plugins magpie suggests, what npm
// says of each, and those added.
type pluginMarketJSON struct {
Listings []pluginListingJSON `json:"listings"`
State pluginsJSON `json:"state"`
}
type pluginListingJSON struct {
plugin.Listing
NPM plugin.NPM `json:"npm"`
}
func pluginMarketState(ctx context.Context, w Windows) pluginMarketJSON {
var ls []plugin.Listing
var st pluginsJSON
done := make(chan struct{})
go func() { st = pluginsState(ctx, w); close(done) }()
ls = plugin.Market(ctx)
names := []string{}
for _, l := range ls {
names = append(names, l.Package)
}
<-done
for _, e := range st.Plugins {
// a plugin from a git repository is that repository's, whatever
// npm has under its name
if !plugin.IsPath(e.Spec) && !plugin.IsGit(e.Spec) {
names = append(names, plugin.Name(e.Spec))
}
}
info := plugin.Info(ctx, names)
m := pluginMarketJSON{Listings: []pluginListingJSON{}, State: st}
for _, l := range ls {
m.Listings = append(m.Listings, pluginListingJSON{Listing: l, NPM: info[l.Package]})
}
for i, e := range m.State.Plugins {
if !plugin.IsGit(e.Spec) {
m.State.Plugins[i].Latest = info[plugin.Name(e.Spec)].Version
}
}
return m
}
func pluginRoutes(mux *http.ServeMux, w Windows) {
// the updates waiting for the reader, which put a dot on Plugins
mux.HandleFunc("GET /api/plugins/updates", func(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, plugin.PendingUpdates())
})
mux.HandleFunc("GET /api/plugins/market", func(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
writeJSON(rw, pluginMarketState(ctx, w))
})
mux.HandleFunc("GET /api/plugins/search", func(rw http.ResponseWriter, r *http.Request) {
hits, err := plugin.Search(r.Context(), r.URL.Query().Get("q"))
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, map[string]any{"hits": hits})
})
mux.HandleFunc("GET /api/plugins/page", func(rw http.ResponseWriter, r *http.Request) {
p, err := plugin.Readme(r.Context(), r.URL.Query().Get("name"))
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, p)
})
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, w))
})
// a folder on this computer, from the system's picker; "" when the
// user cancels it
mux.HandleFunc("POST /api/plugins/choose", func(rw http.ResponseWriter, r *http.Request) {
dir, err := w.ChooseFolder("Choose a plugin folder")
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, map[string]string{"dir": dir})
})
// 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 = provider.RemovePlugin(ctx, in.Spec)
case "update":
err = plugin.Update(ctx)
case "upgrade":
err = plugin.Upgrade(ctx, plugin.Name(in.Spec))
case "off":
err = provider.SetPluginOff(ctx, in.Spec, in.Off)
default:
http.NotFound(rw, r)
return
}
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, pluginsState(ctx, w))
})
// 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
}