Files
magpie/plugins_cli.go
T
yetone 7d0e415e62 plugins: two plugins that sign in to one provider are both listed, each row says which serves it, and Use for picks the other (neiko on Discord)
neiko on Discord installed a third-party plugin beside a plugin of their
own, added by its folder. Only the third-party one could be seen. Both
plugins' auth hooks name the same provider id. The host's auths() keeps
the last plugin in plugins.json for an id, as OpenCode does, so the
user's own plugin silently lost it:
- its row read "Signs in to nothing magpie can use";
- its provider was gone from the Add sheet;
- keepUnloaded could list the id a second time, from the
  plugin-providers.json kept before.
A package-name clash and a display-name clash were tried as well. Both
plugins stay visible in those cases, because a folder plugin's name is
its path.

What changed:
- host.js:
  - init gives each loaded plugin the ids it signs in to (provides) and,
    for each id another plugin serves, that plugin's spec (servedBy).
  - auths() honours plugins.json's new prefer map (id -> spec).
  - Only the serving plugin's provider.models hook runs for the id.
- plugin:
  - Clashes() turns provides and servedBy into {id, by, with} per spec.
  - Prefer() records a pick and restarts the host.
  - Remove drops the removed plugin's picks.
  - Re-adding a plugin under a new spec carries its picks over.
  - remove, off, options and prefer find a plugin by its exact spec
    before its package name (indexOf), so acting on one plugin never
    reaches another of the same package.
  - keepUnloaded no longer re-adds a provider from before when another
    plugin serves its id now.
- GUI:
  - /api/plugins rows carry clashes, with the provider's listed name.
  - POST /api/plugins/prefer {spec, provider}.
  - The serving row says "{other} signs in to {name} too; this one
    serves it".
  - The other row says, in amber, "{name} is served by {other}, which
    signs in to it too", with a "Use for {name}" button.
  - New strings in zh, zh-TW, ja and de.
- CLI: `magpie plugin` prints the same lines, with the command that
  fixes it, and `magpie plugin use <name> <provider>` makes the pick.
- Docs: docs/subsystems/provider-plugins.md, docs/reference.md and
  site/public/docs/{,zh/,ja/}plugins.html.

What was run:
- Reproduced under a sandbox HOME with magpie web on 3562: a fake npm
  plugin and a folder plugin, both on id "acme".
  - Old binary: the local row had providers [] and /api/providers
    listed acme twice.
  - New binary: both rows carry the clash; prefer moves acme to the
    folder plugin; remove clears the pick and leaves the other plugin
    serving acme.
- New Go tests:
  - TestTwoPluginsOneProvider, a real Bun host: Prefer, restart,
    off/on and Remove.
  - TestClashes, TestPluginNamedExactlyFirst and
    TestKeepUnloadedLeavesAServedID.
  - TestPluginsClashRows (gui) and TestPluginListSaysClash (CLI).
  - Each fails when its piece is broken by hand: prefer ignored in
    host.js, the keepUnloaded id check removed, name before exact spec,
    Remove keeping picks, no provides, no clashes in the rows, no
    prefer route.
- plugin-clash.test.cjs, in Chromium and WebKit, in en, zh, zh-TW, ja
  and de, at 1100 and 560px: 22/22 pass. It times out on the old
  plugins.js.
- gui-de, gui-ja, gui-zh-tw, i18n-keys and every plugin-*.test.cjs:
  248 tests.
  - 245 passed in the 4-wide run.
  - plugin-moved-signin (webkit) failed once in that run, then passed
    3/3 alone and 26/26 in a smaller 4-wide rerun.
- gofmt -l; go vet ./...; GOOS=linux and GOOS=windows go vet -tags
  nogui ./...; go build ./...
- The full go test -tags nogui ./... under a temp HOME with the caches
  pinned: 44 packages ok, none failed.
- After rebasing onto 05917100 (plugins.css conflict with a3a8e257's
  chip rules, both kept): plugin, gui and the root plugin tests, vet on
  darwin, linux and windows, and the 248 GUI tests above, all green
  (248/248 this time).

Not tried: a real third-party plugin, neiko's own plugins, and Windows
beyond vet.
2026-10-10 00:55:38 +08:00

529 lines
15 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/yetone/magpie/internal/middleware"
"github.com/yetone/magpie/internal/plugin"
"github.com/yetone/magpie/internal/provider"
)
const pluginUsage = `usage: magpie plugin [list] [--json]
magpie plugin add <npm | git | path> install an OpenCode provider plugin, a pi package or a gateway middleware (opencode-gemini-auth, pi-antigravity, github:owner/repo, ./my-plugin.js, ./alias.middleware.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 options <name> [<json> | off] show or set what a plugin is handed (a middleware's ctx.options)
magpie plugin use <name> <provider> have this plugin serve a provider another plugin signs in to as well
magpie plugin login <provider> [<method>] sign in to a provider a plugin adds
magpie plugin logout <provider> forget the sign-in
magpie plugin move|migrate <subscription> run a built-in subscription's accounts on its community plugin
magpie plugin move-back|unmigrate <subscription> go back to the built-in, with its accounts`
// pluginCmd: `magpie plugin …` — OpenCode's provider plugins and pi's
// packages, which sign in to a subscription and carry its requests
// (internal/plugin), and gateway middleware (internal/middleware).
func pluginCmd(args []string) error {
sub := "list"
if len(args) > 1 {
sub = args[1]
}
rest := args[min(len(args), 2):]
ctx, stop := interruptContext()
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.BunInUse() + ", which plugins run on…"))
}
e, err := plugin.Add(ctx, rest[0])
if err != nil {
return err
}
fmt.Println(green.Render("✓"), "added", e.Spec)
// a deprecated built-in it serves, not signed in to, is its now
provider.HandOver(ctx, false)
return listPlugins(ctx, false)
case "rm", "remove", "uninstall":
if len(rest) != 1 {
return errors.New(pluginUsage)
}
rest[0] = installedName(rest[0])
back := provider.MovedOnto(rest[0])
if err := provider.RemovePlugin(ctx, rest[0]); err != nil {
return err
}
for _, id := range back {
fmt.Println(green.Render("✓"), id, "is back on its built-in")
}
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")
provider.HandOver(ctx, false)
return listPlugins(ctx, false)
case "on", "off":
if len(rest) != 1 {
return errors.New(pluginUsage)
}
rest[0] = installedName(rest[0])
var back []string
if sub == "off" {
back = provider.MovedOnto(rest[0])
}
if err := provider.SetPluginOff(ctx, rest[0], sub == "off"); err != nil {
return err
}
for _, id := range back {
fmt.Println(green.Render("✓"), id, "is back on its built-in")
}
fmt.Println(green.Render("✓"), rest[0], "is", sub)
return nil
case "options", "config":
if len(rest) < 1 || len(rest) > 2 {
return errors.New(pluginUsage)
}
return pluginOptions(rest[0], rest[1:])
case "use", "prefer":
if len(rest) != 2 {
return errors.New(pluginUsage)
}
rest[0] = installedName(rest[0])
if err := plugin.Prefer(rest[0], rest[1]); err != nil {
return err
}
fmt.Println(green.Render("✓"), rest[0], "serves", rest[1])
return listPlugins(ctx, false)
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 "move", "migrate":
if len(rest) != 1 {
return errors.New(pluginUsage)
}
if !provider.Movable(rest[0]) {
return fmt.Errorf("%s has no plugin to move to", rest[0])
}
if !plugin.HasBun() {
fmt.Println(muted.Render("Downloading Bun " + plugin.BunInUse() + ", which plugins run on…"))
}
if err := provider.Move(ctx, rest[0]); err != nil {
return fmt.Errorf("%s stays built-in: %w", rest[0], err)
}
fmt.Println(green.Render("✓"), rest[0], "runs on", provider.MovePackage(rest[0]), muted.Render("(magpie plugin move-back "+rest[0]+" to undo)"))
return nil
case "move-back", "moveback", "unmigrate":
if len(rest) != 1 {
return errors.New(pluginUsage)
}
if !provider.Moved(rest[0]) {
return fmt.Errorf("%s isn't on its plugin", rest[0])
}
if err := provider.MoveBack(ctx, rest[0]); err != nil {
return err
}
fmt.Println(green.Render("✓"), rest[0], "is built-in again")
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 | git repo | path>")
return nil
}
loaded, lerr := plugin.Plugins(ctx)
errs := map[string]string{}
for _, p := range loaded {
errs[p.Spec] = p.Error
}
clashes := plugin.Clashes(loaded)
ps, perr := plugin.Providers(ctx)
mws := middleware.States()
if asJSON {
b, _ := json.MarshalIndent(map[string]any{"plugins": l.Plugins, "loaded": loaded, "providers": ps, "middleware": mws}, "", " ")
fmt.Println(string(b))
return nil
}
// a built-in with accounts the plugin could run: not signed in to the
// plugin is its normal state, not something to fix
onBuiltin := map[string]provider.MoveCandidate{}
for _, c := range provider.MoveCandidates() {
onBuiltin[c.ID] = c
}
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]
}
what := bold.Render(e.Spec)
// a git repository's package is named by its own package.json
if n := plugin.Name(e.Spec); plugin.IsGit(e.Spec) && n != e.Spec {
what += " " + muted.Render("("+strings.TrimSpace(n+" "+plugin.Installed(e.Spec))+")")
}
fmt.Printf("%s %s\n", what, state)
// its calls are counted in the gateway's process, not this one
if m, ok := mws[e.Spec]; ok && !e.Off {
if m.Error != "" {
fmt.Printf(" %s %s\n", muted.Render("gateway middleware"), amber.Render("didn't load: "+m.Error))
} else {
fmt.Printf(" %s %s\n", muted.Render("gateway middleware"), strings.Join(m.Hooks, ", "))
}
}
for _, p := range ps {
if p.Spec != e.Spec {
continue
}
who := muted.Render("not signed in · magpie plugin login " + p.ID)
if c, ok := onBuiltin[p.ID]; ok && !p.SignedIn {
who = muted.Render(fmt.Sprintf("runs on magpie's built-in (%d accounts) · magpie plugin move %s", c.Accounts, 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)
}
// a provider another plugin signs in to as well: one of them runs
// it, and the other's is said, not left out
for _, c := range clashes[e.Spec] {
if e.Off || errs[e.Spec] != "" {
continue
}
if c.By == e.Spec {
fmt.Printf(" %s %s\n", provider.PluginID(c.ID), muted.Render("also signed in to by "+strings.Join(c.With, ", ")+"; this one serves it"))
continue
}
fmt.Printf(" %s %s\n", provider.PluginID(c.ID), amber.Render("served by "+c.By+", which signs in to it too · magpie plugin use "+e.Spec+" "+c.ID))
}
}
if lerr != nil {
return lerr
}
return perr
}
// pluginOptions prints a plugin's options, or sets them from a JSON
// object; off takes them away. With none set, it prints what its
// package suggests.
// installedName is the package name of the plugin a short name, as the
// community's READMEs write it (think-tags for
// @magpie-community/middleware-think-tags), stands for; a name a plugin
// has itself, or one no plugin has, is kept as it is.
func installedName(name string) string {
ps := plugin.Load().Plugins
for _, x := range ps {
if plugin.Name(x.Spec) == name || x.Spec == name {
return name
}
}
for _, x := range ps {
if plugin.ShortName(plugin.Name(x.Spec)) == name {
return plugin.Name(x.Spec)
}
}
return name
}
func pluginOptions(name string, set []string) error {
var e *plugin.Entry
ps := plugin.Load().Plugins
// the one added as name exactly, else the one of that package
for i, x := range ps {
if e == nil && x.Spec == name {
e = &ps[i]
}
}
for i, x := range ps {
if e == nil && plugin.Name(x.Spec) == name {
e = &ps[i]
}
}
// a short name, as the community's READMEs write it
for i, x := range ps {
if e == nil && plugin.ShortName(plugin.Name(x.Spec)) == name {
e = &ps[i]
}
}
if e == nil {
return fmt.Errorf("no plugin %q", name)
}
if len(set) == 0 {
if len(e.Options) == 0 {
ex := plugin.OptionsExample(plugin.Target(e.Spec))
if ex == nil {
fmt.Println(muted.Render("No options set."))
return nil
}
b, _ := json.MarshalIndent(ex, "", " ")
fmt.Println(muted.Render("No options set. Its package suggests:"))
fmt.Println(string(b))
return nil
}
b, _ := json.MarshalIndent(e.Options, "", " ")
fmt.Println(string(b))
return nil
}
var opts map[string]any
if set[0] != "off" {
if err := json.Unmarshal([]byte(set[0]), &opts); err != nil || opts == nil {
return errors.New(`options are a JSON object ('{"key": "value"}'), or off`)
}
}
if err := plugin.SetOptions(e.Spec, opts); err != nil {
return err
}
if opts == nil {
fmt.Println(green.Render("✓"), name, "has no options")
} else {
fmt.Println(green.Render("✓"), name, "options set")
}
return nil
}
// 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 plugin.Saved
if pp.Methods[m].Type == "api" {
key, err := secret("key", keyPrompt(pp.Name, pp.Methods[m]), false)
if err != nil {
return err
}
if saved, err = plugin.APIKey(ctx, pp.ID, m, inputs, key, plugin.NewAccount); err != nil {
return err
}
} else {
a, err := plugin.Authorize(ctx, pp.ID, m, inputs, plugin.NewAccount)
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 a.Method != "code" && provider.PluginPastesCallback(pp.ID, a.URL) {
// a browser on another computer (magpie on a server or in
// Docker) ends on a page that won't load: its address finishes it
fmt.Println("If the page the browser ends on won't load (magpie on a server or in Docker), paste its whole address here and press Enter:")
go func() {
for wait.Err() == nil {
line, err := stdin.ReadString('\n')
if strings.TrimSpace(line) != "" && wait.Err() == nil {
if next, err := provider.PastePluginCallback(wait, a.URL, line); err != nil {
fmt.Println(err)
} else if next != "" {
fmt.Println("Go on signing in at:")
fmt.Println(faint.Render(next))
}
}
if err != nil {
return
}
}
}()
}
if saved, err = plugin.Finish(wait, a.Session, code); err != nil {
return err
}
}
// as the window's sign-in does: its lapsed mark goes and, removed from
// magpie, it comes back
id := provider.PluginSignedIn(saved)
fmt.Println(green.Render("✓"), "signed in to", pp.Name, muted.Render("· its models are "+id+"/<model>"))
return nil
}
// keyPrompt asks an "api" method's key: its title, then the hint the
// plugin gives (its placeholder), as a question's is.
func keyPrompt(name string, m plugin.Method) string {
p := m.KeyTitle(name)
if m.Placeholder != "" {
p += " " + muted.Render("("+m.Placeholder+")")
}
return p + ": "
}
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
}