返回 DeepSeek-Reasonix
plugin_packages.go
根目录 / internal / config / plugin_packages.go
1 package config
2
3 import (
4 "fmt"
5 "path/filepath"
6 "reflect"
7 "sort"
8 "strings"
9
10 "reasonix/internal/command"
11 "reasonix/internal/pluginpkg"
12 )
13
14 // mergeInstalledPluginPackages overlays enabled plugin package capabilities onto
15 // the in-memory config. It never writes config.toml: plugin package state lives
16 // in <Reasonix home>/plugin-packages.json so uninstall/disable can remove the
17 // entire bundle without editing user-authored config.
18 func mergeInstalledPluginPackages(cfg *Config, root string) []string {
19 if cfg == nil {
20 return nil
21 }
22 reasonixHome := ReasonixHomeDir()
23 if strings.TrimSpace(reasonixHome) == "" {
24 return nil
25 }
26 installed, warnings := pluginpkg.LoadInstalled(reasonixHome)
27 sort.SliceStable(installed, func(i, j int) bool {
28 return installed[i].Installed.Name < installed[j].Installed.Name
29 })
30 for _, item := range installed {
31 pkg := item.Package
32 for _, warning := range item.Warnings {
33 warnings = append(warnings, fmt.Sprintf("%s: %s", item.Installed.Name, warning))
34 }
35 for _, skillRoot := range pkg.SkillRoots() {
36 cfg.addPluginSkillRoot(skillRoot, item.Installed.Name, false)
37 }
38 for _, agentRoot := range pkg.AgentRoots() {
39 cfg.addPluginSkillRoot(agentRoot, item.Installed.Name, true)
40 }
41 for name, srv := range pkg.Manifest.MCPServers {
42 entry := PluginEntry{
43 Name: name,
44 Type: srv.Type,
45 Command: pluginPackageCommand(pkg.Root, pluginPackageWorkspaceValue(pkg.Root, root, srv.Command)),
46 Args: pluginPackageWorkspaceValues(pkg.Root, root, srv.Args),
47 Env: pluginPackageEnv(item.Installed, pkg.Root, root, srv.Env),
48 URL: pluginPackageWorkspaceValue(pkg.Root, root, strings.TrimSpace(srv.URL)),
49 Headers: pluginPackageWorkspaceMap(pkg.Root, root, srv.Headers),
50 AutoStart: srv.AutoStart,
51 Tier: srv.Tier,
52 Source: MCPSourcePluginPackage,
53 }
54 if existing, ok := pluginEntryByName(cfg.Plugins, name); ok {
55 if owner, packageOwned := cfg.pluginPackageOwners[name]; packageOwned && pluginPackageEntriesEqual(existing, entry) {
56 continue
57 } else if packageOwned {
58 warnings = append(warnings, fmt.Sprintf("%s: plugin MCP server %q conflicts with package %s and was skipped", item.Installed.Name, name, owner))
59 } else {
60 warnings = append(warnings, fmt.Sprintf("%s: plugin MCP server %q skipped because config already defines that name", item.Installed.Name, name))
61 }
62 continue
63 }
64 cfg.Plugins = append(cfg.Plugins, entry)
65 if cfg.pluginPackageOwners == nil {
66 cfg.pluginPackageOwners = map[string]string{}
67 }
68 cfg.pluginPackageOwners[name] = item.Installed.Name
69 }
70 }
71 return warnings
72 }
73
74 func (c *Config) addPluginSkillRoot(root, plugin string, agent bool) {
75 if !stringSliceContainsPath(c.Skills.Paths, root) {
76 c.Skills.Paths = append(c.Skills.Paths, root)
77 }
78 if c.pluginPackageSkillOwners == nil {
79 c.pluginPackageSkillOwners = map[string][]string{}
80 }
81 key := CanonicalSkillPath(root)
82 if !containsString(c.pluginPackageSkillOwners[key], plugin) {
83 c.pluginPackageSkillOwners[key] = append(c.pluginPackageSkillOwners[key], plugin)
84 }
85 if !agent {
86 return
87 }
88 if c.pluginPackageAgentOwners == nil {
89 c.pluginPackageAgentOwners = map[string][]string{}
90 }
91 if !containsString(c.pluginPackageAgentOwners[key], plugin) {
92 c.pluginPackageAgentOwners[key] = append(c.pluginPackageAgentOwners[key], plugin)
93 }
94 }
95
96 // PluginPackageSkillOwners returns installed plugin package names keyed by
97 // canonical skill-root path. Multiple linked installs may intentionally point
98 // at the same root under different package names.
99 func (c *Config) PluginPackageSkillOwners() map[string][]string {
100 if c == nil || len(c.pluginPackageSkillOwners) == 0 {
101 return nil
102 }
103 out := make(map[string][]string, len(c.pluginPackageSkillOwners))
104 for path, owners := range c.pluginPackageSkillOwners {
105 out[path] = append([]string(nil), owners...)
106 }
107 return out
108 }
109
110 // PluginPackageAgentOwners identifies Claude agents/ roots that must be loaded
111 // as manually invoked subagent profiles rather than ordinary inline skills.
112 func (c *Config) PluginPackageAgentOwners() map[string][]string {
113 if c == nil || len(c.pluginPackageAgentOwners) == 0 {
114 return nil
115 }
116 out := make(map[string][]string, len(c.pluginPackageAgentOwners))
117 for path, owners := range c.pluginPackageAgentOwners {
118 out[path] = append([]string(nil), owners...)
119 }
120 return out
121 }
122
123 // pluginPackageCommandRoots returns the command directories contributed by
124 // enabled installed plugin packages, in deterministic (name, path) order.
125 // CommandRootsForRoot places them ahead of every user/project dir so explicit
126 // commands win exact canonical-name clashes; LoadInstalled filters to enabled
127 // packages.
128 func pluginPackageCommandRoots() []command.Root {
129 reasonixHome := ReasonixHomeDir()
130 if strings.TrimSpace(reasonixHome) == "" {
131 return nil
132 }
133 installed, _ := pluginpkg.LoadInstalled(reasonixHome)
134 var out []command.Root
135 for _, item := range installed {
136 for _, root := range item.Package.CommandRoots() {
137 out = append(out, command.Root{Path: root, Plugin: item.Installed.Name})
138 }
139 }
140 return out
141 }
142
143 // PluginPackageOwner reports the installed plugin package that contributed an
144 // MCP server. Config-authored servers with the same name win during merge and
145 // therefore have no package owner.
146 func (c *Config) PluginPackageOwner(name string) (string, bool) {
147 if c == nil || len(c.pluginPackageOwners) == 0 {
148 return "", false
149 }
150 owner, ok := c.pluginPackageOwners[strings.TrimSpace(name)]
151 return owner, ok
152 }
153
154 func pluginPackageCommand(root, command string) string {
155 command = pluginPackageValue(root, strings.TrimSpace(command))
156 if command == "" || filepath.IsAbs(command) {
157 return command
158 }
159 return filepath.Join(root, filepath.FromSlash(command))
160 }
161
162 func pluginPackageEnv(installed pluginpkg.InstalledPlugin, root, workspaceRoot string, env map[string]string) map[string]string {
163 out := pluginPackageWorkspaceMap(root, workspaceRoot, env)
164 if out == nil {
165 out = map[string]string{}
166 }
167 out["REASONIX_PLUGIN_ROOT"] = root
168 out["REASONIX_PLUGIN_NAME"] = installed.Name
169 out["CLAUDE_PLUGIN_ROOT"] = root
170 out["CLAUDE_PROJECT_DIR"] = workspaceRoot
171 out["REASONIX_WORKSPACE_ROOT"] = workspaceRoot
172 if installed.Version != "" {
173 out["REASONIX_PLUGIN_VERSION"] = installed.Version
174 }
175 return out
176 }
177
178 func pluginPackageWorkspaceValue(root, workspaceRoot, value string) string {
179 value = pluginPackageValue(root, value)
180 value = expandPluginPathVar(value, "${CLAUDE_PROJECT_DIR}", workspaceRoot)
181 return expandPluginPathVar(value, "$CLAUDE_PROJECT_DIR", workspaceRoot)
182 }
183
184 func pluginPackageWorkspaceValues(root, workspaceRoot string, values []string) []string {
185 if values == nil {
186 return nil
187 }
188 out := make([]string, len(values))
189 for i, value := range values {
190 out[i] = pluginPackageWorkspaceValue(root, workspaceRoot, value)
191 }
192 return out
193 }
194
195 func pluginPackageWorkspaceMap(root, workspaceRoot string, values map[string]string) map[string]string {
196 if values == nil {
197 return nil
198 }
199 out := make(map[string]string, len(values))
200 for key, value := range values {
201 out[key] = pluginPackageWorkspaceValue(root, workspaceRoot, value)
202 }
203 return out
204 }
205
206 func pluginPackageValue(root, value string) string {
207 value = expandPluginPathVar(value, "${CLAUDE_PLUGIN_ROOT}", root)
208 return expandPluginPathVar(value, "$CLAUDE_PLUGIN_ROOT", root)
209 }
210
211 // expandPluginPathVar replaces every occurrence of placeholder with root and
212 // normalizes the path suffix that follows each occurrence (up to the next
213 // "$" or the end of the string) to the host separator. Claude manifests
214 // always author that suffix with "/" regardless of host OS (e.g.
215 // "${CLAUDE_PLUGIN_ROOT}/bin/server"); root is already OS-native, so a plain
216 // string replace leaves a mixed "C:\...\pkg/bin/server" value on Windows that
217 // no longer round-trips through filepath.Join comparisons.
218 func expandPluginPathVar(value, placeholder, root string) string {
219 var b strings.Builder
220 rest := value
221 for {
222 idx := strings.Index(rest, placeholder)
223 if idx < 0 {
224 b.WriteString(rest)
225 return b.String()
226 }
227 b.WriteString(rest[:idx])
228 b.WriteString(root)
229 rest = rest[idx+len(placeholder):]
230 end := strings.IndexByte(rest, '$')
231 suffix := rest
232 if end >= 0 {
233 suffix = rest[:end]
234 }
235 b.WriteString(filepath.FromSlash(suffix))
236 if end < 0 {
237 return b.String()
238 }
239 rest = rest[end:]
240 }
241 }
242
243 func pluginEntryByName(entries []PluginEntry, name string) (PluginEntry, bool) {
244 for _, entry := range entries {
245 if entry.Name == name {
246 return entry, true
247 }
248 }
249 return PluginEntry{}, false
250 }
251
252 func pluginPackageEntriesEqual(a, b PluginEntry) bool {
253 a.Env = cloneStringMap(a.Env)
254 b.Env = cloneStringMap(b.Env)
255 for _, env := range []map[string]string{a.Env, b.Env} {
256 delete(env, "REASONIX_PLUGIN_ROOT")
257 delete(env, "REASONIX_PLUGIN_NAME")
258 delete(env, "REASONIX_PLUGIN_VERSION")
259 delete(env, "CLAUDE_PLUGIN_ROOT")
260 }
261 return reflect.DeepEqual(a, b)
262 }
263
264 func stringSliceContainsPath(paths []string, path string) bool {
265 canon := CanonicalSkillPath(path)
266 for _, existing := range paths {
267 if CanonicalSkillPath(ExpandVars(existing)) == canon {
268 return true
269 }
270 }
271 return false
272 }
273
273 lines GO