Files
2026-07-13 13:00:08 +08:00

297 lines
8.9 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"reasonix/internal/command"
"reasonix/internal/config"
"reasonix/internal/installsource"
"reasonix/internal/pluginpkg"
)
type PluginView struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"`
Root string `json:"root"`
ManifestKind string `json:"manifestKind,omitempty"`
Enabled bool `json:"enabled"`
Skills int `json:"skills"`
Commands int `json:"commands"`
Hooks int `json:"hooks"`
MCPServers int `json:"mcpServers"`
SkillDetails []PluginSkillView `json:"skillDetails,omitempty"`
CommandDetails []PluginCommandView `json:"commandDetails,omitempty"`
HookDetails []PluginHookView `json:"hookDetails,omitempty"`
MCPServerDetails []PluginMCPServerView `json:"mcpServerDetails,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Error string `json:"error,omitempty"`
}
type PluginInstallOptions struct {
DryRun bool `json:"dryRun,omitempty"`
Link bool `json:"link,omitempty"`
Replace bool `json:"replace,omitempty"`
Name string `json:"name,omitempty"`
}
type PluginSkillView struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Path string `json:"path,omitempty"`
Invocation string `json:"invocation,omitempty"`
RunAs string `json:"runAs,omitempty"`
}
type PluginCommandView struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
ArgHint string `json:"argHint,omitempty"`
Path string `json:"path,omitempty"`
Invocation string `json:"invocation,omitempty"`
Shadowed bool `json:"shadowed,omitempty"`
ShadowedByPlugin string `json:"shadowedByPlugin,omitempty"`
}
type PluginHookView struct {
Event string `json:"event"`
Match string `json:"match,omitempty"`
Command string `json:"command,omitempty"`
ContextFile string `json:"contextFile,omitempty"`
Description string `json:"description,omitempty"`
}
type PluginMCPServerView struct {
Name string `json:"name"`
Transport string `json:"transport,omitempty"`
Command string `json:"command,omitempty"`
URL string `json:"url,omitempty"`
}
func (a *App) Plugins() []PluginView {
st, err := pluginpkg.LoadState(config.ReasonixHomeDir())
if err != nil {
return []PluginView{{Error: err.Error()}}
}
a.mu.RLock()
ctrl := a.activeCtrlLocked()
a.mu.RUnlock()
var activeCommands []command.Command
if ctrl != nil {
activeCommands = ctrl.Commands()
}
out := make([]PluginView, 0, len(st.Plugins))
for _, p := range st.Plugins {
view := PluginView{
Name: p.Name,
Version: p.Version,
Description: p.Description,
Source: p.Source,
Root: pluginpkg.ResolveRoot(config.ReasonixHomeDir(), p.Root),
ManifestKind: p.ManifestKind,
Enabled: p.Enabled,
}
if pkg, warnings, err := pluginpkg.ParseDir(view.Root); err == nil {
applyPluginPackageDetails(&view, pkg, warnings)
decoratePluginCommandConflicts(&view, activeCommands)
} else {
view.Error = err.Error()
}
out = append(out, view)
}
return out
}
func decoratePluginCommandConflicts(view *PluginView, commands []command.Command) {
if view == nil || !view.Enabled || len(view.CommandDetails) == 0 || len(commands) == 0 {
return
}
byName := make(map[string]command.Command, len(commands))
for _, cmd := range commands {
byName[cmd.Name] = cmd
}
for i := range view.CommandDetails {
detail := &view.CommandDetails[i]
qualified := view.Name + ":" + detail.Name
winner, ok := byName[qualified]
if !ok || winner.Plugin == view.Name && winner.ShortName == detail.Name && !winner.Hidden {
continue
}
detail.Shadowed = true
detail.ShadowedByPlugin = winner.Plugin
}
}
func applyPluginPackageDetails(view *PluginView, pkg pluginpkg.Package, warnings []string) {
view.Skills, view.Commands, view.Hooks, view.MCPServers = pkg.CapabilityCounts()
view.Warnings = warnings
inv := pkg.Inventory()
view.CommandDetails = make([]PluginCommandView, 0, len(inv.Commands))
for _, cmd := range inv.Commands {
view.CommandDetails = append(view.CommandDetails, PluginCommandView{
Name: cmd.Name,
Description: cmd.Description,
ArgHint: cmd.ArgHint,
Path: cmd.Path,
Invocation: "/" + view.Name + ":" + cmd.Name,
})
}
view.SkillDetails = make([]PluginSkillView, 0, len(inv.Skills))
for _, sk := range inv.Skills {
view.SkillDetails = append(view.SkillDetails, PluginSkillView{
Name: sk.Name,
Description: sk.Description,
Path: sk.Path,
Invocation: "/" + view.Name + ":" + sk.Name,
RunAs: sk.RunAs,
})
}
view.HookDetails = make([]PluginHookView, 0, len(inv.Hooks))
for _, hook := range inv.Hooks {
view.HookDetails = append(view.HookDetails, PluginHookView{
Event: hook.Event,
Match: hook.Match,
Command: hook.Command,
ContextFile: hook.ContextFile,
Description: hook.Description,
})
}
view.MCPServerDetails = make([]PluginMCPServerView, 0, len(inv.MCPServers))
for _, server := range inv.MCPServers {
view.MCPServerDetails = append(view.MCPServerDetails, PluginMCPServerView{
Name: server.Name,
Transport: server.Transport,
Command: server.Command,
URL: server.URL,
})
}
}
func (a *App) PlanPluginInstall(source string, opts PluginInstallOptions) (string, error) {
opts.DryRun = true
return a.runPluginInstallSource(source, opts, false)
}
func (a *App) InstallPlugin(source string, opts PluginInstallOptions) (string, error) {
if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
return "", err
}
out, err := a.runPluginInstallSource(source, opts, true)
if err != nil {
return "", err
}
a.invalidateSkillRootsCache()
if rebuildErr := a.rebuild(); rebuildErr != nil {
if _, ok := a.deferredRebuildWarning("plugins", rebuildErr); ok {
return out, nil
}
return out, rebuildErr
}
return out, nil
}
func (a *App) RemovePlugin(name string) error {
if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
return err
}
raw, _ := json.Marshal(map[string]any{"op": "uninstall", "kind": "plugin", "name": strings.TrimSpace(name), "scope": "global"})
tl := installsource.NewTool(installsource.Options{
ProjectRoot: a.activeWorkspaceRoot(),
OnDisconnect: func(serverName string) bool {
tab := a.activeTab()
if tab == nil || tab.Ctrl == nil {
return false
}
return tab.Ctrl.DisconnectMCPServer(serverName)
},
})
if _, err := tl.Execute(context.Background(), raw); err != nil {
return err
}
a.invalidateSkillRootsCache()
if err := a.rebuild(); err != nil {
if _, ok := a.deferredRebuildWarning("plugins", err); ok {
return nil
}
return err
}
return nil
}
func (a *App) SetPluginEnabled(name string, enabled bool) error {
if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
return err
}
if err := pluginpkg.SetEnabled(config.ReasonixHomeDir(), strings.TrimSpace(name), enabled); err != nil {
return err
}
a.invalidateSkillRootsCache()
if err := a.rebuild(); err != nil {
if _, ok := a.deferredRebuildWarning("plugins", err); ok {
return nil
}
return err
}
return nil
}
func (a *App) UpdatePlugin(name string) (string, error) {
name = strings.TrimSpace(name)
for _, p := range a.Plugins() {
if p.Name == name {
if strings.TrimSpace(p.Source) == "" {
return "", fmt.Errorf("plugin %q has no recorded source", name)
}
return a.InstallPlugin(p.Source, PluginInstallOptions{Name: name, Replace: true})
}
}
return "", fmt.Errorf("plugin %q is not installed", name)
}
func (a *App) PluginDoctor(name string) PluginView {
name = strings.TrimSpace(name)
for _, p := range a.Plugins() {
if p.Name != name {
continue
}
if p.Error != "" {
return p
}
if p.Root == "" {
p.Error = "missing plugin root"
return p
}
if _, err := os.Stat(p.Root); err != nil {
p.Error = err.Error()
return p
}
return p
}
return PluginView{Name: name, Error: "plugin is not installed"}
}
func (a *App) runPluginInstallSource(source string, opts PluginInstallOptions, apply bool) (string, error) {
mode := "copy"
if opts.Link {
mode = "link"
}
body := map[string]any{
"source": strings.TrimSpace(source),
"kind": "plugin",
"mode": mode,
"replace": opts.Replace,
"apply": apply && !opts.DryRun,
}
if strings.TrimSpace(opts.Name) != "" {
body["name"] = strings.TrimSpace(opts.Name)
}
raw, _ := json.Marshal(body)
tl := installsource.NewTool(installsource.Options{ProjectRoot: a.activeWorkspaceRoot()})
return tl.Execute(context.Background(), raw)
}