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) }