返回 DeepSeek-Reasonix
plugin_packages_app.go
根目录 / desktop / plugin_packages_app.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "strings"
9
10 "reasonix/internal/command"
11 "reasonix/internal/config"
12 "reasonix/internal/hook"
13 "reasonix/internal/installsource"
14 "reasonix/internal/pluginpkg"
15 )
16
17 type PluginView struct {
18 Name string `json:"name"`
19 Version string `json:"version,omitempty"`
20 Description string `json:"description,omitempty"`
21 Source string `json:"source,omitempty"`
22 Root string `json:"root"`
23 ManifestKind string `json:"manifestKind,omitempty"`
24 Enabled bool `json:"enabled"`
25 Skills int `json:"skills"`
26 Commands int `json:"commands"`
27 Hooks int `json:"hooks"`
28 MCPServers int `json:"mcpServers"`
29 Agents int `json:"agents,omitempty"`
30 Compatibility string `json:"compatibility,omitempty"`
31 MappedCapabilities []string `json:"mappedCapabilities,omitempty"`
32 SkippedCapabilities []pluginpkg.CompatibilityIssue `json:"skippedCapabilities,omitempty"`
33 SkillDetails []PluginSkillView `json:"skillDetails,omitempty"`
34 AgentDetails []PluginAgentView `json:"agentDetails,omitempty"`
35 CommandDetails []PluginCommandView `json:"commandDetails,omitempty"`
36 HookDetails []PluginHookView `json:"hookDetails,omitempty"`
37 MCPServerDetails []PluginMCPServerView `json:"mcpServerDetails,omitempty"`
38 Warnings []string `json:"warnings,omitempty"`
39 Error string `json:"error,omitempty"`
40 }
41
42 type PluginInstallOptions struct {
43 DryRun bool `json:"dryRun,omitempty"`
44 Link bool `json:"link,omitempty"`
45 Replace bool `json:"replace,omitempty"`
46 Name string `json:"name,omitempty"`
47 }
48
49 type PluginSkillView struct {
50 Name string `json:"name"`
51 Description string `json:"description,omitempty"`
52 Path string `json:"path,omitempty"`
53 Invocation string `json:"invocation,omitempty"`
54 RunAs string `json:"runAs,omitempty"`
55 }
56
57 type PluginAgentView struct {
58 Name string `json:"name"`
59 Description string `json:"description,omitempty"`
60 Path string `json:"path,omitempty"`
61 Invocation string `json:"invocation,omitempty"`
62 Model string `json:"model,omitempty"`
63 AllowedTools []string `json:"allowedTools,omitempty"`
64 }
65
66 type PluginCommandView struct {
67 Name string `json:"name"`
68 Description string `json:"description,omitempty"`
69 ArgHint string `json:"argHint,omitempty"`
70 Path string `json:"path,omitempty"`
71 Invocation string `json:"invocation,omitempty"`
72 Shadowed bool `json:"shadowed,omitempty"`
73 ShadowedByPlugin string `json:"shadowedByPlugin,omitempty"`
74 }
75
76 type PluginHookView struct {
77 Event string `json:"event"`
78 Match string `json:"match,omitempty"`
79 Command string `json:"command,omitempty"`
80 ContextFile string `json:"contextFile,omitempty"`
81 Description string `json:"description,omitempty"`
82 }
83
84 type PluginMCPServerView struct {
85 Name string `json:"name"`
86 DisplayName string `json:"displayName,omitempty"`
87 Description string `json:"description,omitempty"`
88 Transport string `json:"transport,omitempty"`
89 Command string `json:"command,omitempty"`
90 URL string `json:"url,omitempty"`
91 AutoStart bool `json:"autoStart,omitempty"`
92 }
93
94 func (a *App) Plugins() []PluginView {
95 st, err := pluginpkg.LoadState(config.ReasonixHomeDir())
96 if err != nil {
97 return []PluginView{{Error: err.Error()}}
98 }
99 a.mu.RLock()
100 ctrl := a.activeCtrlLocked()
101 a.mu.RUnlock()
102 var activeCommands []command.Command
103 if ctrl != nil {
104 activeCommands = ctrl.Commands()
105 }
106 out := make([]PluginView, 0, len(st.Plugins))
107 for _, p := range st.Plugins {
108 view := PluginView{
109 Name: p.Name,
110 Version: p.Version,
111 Description: p.Description,
112 Source: p.Source,
113 Root: pluginpkg.ResolveRoot(config.ReasonixHomeDir(), p.Root),
114 ManifestKind: p.ManifestKind,
115 Enabled: p.Enabled,
116 }
117 if pkg, warnings, err := pluginpkg.ParseDir(view.Root); err == nil {
118 applyPluginPackageDetails(&view, pkg, warnings)
119 decoratePluginCommandConflicts(&view, activeCommands)
120 } else {
121 view.Error = err.Error()
122 }
123 out = append(out, view)
124 }
125 return out
126 }
127
128 func decoratePluginCommandConflicts(view *PluginView, commands []command.Command) {
129 if view == nil || !view.Enabled || len(view.CommandDetails) == 0 || len(commands) == 0 {
130 return
131 }
132 byName := make(map[string]command.Command, len(commands))
133 for _, cmd := range commands {
134 byName[cmd.Name] = cmd
135 }
136 for i := range view.CommandDetails {
137 detail := &view.CommandDetails[i]
138 qualified := view.Name + ":" + detail.Name
139 winner, ok := byName[qualified]
140 if !ok || winner.Plugin == view.Name && winner.ShortName == detail.Name && !winner.Hidden {
141 continue
142 }
143 detail.Shadowed = true
144 detail.ShadowedByPlugin = winner.Plugin
145 }
146 }
147
148 func applyPluginPackageDetails(view *PluginView, pkg pluginpkg.Package, warnings []string) {
149 view.Skills, view.Commands, view.Hooks, view.MCPServers = pkg.CapabilityCounts()
150 view.Agents = pkg.AgentCount()
151 view.Compatibility = pkg.Compatibility.Status
152 view.MappedCapabilities = append([]string(nil), pkg.Compatibility.Mapped...)
153 view.SkippedCapabilities = append([]pluginpkg.CompatibilityIssue(nil), pkg.Compatibility.Skipped...)
154 view.Warnings = warnings
155 inv := pkg.Inventory()
156 view.CommandDetails = make([]PluginCommandView, 0, len(inv.Commands))
157 for _, cmd := range inv.Commands {
158 view.CommandDetails = append(view.CommandDetails, PluginCommandView{
159 Name: cmd.Name,
160 Description: cmd.Description,
161 ArgHint: cmd.ArgHint,
162 Path: cmd.Path,
163 Invocation: "/" + view.Name + ":" + cmd.Name,
164 })
165 }
166 view.SkillDetails = make([]PluginSkillView, 0, len(inv.Skills))
167 for _, sk := range inv.Skills {
168 view.SkillDetails = append(view.SkillDetails, PluginSkillView{
169 Name: sk.Name,
170 Description: sk.Description,
171 Path: sk.Path,
172 Invocation: "/" + view.Name + ":" + sk.Name,
173 RunAs: sk.RunAs,
174 })
175 }
176 view.AgentDetails = make([]PluginAgentView, 0, len(inv.Agents))
177 for _, agent := range inv.Agents {
178 view.AgentDetails = append(view.AgentDetails, PluginAgentView{
179 Name: agent.Name, Description: agent.Description, Path: agent.Path,
180 Invocation: "/" + view.Name + ":agent:" + agent.Name, Model: agent.Model,
181 AllowedTools: append([]string(nil), agent.AllowedTools...),
182 })
183 }
184 view.HookDetails = make([]PluginHookView, 0, len(inv.Hooks))
185 for _, hook := range inv.Hooks {
186 view.HookDetails = append(view.HookDetails, PluginHookView{
187 Event: hook.Event,
188 Match: hook.Match,
189 Command: hook.Command,
190 ContextFile: hook.ContextFile,
191 Description: hook.Description,
192 })
193 }
194 view.MCPServerDetails = make([]PluginMCPServerView, 0, len(inv.MCPServers))
195 for _, server := range inv.MCPServers {
196 view.MCPServerDetails = append(view.MCPServerDetails, PluginMCPServerView{
197 Name: server.Name, DisplayName: server.DisplayName, Description: server.Description,
198 Transport: server.Transport, Command: server.Command, URL: server.URL, AutoStart: server.AutoStart,
199 })
200 }
201 }
202
203 func (a *App) PlanPluginInstall(source string, opts PluginInstallOptions) (string, error) {
204 opts.DryRun = true
205 return a.runPluginInstallSource(source, opts, false)
206 }
207
208 func (a *App) InstallPlugin(source string, opts PluginInstallOptions) (string, error) {
209 if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
210 return "", err
211 }
212 out, err := a.runPluginInstallSource(source, opts, true)
213 if err != nil {
214 return "", err
215 }
216 a.invalidateSkillRootsCache()
217 if rebuildErr := a.rebuild(); rebuildErr != nil {
218 if _, ok := a.deferredRebuildWarning("plugins", rebuildErr); ok {
219 return out, nil
220 }
221 return out, rebuildErr
222 }
223 return out, nil
224 }
225
226 func (a *App) RemovePlugin(name string) error {
227 if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
228 return err
229 }
230 // Uninstall disconnects the plugin's MCP servers, so the whole flow holds
231 // the MCP lifecycle lock: an unlocked disconnect can interleave with a
232 // launch-authorization preflight, which would then relaunch the just-removed server from
233 // its stale snapshot.
234 defer a.lockMCPMutation("remove-plugin")()
235 // A global uninstall touches every runtime, so gate every visible and
236 // detached tab — not only the active one — and hold the gates through the
237 // uninstall and rebuild. The re-check runs under the gates because the
238 // lifecycle-lock wait can outlast the pre-lock check: work that started
239 // mid-wait must fail the removal before anything is deleted, and no tab
240 // may start a turn against a half-removed plugin.
241 releaseGates, err := a.lockRuntimeTurnGates("plugins", nil)
242 if err != nil {
243 return err
244 }
245 defer releaseGates()
246 tab := a.activeTab()
247 if tab == nil && a.ctx != nil {
248 return fmt.Errorf("no active tab")
249 }
250 raw, _ := json.Marshal(map[string]any{"op": "uninstall", "kind": "plugin", "name": strings.TrimSpace(name), "scope": "global"})
251 tl := installsource.NewTool(installsource.Options{
252 ProjectRoot: a.activeWorkspaceRoot(),
253 OnDisconnect: a.disconnectMCPServerAllRuntimes,
254 })
255 if _, err := tl.Execute(context.Background(), raw); err != nil {
256 return err
257 }
258 a.invalidateSkillRootsCache()
259 if tab == nil || a.ctx == nil {
260 return nil
261 }
262 if err := a.rebuildSettingTurnLocked("plugins", tab, true, false); err != nil {
263 if _, ok := a.deferredRebuildWarning("plugins", err); ok {
264 return nil
265 }
266 return err
267 }
268 return nil
269 }
270
271 func (a *App) SetPluginEnabled(name string, enabled bool) error {
272 if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
273 return err
274 }
275 if err := pluginpkg.SetEnabled(config.ReasonixHomeDir(), strings.TrimSpace(name), enabled); err != nil {
276 return err
277 }
278 a.invalidateSkillRootsCache()
279 if err := a.rebuild(); err != nil {
280 if _, ok := a.deferredRebuildWarning("plugins", err); ok {
281 return nil
282 }
283 return err
284 }
285 return nil
286 }
287
288 func (a *App) UpdatePlugin(name string) (string, error) {
289 name = strings.TrimSpace(name)
290 for _, p := range a.Plugins() {
291 if p.Name == name {
292 if strings.TrimSpace(p.Source) == "" {
293 return "", fmt.Errorf("plugin %q has no recorded source", name)
294 }
295 return a.InstallPlugin(p.Source, PluginInstallOptions{Name: name, Replace: true})
296 }
297 }
298 return "", fmt.Errorf("plugin %q is not installed", name)
299 }
300
301 func (a *App) PluginDoctor(name string) PluginView {
302 name = strings.TrimSpace(name)
303 for _, p := range a.Plugins() {
304 if p.Name != name {
305 continue
306 }
307 if p.Error != "" {
308 return p
309 }
310 if p.Root == "" {
311 p.Error = "missing plugin root"
312 return p
313 }
314 if _, err := os.Stat(p.Root); err != nil {
315 p.Error = err.Error()
316 return p
317 }
318 pkg, _, err := pluginpkg.ParseDir(p.Root)
319 if err != nil {
320 p.Error = err.Error()
321 return p
322 }
323 cfg, _ := config.LoadForRootReadOnly(a.activeWorkspaceRoot())
324 runtimeOptions := hook.RuntimeOptions{}
325 if cfg != nil {
326 runtimeOptions = hook.RuntimeOptionsForShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path)
327 }
328 for _, issue := range hook.CheckPackageRuntime(pkg, runtimeOptions) {
329 p.Warnings = append(p.Warnings, fmt.Sprintf(
330 "%s hook is unavailable: %v; install Git for Windows or configure a usable Bash path",
331 issue.Event, issue.Err,
332 ))
333 }
334 return p
335 }
336 return PluginView{Name: name, Error: "plugin is not installed"}
337 }
338
339 func (a *App) runPluginInstallSource(source string, opts PluginInstallOptions, apply bool) (string, error) {
340 mode := "copy"
341 if opts.Link {
342 mode = "link"
343 }
344 body := map[string]any{
345 "source": strings.TrimSpace(source),
346 "kind": "plugin",
347 "mode": mode,
348 "replace": opts.Replace,
349 "apply": apply && !opts.DryRun,
350 }
351 if strings.TrimSpace(opts.Name) != "" {
352 body["name"] = strings.TrimSpace(opts.Name)
353 }
354 raw, _ := json.Marshal(body)
355 tl := installsource.NewTool(installsource.Options{ProjectRoot: a.activeWorkspaceRoot()})
356 return tl.Execute(context.Background(), raw)
357 }
358
358 lines GO