Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions clients/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ func getClaudeDesktopConfigPathImpl() (string, error) {
}

func getClaudeCodeConfigPathImpl() (string, error) {
if claudeConfigDir := os.Getenv("CLAUDE_CONFIG_DIR"); claudeConfigDir != "" {
return filepath.Join(claudeConfigDir, "claude.json"), nil
}

home, err := os.UserHomeDir()
if err != nil {
return "", err
Expand Down
196 changes: 195 additions & 1 deletion clients/clients_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package clients

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -629,7 +630,7 @@ func TestClientSupportsLocal(t *testing.T) {
{"cline", false},
{"vscode", true},
{"continue", false},
{"codex", false},
{"codex", true},
{"gemini", true},
{"kilo-code", true},
{"antigravity", true},
Expand Down Expand Up @@ -1625,6 +1626,199 @@ func TestSyncToOpenCode_PreservesOtherSettings(t *testing.T) {
}
}

func TestClaudeCodeConfigPath_WithEnvVar(t *testing.T) {
customDir := t.TempDir()
t.Setenv("CLAUDE_CONFIG_DIR", customDir)

path, err := getClaudeCodeConfigPathImpl()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

expected := filepath.Join(customDir, "claude.json")
if path != expected {
t.Errorf("expected path %q, got %q", expected, path)
}
}

func TestClaudeCodeConfigPath_WithoutEnvVar(t *testing.T) {
t.Setenv("CLAUDE_CONFIG_DIR", "")

home, _ := os.UserHomeDir()
expected := filepath.Join(home, ".claude.json")

path, err := getClaudeCodeConfigPathImpl()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if path != expected {
t.Errorf("expected path %q, got %q", expected, path)
}
}

func TestVSCodeConfigPath_WithXDGEnvVar(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("XDG_CONFIG_HOME only applies on Linux")
}

customDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", customDir)

path, err := getVSCodeConfigPathImpl()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

expected := filepath.Join(customDir, "Code", "User", "settings.json")
if path != expected {
t.Errorf("expected path %q, got %q", expected, path)
}
}

func TestVSCodeConfigPath_WithoutXDGEnvVar(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("XDG_CONFIG_HOME only applies on Linux")
}

t.Setenv("XDG_CONFIG_HOME", "")

home, _ := os.UserHomeDir()
expected := filepath.Join(home, ".config", "Code", "User", "settings.json")

path, err := getVSCodeConfigPathImpl()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if path != expected {
t.Errorf("expected path %q, got %q", expected, path)
}
}

func TestReadCodexProjectTrustLevel_Trusted(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.toml")

projectPath := "/home/user/myproject"
content := fmt.Sprintf("[projects.%q]\ntrust_level = \"trusted\"\n", projectPath)
os.WriteFile(configPath, []byte(content), 0o644)

level, err := readCodexProjectTrustLevel(configPath, projectPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if level != "trusted" {
t.Errorf("expected trust level %q, got %q", "trusted", level)
}
}

func TestReadCodexProjectTrustLevel_Untrusted(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.toml")

projectPath := "/home/user/myproject"
content := fmt.Sprintf("[projects.%q]\ntrust_level = \"untrusted\"\n", projectPath)
os.WriteFile(configPath, []byte(content), 0o644)

level, err := readCodexProjectTrustLevel(configPath, projectPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if level != "untrusted" {
t.Errorf("expected trust level %q, got %q", "untrusted", level)
}
}

func TestReadCodexProjectTrustLevel_MissingProject(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.toml")

content := "[projects.\"/other/project\"]\ntrust_level = \"trusted\"\n"
os.WriteFile(configPath, []byte(content), 0o644)

level, err := readCodexProjectTrustLevel(configPath, "/home/user/myproject")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if level != "" {
t.Errorf("expected empty trust level, got %q", level)
}
}

func TestReadCodexProjectTrustLevel_NoConfigFile(t *testing.T) {
level, err := readCodexProjectTrustLevel("/nonexistent/path/config.toml", "/home/user/project")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if level != "" {
t.Errorf("expected empty trust level for missing config, got %q", level)
}
}

func TestReadCodexProjectTrustLevel_MultipleProjects(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.toml")

projectPath := "/home/user/myproject"
content := fmt.Sprintf(
"[projects.\"/other/project\"]\ntrust_level = \"trusted\"\n\n[projects.%q]\ntrust_level = \"trusted\"\n",
projectPath,
)
os.WriteFile(configPath, []byte(content), 0o644)

level, err := readCodexProjectTrustLevel(configPath, projectPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if level != "trusted" {
t.Errorf("expected trust level %q, got %q", "trusted", level)
}
}

func TestCodexLocalPath_Trusted(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("CODEX_HOME", tempDir)

cwd, _ := os.Getwd()
content := fmt.Sprintf("[projects.%q]\ntrust_level = \"trusted\"\n", cwd)
os.WriteFile(filepath.Join(tempDir, "config.toml"), []byte(content), 0o644)

path, err := getCodexLocalPathImpl()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

expected := filepath.Join(cwd, ".codex", "config.toml")
if path != expected {
t.Errorf("expected path %q, got %q", expected, path)
}
}

func TestCodexLocalPath_Untrusted(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("CODEX_HOME", tempDir)

// Write config without trust for the current directory
os.WriteFile(filepath.Join(tempDir, "config.toml"), []byte(""), 0o644)

_, err := getCodexLocalPathImpl()
if err == nil {
t.Error("expected error for untrusted project, got nil")
}
}

func TestCodexLocalPath_NoConfigFile(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("CODEX_HOME", tempDir)
// No config.toml written — trust level is absent

_, err := getCodexLocalPathImpl()
if err == nil {
t.Error("expected error when no Codex config exists (project not trusted), got nil")
}
}

func TestSyncIdempotency_OpenCode(t *testing.T) {
tempDir, err := os.MkdirTemp("", "mcpr-test-*")
if err != nil {
Expand Down
89 changes: 79 additions & 10 deletions clients/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,106 @@ import (
"os"
"path/filepath"
"sort"
"strings"

"github.com/mathematic-inc/mcpr/config"
)

// Path functions as variables for testing
var (
getCodexConfigPath = getCodexConfigPathImpl
getCodexLocalPath = getCodexLocalPathImpl
)

func init() {
RegisterClient(&Client{
Name: "codex",
DisplayName: "Codex (OpenAI)",
GlobalPath: func() (string, error) { return getCodexConfigPath() },
LocalPath: nil,
SupportsLocal: false,
LocalPath: func() (string, error) { return getCodexLocalPath() },
SupportsLocal: true,
SyncFunc: syncToCodex,
})
}

func getCodexConfigPathImpl() (string, error) {
// Check CODEX_HOME env var first
codexHome := os.Getenv("CODEX_HOME")
if codexHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
codexHome := codexHomeDir()
return filepath.Join(codexHome, "config.toml"), nil
}

func getCodexLocalPathImpl() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", err
}
if err := validateCodexProjectTrust(cwd); err != nil {
return "", err
}
return filepath.Join(cwd, ".codex", "config.toml"), nil
}

func codexHomeDir() string {
if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" {
return codexHome
}
home, err := os.UserHomeDir()
if err != nil {
return ".codex"
}
return filepath.Join(home, ".codex")
}

func validateCodexProjectTrust(projectPath string) error {
globalConfigPath := filepath.Join(codexHomeDir(), "config.toml")
trustLevel, err := readCodexProjectTrustLevel(globalConfigPath, projectPath)
if err != nil {
return err
}
if trustLevel != "trusted" {
return fmt.Errorf(
"project %q is not trusted in Codex\n\nTo enable local configuration, add the following to %s:\n\n[projects.%q]\ntrust_level = \"trusted\"",
projectPath, globalConfigPath, projectPath,
)
}
return nil
}

// readCodexProjectTrustLevel parses configPath for the trust_level of the given project.
// Returns "" if the project section or key is not found.
func readCodexProjectTrustLevel(configPath, projectPath string) (string, error) {
data, err := os.ReadFile(configPath)
if os.IsNotExist(err) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("failed to read Codex config: %w", err)
}

// Build the expected section header, e.g. [projects."/home/user/project"]
sectionHeader := fmt.Sprintf(`[projects.%q]`, projectPath)

inSection := false
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == sectionHeader {
inSection = true
continue
}
if inSection {
if strings.HasPrefix(trimmed, "[") {
break
}
if strings.HasPrefix(trimmed, "trust_level") {
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) == 2 {
val := strings.TrimSpace(parts[1])
val = strings.Trim(val, `"'`)
return val, nil
}
}
}
codexHome = filepath.Join(home, ".codex")
}
return filepath.Join(codexHome, "config.toml"), nil
return "", nil
}

func syncToCodex(servers []config.MCPServer, path string) error {
Expand Down
6 changes: 5 additions & 1 deletion clients/vscode.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ func getVSCodeConfigPathImpl() (string, error) {
}
return filepath.Join(appData, "Code", "User", "settings.json"), nil
case "linux":
return filepath.Join(home, ".config", "Code", "User", "settings.json"), nil
configDir := os.Getenv("XDG_CONFIG_HOME")
if configDir == "" {
configDir = filepath.Join(home, ".config")
}
return filepath.Join(configDir, "Code", "User", "settings.json"), nil
default:
return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
Expand Down