Files
magpie/internal/plugin/store.go
T
yetone a31f97b30d 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.
2026-09-30 16:44:32 +08:00

271 lines
7.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()) }