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