diff --git a/packages/core/src/platform/windows-system.ts b/packages/core/src/platform/windows-system.ts index 094b71ca..800798b6 100644 --- a/packages/core/src/platform/windows-system.ts +++ b/packages/core/src/platform/windows-system.ts @@ -32,12 +32,7 @@ export function broadcastWindowsEnvironmentChanged(): void { return; } - const script = [ - "$signature = '[DllImport(\"user32.dll\", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);';", - "Add-Type -MemberDefinition $signature -Namespace Win32 -Name NativeMethods;", - "$result = [UIntPtr]::Zero;", - "[Win32.NativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 0x2, 5000, [ref]$result) | Out-Null;" - ].join(" "); + const script = windowsEnvironmentChangedPowerShellLines().join(" "); spawnSync(windowsSystemCommand("powershell.exe"), [ "-NoProfile", @@ -51,3 +46,12 @@ export function broadcastWindowsEnvironmentChanged(): void { windowsHide: true }); } + +export function windowsEnvironmentChangedPowerShellLines(): string[] { + return [ + "$signature = '[DllImport(\"user32.dll\", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);';", + "Add-Type -MemberDefinition $signature -Namespace Win32 -Name NativeMethods;", + "$result = [UIntPtr]::Zero;", + "[Win32.NativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 0x2, 5000, [ref]$result) | Out-Null;" + ]; +} diff --git a/packages/core/src/profiles/launch-service.ts b/packages/core/src/profiles/launch-service.ts index ff7b9a5a..55e8153c 100644 --- a/packages/core/src/profiles/launch-service.ts +++ b/packages/core/src/profiles/launch-service.ts @@ -14,7 +14,7 @@ import { gatewayService } from "@ccr/core/gateway/service"; import { TOOL_HUB_MCP_RUNTIME_FILE_NAME, bundledToolHubMcpEntryPathCandidates } from "@ccr/core/mcp/toolhub-config"; import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, profileOpenCommand, profileOpenSurfaces, resolveClaudeCodeSettingsFile, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core"; import { applyProfileConfig, cleanupGeneratedBinBackups } from "@ccr/core/profiles/service"; -import { broadcastWindowsEnvironmentChanged, windowsSystemCommand } from "@ccr/core/platform/windows-system"; +import { windowsEnvironmentChangedPowerShellLines, windowsSystemCommand } from "@ccr/core/platform/windows-system"; const ccrPathBlockStart = "# >>> Claude Code Router CLI >>>"; const ccrPathBlockEnd = "# <<< Claude Code Router CLI <<<"; @@ -29,6 +29,15 @@ type ProfileOpenCommandOptions = { ensureLauncher?: boolean; }; +export type CcrCliLauncherPreparation = { + binDir: string; + persistentPathRequired: boolean; +}; + +type EnsureCcrCliLauncherOptions = { + persistPath?: boolean; +}; + type ProfileAppLaunchResult = { child: ChildProcess; claudeDesignProxy?: boolean; @@ -1090,8 +1099,9 @@ function commandProfileRef(config: AppConfig, profile: ReturnType key.toLowerCase() === "path") || "Path" + : "PATH"; + return pathSegmentsInclude((process.env[pathKey] || "").split(path.delimiter).filter(Boolean), binDir); +} + function prependProcessPath(binDir: string): void { const pathKey = process.platform === "win32" ? Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "Path" @@ -1309,7 +1343,8 @@ function prependProcessPath(binDir: string): void { process.env[pathKey] = [binDir, ...segments].join(delimiter); } -function ensureWindowsUserPath(binDir: string): void { +function ensureWindowsUserPath(binDir: string): boolean { + const broadcastLines = windowsEnvironmentChangedPowerShellLines().map((line) => ` ${line}`); const script = [ "$ErrorActionPreference = 'Stop'", `$bin = ${powershellString(binDir)}`, @@ -1322,6 +1357,10 @@ function ensureWindowsUserPath(binDir: string): void { "$expandedSegments = $segments | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\\\\') }", "if ($expandedSegments -notcontains $expandedBin) {", " [Environment]::SetEnvironmentVariable('Path', ((@($bin) + $segments) -join ';'), 'User')", + ...broadcastLines, + " Write-Output 'CHANGED'", + "} else {", + " Write-Output 'UNCHANGED'", "}" ].join("\n"); const result = spawnSync(windowsSystemCommand("powershell.exe"), [ @@ -1341,7 +1380,7 @@ function ensureWindowsUserPath(binDir: string): void { if (result.status !== 0) { throw new Error((result.stderr || result.stdout || `powershell.exe exited with ${result.status}`).trim()); } - broadcastWindowsEnvironmentChanged(); + return result.stdout.trim().split(/\r?\n/).includes("CHANGED"); } function ensurePosixShellPath(binDir: string): void { diff --git a/packages/core/src/profiles/service.ts b/packages/core/src/profiles/service.ts index efcc84bf..8773cb66 100644 --- a/packages/core/src/profiles/service.ts +++ b/packages/core/src/profiles/service.ts @@ -34,11 +34,23 @@ const managedToolHubMcpStart = "# BEGIN CCR managed ToolHub MCP"; const managedToolHubMcpEnd = "# END CCR managed ToolHub MCP"; const originalBackupSuffix = ".ccr-original"; const originalMissingSuffix = ".ccr-original-missing"; +const globalProfileTakeoverFile = path.join(CONFIGDIR, "global-profile-takeover.json"); const fallbackClientToken = "ccr-local"; const privateDirMode = 0o700; const privateExecutableMode = 0o700; const privateFileMode = 0o600; const publicExecutableMode = 0o755; +let ownedGlobalProfileTakeovers: GlobalProfileTakeoverRecord[] | undefined; + +type GlobalProfileTakeoverRecord = { + agent: ProfileClientKind; + codexHome?: string; + configFile?: string; + id: string; + name: string; + providerId?: string; + settingsFile?: string; +}; export async function applyProfileConfig(config: AppConfig): Promise { cleanupGeneratedBinBackups(); @@ -49,9 +61,15 @@ export async function applyProfileConfig(config: AppConfig): Promise profile.enabled) }; + const takeoverStatuses = synchronizeGlobalProfileTakeovers( + profiles, + result.enabled && hasAvailableGatewayModels(config) + ); if (!result.enabled) { result.clients = profiles.map(disabledProfileStatus); + result.clients.push(...takeoverStatuses); + result.clients.push(...restoreInactiveGlobalProfileConfigs(profiles)); return result; } @@ -76,6 +94,8 @@ export async function applyProfileConfig(config: AppConfig): Promise profile.agent === "codex"); + if (codexProfiles.length > 0 && !codexProfiles.some((profile) => profile.enabled && isGlobalProfile(profile))) { + for (const file of uniqueResolvedPaths([ + ...codexProfiles.map(globalCodexConfigCandidate) + ])) { + const restoreResult = restoreGlobalConfigFile(file, { + isManagedContent: (content) => isManagedCodexConfigContent(content, "claude-code-router"), + mode: privateFileMode + }); + if (restoreResult.changed || restoreResult.missingBackup) { + statuses.push(inactiveGlobalCleanupStatus("codex", file, restoreResult)); + } + } + } + const zcodeProfiles = profiles.filter((profile) => profile.agent === "zcode"); + if (zcodeProfiles.length > 0 && !zcodeProfiles.some((profile) => profile.enabled && isGlobalProfile(profile))) { + const providerIds = [...new Set([ + "claude-code-router", + ...zcodeProfiles.map((profile) => sanitizeCodexProviderId(profile.providerId || "")).filter(Boolean) + ])]; + const configFiles = uniqueResolvedPaths([ + ...zcodeProfiles.map((profile) => resolveZcodeConfigFile(profile)) + ]); + for (const configFile of configFiles) { + const storageRoot = zcodeHomeFromConfigFile(configFile); + for (const file of [ + configFile, + path.join(storageRoot, "v2", "config.json"), + path.join(storageRoot, "v2", "bots-model-cache.v2.json") + ]) { + const restoreResult = restoreGlobalConfigFile(file, { + isManagedContent: (content) => providerIds.some((providerId) => isManagedZcodeConfigContent(content, providerId)), + mode: privateFileMode + }); + if (restoreResult.changed || restoreResult.missingBackup) { + statuses.push(inactiveGlobalCleanupStatus("zcode", file, restoreResult)); + } + } + } + } return statuses; } +function globalCodexConfigCandidate(profile: ProfileConfig): string { + const codexHome = profile.codexHome?.trim(); + if (codexHome) { + return path.join(resolveUserPath(codexHome), "config.toml"); + } + return profile.configFile || "~/.codex/config.toml"; +} + +export function restoreGlobalProfileConfigsOnExit( + profiles: ProfileConfig[], + options: { manageMarker?: boolean } = {} +): ProfileClientApplyStatus[] { + const manageMarker = options.manageMarker !== false; + const records = dedupeGlobalProfileTakeovers([ + ...(manageMarker ? ownedGlobalProfileTakeovers ?? readGlobalProfileTakeoverMarker() : []), + ...globalProfileTakeoverRecords(profiles) + ]); + const statuses = restoreGlobalProfileTakeoverRecords(records); + if (manageMarker && statuses.every((status) => status.ok)) { + clearGlobalProfileTakeoverMarker(); + ownedGlobalProfileTakeovers = []; + } + return statuses; +} + +function synchronizeGlobalProfileTakeovers(profiles: ProfileConfig[], canTakeOver: boolean): ProfileClientApplyStatus[] { + const next = canTakeOver ? globalProfileTakeoverRecords(profiles) : []; + const previous = ownedGlobalProfileTakeovers ?? readGlobalProfileTakeoverMarker(); + if (ownedGlobalProfileTakeovers !== undefined && JSON.stringify(previous) === JSON.stringify(next)) { + return []; + } + + const statuses = previous.length > 0 ? restoreGlobalProfileTakeoverRecords(previous) : []; + const markerRecords = statuses.every((status) => status.ok) + ? next + : dedupeGlobalProfileTakeovers([...previous, ...next]); + if (markerRecords.length > 0) { + writeGlobalProfileTakeoverMarker(markerRecords); + } else { + clearGlobalProfileTakeoverMarker(); + } + ownedGlobalProfileTakeovers = markerRecords; + return statuses; +} + +function globalProfileTakeoverRecords(profiles: ProfileConfig[]): GlobalProfileTakeoverRecord[] { + return dedupeGlobalProfileTakeovers(profiles + .filter((profile) => profile.enabled && isGlobalProfile(profile)) + .map((profile) => ({ + agent: profile.agent, + codexHome: profile.codexHome?.trim() || undefined, + configFile: profile.configFile?.trim() || undefined, + id: profile.id, + name: profile.name, + providerId: profile.providerId?.trim() || undefined, + settingsFile: profile.settingsFile?.trim() || undefined + }))); +} + +function restoreGlobalProfileTakeoverRecords(records: GlobalProfileTakeoverRecord[]): ProfileClientApplyStatus[] { + return records.map((record) => disabledProfileStatus({ + ...record, + enabled: false, + env: {}, + model: "", + scope: "global", + surface: "auto" + })); +} + +function dedupeGlobalProfileTakeovers(records: GlobalProfileTakeoverRecord[]): GlobalProfileTakeoverRecord[] { + const seen = new Set(); + return records.filter((record) => { + const key = JSON.stringify(record); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +function readGlobalProfileTakeoverMarker(): GlobalProfileTakeoverRecord[] { + try { + const parsed = JSON.parse(readFileSync(globalProfileTakeoverFile, "utf8")) as { profiles?: unknown }; + if (!Array.isArray(parsed.profiles)) { + return []; + } + return parsed.profiles.filter((value): value is GlobalProfileTakeoverRecord => + isRecord(value) && + (value.agent === "claude-code" || value.agent === "codex" || value.agent === "zcode") && + typeof value.id === "string" && + typeof value.name === "string" + ); + } catch { + return []; + } +} + +function writeGlobalProfileTakeoverMarker(records: GlobalProfileTakeoverRecord[]): void { + mkdirSync(path.dirname(globalProfileTakeoverFile), { recursive: true }); + writeFileSync(globalProfileTakeoverFile, `${JSON.stringify({ profiles: records, version: 1 }, null, 2)}\n`, { + encoding: "utf8", + mode: privateFileMode + }); +} + +function clearGlobalProfileTakeoverMarker(): void { + rmSync(globalProfileTakeoverFile, { force: true }); +} + function inactiveGlobalCleanupStatus( client: ProfileClientKind, file: string, @@ -1513,6 +1685,20 @@ function restoreGlobalConfigFile( return { changed: false, file, missingBackup: false, restored: false }; } + const snapshot = originalSnapshotCandidate(file, options.isManagedContent); + if (snapshot) { + if (current === snapshot.content) { + chmodFileIfRequested(file, options.mode); + return { changed: false, file, missingBackup: false, restored: true }; + } + + const backupFile = current === undefined ? undefined : backupCurrentConfigFile(file, options.mode); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, snapshot.content, options.mode === undefined ? "utf8" : { encoding: "utf8", mode: options.mode }); + chmodFileIfRequested(file, options.mode); + return { backupFile, changed: true, file, missingBackup: false, restored: true }; + } + if (existsSync(originalMissingFilePath(file))) { if (currentManaged) { const backupFile = backupCurrentConfigFile(file, options.mode); @@ -1522,33 +1708,22 @@ function restoreGlobalConfigFile( return { changed: false, file, missingBackup: false, restored: current === undefined }; } - const snapshot = originalSnapshotCandidate(file, options.isManagedContent); - if (!snapshot) { - return { - changed: false, - file, - missingBackup: Boolean(currentManaged), - restored: false - }; - } - - if (current === snapshot.content) { - chmodFileIfRequested(file, options.mode); - return { changed: false, file, missingBackup: false, restored: true }; - } - - const backupFile = current === undefined ? undefined : backupCurrentConfigFile(file, options.mode); - mkdirSync(path.dirname(file), { recursive: true }); - writeFileSync(file, snapshot.content, options.mode === undefined ? "utf8" : { encoding: "utf8", mode: options.mode }); - chmodFileIfRequested(file, options.mode); - return { backupFile, changed: true, file, missingBackup: false, restored: true }; + return { + changed: false, + file, + missingBackup: Boolean(currentManaged), + restored: false + }; } function originalSnapshotCandidate( file: string, isManagedContent: (content: string) => boolean ): { content: string; file: string } | undefined { - for (const candidate of [originalBackupFilePath(file), ...backupFiles(file)]) { + // Prefer the most recent non-CCR snapshot captured immediately before the + // latest takeover. The permanent .ccr-original file can be stale when the + // user changes the agent config between separate CCR sessions. + for (const candidate of [...backupFiles(file).reverse(), originalBackupFilePath(file)]) { if (!existsSync(candidate)) { continue; } diff --git a/packages/electron/src/main/main-app.ts b/packages/electron/src/main/main-app.ts index 9f3ab9a9..4cc37d15 100644 --- a/packages/electron/src/main/main-app.ts +++ b/packages/electron/src/main/main-app.ts @@ -5,8 +5,8 @@ import { restoreClaudeAppGatewayConfig, syncClaudeAppGatewayConfig } from "@ccr/ import { deepLinkService } from "./deep-link"; import { gatewayService } from "@ccr/core/gateway/service"; import "./ipc"; -import { applyProfileConfig } from "@ccr/core/profiles/service"; -import { ensureCcrCliLauncher } from "@ccr/core/profiles/launch-service"; +import { applyProfileConfig, restoreGlobalProfileConfigsOnExit } from "@ccr/core/profiles/service"; +import { ensureCcrCliLauncher, persistPreparedCcrCliPath, prepareCcrCliLauncherRuntime, type CcrCliLauncherPreparation } from "@ccr/core/profiles/launch-service"; import { syncLaunchAtLogin } from "./launch-at-login"; import { proxyService } from "@ccr/core/proxy/service"; import trayController from "./tray-controller"; @@ -43,13 +43,25 @@ function startPrimaryInstance(): void { void app.whenReady().then(() => { configureProxyDesktopIntegration(); + let ccrLauncherPreparation: CcrCliLauncherPreparation | undefined; try { - ensureCcrCliLauncher(); + ccrLauncherPreparation = prepareCcrCliLauncherRuntime(); } catch (error) { - console.error(`Failed to install ccr CLI launcher: ${formatError(error)}`); + console.error(`Failed to prepare ccr CLI runtime: ${formatError(error)}`); } setupApplicationMenu(); - windowsManager.createMainWindow(); + const mainWindow = windowsManager.createMainWindow(); + if (ccrLauncherPreparation?.persistentPathRequired) { + mainWindow.once("ready-to-show", () => { + setTimeout(() => { + try { + persistPreparedCcrCliPath(ccrLauncherPreparation); + } catch (error) { + console.error(`Failed to persist ccr CLI PATH: ${formatError(error)}`); + } + }, 0); + }); + } trayController.start(); appUpdateService.start(); appUpdateService.setInstallPreparation(prepareForUpdateInstall); @@ -164,7 +176,17 @@ function stopServicesForQuit(): Promise { .catch((error) => { console.error(`Failed to stop services before quit: ${formatError(error)}`); }) - .finally(() => { + .finally(async () => { + try { + const config = await loadAppConfig(); + for (const status of restoreGlobalProfileConfigsOnExit(config.profile.profiles)) { + if (!status.ok) { + console.error(`Failed to restore ${status.client} global profile config before quit: ${status.message}`); + } + } + } catch (error) { + console.error(`Failed to restore global profile configs before quit: ${formatError(error)}`); + } try { restoreClaudeAppGatewayConfig(); } catch (error) { @@ -185,6 +207,11 @@ function startConfiguredServices(reason: string): Promise { } catch (error) { console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`); } + try { + ensureCcrCliLauncher(config, { persistPath: false }); + } catch (error) { + console.error(`Failed to install ccr CLI launcher during ${reason}: ${formatError(error)}`); + } try { syncLaunchAtLogin(config); } catch (error) { diff --git a/packages/electron/src/main/update-service.ts b/packages/electron/src/main/update-service.ts index 9d1c3ba6..2af3ac9c 100644 --- a/packages/electron/src/main/update-service.ts +++ b/packages/electron/src/main/update-service.ts @@ -10,7 +10,7 @@ type UpdateCheckOptions = { }; const startupCheckDelayMs = 12_000; -const defaultUpdateSource = "GitHub Releases (musistudio/claude-code-router)"; +const defaultUpdateSource = "GitHub Releases"; class AppUpdateService { private activeSilentCheckFailureRestoreStatus?: AppUpdateStatus; diff --git a/packages/ui/src/pages/home/components/update.tsx b/packages/ui/src/pages/home/components/update.tsx index 87af7b66..6215a13b 100644 --- a/packages/ui/src/pages/home/components/update.tsx +++ b/packages/ui/src/pages/home/components/update.tsx @@ -46,12 +46,13 @@ export function UpdateDialog({
-
-
{updateStateLabel(status, t)}
-
{updateStateDescription(status, t)}
+
+ {updateStateDescription(status, t) ? ( +
{updateStateDescription(status, t)}
+ ) : null}
@@ -59,8 +60,8 @@ export function UpdateDialog({
@@ -156,8 +157,10 @@ function UpdateStateBadge({ label, status }: { label: string; status: AppUpdateS "shrink-0 rounded-full border px-2 py-1 text-[11px] font-medium", status.state === "error" ? "border-destructive/25 bg-destructive/10 text-destructive" + : status.state === "not-available" + ? "border-emerald-200 bg-emerald-50 text-emerald-700" : status.state === "available" || status.state === "downloaded" || status.state === "downloading" - ? "border-primary/25 bg-primary/10 text-primary" + ? "border-amber-200 bg-amber-50 text-amber-700" : "border-border bg-muted/40 text-muted-foreground" )}> {label} @@ -181,7 +184,7 @@ function updateStateDescription(status: AppUpdateStatus, t: (value: string) => s if (!status.supported) return t("Updates are only available in packaged builds."); if (status.state === "available" && status.availableVersion) return `${t("Available version")}: ${status.availableVersion}`; if (status.state === "downloaded") return t("Update downloaded"); - if (status.state === "not-available") return t("No updates available"); + if (status.state === "not-available") return ""; if (status.state === "downloading") return t("Downloading update"); return t("Online updates"); } diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 45bab28a..002d0af6 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -1679,6 +1679,7 @@ export const appCopy: Record = { "System status": "系统状态", "System Proxy": "系统代理", "Available version": "可用版本", + "Latest version": "最新版本", "Download update": "下载更新", "Downloading update": "正在下载更新", "Feed URL": "更新源", diff --git a/tests/main/profile-service.test.mjs b/tests/main/profile-service.test.mjs index 46283389..e04e2a1d 100644 --- a/tests/main/profile-service.test.mjs +++ b/tests/main/profile-service.test.mjs @@ -5,7 +5,7 @@ import path from "node:path"; import test from "node:test"; import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts"; import { CONFIGDIR } from "../../packages/core/src/config/constants.ts"; -import { applyProfileConfig, cleanupGeneratedBinBackups, restoreInactiveGlobalProfileConfigs } from "../../packages/core/src/profiles/service.ts"; +import { applyProfileConfig, cleanupGeneratedBinBackups, restoreGlobalProfileConfigsOnExit, restoreInactiveGlobalProfileConfigs } from "../../packages/core/src/profiles/service.ts"; test("profile service cleans stale generated bin backups only", () => { const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-generated-bin-cleanup-")); @@ -386,3 +386,87 @@ test("profile service keeps managed global Claude settings when a global Claude rmSync(home, { force: true, recursive: true }); } }); + +test("profile service restores global agent configs on exit", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "ccr-global-profile-exit-")); + try { + const claudeFile = path.join(root, "claude", "settings.json"); + const codexFile = path.join(root, "codex", "config.toml"); + const zcodeFile = path.join(root, "zcode", "cli", "config.json"); + const zcodeRoot = path.dirname(path.dirname(zcodeFile)); + const zcodeV2File = path.join(zcodeRoot, "v2", "config.json"); + const zcodeCacheFile = path.join(zcodeRoot, "v2", "bots-model-cache.v2.json"); + const files = [claudeFile, codexFile, zcodeFile, zcodeV2File, zcodeCacheFile]; + const originals = new Map(files.map((file, index) => [file, `original-${index}\n`])); + const latestSnapshots = new Map(files.map((file, index) => [file, `latest-${index}\n`])); + + for (const [file, original] of originals) { + mkdirSync(path.dirname(file), { recursive: true }); + if (file === zcodeCacheFile) { + writeFileSync(`${file}.ccr-original-missing`, ""); + } else { + writeFileSync(`${file}.ccr-original`, original); + } + writeFileSync(`${file}.ccr-backup-2026-07-11T00-00-00-000Z`, latestSnapshots.get(file)); + } + writeFileSync(claudeFile, `${JSON.stringify({ + apiKeyHelper: "ccr-claude-code-api-key-test", + env: { + ANTHROPIC_API_BASE_URL: "http://127.0.0.1:3456", + ANTHROPIC_BASE_URL: "http://127.0.0.1:3456", + CLAUDE_AGENT_API_BASE_URL: "http://127.0.0.1:3456" + } + })}\n`); + writeFileSync(codexFile, "# BEGIN CCR managed profile\nmodel = \"test\"\n# END CCR managed profile\n"); + for (const file of [zcodeFile, zcodeV2File]) { + writeFileSync(file, `${JSON.stringify({ provider: { "claude-code-router": {} } })}\n`); + } + writeFileSync(zcodeCacheFile, `${JSON.stringify({ providers: [{ id: "claude-code-router" }] })}\n`); + + const statuses = restoreGlobalProfileConfigsOnExit([ + { + agent: "claude-code", enabled: true, env: {}, id: "claude", model: "test", name: "Claude", + scope: "global", settingsFile: claudeFile, smallFastModel: "", surface: "cli" + }, + { + agent: "codex", configFile: codexFile, enabled: true, env: {}, id: "codex", model: "test", name: "Codex", + providerId: "claude-code-router", scope: "global", surface: "cli" + }, + { + agent: "zcode", configFile: zcodeFile, enabled: true, env: {}, id: "zcode", model: "test", name: "ZCode", + providerId: "claude-code-router", scope: "global", surface: "app" + } + ], { manageMarker: false }); + + assert.equal(statuses.length, 3); + assert.equal(statuses.every((status) => status.ok), true); + for (const [file, latest] of latestSnapshots) { + assert.equal(readFileSync(file, "utf8"), latest); + } + + writeFileSync(codexFile, "# BEGIN CCR managed profile\nmodel = \"test\"\n# END CCR managed profile\n"); + for (const file of [zcodeFile, zcodeV2File]) { + writeFileSync(file, `${JSON.stringify({ provider: { "claude-code-router": {} } })}\n`); + } + writeFileSync(zcodeCacheFile, `${JSON.stringify({ providers: [{ id: "claude-code-router" }] })}\n`); + const inactiveStatuses = restoreInactiveGlobalProfileConfigs([ + { + agent: "codex", configFile: codexFile, enabled: false, env: {}, id: "codex", model: "test", name: "Codex", + providerId: "claude-code-router", scope: "ccr", surface: "cli" + }, + { + agent: "zcode", configFile: zcodeFile, enabled: false, env: {}, id: "zcode", model: "test", name: "ZCode", + providerId: "claude-code-router", scope: "ccr", surface: "app" + } + ]); + assert.equal(inactiveStatuses.filter((status) => status.client === "codex").length, 1); + assert.equal(inactiveStatuses.filter((status) => status.client === "zcode").length, 3); + for (const [file, latest] of latestSnapshots) { + if (file !== claudeFile) { + assert.equal(readFileSync(file, "utf8"), latest); + } + } + } finally { + rmSync(root, { force: true, recursive: true }); + } +});