Skip to content

Commit ab93a92

Browse files
committed
feat: Add configuration management options for Codex and Claude Code
- Introduced ManageCodexConfig and ManageClaudeCodeConfig properties in AppConfig to control whether CodexSwitch manages user configuration files. - Updated ProxyHostService to respect these new configuration options, ensuring user files are untouched when management is disabled. - Enhanced UI with new sidebar groups for configuration and monitoring, including relevant menu items. - Added corresponding translations for new UI elements in English, Japanese, and Chinese. - Implemented tests to verify that configuration management behaves as expected, including backup and restoration of user files.
1 parent ea91e32 commit ab93a92

15 files changed

Lines changed: 715 additions & 113 deletions
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using CodexSwitch.Models;
2+
using CodexSwitch.Services;
3+
4+
namespace CodexSwitch.Tests;
5+
6+
public sealed class ClaudeCodeConfigWriterTests : IDisposable
7+
{
8+
private readonly string _tempDirectory = Path.Combine(
9+
Path.GetTempPath(),
10+
"CodexSwitchTests",
11+
Guid.NewGuid().ToString("N"));
12+
13+
[Fact]
14+
public void Apply_LeavesUserSettingsUntouched_WhenManageDisabled()
15+
{
16+
var paths = CreatePaths("disabled");
17+
Directory.CreateDirectory(paths.ClaudeDirectory);
18+
const string original = "{\n \"env\": { \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\" }\n}\n";
19+
File.WriteAllText(paths.ClaudeSettingsPath, original);
20+
21+
var writer = new ClaudeCodeConfigWriter(paths);
22+
writer.Apply(CreateConfig(manageClaudeCode: false));
23+
24+
Assert.Equal(original, File.ReadAllText(paths.ClaudeSettingsPath));
25+
Assert.False(File.Exists(BackupPath(paths.ClaudeSettingsPath)));
26+
}
27+
28+
[Fact]
29+
public void Apply_WritesManagedSettings_WhenManageEnabled()
30+
{
31+
var paths = CreatePaths("enabled");
32+
Directory.CreateDirectory(paths.ClaudeDirectory);
33+
const string original = "{\n \"env\": { \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\" }\n}\n";
34+
File.WriteAllText(paths.ClaudeSettingsPath, original);
35+
36+
var writer = new ClaudeCodeConfigWriter(paths);
37+
writer.Apply(CreateConfig(manageClaudeCode: true));
38+
39+
var settings = File.ReadAllText(paths.ClaudeSettingsPath);
40+
Assert.Contains("\"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:12785\"", settings, StringComparison.Ordinal);
41+
Assert.Contains("\"ANTHROPIC_AUTH_TOKEN\": \"sk-codex\"", settings, StringComparison.Ordinal);
42+
Assert.True(File.Exists(BackupPath(paths.ClaudeSettingsPath)));
43+
Assert.Equal(original, File.ReadAllText(BackupPath(paths.ClaudeSettingsPath)));
44+
}
45+
46+
[Fact]
47+
public void RestoreOriginal_DoesNotDeleteUserSettings_WhenNeverManaged()
48+
{
49+
var paths = CreatePaths("restore-safe");
50+
Directory.CreateDirectory(paths.ClaudeDirectory);
51+
const string original = "{\n \"model\": \"claude-sonnet-4-5\"\n}\n";
52+
File.WriteAllText(paths.ClaudeSettingsPath, original);
53+
54+
var writer = new ClaudeCodeConfigWriter(paths);
55+
writer.RestoreOriginal();
56+
57+
Assert.True(File.Exists(paths.ClaudeSettingsPath));
58+
Assert.Equal(original, File.ReadAllText(paths.ClaudeSettingsPath));
59+
}
60+
61+
[Fact]
62+
public void Apply_RestoresOriginal_WhenManageToggledOff()
63+
{
64+
var paths = CreatePaths("toggle-off");
65+
Directory.CreateDirectory(paths.ClaudeDirectory);
66+
const string original = "{\n \"env\": { \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\" }\n}\n";
67+
File.WriteAllText(paths.ClaudeSettingsPath, original);
68+
69+
var writer = new ClaudeCodeConfigWriter(paths);
70+
writer.Apply(CreateConfig(manageClaudeCode: true));
71+
Assert.NotEqual(original, File.ReadAllText(paths.ClaudeSettingsPath));
72+
73+
writer.Apply(CreateConfig(manageClaudeCode: false));
74+
75+
Assert.Equal(original, File.ReadAllText(paths.ClaudeSettingsPath));
76+
Assert.False(File.Exists(BackupPath(paths.ClaudeSettingsPath)));
77+
}
78+
79+
private AppPaths CreatePaths(string name)
80+
{
81+
var appRoot = Path.Combine(_tempDirectory, name, "appdata");
82+
var codexRoot = Path.Combine(_tempDirectory, name, "codex");
83+
var claudeRoot = Path.Combine(_tempDirectory, name, "claude");
84+
return new AppPaths(appRoot, codexRoot, claudeRoot);
85+
}
86+
87+
private static AppConfig CreateConfig(bool manageClaudeCode)
88+
{
89+
return new AppConfig
90+
{
91+
ActiveClaudeCodeProviderId = "anthropic",
92+
Proxy = new ProxySettings
93+
{
94+
Host = "127.0.0.1",
95+
Port = 12785,
96+
InboundApiKey = "sk-codex",
97+
ManageClaudeCodeConfig = manageClaudeCode
98+
},
99+
Providers =
100+
{
101+
new ProviderConfig
102+
{
103+
Id = "anthropic",
104+
Protocol = ProviderProtocol.AnthropicMessages,
105+
SupportsClaudeCode = true,
106+
DefaultModel = "claude-sonnet-4-5",
107+
Models =
108+
{
109+
new ModelRouteConfig { Id = "claude-sonnet-4-5" }
110+
}
111+
}
112+
}
113+
};
114+
}
115+
116+
private static string BackupPath(string path)
117+
{
118+
return path + ".bak";
119+
}
120+
121+
public void Dispose()
122+
{
123+
if (Directory.Exists(_tempDirectory))
124+
Directory.Delete(_tempDirectory, recursive: true);
125+
}
126+
}

‎CodexSwitch.Tests/CodexConfigWriterTests.cs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,8 @@ public void ClaudeCodeApply_MergesSettingsAndRestoresOriginal()
318318
{
319319
Host = "127.0.0.1",
320320
Port = 12785,
321-
InboundApiKey = "local-secret"
321+
InboundApiKey = "local-secret",
322+
ManageClaudeCodeConfig = true
322323
},
323324
Providers =
324325
{
@@ -524,6 +525,11 @@ public void RestoreOriginal_DeletesManagedFiles_WhenTheyDidNotExistBefore()
524525
var writer = new CodexConfigWriter(paths);
525526
writer.Apply(new AppConfig());
526527

528+
// Requirement: config CodexSwitch generated (no pre-existing original) is never backed up.
529+
Assert.True(File.Exists(paths.CodexConfigPath));
530+
Assert.False(File.Exists(BackupPath(paths.CodexConfigPath)));
531+
Assert.False(File.Exists(BackupPath(paths.CodexAuthPath)));
532+
527533
writer.RestoreOriginal();
528534

529535
Assert.False(File.Exists(paths.CodexConfigPath));

‎CodexSwitch.Tests/UiV2InfrastructureTests.cs‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,8 @@ public async Task ProxyHostService_StopAsync_RestoresBakFiles()
354354
Enabled = true,
355355
Host = "127.0.0.1",
356356
Port = GetAvailablePort(),
357-
InboundApiKey = "local-secret"
357+
InboundApiKey = "local-secret",
358+
ManageCodexConfig = true
358359
},
359360
Providers =
360361
{
@@ -393,6 +394,60 @@ public async Task ProxyHostService_StopAsync_RestoresBakFiles()
393394
Assert.False(File.Exists(paths.CodexAuthPath + ".bak"));
394395
}
395396

397+
[Fact]
398+
public async Task ProxyHostService_StartAsync_LeavesCodexConfigUntouched_WhenManageDisabled()
399+
{
400+
var paths = CreatePaths("unmanaged-codex-proxy");
401+
var catalog = new ModelPricingCatalog();
402+
var calculator = new PriceCalculator(catalog);
403+
var configStore = new ConfigurationStore(paths);
404+
Directory.CreateDirectory(paths.CodexDirectory);
405+
const string originalConfig = "model = \"before\"\n";
406+
File.WriteAllText(paths.CodexConfigPath, originalConfig);
407+
408+
var config = new AppConfig
409+
{
410+
ActiveProviderId = "first",
411+
Proxy =
412+
{
413+
Enabled = true,
414+
Host = "127.0.0.1",
415+
Port = GetAvailablePort(),
416+
InboundApiKey = "local-secret"
417+
},
418+
Providers =
419+
{
420+
new ProviderConfig
421+
{
422+
Id = "first",
423+
BaseUrl = "https://example.com/v1",
424+
Protocol = ProviderProtocol.OpenAiResponses,
425+
DefaultModel = "gpt-5.5"
426+
}
427+
}
428+
};
429+
430+
using var authHttpClient = new HttpClient();
431+
await using var service = new ProxyHostService(
432+
new UsageMeter(calculator),
433+
calculator,
434+
CreateUsageLogWriter(paths),
435+
new CodexConfigWriter(paths),
436+
new ClaudeCodeConfigWriter(paths),
437+
new ProviderAuthService(configStore, config, authHttpClient),
438+
Array.Empty<IProviderProtocolAdapter>());
439+
440+
await service.StartAsync(config);
441+
442+
// ManageCodexConfig defaults false, so the user's ~/.codex/config.toml is never rewritten or backed up.
443+
Assert.Equal(originalConfig, File.ReadAllText(paths.CodexConfigPath));
444+
Assert.False(File.Exists(paths.CodexConfigPath + ".bak"));
445+
446+
await service.StopAsync();
447+
448+
Assert.Equal(originalConfig, File.ReadAllText(paths.CodexConfigPath));
449+
}
450+
396451
[Fact]
397452
public async Task ProxyHostService_StartAsync_ReusesLocalHealthConnection()
398453
{

‎CodexSwitch/Assets/i18n/en-US.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
"nav.codexSessions": "Codex sessions",
2222
"nav.settings": "Settings",
2323
"nav.addProvider": "Add provider",
24+
"nav.group.configure": "Configure",
25+
"nav.group.monitor": "Activity",
26+
"nav.group.system": "System",
2427
"codexSessions.title": "Codex sessions",
2528
"codexSessions.description": "Codex filters the session list by the model_provider stored in session metadata; migrating moves older sessions under the CodexSwitch-managed provider and can be restored later.",
2629
"codexSessions.migrationTarget": "Migration target",
@@ -130,6 +133,8 @@
130133
"settings.auth.inboundApiKey": "Inbound API key",
131134
"settings.auth.preserveCodexAppAuth": "Preserve Codex App ChatGPT login for plugins",
132135
"settings.auth.useFakeCodexAppAuth": "Use fake Codex App auth for plugin testing",
136+
"settings.auth.manageCodexConfig": "Manage Codex config (overwrites ~/.codex/config.toml)",
137+
"settings.auth.manageClaudeCodeConfig": "Manage Claude Code config (overwrites ~/.claude/settings.json)",
133138
"settings.paths.title": "Local paths",
134139
"settings.paths.description": "These paths are managed by CodexSwitch.",
135140
"settings.notes.title": "Notes",

‎CodexSwitch/Assets/i18n/ja-JP.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
"nav.codexSessions": "Codex セッション",
2222
"nav.settings": "設定",
2323
"nav.addProvider": "プロバイダーを追加",
24+
"nav.group.configure": "構成",
25+
"nav.group.monitor": "アクティビティ",
26+
"nav.group.system": "システム",
2427
"codexSessions.title": "Codex セッション",
2528
"codexSessions.description": "Codex はセッションメタデータの model_provider で一覧を絞り込みます。移行すると古いセッションを CodexSwitch 管理のプロバイダーに寄せ、後から復元できます。",
2629
"codexSessions.migrationTarget": "移行先",
@@ -130,6 +133,8 @@
130133
"settings.auth.inboundApiKey": "受信 API key",
131134
"settings.auth.preserveCodexAppAuth": "プラグイン用に Codex App の ChatGPT ログインを保持",
132135
"settings.auth.useFakeCodexAppAuth": "プラグインテスト用の偽 Codex App auth を使用",
136+
"settings.auth.manageCodexConfig": "Codex 設定を管理する(~/.codex/config.toml を上書き)",
137+
"settings.auth.manageClaudeCodeConfig": "Claude Code 設定を管理する(~/.claude/settings.json を上書き)",
133138
"settings.paths.title": "ローカルパス",
134139
"settings.paths.description": "これらのパスは CodexSwitch が管理します。",
135140
"settings.notes.title": "メモ",

‎CodexSwitch/Assets/i18n/zh-CN.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
"nav.codexSessions": "Codex 会话",
2222
"nav.settings": "设置",
2323
"nav.addProvider": "添加提供商",
24+
"nav.group.configure": "配置",
25+
"nav.group.monitor": "监控",
26+
"nav.group.system": "系统",
2427
"codexSessions.title": "Codex 会话",
2528
"codexSessions.description": "Codex 会话列表会按 session 元数据里的 model_provider 过滤;迁移后旧会话会归入 CodexSwitch 管理的提供商,并可在之后还原。",
2629
"codexSessions.migrationTarget": "迁移目标",
@@ -130,6 +133,8 @@
130133
"settings.auth.inboundApiKey": "入站 API key",
131134
"settings.auth.preserveCodexAppAuth": "保留 Codex App 的 ChatGPT 登录以支持插件",
132135
"settings.auth.useFakeCodexAppAuth": "使用假的 Codex App auth 测试插件",
136+
"settings.auth.manageCodexConfig": "接管 Codex 配置(会覆盖 ~/.codex/config.toml)",
137+
"settings.auth.manageClaudeCodeConfig": "接管 Claude Code 配置(会覆盖 ~/.claude/settings.json)",
133138
"settings.paths.title": "本地路径",
134139
"settings.paths.description": "这些路径由 CodexSwitch 管理。",
135140
"settings.notes.title": "备注",

‎CodexSwitch/Models/AppConfig.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ public sealed class ProxySettings
6262

6363
public bool UseFakeCodexAppAuth { get; set; }
6464

65+
public bool ManageCodexConfig { get; set; }
66+
67+
public bool ManageClaudeCodeConfig { get; set; }
68+
6569
public string Endpoint => $"http://{Host}:{Port}/v1";
6670
}
6771

‎CodexSwitch/Proxy/ProxyHostService.cs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,8 +278,11 @@ public async ValueTask DisposeAsync()
278278

279279
private void ApplyManagedClientConfig(AppConfig config)
280280
{
281+
// Codex integration is opt-in: only touch ~/.codex when the user enabled it and a Codex
282+
// provider is active. Otherwise restore the user's original (or remove files we generated).
283+
// Claude Code applies the equivalent opt-in check inside its own writer.
281284
var codexProvider = ProviderRoutingResolver.ResolveActiveProvider(config, ClientAppKind.Codex);
282-
if (codexProvider is null)
285+
if (!config.Proxy.ManageCodexConfig || codexProvider is null)
283286
_codexConfigWriter.RestoreOriginal();
284287
else
285288
_codexConfigWriter.Apply(config);

‎CodexSwitch/Services/ClaudeCodeConfigWriter.cs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ public ClaudeCodeConfigWriter(AppPaths paths)
1515

1616
public void Apply(AppConfig config)
1717
{
18+
// Claude Code integration is opt-in. When it is disabled we never write ~/.claude/settings.json,
19+
// and we only restore an original the user had if CodexSwitch previously managed the file.
20+
if (!config.Proxy.ManageClaudeCodeConfig)
21+
{
22+
RestoreOriginal();
23+
return;
24+
}
25+
1826
var provider = ResolveClaudeCodeProvider(config);
1927
if (provider is null)
2028
{
@@ -50,6 +58,8 @@ public void Apply(AppConfig config)
5058

5159
public void RestoreOriginal()
5260
{
61+
// Restores the user's captured original, or removes a settings file CodexSwitch generated.
62+
// A file we never managed is left untouched (see ManagedFileBackup.RestoreOriginal).
5363
ManagedFileBackup.RestoreOriginal(_paths.ClaudeSettingsPath);
5464
}
5565

0 commit comments

Comments
 (0)