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