mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-15 17:33:08 +08:00
765 lines
26 KiB
Go
765 lines
26 KiB
Go
package aiservice
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"GoNavi-Wails/internal/ai"
|
|
)
|
|
|
|
const (
|
|
gonaviMCPServerID = "gonavi"
|
|
defaultCodexMCPStartupTimeoutSecond = 60
|
|
claudeCodeClientCommandName = "claude"
|
|
codexClientCommandName = "codex"
|
|
)
|
|
|
|
type mcpClientInstallTextFunc func(string, map[string]any) string
|
|
|
|
var errMCPClientUserHomeDirUnavailable = errors.New("user home directory is unavailable")
|
|
|
|
var claudeCodeConfigPathFunc = func() (string, error) {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
homeDir = strings.TrimSpace(homeDir)
|
|
if homeDir == "" {
|
|
return "", errMCPClientUserHomeDirUnavailable
|
|
}
|
|
return filepath.Join(homeDir, ".claude.json"), nil
|
|
}
|
|
|
|
var codexConfigPathFunc = func() (string, error) {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
homeDir = strings.TrimSpace(homeDir)
|
|
if homeDir == "" {
|
|
return "", errMCPClientUserHomeDirUnavailable
|
|
}
|
|
return filepath.Join(homeDir, ".codex", "config.toml"), nil
|
|
}
|
|
|
|
var localMCPExecutablePathFunc = os.Executable
|
|
var localCLICommandPathFunc = exec.LookPath
|
|
|
|
type claudeCodeMCPServerConfig struct {
|
|
Type string `json:"type"`
|
|
Command string `json:"command"`
|
|
Args []string `json:"args,omitempty"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
}
|
|
|
|
type codexMCPServerConfig struct {
|
|
Command string
|
|
Args []string
|
|
StartupTimeoutSec int
|
|
}
|
|
|
|
// AIGetMCPClientInstallStatuses 返回 GoNavi MCP 在常见外部客户端中的安装状态。
|
|
func (s *Service) AIGetMCPClientInstallStatuses() []ai.MCPClientInstallStatus {
|
|
command, args, resolveErr := resolveCurrentLocalMCPCommand(s.serviceText)
|
|
return []ai.MCPClientInstallStatus{
|
|
inspectClaudeCodeMCPInstallStatus(command, args, resolveErr, s.serviceText),
|
|
inspectCodexMCPInstallStatus(command, args, resolveErr, s.serviceText),
|
|
buildRemoteMCPClientInstallStatus("openclaw", "OpenClaw", s.serviceText),
|
|
buildRemoteMCPClientInstallStatus("hermans", "Hermans", s.serviceText),
|
|
}
|
|
}
|
|
|
|
// AIInstallClaudeCodeMCP 把 GoNavi 的 MCP server 写入 Claude Code 用户级 MCP 配置。
|
|
func (s *Service) AIInstallClaudeCodeMCP() (ai.MCPClientInstallResult, error) {
|
|
configPath, err := claudeCodeConfigPathFunc()
|
|
if err != nil {
|
|
return ai.MCPClientInstallResult{}, fmt.Errorf("%s", s.serviceText("ai.service.mcp_client.claude_code.config_path_failed", map[string]any{"detail": localizeMCPClientPathDetail(s.serviceText, err)}))
|
|
}
|
|
|
|
executablePath, err := localMCPExecutablePathFunc()
|
|
if err != nil {
|
|
return ai.MCPClientInstallResult{}, fmt.Errorf("%s", s.serviceText("ai.service.mcp_client.executable_path_failed", map[string]any{"detail": err.Error()}))
|
|
}
|
|
|
|
command, args, err := resolveLocalMCPCommand(executablePath, s.serviceText)
|
|
if err != nil {
|
|
return ai.MCPClientInstallResult{}, err
|
|
}
|
|
|
|
serverConfig := claudeCodeMCPServerConfig{
|
|
Type: "stdio",
|
|
Command: command,
|
|
Args: append([]string(nil), args...),
|
|
Env: map[string]string{},
|
|
}
|
|
if err := upsertClaudeCodeMCPServerConfig(configPath, gonaviMCPServerID, serverConfig, s.serviceText); err != nil {
|
|
return ai.MCPClientInstallResult{}, err
|
|
}
|
|
|
|
return ai.MCPClientInstallResult{
|
|
Success: true,
|
|
Client: "claude-code",
|
|
Message: s.serviceText("ai.service.mcp_client.claude_code.install_success", nil),
|
|
ConfigPath: configPath,
|
|
Command: command,
|
|
Args: append([]string(nil), args...),
|
|
}, nil
|
|
}
|
|
|
|
// AIInstallCodexMCP 把 GoNavi 的 MCP server 写入 Codex 用户级 MCP 配置。
|
|
func (s *Service) AIInstallCodexMCP() (ai.MCPClientInstallResult, error) {
|
|
configPath, err := codexConfigPathFunc()
|
|
if err != nil {
|
|
return ai.MCPClientInstallResult{}, fmt.Errorf("%s", s.serviceText("ai.service.mcp_client.codex.config_path_failed", map[string]any{"detail": localizeMCPClientPathDetail(s.serviceText, err)}))
|
|
}
|
|
|
|
executablePath, err := localMCPExecutablePathFunc()
|
|
if err != nil {
|
|
return ai.MCPClientInstallResult{}, fmt.Errorf("%s", s.serviceText("ai.service.mcp_client.executable_path_failed", map[string]any{"detail": err.Error()}))
|
|
}
|
|
|
|
command, args, err := resolveLocalMCPCommand(executablePath, s.serviceText)
|
|
if err != nil {
|
|
return ai.MCPClientInstallResult{}, err
|
|
}
|
|
|
|
serverConfig := codexMCPServerConfig{
|
|
Command: command,
|
|
Args: append([]string(nil), args...),
|
|
StartupTimeoutSec: defaultCodexMCPStartupTimeoutSecond,
|
|
}
|
|
if err := upsertCodexMCPServerConfig(configPath, gonaviMCPServerID, serverConfig, s.serviceText); err != nil {
|
|
return ai.MCPClientInstallResult{}, err
|
|
}
|
|
|
|
return ai.MCPClientInstallResult{
|
|
Success: true,
|
|
Client: "codex",
|
|
Message: s.serviceText("ai.service.mcp_client.codex.install_success", nil),
|
|
ConfigPath: configPath,
|
|
Command: command,
|
|
Args: append([]string(nil), args...),
|
|
}, nil
|
|
}
|
|
|
|
func resolveCurrentLocalMCPCommand(textFuncs ...mcpClientInstallTextFunc) (string, []string, error) {
|
|
text := firstMCPClientInstallText(textFuncs)
|
|
executablePath, err := localMCPExecutablePathFunc()
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.executable_path_failed", map[string]any{"detail": err.Error()}))
|
|
}
|
|
command, args, err := resolveLocalMCPCommand(executablePath, text)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return command, args, nil
|
|
}
|
|
|
|
func resolveLocalMCPCommand(executablePath string, textFuncs ...mcpClientInstallTextFunc) (string, []string, error) {
|
|
text := firstMCPClientInstallText(textFuncs)
|
|
executablePath = strings.TrimSpace(executablePath)
|
|
if executablePath == "" {
|
|
return "", nil, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.executable_path_empty", nil))
|
|
}
|
|
|
|
cleaned := filepath.Clean(executablePath)
|
|
baseName := strings.ToLower(strings.TrimSpace(filepath.Base(cleaned)))
|
|
switch baseName {
|
|
case "gonavi-mcp-server", "gonavi-mcp-server.exe":
|
|
return cleaned, []string{}, nil
|
|
default:
|
|
return cleaned, []string{"mcp-server"}, nil
|
|
}
|
|
}
|
|
|
|
func detectLocalCLICommand(commandName string) (bool, string) {
|
|
commandName = strings.TrimSpace(commandName)
|
|
if commandName == "" {
|
|
return false, ""
|
|
}
|
|
resolvedPath, err := localCLICommandPathFunc(commandName)
|
|
if err != nil {
|
|
return false, ""
|
|
}
|
|
resolvedPath = strings.TrimSpace(resolvedPath)
|
|
if resolvedPath == "" {
|
|
return false, ""
|
|
}
|
|
return true, filepath.Clean(resolvedPath)
|
|
}
|
|
|
|
func mcpClientInstallText(text mcpClientInstallTextFunc, key string, params map[string]any) string {
|
|
if text == nil {
|
|
return serviceTextFromLocalizer(nil, key, params)
|
|
}
|
|
return text(key, params)
|
|
}
|
|
|
|
func firstMCPClientInstallText(textFuncs []mcpClientInstallTextFunc) mcpClientInstallTextFunc {
|
|
if len(textFuncs) == 0 {
|
|
return nil
|
|
}
|
|
return textFuncs[0]
|
|
}
|
|
|
|
func localizeMCPClientPathDetail(text mcpClientInstallTextFunc, err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
detail := strings.TrimSpace(err.Error())
|
|
if errors.Is(err, errMCPClientUserHomeDirUnavailable) || detail == errMCPClientUserHomeDirUnavailable.Error() {
|
|
return mcpClientInstallText(text, "ai.service.mcp_client.user_home_dir_unavailable", nil)
|
|
}
|
|
return detail
|
|
}
|
|
|
|
func inspectClaudeCodeMCPInstallStatus(expectedCommand string, expectedArgs []string, expectedErr error, textFuncs ...mcpClientInstallTextFunc) ai.MCPClientInstallStatus {
|
|
text := firstMCPClientInstallText(textFuncs)
|
|
configPath, pathErr := claudeCodeConfigPathFunc()
|
|
clientDetected, clientPath := detectLocalCLICommand(claudeCodeClientCommandName)
|
|
status := ai.MCPClientInstallStatus{
|
|
Client: "claude-code",
|
|
DisplayName: "Claude Code",
|
|
InstallMode: "auto",
|
|
ClientDetected: clientDetected,
|
|
ClientCommand: claudeCodeClientCommandName,
|
|
ClientPath: clientPath,
|
|
ConfigPath: strings.TrimSpace(configPath),
|
|
Message: mcpClientInstallText(text, "ai.service.mcp_client.claude_code.status.missing", nil),
|
|
}
|
|
if pathErr != nil {
|
|
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_path_failed", map[string]any{"detail": localizeMCPClientPathDetail(text, pathErr)})
|
|
return status
|
|
}
|
|
|
|
serverConfig, found, err := readClaudeCodeMCPServerConfig(configPath, gonaviMCPServerID, text)
|
|
if err != nil {
|
|
status.Installed = found
|
|
status.Message = err.Error()
|
|
if found {
|
|
status.Command = strings.TrimSpace(serverConfig.Command)
|
|
status.Args = append([]string(nil), serverConfig.Args...)
|
|
}
|
|
return status
|
|
}
|
|
if !found {
|
|
return status
|
|
}
|
|
|
|
status.Installed = true
|
|
status.Command = strings.TrimSpace(serverConfig.Command)
|
|
status.Args = append([]string(nil), serverConfig.Args...)
|
|
if expectedErr != nil {
|
|
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.claude_code.status.path_check_failed", map[string]any{"detail": expectedErr.Error()})
|
|
return status
|
|
}
|
|
|
|
status.MatchesCurrent = strings.EqualFold(strings.TrimSpace(serverConfig.Type), "stdio") &&
|
|
sameMCPCommand(serverConfig.Command, serverConfig.Args, expectedCommand, expectedArgs)
|
|
if status.MatchesCurrent {
|
|
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.claude_code.status.connected", nil)
|
|
return status
|
|
}
|
|
|
|
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.claude_code.status.path_mismatch", nil)
|
|
return status
|
|
}
|
|
|
|
func inspectCodexMCPInstallStatus(expectedCommand string, expectedArgs []string, expectedErr error, textFuncs ...mcpClientInstallTextFunc) ai.MCPClientInstallStatus {
|
|
text := firstMCPClientInstallText(textFuncs)
|
|
configPath, pathErr := codexConfigPathFunc()
|
|
clientDetected, clientPath := detectLocalCLICommand(codexClientCommandName)
|
|
status := ai.MCPClientInstallStatus{
|
|
Client: "codex",
|
|
DisplayName: "Codex",
|
|
InstallMode: "auto",
|
|
ClientDetected: clientDetected,
|
|
ClientCommand: codexClientCommandName,
|
|
ClientPath: clientPath,
|
|
ConfigPath: strings.TrimSpace(configPath),
|
|
Message: mcpClientInstallText(text, "ai.service.mcp_client.codex.status.missing", nil),
|
|
}
|
|
if pathErr != nil {
|
|
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.codex.config_path_failed", map[string]any{"detail": localizeMCPClientPathDetail(text, pathErr)})
|
|
return status
|
|
}
|
|
|
|
serverConfig, found, err := readCodexMCPServerConfig(configPath, gonaviMCPServerID, text)
|
|
if err != nil {
|
|
status.Installed = found
|
|
status.Message = err.Error()
|
|
if found {
|
|
status.Command = strings.TrimSpace(serverConfig.Command)
|
|
status.Args = append([]string(nil), serverConfig.Args...)
|
|
}
|
|
return status
|
|
}
|
|
if !found {
|
|
return status
|
|
}
|
|
|
|
status.Installed = true
|
|
status.Command = strings.TrimSpace(serverConfig.Command)
|
|
status.Args = append([]string(nil), serverConfig.Args...)
|
|
if expectedErr != nil {
|
|
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.codex.status.path_check_failed", map[string]any{"detail": expectedErr.Error()})
|
|
return status
|
|
}
|
|
|
|
status.MatchesCurrent = sameMCPCommand(serverConfig.Command, serverConfig.Args, expectedCommand, expectedArgs) &&
|
|
(serverConfig.StartupTimeoutSec == 0 || serverConfig.StartupTimeoutSec == defaultCodexMCPStartupTimeoutSecond)
|
|
if status.MatchesCurrent {
|
|
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.codex.status.connected", nil)
|
|
return status
|
|
}
|
|
|
|
status.Message = mcpClientInstallText(text, "ai.service.mcp_client.codex.status.path_mismatch", nil)
|
|
return status
|
|
}
|
|
|
|
func buildRemoteMCPClientInstallStatus(client string, displayName string, textFuncs ...mcpClientInstallTextFunc) ai.MCPClientInstallStatus {
|
|
text := firstMCPClientInstallText(textFuncs)
|
|
return ai.MCPClientInstallStatus{
|
|
Client: client,
|
|
DisplayName: displayName,
|
|
InstallMode: "remote",
|
|
ClientDetected: false,
|
|
Message: mcpClientInstallText(text, "ai.service.mcp_client.remote.status.message", map[string]any{"label": displayName}),
|
|
}
|
|
}
|
|
|
|
func readClaudeCodeMCPServerConfig(configPath string, serverID string, textFuncs ...mcpClientInstallTextFunc) (claudeCodeMCPServerConfig, bool, error) {
|
|
text := firstMCPClientInstallText(textFuncs)
|
|
root, err := readClaudeCodeConfig(configPath, text)
|
|
if err != nil {
|
|
return claudeCodeMCPServerConfig{}, false, err
|
|
}
|
|
|
|
rawServers, exists := root["mcpServers"]
|
|
if !exists || rawServers == nil {
|
|
return claudeCodeMCPServerConfig{}, false, nil
|
|
}
|
|
mcpServers, ok := rawServers.(map[string]any)
|
|
if !ok {
|
|
return claudeCodeMCPServerConfig{}, false, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_format_invalid", map[string]any{"path": "mcpServers", "expected": "an object"}))
|
|
}
|
|
|
|
rawServer, exists := mcpServers[strings.TrimSpace(serverID)]
|
|
if !exists || rawServer == nil {
|
|
return claudeCodeMCPServerConfig{}, false, nil
|
|
}
|
|
serverMap, ok := rawServer.(map[string]any)
|
|
if !ok {
|
|
return claudeCodeMCPServerConfig{}, true, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_format_invalid", map[string]any{"path": fmt.Sprintf("mcpServers.%s", strings.TrimSpace(serverID)), "expected": "an object"}))
|
|
}
|
|
|
|
args, err := decodeJSONLikeStringSlice(serverMap["args"])
|
|
if err != nil {
|
|
return claudeCodeMCPServerConfig{}, true, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_format_invalid", map[string]any{"path": fmt.Sprintf("mcpServers.%s.args", strings.TrimSpace(serverID)), "expected": "a string array"}))
|
|
}
|
|
return claudeCodeMCPServerConfig{
|
|
Type: strings.TrimSpace(anyString(serverMap["type"])),
|
|
Command: strings.TrimSpace(anyString(serverMap["command"])),
|
|
Args: args,
|
|
}, true, nil
|
|
}
|
|
|
|
func upsertClaudeCodeMCPServerConfig(configPath string, serverID string, serverConfig claudeCodeMCPServerConfig, textFuncs ...mcpClientInstallTextFunc) error {
|
|
text := firstMCPClientInstallText(textFuncs)
|
|
root, err := readClaudeCodeConfig(configPath, text)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
mcpServers, err := ensureJSONMap(root, "mcpServers", text)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
mcpServers[strings.TrimSpace(serverID)] = map[string]any{
|
|
"type": serverConfig.Type,
|
|
"command": serverConfig.Command,
|
|
"args": append([]string(nil), serverConfig.Args...),
|
|
"env": cloneStringMap(serverConfig.Env),
|
|
}
|
|
root["mcpServers"] = mcpServers
|
|
|
|
data, err := json.MarshalIndent(root, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_serialize_failed", map[string]any{"detail": err.Error()}))
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
return fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_dir_create_failed", map[string]any{"detail": err.Error()}))
|
|
}
|
|
if err := os.WriteFile(configPath, append(data, '\n'), 0o644); err != nil {
|
|
return fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_write_failed", map[string]any{"detail": err.Error()}))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readClaudeCodeConfig(configPath string, textFuncs ...mcpClientInstallTextFunc) (map[string]any, error) {
|
|
text := firstMCPClientInstallText(textFuncs)
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return map[string]any{}, nil
|
|
}
|
|
return nil, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_read_failed", map[string]any{"detail": err.Error()}))
|
|
}
|
|
|
|
if strings.TrimSpace(string(data)) == "" {
|
|
return map[string]any{}, nil
|
|
}
|
|
|
|
var root map[string]any
|
|
if err := json.Unmarshal(data, &root); err != nil {
|
|
return nil, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_parse_failed", map[string]any{"detail": err.Error()}))
|
|
}
|
|
if root == nil {
|
|
return map[string]any{}, nil
|
|
}
|
|
return root, nil
|
|
}
|
|
|
|
func ensureJSONMap(root map[string]any, key string, textFuncs ...mcpClientInstallTextFunc) (map[string]any, error) {
|
|
text := firstMCPClientInstallText(textFuncs)
|
|
if root == nil {
|
|
return nil, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_format_invalid", map[string]any{"path": "JSON root", "expected": "an object"}))
|
|
}
|
|
|
|
value, exists := root[key]
|
|
if !exists || value == nil {
|
|
result := map[string]any{}
|
|
root[key] = result
|
|
return result, nil
|
|
}
|
|
|
|
typed, ok := value.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.claude_code.config_format_invalid", map[string]any{"path": key, "expected": "an object"}))
|
|
}
|
|
return typed, nil
|
|
}
|
|
|
|
func readCodexMCPServerConfig(configPath string, serverID string, textFuncs ...mcpClientInstallTextFunc) (codexMCPServerConfig, bool, error) {
|
|
text := firstMCPClientInstallText(textFuncs)
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return codexMCPServerConfig{}, false, nil
|
|
}
|
|
return codexMCPServerConfig{}, false, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.codex.config_read_failed", map[string]any{"detail": err.Error()}))
|
|
}
|
|
return parseCodexMCPServerConfig(string(data), serverID, textFuncs...)
|
|
}
|
|
|
|
func upsertCodexMCPServerConfig(configPath string, serverID string, serverConfig codexMCPServerConfig, textFuncs ...mcpClientInstallTextFunc) error {
|
|
text := firstMCPClientInstallText(textFuncs)
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.codex.config_read_failed", map[string]any{"detail": err.Error()}))
|
|
}
|
|
|
|
updated := replaceOrAppendCodexMCPServerBlock(string(data), strings.TrimSpace(serverID), renderCodexMCPServerBlock(serverID, serverConfig))
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
|
return fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.codex.config_dir_create_failed", map[string]any{"detail": err.Error()}))
|
|
}
|
|
if err := os.WriteFile(configPath, []byte(updated), 0o644); err != nil {
|
|
return fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.codex.config_write_failed", map[string]any{"detail": err.Error()}))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func renderCodexMCPServerBlock(serverID string, serverConfig codexMCPServerConfig) string {
|
|
trimmedID := strings.TrimSpace(serverID)
|
|
if trimmedID == "" {
|
|
trimmedID = gonaviMCPServerID
|
|
}
|
|
|
|
lines := []string{
|
|
fmt.Sprintf("[mcp_servers.%s]", trimmedID),
|
|
fmt.Sprintf("command = %s", tomlString(serverConfig.Command)),
|
|
fmt.Sprintf("args = [%s]", strings.Join(renderTomlStringArray(serverConfig.Args), ", ")),
|
|
}
|
|
if serverConfig.StartupTimeoutSec > 0 {
|
|
lines = append(lines, fmt.Sprintf("startup_timeout_sec = %d", serverConfig.StartupTimeoutSec))
|
|
}
|
|
return strings.Join(lines, "\n") + "\n"
|
|
}
|
|
|
|
func parseCodexMCPServerConfig(content string, serverID string, textFuncs ...mcpClientInstallTextFunc) (codexMCPServerConfig, bool, error) {
|
|
text := firstMCPClientInstallText(textFuncs)
|
|
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
|
mainHeader := fmt.Sprintf("[mcp_servers.%s]", strings.TrimSpace(serverID))
|
|
result := codexMCPServerConfig{}
|
|
found := false
|
|
inside := false
|
|
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if !inside {
|
|
if trimmed == mainHeader {
|
|
inside = true
|
|
found = true
|
|
}
|
|
continue
|
|
}
|
|
if isTOMLHeaderLine(trimmed) {
|
|
break
|
|
}
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
continue
|
|
}
|
|
|
|
key, value, ok := splitTOMLAssignment(trimmed)
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch key {
|
|
case "command":
|
|
parsed, err := parseTOMLString(value)
|
|
if err != nil {
|
|
return result, true, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.codex.config_format_invalid", map[string]any{"path": fmt.Sprintf("mcp_servers.%s.command", strings.TrimSpace(serverID)), "expected": "a TOML string"}))
|
|
}
|
|
result.Command = parsed
|
|
case "args":
|
|
parsed, err := parseTOMLStringArray(value)
|
|
if err != nil {
|
|
return result, true, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.codex.config_format_invalid", map[string]any{"path": fmt.Sprintf("mcp_servers.%s.args", strings.TrimSpace(serverID)), "expected": "a TOML string array"}))
|
|
}
|
|
result.Args = parsed
|
|
case "startup_timeout_sec":
|
|
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
|
if err != nil {
|
|
return result, true, fmt.Errorf("%s", mcpClientInstallText(text, "ai.service.mcp_client.codex.config_format_invalid", map[string]any{"path": fmt.Sprintf("mcp_servers.%s.startup_timeout_sec", strings.TrimSpace(serverID)), "expected": "an integer"}))
|
|
}
|
|
result.StartupTimeoutSec = parsed
|
|
}
|
|
}
|
|
|
|
return result, found, nil
|
|
}
|
|
|
|
func replaceOrAppendCodexMCPServerBlock(content string, serverID string, block string) string {
|
|
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
|
mainHeader := fmt.Sprintf("[mcp_servers.%s]", serverID)
|
|
nestedPrefix := fmt.Sprintf("[mcp_servers.%s.", serverID)
|
|
|
|
start, end := -1, -1
|
|
for index, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if start == -1 {
|
|
if trimmed == mainHeader || strings.HasPrefix(trimmed, nestedPrefix) {
|
|
start = index
|
|
}
|
|
continue
|
|
}
|
|
if isTOMLHeaderLine(trimmed) && trimmed != mainHeader && !strings.HasPrefix(trimmed, nestedPrefix) {
|
|
end = index
|
|
break
|
|
}
|
|
}
|
|
if start != -1 && end == -1 {
|
|
end = len(lines)
|
|
}
|
|
|
|
rendered := strings.TrimRight(block, "\n")
|
|
if start == -1 {
|
|
base := strings.TrimSpace(strings.Join(lines, "\n"))
|
|
if base == "" {
|
|
return rendered + "\n"
|
|
}
|
|
return strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n\n" + rendered + "\n"
|
|
}
|
|
|
|
before := strings.TrimRight(strings.Join(lines[:start], "\n"), "\n")
|
|
after := strings.TrimLeft(strings.Join(lines[end:], "\n"), "\n")
|
|
switch {
|
|
case before == "" && after == "":
|
|
return rendered + "\n"
|
|
case before == "":
|
|
return rendered + "\n\n" + after
|
|
case after == "":
|
|
return before + "\n\n" + rendered + "\n"
|
|
default:
|
|
return before + "\n\n" + rendered + "\n\n" + after
|
|
}
|
|
}
|
|
|
|
func renderTomlStringArray(values []string) []string {
|
|
rendered := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
rendered = append(rendered, tomlString(value))
|
|
}
|
|
return rendered
|
|
}
|
|
|
|
func tomlString(value string) string {
|
|
if !strings.Contains(value, "'") && !strings.Contains(value, "\n") && !strings.Contains(value, "\r") {
|
|
return "'" + value + "'"
|
|
}
|
|
return strconv.Quote(value)
|
|
}
|
|
|
|
func splitTOMLAssignment(line string) (string, string, bool) {
|
|
index := strings.Index(line, "=")
|
|
if index <= 0 {
|
|
return "", "", false
|
|
}
|
|
key := strings.TrimSpace(line[:index])
|
|
value := strings.TrimSpace(line[index+1:])
|
|
if key == "" {
|
|
return "", "", false
|
|
}
|
|
return key, value, true
|
|
}
|
|
|
|
func parseTOMLString(value string) (string, error) {
|
|
value = strings.TrimSpace(value)
|
|
if len(value) < 2 {
|
|
return "", fmt.Errorf("invalid string format")
|
|
}
|
|
switch value[0] {
|
|
case '\'':
|
|
if value[len(value)-1] != '\'' {
|
|
return "", fmt.Errorf("single-quoted string is not closed")
|
|
}
|
|
return value[1 : len(value)-1], nil
|
|
case '"':
|
|
parsed, err := strconv.Unquote(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return parsed, nil
|
|
default:
|
|
return "", fmt.Errorf("not a string")
|
|
}
|
|
}
|
|
|
|
func parseTOMLStringArray(value string) ([]string, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return []string{}, nil
|
|
}
|
|
if !strings.HasPrefix(value, "[") || !strings.HasSuffix(value, "]") {
|
|
return nil, fmt.Errorf("not an array")
|
|
}
|
|
|
|
inner := strings.TrimSpace(value[1 : len(value)-1])
|
|
if inner == "" {
|
|
return []string{}, nil
|
|
}
|
|
|
|
result := make([]string, 0, 4)
|
|
for inner != "" {
|
|
item, rest, err := consumeTOMLQuotedString(inner)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, item)
|
|
inner = strings.TrimSpace(rest)
|
|
if inner == "" {
|
|
break
|
|
}
|
|
if !strings.HasPrefix(inner, ",") {
|
|
return nil, fmt.Errorf("invalid array separator")
|
|
}
|
|
inner = strings.TrimSpace(inner[1:])
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func consumeTOMLQuotedString(value string) (string, string, error) {
|
|
value = strings.TrimLeft(value, " \t")
|
|
if value == "" {
|
|
return "", "", fmt.Errorf("string is empty")
|
|
}
|
|
switch value[0] {
|
|
case '\'':
|
|
end := strings.IndexByte(value[1:], '\'')
|
|
if end < 0 {
|
|
return "", "", fmt.Errorf("single-quoted string is not closed")
|
|
}
|
|
end++
|
|
return value[1:end], value[end+1:], nil
|
|
case '"':
|
|
escaped := false
|
|
for index := 1; index < len(value); index++ {
|
|
ch := value[index]
|
|
if escaped {
|
|
escaped = false
|
|
continue
|
|
}
|
|
if ch == '\\' {
|
|
escaped = true
|
|
continue
|
|
}
|
|
if ch == '"' {
|
|
parsed, err := strconv.Unquote(value[:index+1])
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return parsed, value[index+1:], nil
|
|
}
|
|
}
|
|
return "", "", fmt.Errorf("double-quoted string is not closed")
|
|
default:
|
|
return "", "", fmt.Errorf("not a string")
|
|
}
|
|
}
|
|
|
|
func decodeJSONLikeStringSlice(value any) ([]string, error) {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return []string{}, nil
|
|
case []string:
|
|
return append([]string(nil), typed...), nil
|
|
case []any:
|
|
result := make([]string, 0, len(typed))
|
|
for _, item := range typed {
|
|
str, ok := item.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("array element is not a string")
|
|
}
|
|
result = append(result, str)
|
|
}
|
|
return result, nil
|
|
default:
|
|
return nil, fmt.Errorf("not a string array")
|
|
}
|
|
}
|
|
|
|
func anyString(value any) string {
|
|
text, _ := value.(string)
|
|
return text
|
|
}
|
|
|
|
func sameMCPCommand(actualCommand string, actualArgs []string, expectedCommand string, expectedArgs []string) bool {
|
|
return strings.TrimSpace(actualCommand) == strings.TrimSpace(expectedCommand) &&
|
|
reflect.DeepEqual(normalizeStringSlice(actualArgs), normalizeStringSlice(expectedArgs))
|
|
}
|
|
|
|
func normalizeStringSlice(values []string) []string {
|
|
if len(values) == 0 {
|
|
return []string{}
|
|
}
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
result = append(result, strings.TrimSpace(value))
|
|
}
|
|
return result
|
|
}
|
|
|
|
func isTOMLHeaderLine(line string) bool {
|
|
line = strings.TrimSpace(line)
|
|
return strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]")
|
|
}
|