| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "os/exec" |
| 8 | "path/filepath" |
| 9 | "sort" |
| 10 | "strings" |
| 11 | |
| 12 | fileencoding "reasonix/internal/fileutil/encoding" |
| 13 | "reasonix/internal/secrets" |
| 14 | ) |
| 15 | |
| 16 | const ccSwitchDir = ".cc-switch" |
| 17 | |
| 18 | type ccSwitchMCPRow struct { |
| 19 | ID string `json:"id"` |
| 20 | Name string `json:"name"` |
| 21 | ServerConfig string `json:"server_config"` |
| 22 | } |
| 23 | |
| 24 | type ccSwitchLegacyServer struct { |
| 25 | ID string `json:"id"` |
| 26 | Name string `json:"name"` |
| 27 | Server mcpServerSpec `json:"server"` |
| 28 | Apps struct { |
| 29 | Codex bool `json:"codex"` |
| 30 | Reasonix *bool `json:"reasonix"` |
| 31 | } `json:"apps"` |
| 32 | } |
| 33 | |
| 34 | type MCPImportCandidate struct { |
| 35 | Entry PluginEntry |
| 36 | Recommended bool |
| 37 | Reasons []string |
| 38 | } |
| 39 | |
| 40 | func LoadCCSwitchMCPCandidates() ([]MCPImportCandidate, error) { |
| 41 | entries, err := LoadCCSwitchMCP() |
| 42 | if err != nil { |
| 43 | return nil, err |
| 44 | } |
| 45 | candidates := make([]MCPImportCandidate, len(entries)) |
| 46 | for i, e := range entries { |
| 47 | candidates[i] = classifyMCPImportCandidate(e) |
| 48 | } |
| 49 | return candidates, nil |
| 50 | } |
| 51 | |
| 52 | // LoadCCSwitchMCP reads MCP servers enabled for Reasonix from cc-switch and maps |
| 53 | // them to Reasonix plugin entries. Newer cc-switch stores servers in SQLite; |
| 54 | // older installs kept them in config.json(.migrated/.bak), so we support both. |
| 55 | // |
| 56 | // CC Switch v16+ stores dedicated enabled_reasonix / apps.reasonix flags. |
| 57 | // Treat those as authoritative when present, and fall back to Codex only for |
| 58 | // pre-v16 SQLite schemas or legacy JSON entries without Reasonix enablement. |
| 59 | func LoadCCSwitchMCP() ([]PluginEntry, error) { |
| 60 | if IsolatedHomeDir() != "" { |
| 61 | return nil, nil |
| 62 | } |
| 63 | home, err := os.UserHomeDir() |
| 64 | if err != nil { |
| 65 | return nil, fmt.Errorf("cc-switch import: resolve home: %w", err) |
| 66 | } |
| 67 | return loadCCSwitchMCPFromRoot(filepath.Join(home, ccSwitchDir)) |
| 68 | } |
| 69 | |
| 70 | func loadCCSwitchMCPFromRoot(root string) ([]PluginEntry, error) { |
| 71 | dbPath := filepath.Join(root, "cc-switch.db") |
| 72 | if _, err := os.Stat(dbPath); err == nil { |
| 73 | entries, err := loadCCSwitchMCPDB(dbPath) |
| 74 | if err != nil { |
| 75 | return nil, err |
| 76 | } |
| 77 | return entries, nil |
| 78 | } else if !os.IsNotExist(err) { |
| 79 | return nil, err |
| 80 | } |
| 81 | |
| 82 | for _, name := range []string{"config.json", "config.json.migrated", "config.json.bak"} { |
| 83 | entries, err := loadCCSwitchLegacyConfig(filepath.Join(root, name)) |
| 84 | if err == nil && len(entries) > 0 { |
| 85 | return entries, nil |
| 86 | } |
| 87 | if err != nil && !os.IsNotExist(err) { |
| 88 | return nil, err |
| 89 | } |
| 90 | } |
| 91 | return nil, fmt.Errorf("cc-switch import: no Reasonix-enabled MCP servers found in %s", root) |
| 92 | } |
| 93 | |
| 94 | func ImportCCSwitchMCPEntries(entries []PluginEntry) (total, added, updated int, err error) { |
| 95 | return importMCPEntries(entries) |
| 96 | } |
| 97 | |
| 98 | // ImportCCSwitchMCP upserts cc-switch's Reasonix-enabled MCP servers into the |
| 99 | // user-global Reasonix config and saves it. |
| 100 | func ImportCCSwitchMCP() (total, added, updated int, err error) { |
| 101 | entries, err := LoadCCSwitchMCP() |
| 102 | if err != nil { |
| 103 | return 0, 0, 0, err |
| 104 | } |
| 105 | return importMCPEntries(entries) |
| 106 | } |
| 107 | |
| 108 | func importMCPEntries(entries []PluginEntry) (total, added, updated int, err error) { |
| 109 | path := UserConfigPath() |
| 110 | if strings.TrimSpace(path) == "" { |
| 111 | return 0, 0, 0, fmt.Errorf("cc-switch import: cannot resolve user config path") |
| 112 | } |
| 113 | unlock := LockUserConfigEdits() |
| 114 | defer unlock() |
| 115 | cfg := LoadForEdit(path) |
| 116 | existing := make(map[string]PluginEntry, len(cfg.Plugins)) |
| 117 | for _, p := range cfg.Plugins { |
| 118 | existing[p.Name] = p |
| 119 | } |
| 120 | for _, e := range entries { |
| 121 | if _, ok := existing[e.Name]; ok { |
| 122 | updated++ |
| 123 | } else { |
| 124 | added++ |
| 125 | } |
| 126 | e.Source = MCPSourceUserConfig |
| 127 | if err := cfg.UpsertPlugin(e); err != nil { |
| 128 | return 0, 0, 0, err |
| 129 | } |
| 130 | existing[e.Name] = e |
| 131 | } |
| 132 | if err := cfg.SaveTo(path); err != nil { |
| 133 | return 0, 0, 0, err |
| 134 | } |
| 135 | return len(entries), added, updated, nil |
| 136 | } |
| 137 | |
| 138 | func loadCCSwitchMCPDB(path string) ([]PluginEntry, error) { |
| 139 | if _, err := os.Stat(path); err != nil { |
| 140 | return nil, err |
| 141 | } |
| 142 | sqlite, err := exec.LookPath("sqlite3") |
| 143 | if err != nil { |
| 144 | return nil, fmt.Errorf("cc-switch import: sqlite3 not found to read %s", path) |
| 145 | } |
| 146 | query := `SELECT id, name, server_config FROM mcp_servers WHERE enabled_codex = 1 ORDER BY name, id` |
| 147 | hasReasonix, err := ccSwitchDBHasReasonixColumn(sqlite, path) |
| 148 | if err != nil { |
| 149 | return nil, err |
| 150 | } |
| 151 | if hasReasonix { |
| 152 | query = `SELECT id, name, server_config FROM mcp_servers WHERE enabled_reasonix = 1 ORDER BY name, id` |
| 153 | } |
| 154 | cmd := exec.Command(sqlite, "-readonly", "-json", path, query) |
| 155 | cmd.Env = secrets.ProcessEnv() |
| 156 | out, err := cmd.Output() |
| 157 | if err != nil { |
| 158 | return nil, fmt.Errorf("cc-switch import: read %s: %w", path, err) |
| 159 | } |
| 160 | if strings.TrimSpace(string(out)) == "" { |
| 161 | return nil, nil |
| 162 | } |
| 163 | var rows []ccSwitchMCPRow |
| 164 | if err := json.Unmarshal(out, &rows); err != nil { |
| 165 | return nil, fmt.Errorf("cc-switch import: parse sqlite output: %w", err) |
| 166 | } |
| 167 | return ccSwitchRowsToPlugins(rows) |
| 168 | } |
| 169 | |
| 170 | func ccSwitchDBHasReasonixColumn(sqlite, path string) (bool, error) { |
| 171 | const query = `SELECT COUNT(*) FROM pragma_table_info('mcp_servers') WHERE name = 'enabled_reasonix'` |
| 172 | cmd := exec.Command(sqlite, "-readonly", path, query) |
| 173 | cmd.Env = secrets.ProcessEnv() |
| 174 | out, err := cmd.Output() |
| 175 | if err != nil { |
| 176 | return false, fmt.Errorf("cc-switch import: inspect %s: %w", path, err) |
| 177 | } |
| 178 | switch strings.TrimSpace(string(out)) { |
| 179 | case "0": |
| 180 | return false, nil |
| 181 | case "1": |
| 182 | return true, nil |
| 183 | default: |
| 184 | return false, fmt.Errorf("cc-switch import: inspect %s: unexpected enabled_reasonix column count %q", path, strings.TrimSpace(string(out))) |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | func ccSwitchRowsToPlugins(rows []ccSwitchMCPRow) ([]PluginEntry, error) { |
| 189 | entries := make([]PluginEntry, 0, len(rows)) |
| 190 | for _, row := range rows { |
| 191 | var s mcpServerSpec |
| 192 | if err := json.Unmarshal([]byte(row.ServerConfig), &s); err != nil { |
| 193 | return nil, fmt.Errorf("cc-switch import: server %q config: %w", row.Name, err) |
| 194 | } |
| 195 | name := strings.TrimSpace(row.ID) |
| 196 | if name == "" { |
| 197 | name = row.Name |
| 198 | } |
| 199 | e := pluginFromMCPServerSpec(name, s) |
| 200 | if err := validatePlugin(e); err != nil { |
| 201 | return nil, fmt.Errorf("cc-switch import: %w", err) |
| 202 | } |
| 203 | entries = append(entries, e) |
| 204 | } |
| 205 | return entries, nil |
| 206 | } |
| 207 | |
| 208 | func loadCCSwitchLegacyConfig(path string) ([]PluginEntry, error) { |
| 209 | b, err := fileencoding.ReadFileUTF8(path) |
| 210 | if err != nil { |
| 211 | return nil, err |
| 212 | } |
| 213 | var doc struct { |
| 214 | MCP struct { |
| 215 | Servers map[string]ccSwitchLegacyServer `json:"servers"` |
| 216 | } `json:"mcp"` |
| 217 | } |
| 218 | if err := json.Unmarshal(b, &doc); err != nil { |
| 219 | return nil, fmt.Errorf("cc-switch import: parse %s: %w", path, err) |
| 220 | } |
| 221 | keys := make([]string, 0, len(doc.MCP.Servers)) |
| 222 | for key := range doc.MCP.Servers { |
| 223 | keys = append(keys, key) |
| 224 | } |
| 225 | sort.Strings(keys) |
| 226 | var entries []PluginEntry |
| 227 | for _, key := range keys { |
| 228 | srv := doc.MCP.Servers[key] |
| 229 | enabled := srv.Apps.Codex |
| 230 | if srv.Apps.Reasonix != nil { |
| 231 | enabled = *srv.Apps.Reasonix |
| 232 | } |
| 233 | if !enabled { |
| 234 | continue |
| 235 | } |
| 236 | name := srv.Name |
| 237 | if name == "" { |
| 238 | name = srv.ID |
| 239 | } |
| 240 | if name == "" { |
| 241 | name = key |
| 242 | } |
| 243 | e := pluginFromMCPServerSpec(name, srv.Server) |
| 244 | if err := validatePlugin(e); err != nil { |
| 245 | return nil, fmt.Errorf("cc-switch import: %w", err) |
| 246 | } |
| 247 | entries = append(entries, e) |
| 248 | } |
| 249 | return entries, nil |
| 250 | } |
| 251 | |
| 252 | func pluginFromMCPServerSpec(name string, s mcpServerSpec) PluginEntry { |
| 253 | return PluginEntry{ |
| 254 | Name: name, |
| 255 | Type: s.Type, |
| 256 | Command: s.Command, |
| 257 | Args: s.Args, |
| 258 | Env: s.Env, |
| 259 | URL: s.URL, |
| 260 | Headers: s.Headers, |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | func classifyMCPImportCandidate(e PluginEntry) MCPImportCandidate { |
| 265 | c := MCPImportCandidate{Entry: e, Recommended: true} |
| 266 | typ := strings.ToLower(strings.TrimSpace(e.Type)) |
| 267 | name := strings.ToLower(e.Name) |
| 268 | cmd := strings.ToLower(filepath.Base(e.Command)) |
| 269 | if typ == "sse" { |
| 270 | c.Reasons = append(c.Reasons, "unsupported sse") |
| 271 | c.Recommended = false |
| 272 | } |
| 273 | if strings.Contains(name, "chrome-devtools") { |
| 274 | c.Reasons = append(c.Reasons, "browser/heavy") |
| 275 | c.Recommended = false |
| 276 | } |
| 277 | if cmd == "npx" || cmd == "uvx" { |
| 278 | for _, a := range e.Args { |
| 279 | if strings.Contains(strings.ToLower(a), "@latest") { |
| 280 | c.Reasons = append(c.Reasons, "@latest") |
| 281 | c.Recommended = false |
| 282 | break |
| 283 | } |
| 284 | } |
| 285 | } |
| 286 | if len(e.Headers) > 0 || len(e.Env) > 0 { |
| 287 | c.Reasons = append(c.Reasons, "auth/env") |
| 288 | if !isCommonDocMCP(name) { |
| 289 | c.Recommended = false |
| 290 | } |
| 291 | } |
| 292 | if strings.Contains(name, "flomo") || strings.Contains(name, "dida") || |
| 293 | strings.Contains(name, "ynote") || strings.Contains(name, "youdao") { |
| 294 | c.Reasons = append(c.Reasons, "personal") |
| 295 | c.Recommended = false |
| 296 | } |
| 297 | if c.Recommended { |
| 298 | c.Reasons = append([]string{"recommended"}, c.Reasons...) |
| 299 | } |
| 300 | if len(c.Reasons) == 0 { |
| 301 | c.Reasons = append(c.Reasons, "candidate") |
| 302 | } |
| 303 | return c |
| 304 | } |
| 305 | |
| 306 | func isCommonDocMCP(name string) bool { |
| 307 | return strings.Contains(name, "context7") || strings.Contains(name, "exa") || strings.Contains(name, "fetch") |
| 308 | } |
| 309 |