276 lines
7.2 KiB
Go
276 lines
7.2 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
fileencoding "reasonix/internal/fileutil/encoding"
|
|
"reasonix/internal/secrets"
|
|
)
|
|
|
|
const ccSwitchDir = ".cc-switch"
|
|
|
|
type ccSwitchMCPRow struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
ServerConfig string `json:"server_config"`
|
|
}
|
|
|
|
type ccSwitchLegacyServer struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Server mcpServerSpec `json:"server"`
|
|
Apps struct {
|
|
Codex bool `json:"codex"`
|
|
} `json:"apps"`
|
|
}
|
|
|
|
type MCPImportCandidate struct {
|
|
Entry PluginEntry
|
|
Recommended bool
|
|
Reasons []string
|
|
}
|
|
|
|
func LoadCCSwitchMCPCandidates() ([]MCPImportCandidate, error) {
|
|
entries, err := LoadCCSwitchMCP()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
candidates := make([]MCPImportCandidate, len(entries))
|
|
for i, e := range entries {
|
|
candidates[i] = classifyMCPImportCandidate(e)
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
// LoadCCSwitchMCP reads MCP servers enabled for Codex from cc-switch and maps
|
|
// them to Reasonix plugin entries. Newer cc-switch stores servers in SQLite;
|
|
// older installs kept them in config.json(.migrated/.bak), so we support both.
|
|
func LoadCCSwitchMCP() ([]PluginEntry, error) {
|
|
if IsolatedHomeDir() != "" {
|
|
return nil, nil
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cc-switch import: resolve home: %w", err)
|
|
}
|
|
return loadCCSwitchMCPFromRoot(filepath.Join(home, ccSwitchDir))
|
|
}
|
|
|
|
func loadCCSwitchMCPFromRoot(root string) ([]PluginEntry, error) {
|
|
dbPath := filepath.Join(root, "cc-switch.db")
|
|
if _, err := os.Stat(dbPath); err == nil {
|
|
entries, err := loadCCSwitchMCPDB(dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return entries, nil
|
|
} else if !os.IsNotExist(err) {
|
|
// A present but unreadable/corrupt database should be visible to the user.
|
|
return nil, err
|
|
}
|
|
|
|
for _, name := range []string{"config.json", "config.json.migrated", "config.json.bak"} {
|
|
entries, err := loadCCSwitchLegacyConfig(filepath.Join(root, name))
|
|
if err == nil && len(entries) > 0 {
|
|
return entries, nil
|
|
}
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("cc-switch import: no Codex-enabled MCP servers found in %s", root)
|
|
}
|
|
|
|
func ImportCCSwitchMCPEntries(entries []PluginEntry) (total, added, updated int, err error) {
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
return 0, 0, 0, err
|
|
}
|
|
return importMCPEntries(cfg, entries)
|
|
}
|
|
|
|
// ImportCCSwitchMCP upserts cc-switch's Codex-enabled MCP servers into the
|
|
// active Reasonix config and saves it.
|
|
func ImportCCSwitchMCP() (total, added, updated int, err error) {
|
|
entries, err := LoadCCSwitchMCP()
|
|
if err != nil {
|
|
return 0, 0, 0, err
|
|
}
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
return 0, 0, 0, err
|
|
}
|
|
return importMCPEntries(cfg, entries)
|
|
}
|
|
|
|
func importMCPEntries(cfg *Config, entries []PluginEntry) (total, added, updated int, err error) {
|
|
existing := make(map[string]PluginEntry, len(cfg.Plugins))
|
|
for _, p := range cfg.Plugins {
|
|
existing[p.Name] = p
|
|
}
|
|
for _, e := range entries {
|
|
if _, ok := existing[e.Name]; ok {
|
|
updated++
|
|
} else {
|
|
added++
|
|
}
|
|
if err := cfg.UpsertPlugin(e); err != nil {
|
|
return 0, 0, 0, err
|
|
}
|
|
existing[e.Name] = e
|
|
}
|
|
if err := cfg.Save(); err != nil {
|
|
return 0, 0, 0, err
|
|
}
|
|
return len(entries), added, updated, nil
|
|
}
|
|
|
|
func loadCCSwitchMCPDB(path string) ([]PluginEntry, error) {
|
|
if _, err := os.Stat(path); err != nil {
|
|
return nil, err
|
|
}
|
|
sqlite, err := exec.LookPath("sqlite3")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cc-switch import: sqlite3 not found to read %s", path)
|
|
}
|
|
query := `SELECT id, name, server_config FROM mcp_servers WHERE enabled_codex = 1 ORDER BY name, id`
|
|
cmd := exec.Command(sqlite, "-readonly", "-json", path, query)
|
|
cmd.Env = secrets.ProcessEnv()
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cc-switch import: read %s: %w", path, err)
|
|
}
|
|
if strings.TrimSpace(string(out)) == "" {
|
|
return nil, nil
|
|
}
|
|
var rows []ccSwitchMCPRow
|
|
if err := json.Unmarshal(out, &rows); err != nil {
|
|
return nil, fmt.Errorf("cc-switch import: parse sqlite output: %w", err)
|
|
}
|
|
return ccSwitchRowsToPlugins(rows)
|
|
}
|
|
|
|
func ccSwitchRowsToPlugins(rows []ccSwitchMCPRow) ([]PluginEntry, error) {
|
|
entries := make([]PluginEntry, 0, len(rows))
|
|
for _, row := range rows {
|
|
var s mcpServerSpec
|
|
if err := json.Unmarshal([]byte(row.ServerConfig), &s); err != nil {
|
|
return nil, fmt.Errorf("cc-switch import: server %q config: %w", row.Name, err)
|
|
}
|
|
name := strings.TrimSpace(row.ID)
|
|
if name == "" {
|
|
name = row.Name
|
|
}
|
|
e := pluginFromMCPServerSpec(name, s)
|
|
if err := validatePlugin(e); err != nil {
|
|
return nil, fmt.Errorf("cc-switch import: %w", err)
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func loadCCSwitchLegacyConfig(path string) ([]PluginEntry, error) {
|
|
b, err := fileencoding.ReadFileUTF8(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var doc struct {
|
|
MCP struct {
|
|
Servers map[string]ccSwitchLegacyServer `json:"servers"`
|
|
} `json:"mcp"`
|
|
}
|
|
if err := json.Unmarshal(b, &doc); err != nil {
|
|
return nil, fmt.Errorf("cc-switch import: parse %s: %w", path, err)
|
|
}
|
|
keys := make([]string, 0, len(doc.MCP.Servers))
|
|
for key := range doc.MCP.Servers {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
var entries []PluginEntry
|
|
for _, key := range keys {
|
|
srv := doc.MCP.Servers[key]
|
|
if !srv.Apps.Codex {
|
|
continue
|
|
}
|
|
name := srv.Name
|
|
if name == "" {
|
|
name = srv.ID
|
|
}
|
|
if name == "" {
|
|
name = key
|
|
}
|
|
e := pluginFromMCPServerSpec(name, srv.Server)
|
|
if err := validatePlugin(e); err != nil {
|
|
return nil, fmt.Errorf("cc-switch import: %w", err)
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func pluginFromMCPServerSpec(name string, s mcpServerSpec) PluginEntry {
|
|
return PluginEntry{
|
|
Name: name,
|
|
Type: s.Type,
|
|
Command: s.Command,
|
|
Args: s.Args,
|
|
Env: s.Env,
|
|
URL: s.URL,
|
|
Headers: s.Headers,
|
|
}
|
|
}
|
|
|
|
func classifyMCPImportCandidate(e PluginEntry) MCPImportCandidate {
|
|
c := MCPImportCandidate{Entry: e, Recommended: true}
|
|
typ := strings.ToLower(strings.TrimSpace(e.Type))
|
|
name := strings.ToLower(e.Name)
|
|
cmd := strings.ToLower(filepath.Base(e.Command))
|
|
if typ == "sse" {
|
|
c.Reasons = append(c.Reasons, "unsupported sse")
|
|
c.Recommended = false
|
|
}
|
|
if strings.Contains(name, "chrome-devtools") {
|
|
c.Reasons = append(c.Reasons, "browser/heavy")
|
|
c.Recommended = false
|
|
}
|
|
if cmd == "npx" || cmd == "uvx" {
|
|
for _, a := range e.Args {
|
|
if strings.Contains(strings.ToLower(a), "@latest") {
|
|
c.Reasons = append(c.Reasons, "@latest")
|
|
c.Recommended = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(e.Headers) > 0 || len(e.Env) > 0 {
|
|
c.Reasons = append(c.Reasons, "auth/env")
|
|
if !isCommonDocMCP(name) {
|
|
c.Recommended = false
|
|
}
|
|
}
|
|
if strings.Contains(name, "flomo") || strings.Contains(name, "dida") ||
|
|
strings.Contains(name, "ynote") || strings.Contains(name, "youdao") {
|
|
c.Reasons = append(c.Reasons, "personal")
|
|
c.Recommended = false
|
|
}
|
|
if c.Recommended {
|
|
c.Reasons = append([]string{"recommended"}, c.Reasons...)
|
|
}
|
|
if len(c.Reasons) == 0 {
|
|
c.Reasons = append(c.Reasons, "candidate")
|
|
}
|
|
return c
|
|
}
|
|
|
|
func isCommonDocMCP(name string) bool {
|
|
return strings.Contains(name, "context7") || strings.Contains(name, "exa") || strings.Contains(name, "fetch")
|
|
}
|