ChatGPT Desktop + Codex CLI setup
Choose this guide when you use both the graphical Codex Agent and the terminal Codex Agent. Configure the URL, Access Key, and client-facing model once; Codex in ChatGPT desktop, plain Codex CLI, and the Codex IDE extension then read the same user-level settings.
Ordinary ChatGPT conversations are unaffected. The shared settings apply to the Codex workspace in ChatGPT desktop, Codex CLI, and the Codex IDE extension.
Connection modes
| Mode | Available | Purpose and reason |
|---|---|---|
| One-time launch | No | Shared mode must be readable by graphical apps; use isolated Codex CLI setup when only a temporary CLI run is needed |
| Persistent use | Yes | Desktop, CLI, and IDE share one user-level provider, model catalog, model, and credential |
| Remove setup | Yes | Removes the shared Lumoswitch setup from all three surfaces while preserving other Codex settings |
1. Confirm that both surfaces are available
- ChatGPT desktop opens normally and can enter Codex.
codex --versionprints a version in a new terminal.- The Lumoswitch API configuration enables Responses output.
If codex is unavailable, choose one official installation method. The npm method works on Windows, macOS, and Linux:
# Standalone installer for macOS or Linux
curl -fsSL https://chatgpt.com/codex/install.sh | sh
# Windows, macOS, or Linux with npm
npm install -g @openai/codex
# Or use Homebrew
brew install --cask codexInstalling ChatGPT desktop does not guarantee that Codex CLI is installed.
2. Prepare four configuration values
Replace each placeholder in the settings command with the real value shown on the API configuration result:
| Placeholder | Value |
|---|---|
{{api_base_url}} | Lumoswitch API URL, normally ending in /v1 |
{{access_key}} | The API configuration's Access Key; never share it or commit it to Git |
{{model}} | The client-facing model name, not an upstream platform display name |
{{codex_model_catalog_base64}} | Capability-aware Codex catalog, Base64 encoded |
3. Persistent use with shared settings
This setup is persistent only. Temporary environment variables cannot be passed reliably to graphical applications opened from Finder, Dock, or an IDE.
Desktop-only and desktop + CLI use must run the exact same settings command on each operating system so the two paths never develop different merge behavior. On macOS, quit ChatGPT completely with Command + Q and end every active Codex CLI session, then replace the four placeholders below and run the complete command in a trusted terminal:
set -e
LUMOSWITCH_CODEX_DIR="$HOME/.codex"
LUMOSWITCH_CODEX_CONFIG="$LUMOSWITCH_CODEX_DIR/config.toml"
LUMOSWITCH_CODEX_ENV="$LUMOSWITCH_CODEX_DIR/.env"
LUMOSWITCH_CODEX_CATALOG="$LUMOSWITCH_CODEX_DIR/lumoswitch-model-catalog.json"
LUMOSWITCH_CODEX_BIN="/Applications/ChatGPT.app/Contents/Resources/codex"
LUMOSWITCH_BACKUP_SUFFIX="$(date +%Y%m%d-%H%M%S)-$$"
LUMOSWITCH_CONFIG_TMP=""
LUMOSWITCH_ENV_TMP=""
LUMOSWITCH_CATALOG_TMP=""
lumoswitch_cleanup() {
[ -z "$LUMOSWITCH_CONFIG_TMP" ] || rm -f "$LUMOSWITCH_CONFIG_TMP"
[ -z "$LUMOSWITCH_ENV_TMP" ] || rm -f "$LUMOSWITCH_ENV_TMP"
[ -z "$LUMOSWITCH_CATALOG_TMP" ] || rm -f "$LUMOSWITCH_CATALOG_TMP"
}
trap lumoswitch_cleanup EXIT HUP INT TERM
if pgrep -x ChatGPT >/dev/null 2>&1; then
echo "Quit ChatGPT completely with Command + Q, then run this command again." >&2
exit 1
fi
mkdir -p "$LUMOSWITCH_CODEX_DIR"
umask 077
if [ ! -x "$LUMOSWITCH_CODEX_BIN" ]; then
echo "ChatGPT's bundled Codex executable was not found. Install or update ChatGPT for macOS first." >&2
exit 1
fi
if [ -L "$LUMOSWITCH_CODEX_CONFIG" ] || [ -L "$LUMOSWITCH_CODEX_ENV" ] || [ -L "$LUMOSWITCH_CODEX_CATALOG" ]; then
echo "A Codex settings, credential, or Lumoswitch model-catalog file is a symbolic link. Automatic merge stopped; edit its target manually." >&2
exit 1
fi
if [ -e "$LUMOSWITCH_CODEX_CATALOG" ] && [ ! -f "$LUMOSWITCH_CODEX_CATALOG" ]; then
echo "The Lumoswitch model-catalog path is occupied by a non-regular file. Automatic merge stopped: $LUMOSWITCH_CODEX_CATALOG" >&2
exit 1
fi
LUMOSWITCH_MODEL_VALUE="$(/bin/cat <<'LUMOSWITCH_MODEL_VALUE_EOF'
{{model}}
LUMOSWITCH_MODEL_VALUE_EOF
)"
LUMOSWITCH_CODEX_CATALOG_BASE64="$(/bin/cat <<'LUMOSWITCH_CODEX_CATALOG_BASE64_EOF'
{{codex_model_catalog_base64}}
LUMOSWITCH_CODEX_CATALOG_BASE64_EOF
)"
LUMOSWITCH_MODEL_LITERAL="$(/usr/bin/osascript -l JavaScript - "$LUMOSWITCH_MODEL_VALUE" <<'LUMOSWITCH_STRING_JXA'
function run(argv) {
return JSON.stringify(argv[0]);
}
LUMOSWITCH_STRING_JXA
)"
LUMOSWITCH_CATALOG_LITERAL="$(/usr/bin/osascript -l JavaScript - "$LUMOSWITCH_CODEX_CATALOG" <<'LUMOSWITCH_PATH_JXA'
function run(argv) {
return JSON.stringify(argv[0]);
}
LUMOSWITCH_PATH_JXA
)"
if [ -f "$LUMOSWITCH_CODEX_CONFIG" ]; then
cp "$LUMOSWITCH_CODEX_CONFIG" "$LUMOSWITCH_CODEX_CONFIG.backup.$LUMOSWITCH_BACKUP_SUFFIX"
chmod 600 "$LUMOSWITCH_CODEX_CONFIG.backup.$LUMOSWITCH_BACKUP_SUFFIX"
if awk '
BEGIN { root = 1; found = 0 }
/^[[:space:]]*\[/ { root = 0 }
root && /^[[:space:]]*model_providers[[:space:]]*=/ { found = 1 }
END { exit found ? 0 : 1 }
' "$LUMOSWITCH_CODEX_CONFIG"; then
echo "Inline model_providers detected. Automatic merge stopped; merge it manually. The original file is unchanged." >&2
exit 1
fi
if grep -Eq "^[[:space:]]*('features'([.][^[:space:]=]+)?[[:space:]]*=|\\[[^]]*'features')" "$LUMOSWITCH_CODEX_CONFIG"; then
echo "Single-quoted features settings cannot be merged safely. Automatic merge stopped; the original file is unchanged." >&2
exit 1
fi
if awk '
BEGIN { root = 1; found = 0 }
/^[[:space:]]*\[/ { root = 0 }
root && /^[[:space:]]*(features|"features")([.][^[:space:]=]+)?[[:space:]]*=/ { found = 1 }
END { exit found ? 0 : 1 }
' "$LUMOSWITCH_CODEX_CONFIG"; then
echo "Top-level inline features settings cannot be merged safely into [features]. Automatic merge stopped; the original file is unchanged." >&2
exit 1
fi
if awk '
BEGIN { count = 0; invalid = 0 }
/^[[:space:]]*\[/ {
value = $0
gsub(/[[:space:]]/, "", value)
if (value == "[features]") count++
else if (value ~ /^\[features[.]/ || value ~ /^\["features"/) invalid = 1
}
END { exit count > 1 || invalid ? 0 : 1 }
' "$LUMOSWITCH_CODEX_CONFIG"; then
echo "Duplicate, quoted, or nested features tables cannot be merged safely. Automatic merge stopped; the original file is unchanged." >&2
exit 1
fi
if grep -Eq '^[[:space:]]*\[[[:space:]]*model_providers[.]"lumoswitch"' "$LUMOSWITCH_CODEX_CONFIG"; then
echo "A quoted Lumoswitch provider table was detected. Automatic merge stopped; merge it manually. The original file is unchanged." >&2
exit 1
fi
if awk -v expected="$LUMOSWITCH_CATALOG_LITERAL" '
BEGIN { root = 1; mismatch = 0 }
/^[[:space:]]*\[/ { root = 0 }
root && /^[[:space:]]*model_catalog_json[[:space:]]*=/ {
value = $0
sub(/^[[:space:]]*model_catalog_json[[:space:]]*=[[:space:]]*/, "", value)
sub(/[[:space:]]*(#.*)?$/, "", value)
if (value != expected) mismatch = 1
}
END { exit mismatch ? 0 : 1 }
' "$LUMOSWITCH_CODEX_CONFIG"; then
echo "Another Codex custom model catalog is already configured. Automatic replacement stopped; merge the model manually instead." >&2
exit 1
fi
fi
LUMOSWITCH_CATALOG_TMP="$(mktemp "$LUMOSWITCH_CODEX_DIR/lumoswitch-model-catalog.json.lumoswitch.XXXXXX")"
printf '%s' "$LUMOSWITCH_CODEX_CATALOG_BASE64" | /usr/bin/base64 -D > "$LUMOSWITCH_CATALOG_TMP"
chmod 600 "$LUMOSWITCH_CATALOG_TMP"
LUMOSWITCH_CATALOG_TMP_LITERAL="$(/usr/bin/osascript -l JavaScript - "$LUMOSWITCH_CATALOG_TMP" <<'LUMOSWITCH_TMP_PATH_JXA'
function run(argv) {
return JSON.stringify(argv[0]);
}
LUMOSWITCH_TMP_PATH_JXA
)"
"$LUMOSWITCH_CODEX_BIN" debug models -c "model_catalog_json=$LUMOSWITCH_CATALOG_TMP_LITERAL" >/dev/null
LUMOSWITCH_CONFIG_TMP="$(mktemp "$LUMOSWITCH_CODEX_DIR/config.toml.lumoswitch.XXXXXX")"
{
printf 'model = %s\n' "$LUMOSWITCH_MODEL_LITERAL"
printf 'model_provider = "lumoswitch"\n'
printf 'model_catalog_json = %s\n' "$LUMOSWITCH_CATALOG_LITERAL"
printf 'web_search = "disabled"\n'
if [ -f "$LUMOSWITCH_CODEX_CONFIG" ]; then
printf '\n'
awk '
BEGIN { root = 1; skip = 0; in_features = 0; features_seen = 0 }
/^[[:space:]]*\[/ {
if (in_features) {
print "multi_agent = false"
in_features = 0
}
root = 0
if ($0 ~ /^[[:space:]]*\[[[:space:]]*model_providers[.]lumoswitch([.][^]]+)?[[:space:]]*\]/) {
skip = 1
next
}
skip = 0
if ($0 ~ /^[[:space:]]*\[[[:space:]]*features[[:space:]]*\]/) {
in_features = 1
features_seen = 1
}
}
skip { next }
in_features && /^[[:space:]]*multi_agent[[:space:]]*=/ { next }
root && /^[[:space:]]*(model|model_provider|model_catalog_json|model_reasoning_effort|web_search)[[:space:]]*=/ { next }
{ print }
END {
if (in_features) print "multi_agent = false"
else if (!features_seen) {
print ""
print "[features]"
print "multi_agent = false"
}
}
' "$LUMOSWITCH_CODEX_CONFIG"
else
printf '\n[features]\n'
printf 'multi_agent = false\n'
fi
printf '\n[model_providers.lumoswitch]\n'
printf 'name = "Lumoswitch"\n'
printf 'base_url = "%s"\n' "{{api_base_url}}"
printf 'env_key = "LUMOSWITCH_API_KEY"\n'
printf 'wire_api = "responses"\n'
printf 'http_headers = { "X-Lumoswitch-Agent" = "codex-v1" }\n'
printf 'requires_openai_auth = false\n'
} > "$LUMOSWITCH_CONFIG_TMP"
chmod 600 "$LUMOSWITCH_CONFIG_TMP"
mv "$LUMOSWITCH_CATALOG_TMP" "$LUMOSWITCH_CODEX_CATALOG"
LUMOSWITCH_CATALOG_TMP=""
mv "$LUMOSWITCH_CONFIG_TMP" "$LUMOSWITCH_CODEX_CONFIG"
LUMOSWITCH_CONFIG_TMP=""
if [ -f "$LUMOSWITCH_CODEX_ENV" ]; then
LUMOSWITCH_ENV_TMP="$(mktemp "$LUMOSWITCH_CODEX_DIR/.env.backup.lumoswitch.XXXXXX")"
awk '!/^[[:space:]]*(export[[:space:]]+)?LUMOSWITCH_API_KEY[[:space:]]*=/' "$LUMOSWITCH_CODEX_ENV" > "$LUMOSWITCH_ENV_TMP"
chmod 600 "$LUMOSWITCH_ENV_TMP"
mv "$LUMOSWITCH_ENV_TMP" "$LUMOSWITCH_CODEX_ENV.backup.lumoswitch.$LUMOSWITCH_BACKUP_SUFFIX"
LUMOSWITCH_ENV_TMP=""
fi
LUMOSWITCH_ENV_TMP="$(mktemp "$LUMOSWITCH_CODEX_DIR/.env.lumoswitch.XXXXXX")"
if [ -f "$LUMOSWITCH_CODEX_ENV" ]; then
awk '!/^[[:space:]]*(export[[:space:]]+)?LUMOSWITCH_API_KEY[[:space:]]*=/' "$LUMOSWITCH_CODEX_ENV" > "$LUMOSWITCH_ENV_TMP"
fi
printf 'LUMOSWITCH_API_KEY=%s\n' "{{access_key}}" >> "$LUMOSWITCH_ENV_TMP"
chmod 600 "$LUMOSWITCH_ENV_TMP"
mv "$LUMOSWITCH_ENV_TMP" "$LUMOSWITCH_CODEX_ENV"
LUMOSWITCH_ENV_TMP=""
printf 'Shared Codex settings updated; backup suffix: %s\n' "$LUMOSWITCH_BACKUP_SUFFIX"
open -a ChatGPTThe command:
- backs up and merges
~/.codex/config.tomlwhile preserving MCP, approval, plugin, and other provider settings; - creates
~/.codex/lumoswitch-model-catalog.json, containing only the client-facing model from the Lumoswitch configuration result; - validates that catalog with the Codex executable bundled in ChatGPT before installing it;
- installs the capability-aware model catalog produced by Lumoswitch, exposing only reasoning levels supported by both the model and its effective route;
- applies a cross-model minimum-compatibility profile by setting top-level
web_search = "disabled"and mergingmulti_agent = falseinto an existing or new[features]table; this disables Codex's multi-agent namespace and native web search so downstream models that do not recognize those tools do not reject the entire Responses request, while standard function tools, terminal commands, and file operations remain available; - stores the Access Key in
~/.codex/.envwith0600permissions, while.envbackups preserve other values but deliberately excludeLUMOSWITCH_API_KEY; and - preserves other feature entries, and stops before changing a symbolic link, a non-regular dedicated catalog path, another configured model catalog, or an inline, duplicate, quoted, nested, or otherwise unsupported TOML form.
Native Windows PowerShell import
When ChatGPT uses its default Windows native Agent environment, the desktop app, native Codex CLI, and IDE extension share the user-level Codex settings in %USERPROFILE%\.codex. If Settings > Agent environment is set to WSL, switch it to Windows native and restart ChatGPT before importing because WSL uses a different Codex home by default. Quit ChatGPT completely and end all running Codex CLI sessions, then run this complete command in a trusted PowerShell terminal. A one-time shared mode is intentionally unavailable because a Start menu app does not reliably inherit process-scoped PowerShell variables.
& {
$ErrorActionPreference = 'Stop'
$lumoswitchCodexDir = Join-Path $HOME '.codex'
$lumoswitchConfig = Join-Path $lumoswitchCodexDir 'config.toml'
$lumoswitchEnvironment = Join-Path $lumoswitchCodexDir '.env'
$lumoswitchCatalog = Join-Path $lumoswitchCodexDir 'lumoswitch-model-catalog.json'
$lumoswitchBackupSuffix = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + $PID
$lumoswitchChatGptApp = Get-StartApps | Where-Object { $_.Name -eq 'ChatGPT' -or $_.AppID -match 'ChatGPT' } | Select-Object -First 1
if ($null -eq $lumoswitchChatGptApp) { throw 'ChatGPT was not found in the Start menu. Install or update the Windows desktop app first.' }
if (Get-Process -Name 'ChatGPT' -ErrorAction SilentlyContinue) { throw 'Quit ChatGPT completely, then run this command again.' }
function Assert-LumoswitchFile([string] $Path) {
if (-not (Test-Path -LiteralPath $Path)) { return }
$lumoswitchItem = Get-Item -LiteralPath $Path -Force
if ($lumoswitchItem.PSIsContainer -or ($lumoswitchItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
throw "Lumoswitch refused to replace a directory or link: $Path"
}
}
function Protect-LumoswitchFile([string] $Path) {
$lumoswitchSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
& icacls.exe $Path '/inheritance:r' '/grant:r' ('*' + $lumoswitchSid + ':(F)') '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Could not restrict access to $Path" }
}
function Write-LumoswitchFile([string] $Path, [string] $Content) {
Assert-LumoswitchFile $Path
[IO.Directory]::CreateDirectory((Split-Path -Parent $Path)) | Out-Null
$lumoswitchTemporary = $Path + '.' + [Guid]::NewGuid().ToString('N') + '.tmp'
try {
[IO.File]::WriteAllText($lumoswitchTemporary, $Content, [Text.UTF8Encoding]::new($false))
Move-Item -LiteralPath $lumoswitchTemporary -Destination $Path -Force
Protect-LumoswitchFile $Path
} finally {
Remove-Item -LiteralPath $lumoswitchTemporary -Force -ErrorAction SilentlyContinue
}
}
if (Test-Path -LiteralPath $lumoswitchCodexDir) {
$lumoswitchRootItem = Get-Item -LiteralPath $lumoswitchCodexDir -Force
if (-not $lumoswitchRootItem.PSIsContainer -or ($lumoswitchRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
throw 'The user-level Codex directory is not a regular directory. Automatic merge stopped.'
}
} else {
[IO.Directory]::CreateDirectory($lumoswitchCodexDir) | Out-Null
}
foreach ($lumoswitchPath in @($lumoswitchConfig, $lumoswitchEnvironment, $lumoswitchCatalog)) { Assert-LumoswitchFile $lumoswitchPath }
$lumoswitchCatalogText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{codex_model_catalog_base64}}'))
$lumoswitchCatalogObject = $lumoswitchCatalogText | ConvertFrom-Json
$lumoswitchCatalogModels = @($lumoswitchCatalogObject.models)
if ($lumoswitchCatalogModels.Count -eq 0 -or @($lumoswitchCatalogModels.slug) -notcontains '{{model}}') {
throw 'The generated Codex model catalog is empty or does not contain the selected model.'
}
$lumoswitchCatalogToml = $lumoswitchCatalog.Replace('\', '/')
$lumoswitchExpectedCatalog = '"' + $lumoswitchCatalogToml + '"'
$lumoswitchConfigLines = if (Test-Path -LiteralPath $lumoswitchConfig -PathType Leaf) { @([IO.File]::ReadAllLines($lumoswitchConfig, [Text.Encoding]::UTF8)) } else { @() }
$lumoswitchRoot = $true
$lumoswitchFeaturesCount = 0
foreach ($lumoswitchLine in $lumoswitchConfigLines) {
if ($lumoswitchLine -match '^\s*\[') {
$lumoswitchRoot = $false
$lumoswitchNormalized = $lumoswitchLine -replace '\s', ''
if ($lumoswitchNormalized -eq '[features]') { $lumoswitchFeaturesCount++ }
elseif ($lumoswitchNormalized -match '^\[(?:"features"|features\.)') { throw 'Quoted or nested features tables cannot be merged safely.' }
if ($lumoswitchNormalized -match '^\[model_providers\."lumoswitch"') { throw 'A quoted Lumoswitch provider table cannot be merged safely.' }
}
if ($lumoswitchRoot -and $lumoswitchLine -match '^\s*model_providers\s*=') { throw 'Inline model_providers cannot be merged safely.' }
if ($lumoswitchRoot -and $lumoswitchLine -match '^\s*features(?:\.|\s*=)') { throw 'Inline features settings cannot be merged safely.' }
if ($lumoswitchRoot -and $lumoswitchLine -match '^\s*model_catalog_json\s*=') {
$lumoswitchValue = ($lumoswitchLine -replace '^\s*model_catalog_json\s*=\s*', '') -replace '\s*(#.*)?$', ''
if ($lumoswitchValue -ne $lumoswitchExpectedCatalog) { throw 'Another Codex custom model catalog is already configured.' }
}
}
if ($lumoswitchFeaturesCount -gt 1) { throw 'Duplicate features tables cannot be merged safely.' }
if (Test-Path -LiteralPath $lumoswitchConfig -PathType Leaf) {
$lumoswitchConfigBackup = $lumoswitchConfig + '.backup.' + $lumoswitchBackupSuffix
Assert-LumoswitchFile $lumoswitchConfigBackup
Copy-Item -LiteralPath $lumoswitchConfig -Destination $lumoswitchConfigBackup
Protect-LumoswitchFile $lumoswitchConfigBackup
}
$lumoswitchOutput = [Collections.Generic.List[string]]::new()
foreach ($lumoswitchLine in @(
'model = "{{model}}"',
'model_provider = "lumoswitch"',
('model_catalog_json = "' + $lumoswitchCatalogToml + '"'),
'web_search = "disabled"',
''
)) { $lumoswitchOutput.Add($lumoswitchLine) | Out-Null }
$lumoswitchCopyRoot = $true
$lumoswitchSkip = $false
$lumoswitchInFeatures = $false
$lumoswitchFeaturesSeen = $false
foreach ($lumoswitchLine in $lumoswitchConfigLines) {
if ($lumoswitchLine -match '^\s*\[') {
if ($lumoswitchInFeatures) { $lumoswitchOutput.Add('multi_agent = false') | Out-Null }
$lumoswitchInFeatures = $false
$lumoswitchCopyRoot = $false
if ($lumoswitchLine -match '^\s*\[\s*model_providers\.lumoswitch(?:\.[^]]+)?\s*\]\s*$') { $lumoswitchSkip = $true; continue }
$lumoswitchSkip = $false
if (($lumoswitchLine -replace '\s', '') -eq '[features]') { $lumoswitchInFeatures = $true; $lumoswitchFeaturesSeen = $true }
}
if ($lumoswitchSkip) { continue }
if ($lumoswitchInFeatures -and $lumoswitchLine -match '^\s*multi_agent\s*=') { continue }
if ($lumoswitchCopyRoot -and $lumoswitchLine -match '^\s*(model|model_provider|model_catalog_json|model_reasoning_effort|web_search)\s*=') { continue }
$lumoswitchOutput.Add($lumoswitchLine) | Out-Null
}
if ($lumoswitchInFeatures) { $lumoswitchOutput.Add('multi_agent = false') | Out-Null }
if (-not $lumoswitchFeaturesSeen) {
$lumoswitchOutput.Add('') | Out-Null
$lumoswitchOutput.Add('[features]') | Out-Null
$lumoswitchOutput.Add('multi_agent = false') | Out-Null
}
foreach ($lumoswitchLine in @(
'',
'[model_providers.lumoswitch]',
'name = "Lumoswitch"',
'base_url = "{{api_base_url}}"',
'env_key = "LUMOSWITCH_API_KEY"',
'wire_api = "responses"',
'http_headers = { "X-Lumoswitch-Agent" = "codex-v1" }',
'requires_openai_auth = false'
)) { $lumoswitchOutput.Add($lumoswitchLine) | Out-Null }
$lumoswitchNewLine = [Environment]::NewLine
Write-LumoswitchFile $lumoswitchCatalog $lumoswitchCatalogText
Write-LumoswitchFile $lumoswitchConfig (($lumoswitchOutput -join $lumoswitchNewLine) + $lumoswitchNewLine)
$lumoswitchEnvironmentLines = if (Test-Path -LiteralPath $lumoswitchEnvironment -PathType Leaf) {
@([IO.File]::ReadAllLines($lumoswitchEnvironment, [Text.Encoding]::UTF8) | Where-Object { $_ -notmatch '^\s*(?:export\s+)?LUMOSWITCH_API_KEY\s*=' })
} else { @() }
if (Test-Path -LiteralPath $lumoswitchEnvironment -PathType Leaf) {
$lumoswitchEnvironmentBackup = $lumoswitchEnvironment + '.backup.lumoswitch.' + $lumoswitchBackupSuffix
$lumoswitchEnvironmentBackupText = if ($lumoswitchEnvironmentLines.Count -gt 0) { ($lumoswitchEnvironmentLines -join $lumoswitchNewLine) + $lumoswitchNewLine } else { '' }
Write-LumoswitchFile $lumoswitchEnvironmentBackup $lumoswitchEnvironmentBackupText
}
$lumoswitchEnvironmentLines += 'LUMOSWITCH_API_KEY={{access_key}}'
Write-LumoswitchFile $lumoswitchEnvironment (($lumoswitchEnvironmentLines -join $lumoswitchNewLine) + $lumoswitchNewLine)
Write-Host ('Shared Codex settings updated; backup suffix: ' + $lumoswitchBackupSuffix)
Start-Process -FilePath explorer.exe -ArgumentList @('shell:AppsFolder\' + $lumoswitchChatGptApp.AppID)
}The Windows command discovers the installed ChatGPT Start menu App ID instead of hard-coding a Store package identity. It validates the generated JSON catalog and selected model before writing, uses the same conservative TOML merge rules as macOS, restricts the catalog, configuration, credential, and backup files with Windows ACLs, excludes the Access Key from .env backups, and reopens ChatGPT when configuration finishes. Codex CLI and the IDE extension read those same shared files after their old sessions are restarted.
4. Launch each surface
ChatGPT desktop
Open ChatGPT normally and enter Codex, or run:
open -a ChatGPTCodex CLI
Open the target project directory and run:
codexUse /status in Codex CLI to confirm the active model and provider. The model picker should contain the same client-facing Lumoswitch model shown in ChatGPT desktop.
New tasks use the effective default from the generated catalog. /reasoning lists only verified levels; toggle-style models such as DeepSeek expose one equivalent enabled level.
Codex IDE extension
After changing settings, fully restart the IDE or reload the Codex extension, then begin a new task. The extension reads the same ~/.codex settings and does not need a separate command.
| Surface | Settings read | Action after a change |
|---|---|---|
| Codex in ChatGPT desktop | ~/.codex/config.toml, ~/.codex/.env, and ~/.codex/lumoswitch-model-catalog.json | Quit ChatGPT completely and reopen it |
| Plain Codex CLI | The same three files | Exit the old session and run codex |
| Codex IDE extension | The same three files | Fully restart the IDE and begin a new task |
| Ordinary ChatGPT chats | None | Unaffected |
After changing the Access Key, URL, or client-facing model name, run this page's persistent-use command again and fully restart every open Codex surface. In ChatGPT desktop and CLI, the generated catalog intentionally replaces the built-in catalog with the single configured downstream model. Send a short text request from each surface and confirm both requests in Lumoswitch logs.
5. Remove the Lumoswitch setup
Removal affects Codex in ChatGPT desktop, plain Codex CLI, and the Codex IDE extension together.
macOS
Quit ChatGPT with Command + Q, end every Codex CLI session, then run:
set -e
LUMOSWITCH_CODEX_DIR="$HOME/.codex"
LUMOSWITCH_CODEX_CONFIG="$LUMOSWITCH_CODEX_DIR/config.toml"
LUMOSWITCH_CODEX_ENV="$LUMOSWITCH_CODEX_DIR/.env"
LUMOSWITCH_CODEX_CATALOG="$LUMOSWITCH_CODEX_DIR/lumoswitch-model-catalog.json"
LUMOSWITCH_BACKUP_SUFFIX="$(date +%Y%m%d-%H%M%S)-remove-$$"
LUMOSWITCH_CONFIG_TMP=""
LUMOSWITCH_ENV_TMP=""
lumoswitch_cleanup() {
[ -z "$LUMOSWITCH_CONFIG_TMP" ] || rm -f "$LUMOSWITCH_CONFIG_TMP"
[ -z "$LUMOSWITCH_ENV_TMP" ] || rm -f "$LUMOSWITCH_ENV_TMP"
}
trap lumoswitch_cleanup EXIT HUP INT TERM
if pgrep -x ChatGPT >/dev/null 2>&1; then
echo "Quit ChatGPT completely with Command + Q, then run this command again." >&2
exit 1
fi
mkdir -p "$LUMOSWITCH_CODEX_DIR"
umask 077
if [ -L "$LUMOSWITCH_CODEX_CONFIG" ] || [ -L "$LUMOSWITCH_CODEX_ENV" ] || [ -L "$LUMOSWITCH_CODEX_CATALOG" ]; then
echo "A Codex settings, credential, or Lumoswitch model-catalog file is a symbolic link. Automatic removal stopped; edit its target manually." >&2
exit 1
fi
if [ -e "$LUMOSWITCH_CODEX_CATALOG" ] && [ ! -f "$LUMOSWITCH_CODEX_CATALOG" ]; then
echo "The Lumoswitch model-catalog path is occupied by a non-regular file. Automatic removal stopped: $LUMOSWITCH_CODEX_CATALOG" >&2
exit 1
fi
LUMOSWITCH_CATALOG_LITERAL="$(/usr/bin/osascript -l JavaScript - "$LUMOSWITCH_CODEX_CATALOG" <<'LUMOSWITCH_PATH_JXA'
function run(argv) {
return JSON.stringify(argv[0]);
}
LUMOSWITCH_PATH_JXA
)"
LUMOSWITCH_REMOVE_DEFAULTS=0
if [ -f "$LUMOSWITCH_CODEX_CONFIG" ] && awk '
BEGIN { root = 1; found = 0 }
/^[[:space:]]*\[/ { root = 0 }
root && /^[[:space:]]*model_provider[[:space:]]*=[[:space:]]*"lumoswitch"[[:space:]]*(#.*)?$/ { found = 1 }
END { exit found ? 0 : 1 }
' "$LUMOSWITCH_CODEX_CONFIG"; then
LUMOSWITCH_REMOVE_DEFAULTS=1
fi
LUMOSWITCH_REMOVE_CATALOG_SETTING=0
if [ -f "$LUMOSWITCH_CODEX_CONFIG" ] && awk -v expected="$LUMOSWITCH_CATALOG_LITERAL" '
BEGIN { root = 1; found = 0; mismatch = 0 }
/^[[:space:]]*\[/ { root = 0 }
root && /^[[:space:]]*model_catalog_json[[:space:]]*=/ {
value = $0
sub(/^[[:space:]]*model_catalog_json[[:space:]]*=[[:space:]]*/, "", value)
sub(/[[:space:]]*(#.*)?$/, "", value)
if (value == expected) found = 1
if (value != expected) mismatch = 1
}
END { exit found && !mismatch ? 0 : 1 }
' "$LUMOSWITCH_CODEX_CONFIG"; then
LUMOSWITCH_REMOVE_CATALOG_SETTING=1
fi
if [ -f "$LUMOSWITCH_CODEX_CONFIG" ]; then
cp "$LUMOSWITCH_CODEX_CONFIG" "$LUMOSWITCH_CODEX_CONFIG.backup.$LUMOSWITCH_BACKUP_SUFFIX"
chmod 600 "$LUMOSWITCH_CODEX_CONFIG.backup.$LUMOSWITCH_BACKUP_SUFFIX"
LUMOSWITCH_CONFIG_TMP="$(mktemp "$LUMOSWITCH_CODEX_DIR/config.toml.lumoswitch-remove.XXXXXX")"
awk -v remove_defaults="$LUMOSWITCH_REMOVE_DEFAULTS" -v remove_catalog="$LUMOSWITCH_REMOVE_CATALOG_SETTING" '
BEGIN { root = 1; skip = 0; in_features = 0 }
/^[[:space:]]*\[/ {
root = 0
in_features = ($0 ~ /^[[:space:]]*\[[[:space:]]*features[[:space:]]*\]/)
if ($0 ~ /^[[:space:]]*\[[[:space:]]*model_providers[.]lumoswitch([.][^]]+)?[[:space:]]*\]/) {
skip = 1
next
}
skip = 0
}
skip { next }
in_features && remove_defaults == "1" && /^[[:space:]]*multi_agent[[:space:]]*=[[:space:]]*false[[:space:]]*(#.*)?$/ { next }
root && remove_defaults == "1" && /^[[:space:]]*(model|model_provider|model_reasoning_effort)[[:space:]]*=/ { next }
root && remove_defaults == "1" && /^[[:space:]]*web_search[[:space:]]*=[[:space:]]*"disabled"[[:space:]]*(#.*)?$/ { next }
root && remove_catalog == "1" && /^[[:space:]]*model_catalog_json[[:space:]]*=/ { next }
{ print }
' "$LUMOSWITCH_CODEX_CONFIG" > "$LUMOSWITCH_CONFIG_TMP"
chmod 600 "$LUMOSWITCH_CONFIG_TMP"
mv "$LUMOSWITCH_CONFIG_TMP" "$LUMOSWITCH_CODEX_CONFIG"
LUMOSWITCH_CONFIG_TMP=""
fi
if [ -f "$LUMOSWITCH_CODEX_CATALOG" ]; then
rm -f "$LUMOSWITCH_CODEX_CATALOG"
fi
if [ -f "$LUMOSWITCH_CODEX_ENV" ]; then
LUMOSWITCH_ENV_TMP="$(mktemp "$LUMOSWITCH_CODEX_DIR/.env.backup.lumoswitch.XXXXXX")"
awk '!/^[[:space:]]*(export[[:space:]]+)?LUMOSWITCH_API_KEY[[:space:]]*=/' "$LUMOSWITCH_CODEX_ENV" > "$LUMOSWITCH_ENV_TMP"
chmod 600 "$LUMOSWITCH_ENV_TMP"
mv "$LUMOSWITCH_ENV_TMP" "$LUMOSWITCH_CODEX_ENV.backup.lumoswitch.$LUMOSWITCH_BACKUP_SUFFIX"
LUMOSWITCH_ENV_TMP=""
LUMOSWITCH_ENV_TMP="$(mktemp "$LUMOSWITCH_CODEX_DIR/.env.lumoswitch-remove.XXXXXX")"
awk '!/^[[:space:]]*(export[[:space:]]+)?LUMOSWITCH_API_KEY[[:space:]]*=/' "$LUMOSWITCH_CODEX_ENV" > "$LUMOSWITCH_ENV_TMP"
chmod 600 "$LUMOSWITCH_ENV_TMP"
mv "$LUMOSWITCH_ENV_TMP" "$LUMOSWITCH_CODEX_ENV"
LUMOSWITCH_ENV_TMP=""
fi
printf 'Lumoswitch Codex setup removed; pre-removal backup suffix: %s\n' "$LUMOSWITCH_BACKUP_SUFFIX"
open -a ChatGPTThe command first creates a safe backup that omits the Lumoswitch Key, then removes only Lumoswitch-related settings. If top-level model_provider still points to lumoswitch, it also removes top-level model, model_provider, and any model_reasoning_effort left by an earlier version of this guide. It removes web_search only while it still equals "disabled", and removes [features].multi_agent only while it still equals false; later user changes and every other feature entry remain intact, and an empty [features] table may remain. It removes top-level model_catalog_json only when its value exactly matches the dedicated absolute path, and deletes only the regular ~/.codex/lumoswitch-model-catalog.json file. Other catalog paths remain unchanged. Symbolic links and non-regular dedicated catalog paths stop automatic removal and are never followed. Automatic removal cannot know the defaults overwritten before setup. Exact restoration therefore requires manually selecting the matching config.toml installation backup and is safe only when no later configuration changed. The Access Key is deliberately excluded from backups and must be entered again when reconnecting.
Windows PowerShell
Quit ChatGPT completely and end every running Codex CLI session, then run:
& {
$ErrorActionPreference = 'Stop'
$lumoswitchCodexDir = Join-Path $HOME '.codex'
$lumoswitchConfig = Join-Path $lumoswitchCodexDir 'config.toml'
$lumoswitchEnvironment = Join-Path $lumoswitchCodexDir '.env'
$lumoswitchCatalog = Join-Path $lumoswitchCodexDir 'lumoswitch-model-catalog.json'
$lumoswitchBackupSuffix = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-remove-' + $PID
if (Get-Process -Name 'ChatGPT' -ErrorAction SilentlyContinue) { throw 'Quit ChatGPT completely, then run this command again.' }
$lumoswitchChatGptApp = Get-StartApps | Where-Object { $_.Name -eq 'ChatGPT' -or $_.AppID -match 'ChatGPT' } | Select-Object -First 1
function Assert-LumoswitchFile([string] $Path) {
if (-not (Test-Path -LiteralPath $Path)) { return }
$lumoswitchItem = Get-Item -LiteralPath $Path -Force
if ($lumoswitchItem.PSIsContainer -or ($lumoswitchItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
throw "Lumoswitch refused to modify a directory or link: $Path"
}
}
function Protect-LumoswitchFile([string] $Path) {
$lumoswitchSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
& icacls.exe $Path '/inheritance:r' '/grant:r' ('*' + $lumoswitchSid + ':(F)') '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Could not restrict access to $Path" }
}
function Write-LumoswitchFile([string] $Path, [string] $Content) {
Assert-LumoswitchFile $Path
[IO.Directory]::CreateDirectory((Split-Path -Parent $Path)) | Out-Null
$lumoswitchTemporary = $Path + '.' + [Guid]::NewGuid().ToString('N') + '.tmp'
try {
[IO.File]::WriteAllText($lumoswitchTemporary, $Content, [Text.UTF8Encoding]::new($false))
Move-Item -LiteralPath $lumoswitchTemporary -Destination $Path -Force
Protect-LumoswitchFile $Path
} finally {
Remove-Item -LiteralPath $lumoswitchTemporary -Force -ErrorAction SilentlyContinue
}
}
if (Test-Path -LiteralPath $lumoswitchCodexDir) {
$lumoswitchRootItem = Get-Item -LiteralPath $lumoswitchCodexDir -Force
if (-not $lumoswitchRootItem.PSIsContainer -or ($lumoswitchRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
throw 'The user-level Codex directory is not a regular directory. Automatic removal stopped.'
}
} else {
[IO.Directory]::CreateDirectory($lumoswitchCodexDir) | Out-Null
}
foreach ($lumoswitchPath in @($lumoswitchConfig, $lumoswitchEnvironment, $lumoswitchCatalog)) { Assert-LumoswitchFile $lumoswitchPath }
$lumoswitchNewLine = [Environment]::NewLine
$lumoswitchConfigLines = if (Test-Path -LiteralPath $lumoswitchConfig -PathType Leaf) { @([IO.File]::ReadAllLines($lumoswitchConfig, [Text.Encoding]::UTF8)) } else { @() }
$lumoswitchExpectedCatalog = '"' + $lumoswitchCatalog.Replace('\', '/') + '"'
$lumoswitchRemoveDefaults = $false
$lumoswitchCatalogSettings = 0
$lumoswitchCatalogMatches = 0
$lumoswitchRoot = $true
foreach ($lumoswitchLine in $lumoswitchConfigLines) {
if ($lumoswitchLine -match '^\s*\[') { $lumoswitchRoot = $false }
if ($lumoswitchRoot -and $lumoswitchLine -match '^\s*model_provider\s*=\s*"lumoswitch"\s*(?:#.*)?$') { $lumoswitchRemoveDefaults = $true }
if ($lumoswitchRoot -and $lumoswitchLine -match '^\s*model_catalog_json\s*=') {
$lumoswitchCatalogSettings++
$lumoswitchValue = ($lumoswitchLine -replace '^\s*model_catalog_json\s*=\s*', '') -replace '\s*(#.*)?$', ''
if ($lumoswitchValue -eq $lumoswitchExpectedCatalog) { $lumoswitchCatalogMatches++ }
}
}
$lumoswitchRemoveCatalog = ($lumoswitchCatalogSettings -eq 1 -and $lumoswitchCatalogMatches -eq 1)
if (Test-Path -LiteralPath $lumoswitchConfig -PathType Leaf) {
$lumoswitchConfigBackup = $lumoswitchConfig + '.backup.' + $lumoswitchBackupSuffix
Assert-LumoswitchFile $lumoswitchConfigBackup
Copy-Item -LiteralPath $lumoswitchConfig -Destination $lumoswitchConfigBackup
Protect-LumoswitchFile $lumoswitchConfigBackup
$lumoswitchOutput = [Collections.Generic.List[string]]::new()
$lumoswitchCopyRoot = $true
$lumoswitchSkip = $false
$lumoswitchInFeatures = $false
foreach ($lumoswitchLine in $lumoswitchConfigLines) {
if ($lumoswitchLine -match '^\s*\[') {
$lumoswitchCopyRoot = $false
$lumoswitchInFeatures = (($lumoswitchLine -replace '\s', '') -eq '[features]')
if ($lumoswitchLine -match '^\s*\[\s*model_providers\.lumoswitch(?:\.[^]]+)?\s*\]\s*$') { $lumoswitchSkip = $true; continue }
$lumoswitchSkip = $false
}
if ($lumoswitchSkip) { continue }
if ($lumoswitchInFeatures -and $lumoswitchRemoveDefaults -and $lumoswitchLine -match '^\s*multi_agent\s*=\s*false\s*(?:#.*)?$') { continue }
if ($lumoswitchCopyRoot -and $lumoswitchRemoveDefaults -and $lumoswitchLine -match '^\s*(?:model|model_provider|model_reasoning_effort)\s*=') { continue }
if ($lumoswitchCopyRoot -and $lumoswitchRemoveDefaults -and $lumoswitchLine -match '^\s*web_search\s*=\s*"disabled"\s*(?:#.*)?$') { continue }
if ($lumoswitchCopyRoot -and $lumoswitchRemoveCatalog -and $lumoswitchLine -match '^\s*model_catalog_json\s*=') { continue }
$lumoswitchOutput.Add($lumoswitchLine) | Out-Null
}
$lumoswitchConfigText = if ($lumoswitchOutput.Count -gt 0) { ($lumoswitchOutput -join $lumoswitchNewLine) + $lumoswitchNewLine } else { '' }
Write-LumoswitchFile $lumoswitchConfig $lumoswitchConfigText
}
if ($lumoswitchRemoveCatalog -and (Test-Path -LiteralPath $lumoswitchCatalog -PathType Leaf)) {
Remove-Item -LiteralPath $lumoswitchCatalog -Force
}
if (Test-Path -LiteralPath $lumoswitchEnvironment -PathType Leaf) {
$lumoswitchEnvironmentLines = @([IO.File]::ReadAllLines($lumoswitchEnvironment, [Text.Encoding]::UTF8) | Where-Object { $_ -notmatch '^\s*(?:export\s+)?LUMOSWITCH_API_KEY\s*=' })
$lumoswitchEnvironmentText = if ($lumoswitchEnvironmentLines.Count -gt 0) { ($lumoswitchEnvironmentLines -join $lumoswitchNewLine) + $lumoswitchNewLine } else { '' }
$lumoswitchEnvironmentBackup = $lumoswitchEnvironment + '.backup.lumoswitch.' + $lumoswitchBackupSuffix
Write-LumoswitchFile $lumoswitchEnvironmentBackup $lumoswitchEnvironmentText
Write-LumoswitchFile $lumoswitchEnvironment $lumoswitchEnvironmentText
}
Write-Host ('Lumoswitch Codex setup removed; pre-removal backup suffix: ' + $lumoswitchBackupSuffix)
if ($null -ne $lumoswitchChatGptApp) {
Start-Process -FilePath explorer.exe -ArgumentList @('shell:AppsFolder\' + $lumoswitchChatGptApp.AppID)
}
}The Windows command applies the same conservative cleanup rules to %USERPROFILE%\.codex, creates ACL-restricted backups that omit the Access Key, and reopens ChatGPT through its discovered Start menu App ID when it is still installed.
6. When not to share settings
If ChatGPT desktop should keep its original provider while only terminal Codex uses Lumoswitch, use Codex CLI setup. Its native CODEX_HOME keeps the terminal configuration separate without changing shared files.
If you currently use only ChatGPT desktop and have no CLI installed, use the simpler ChatGPT Desktop (Codex) setup. Both scenarios use the same persistent settings template, but the desktop-only guide does not ask a new user to install or verify CLI.
Publication fields
- Platform ID:
codex-desktop-cli - Display name:
ChatGPT Desktop + Codex CLI - Downstream protocol:
responses - macOS/Linux temporary mode: disabled; leave
commandTemplateempty - macOS persistence strategy:
native persistentCommandTemplate: copy the complete code block from this page's Persistent use with shared settings section; confirm that it is byte-for-byte identical to thechatgpt-desktoprecord before publishing- macOS
futureCommand:codex - Windows temporary mode: disabled; leave
windowsCommandTemplateempty - Windows persistence strategy:
native windowsPersistentCommandTemplate: copy the complete Native Windows PowerShell import code block; confirm that it is byte-for-byte identical to thechatgpt-desktopWindows record before publishing- Windows
windowsFutureCommand:codex - Sort order:
16 - Publication limit: publish the macOS and Windows templates only for their marked operating systems; verify both desktop and Codex CLI on each system
- Verification status: the official configuration contract was revalidated on
2026-08-27; shared model-catalog parsing was last validated with the Codex executable bundled in ChatGPT on2026-08-03; Windows PowerShell is covered by structural and bilingual-template contract tests, while a real Windows dual-surface Lumoswitch traffic check is still pending
Troubleshooting
- Desktop works but CLI does not: Exit the old CLI session and run plain
codexin a new terminal. Do not keep testing shared mode with an old isolated Lumoswitch launcher. - CLI works but desktop does not: Quit ChatGPT completely (
Command + Qon macOS, or the app menu on Windows), then reopen it. - The picker still shows only GPT models: Rerun the latest persistent-use command, then fully restart both surfaces. Confirm that root
model_catalog_jsonpoints to the dedicated absolute path and that the JSON contains the exact client-facing model name. - Installation reports another custom model catalog: The command stops to protect that catalog. Add the Lumoswitch model entry to the existing JSON manually and keep its current
model_catalog_jsonpath. - The surfaces show different models: Check whether a project-level
.codex/config.tomlor CLI argument overrides the user-level model and catalog. - The runtime says the inference request is invalid: Confirm the selected model and Responses output first, then rerun one-click import to refresh the capability-aware catalog. Use the matching Lumoswitch log to identify any remaining rejected field.
- You need to restore the old settings: Use the backup suffix printed by the settings command and copy the matching
.backup.<suffix>files back to their original paths.
References: ChatGPT desktop for Windows, Codex CLI, Codex custom model providers, Codex configuration reference, Codex model listing, and desktop and IDE environment variables.