返回 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.bumpExtensionGeneration()
217 a.invalidateSkillRootsCache()
218 if rebuildErr := a.rebuild(); rebuildErr != nil {
219 if _, ok := a.deferredRebuildWarning("plugins", rebuildErr); ok {
220 return out, nil
221 }
222 return out, rebuildErr
223 }
224 return out, nil
225 }
226
227 func (a *App) RemovePlugin(name string) error {
228 if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
229 return err
230 }
231 // Uninstall disconnects the plugin's MCP servers, so the whole flow holds
232 // the MCP lifecycle lock: an unlocked disconnect can interleave with a
233 // launch-authorization preflight, which would then relaunch the just-removed server from
234 // its stale snapshot.
235 defer a.lockMCPMutation("remove-plugin")()
236 // A global uninstall touches every runtime, so gate every visible and
237 // detached tab — not only the active one — and hold the gates through the
238 // uninstall and rebuild. The re-check runs under the gates because the
239 // lifecycle-lock wait can outlast the pre-lock check: work that started
240 // mid-wait must fail the removal before anything is deleted, and no tab
241 // may start a turn against a half-removed plugin.
242 releaseGates, err := a.lockRuntimeTurnGates("plugins", nil)
243 if err != nil {
244 return err
245 }
246 defer releaseGates()
247 tab := a.activeTab()
248 if tab == nil && a.ctx != nil {
249 return fmt.Errorf("no active tab")
250 }
251 raw, _ := json.Marshal(map[string]any{"op": "uninstall", "kind": "plugin", "name": strings.TrimSpace(name), "scope": "global"})
252 tl := installsource.NewTool(installsource.Options{
253 ProjectRoot: a.activeWorkspaceRoot(),
254 OnDisconnect: a.disconnectMCPServerAllRuntimes,
255 })
256 if _, err := tl.Execute(context.Background(), raw); err != nil {
257 return err
258 }
259 a.bumpExtensionGeneration()
260 a.invalidateSkillRootsCache()
261 if tab == nil || a.ctx == nil {
262 return nil
263 }
264 if err := a.rebuildSettingTurnLocked("plugins", tab, true, false); err != nil {
265 if _, ok := a.deferredRebuildWarning("plugins", err); ok {
266 return nil
267 }
268 return err
269 }
270 return nil
271 }
272
273 func (a *App) SetPluginEnabled(name string, enabled bool) error {
274 if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
275 return err
276 }
277 if err := pluginpkg.SetEnabled(config.ReasonixHomeDir(), strings.TrimSpace(name), enabled); err != nil {
278 return err
279 }
280 a.bumpExtensionGeneration()
281 a.invalidateSkillRootsCache()
282 if err := a.rebuild(); err != nil {
283 if _, ok := a.deferredRebuildWarning("plugins", err); ok {
284 return nil
285 }
286 return err
287 }
288 return nil
289 }
290
291 func (a *App) UpdatePlugin(name string) (string, error) {
292 name = strings.TrimSpace(name)
293 for _, p := range a.Plugins() {
294 if p.Name == name {
295 if strings.TrimSpace(p.Source) == "" {
296 return "", fmt.Errorf("plugin %q has no recorded source", name)
297 }
298 return a.InstallPlugin(p.Source, PluginInstallOptions{Name: name, Replace: true})
299 }
300 }
301 return "", fmt.Errorf("plugin %q is not installed", name)
302 }
303
304 func (a *App) PluginDoctor(name string) PluginView {
305 name = strings.TrimSpace(name)
306 for _, p := range a.Plugins() {
307 if p.Name != name {
308 continue
309 }
310 if p.Error != "" {
311 return p
312 }
313 if p.Root == "" {
314 p.Error = "missing plugin root"
315 return p
316 }
317 if _, err := os.Stat(p.Root); err != nil {
318 p.Error = err.Error()
319 return p
320 }
321 pkg, _, err := pluginpkg.ParseDir(p.Root)
322 if err != nil {
323 p.Error = err.Error()
324 return p
325 }
326 cfg, _ := config.LoadForRootReadOnly(a.activeWorkspaceRoot())
327 runtimeOptions := hook.RuntimeOptions{}
328 if cfg != nil {
329 runtimeOptions = hook.RuntimeOptionsForShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path)
330 }
331 for _, issue := range hook.CheckPackageRuntime(pkg, runtimeOptions) {
332 p.Warnings = append(p.Warnings, fmt.Sprintf(
333 "%s hook is unavailable: %v; install Git for Windows or configure a usable Bash path",
334 issue.Event, issue.Err,
335 ))
336 }
337 return p
338 }
339 return PluginView{Name: name, Error: "plugin is not installed"}
340 }
341
342 func (a *App) runPluginInstallSource(source string, opts PluginInstallOptions, apply bool) (string, error) {
343 mode := "copy"
344 if opts.Link {
345 mode = "link"
346 }
347 body := map[string]any{
348 "source": strings.TrimSpace(source),
349 "kind": "plugin",
350 "mode": mode,
351 "replace": opts.Replace,
352 "apply": apply && !opts.DryRun,
353 }
354 if strings.TrimSpace(opts.Name) != "" {
355 body["name"] = strings.TrimSpace(opts.Name)
356 }
357 raw, _ := json.Marshal(body)
358 tl := installsource.NewTool(installsource.Options{ProjectRoot: a.activeWorkspaceRoot()})
359 return tl.Execute(context.Background(), raw)
360 }
361
361 lines GO