返回 DeepSeek-Reasonix
ccswitch.go
根目录 / internal / config / ccswitch.go
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
309 lines GO