Files
magpie/internal/plugin/store.go
T
yetone 3bec6c0e40 providers: a built-in subscription moves onto its community plugin and back, its accounts with it (the user: 慢慢下掉内置的那些 Subscription Provider…不要让用户一升级完之后,突然发现他的所有服务都不可用了) — eleven built-ins (Zed, Qoder, Factory, MiMo, Devin, Grok, Command Code Plan, Cursor, Kiro, WorkBuddy, ZCode) each get a mover to their @magpie-community package. A move installs the plugin (updated when older than the mover needs), hands it every account in the plugin's shape, checks the plugin lists models for them, and only then lets the plugin serve the same provider id, so agents' model ids don't change; any failure rolls back and the built-in keeps serving. The built-in's accounts are kept as a backup, and "Run built-in again" (magpie plugin move-back <id>) takes the plugin's current sign-ins back, never copying a rotating token: they are taken out of the plugin in one step, and an account the built-in renewed meanwhile keeps its newer token. An account only the plugin can hold (Cursor's browser sign-in) stays with it. The provider editor shows "Runs on" with the button, a failed move's reason, and the CLI has magpie plugin move / move-back. Automatic moves (Retiring) are wired in but the list stays empty until the packages are on npm. Checked with real accounts in a sandbox HOME: Command Code Plan and Devin moved, answered a chat through the gateway on the plugin, moved back and answered again on the built-in. TestMove*, TestPutBackKeepsNewer, the movers' round-trip tests and plugin-move.test.cjs (Chromium, WebKit, en/zh) are new. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; click-scroll, old-webkit pass.
2026-09-30 21:21:58 +08:00

306 lines
8.4 KiB
Go

// 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()) }
// Version is the version of the package spec installed (or checked out
// at its path), "" when there is none.
func Version(spec string) string {
dir := Target(spec)
if fi, err := os.Stat(dir); err == nil && !fi.IsDir() {
dir = filepath.Dir(dir)
}
var pj struct {
Version string `json:"version"`
}
if b, err := os.ReadFile(filepath.Join(dir, "package.json")); err == nil && json.Unmarshal(b, &pj) == nil {
return pj.Version
}
return ""
}
// PackageName is the npm package spec is: its name, or, for a plugin added
// from a folder or file (a checkout of it), the name its package.json gives.
func PackageName(spec string) string {
if !IsPath(spec) {
return Name(spec)
}
dir := Target(spec)
if fi, err := os.Stat(dir); err == nil && !fi.IsDir() {
dir = filepath.Dir(dir)
}
var pj struct {
Name string `json:"name"`
}
if b, err := os.ReadFile(filepath.Join(dir, "package.json")); err == nil && json.Unmarshal(b, &pj) == nil && pj.Name != "" {
return pj.Name
}
return spec
}