@getrouter/getrouter-cli 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -86,6 +86,7 @@ getrouter codex uninstall
86
86
 
87
87
  - `~/.codex/config.toml`(model + reasoning + provider 設定)
88
88
  - `~/.codex/auth.json`(OPENAI_API_KEY)
89
+ - `~/.getrouter/codex-backup.json`(`getrouter codex uninstall` 用のバックアップ。uninstall で削除)
89
90
 
90
91
  `getrouter claude` は Anthropic 互換の環境変数を `~/.getrouter/env.sh`(または `env.ps1`)へ書き込みます。
91
92
 
package/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # GetRouter CLI
2
2
 
3
+ [![CI](https://github.com/getrouter/getrouter-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/getrouter/getrouter-cli/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/@getrouter/getrouter-cli)](https://www.npmjs.com/package/@getrouter/getrouter-cli)
5
+ [![npm downloads](https://img.shields.io/npm/dm/@getrouter/getrouter-cli)](https://www.npmjs.com/package/@getrouter/getrouter-cli)
6
+ [![node](https://img.shields.io/node/v/@getrouter/getrouter-cli)](https://www.npmjs.com/package/@getrouter/getrouter-cli)
7
+ [![bun](https://img.shields.io/badge/bun-1.3.5-000?logo=bun&logoColor=white)](https://bun.sh)
8
+
3
9
  CLI for getrouter.dev — manage API keys, subscriptions, and configure vibecoding tools.
4
10
 
5
11
  English | [简体中文](README.zh-cn.md) | [日本語](README.ja.md)
@@ -86,6 +92,7 @@ Files written (codex):
86
92
 
87
93
  - `~/.codex/config.toml` (model + reasoning + provider settings)
88
94
  - `~/.codex/auth.json` (OPENAI_API_KEY)
95
+ - `~/.getrouter/codex-backup.json` (backup for `getrouter codex uninstall`; deleted on uninstall)
89
96
 
90
97
  `getrouter claude` writes Anthropic-compatible env vars to `~/.getrouter/env.sh` (or `env.ps1`).
91
98
 
@@ -121,3 +128,5 @@ Edit `~/.getrouter/config.json` directly to update CLI settings.
121
128
  - `bun run format` — format and lint code with Biome
122
129
  - `bun run test` — run the test suite
123
130
  - `bun run typecheck` — run TypeScript type checks
131
+
132
+ [![Download History (last 30 days)](https://quickchart.io/chart/render/zf-3cc45f8d-a7de-4553-bdfa-877c4592ce59)](https://www.npmjs.com/package/@getrouter/getrouter-cli)
package/README.zh-cn.md CHANGED
@@ -86,6 +86,7 @@ getrouter codex uninstall
86
86
 
87
87
  - `~/.codex/config.toml`(model + reasoning + provider 设置)
88
88
  - `~/.codex/auth.json`(OPENAI_API_KEY)
89
+ - `~/.getrouter/codex-backup.json`(用于 `getrouter codex uninstall` 的备份;卸载后会删除)
89
90
 
90
91
  `getrouter claude` 写入 Anthropic 兼容环境变量到 `~/.getrouter/env.sh`(或 `env.ps1`)。
91
92
 
package/dist/bin.mjs CHANGED
@@ -8,7 +8,7 @@ import { randomInt } from "node:crypto";
8
8
  import prompts from "prompts";
9
9
 
10
10
  //#region package.json
11
- var version = "0.1.10";
11
+ var version = "0.1.12";
12
12
 
13
13
  //#endregion
14
14
  //#region src/generated/router/dashboard/v1/index.ts
@@ -662,7 +662,10 @@ const selectConsumer = async (consumerService) => {
662
662
  pageSize: void 0,
663
663
  pageToken
664
664
  }), (res) => res?.consumers ?? [], (res) => res?.nextPageToken || void 0);
665
- if (consumers.length === 0) throw new Error("No available API keys");
665
+ if (consumers.length === 0) {
666
+ console.log("No available API keys. Create one at https://getrouter.dev/dashboard/keys");
667
+ return null;
668
+ }
666
669
  const sorted = sortConsumersByUpdatedAtDesc(consumers);
667
670
  const nameCounts = buildNameCounts(sorted);
668
671
  return await fuzzySelect({
@@ -683,7 +686,10 @@ const selectConsumerList = async (consumerService, message) => {
683
686
  pageSize: void 0,
684
687
  pageToken
685
688
  }), (res) => res?.consumers ?? [], (res) => res?.nextPageToken || void 0);
686
- if (consumers.length === 0) throw new Error("No available API keys");
689
+ if (consumers.length === 0) {
690
+ console.log("No available API keys. Create one at https://getrouter.dev/dashboard/keys");
691
+ return null;
692
+ }
687
693
  const sorted = sortConsumersByUpdatedAtDesc(consumers);
688
694
  const nameCounts = buildNameCounts(sorted);
689
695
  const response = await prompts({
@@ -1011,6 +1017,15 @@ const mapReasoningValue = (id) => REASONING_CHOICES.find((choice) => choice.id =
1011
1017
  //#region src/core/setup/codex.ts
1012
1018
  const CODEX_PROVIDER = "getrouter";
1013
1019
  const CODEX_BASE_URL = "https://api.getrouter.dev/codex";
1020
+ const LEGACY_TOML_ROOT_MARKERS = [
1021
+ "_getrouter_codex_backup_model",
1022
+ "_getrouter_codex_backup_model_reasoning_effort",
1023
+ "_getrouter_codex_backup_model_provider",
1024
+ "_getrouter_codex_installed_model",
1025
+ "_getrouter_codex_installed_model_reasoning_effort",
1026
+ "_getrouter_codex_installed_model_provider"
1027
+ ];
1028
+ const LEGACY_AUTH_MARKERS = ["_getrouter_codex_backup_openai_api_key", "_getrouter_codex_installed_openai_api_key"];
1014
1029
  const ROOT_KEYS = [
1015
1030
  "model",
1016
1031
  "model_reasoning_effort",
@@ -1036,9 +1051,56 @@ const providerValues = () => ({
1036
1051
  });
1037
1052
  const matchHeader = (line) => line.match(/^\s*\[([^\]]+)\]\s*$/);
1038
1053
  const matchKey = (line) => line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/);
1039
- const matchProviderValue = (line) => line.match(/^\s*model_provider\s*=\s*(['"]?)([^'"]+)\1\s*(?:#.*)?$/);
1054
+ const parseTomlRhsValue = (rhs) => {
1055
+ const trimmed = rhs.trim();
1056
+ if (!trimmed) return "";
1057
+ const first = trimmed[0];
1058
+ if (first === "\"" || first === "'") {
1059
+ const end = trimmed.indexOf(first, 1);
1060
+ return end === -1 ? trimmed : trimmed.slice(0, end + 1);
1061
+ }
1062
+ const hashIndex = trimmed.indexOf("#");
1063
+ return (hashIndex === -1 ? trimmed : trimmed.slice(0, hashIndex)).trim();
1064
+ };
1065
+ const readRootValue = (lines, key) => {
1066
+ for (const line of lines) {
1067
+ if (matchHeader(line)) break;
1068
+ if (matchKey(line)?.[1] === key) {
1069
+ const parts = line.split("=");
1070
+ parts.shift();
1071
+ return parseTomlRhsValue(parts.join("="));
1072
+ }
1073
+ }
1074
+ };
1075
+ const readCodexTomlRootValues = (content) => {
1076
+ const lines = content.length ? content.split(/\r?\n/) : [];
1077
+ return {
1078
+ model: readRootValue(lines, "model"),
1079
+ reasoning: readRootValue(lines, "model_reasoning_effort"),
1080
+ provider: readRootValue(lines, "model_provider")
1081
+ };
1082
+ };
1083
+ const normalizeTomlString = (value) => {
1084
+ if (!value) return "";
1085
+ const trimmed = value.trim();
1086
+ if (trimmed.startsWith("\"") && trimmed.endsWith("\"") || trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1).trim().toLowerCase();
1087
+ return trimmed.replace(/['"]/g, "").trim().toLowerCase();
1088
+ };
1089
+ const stripLegacyRootMarkers = (lines) => {
1090
+ const updated = [];
1091
+ let inRoot = true;
1092
+ for (const line of lines) {
1093
+ if (matchHeader(line)) inRoot = false;
1094
+ if (inRoot) {
1095
+ const key = matchKey(line)?.[1];
1096
+ if (key && LEGACY_TOML_ROOT_MARKERS.includes(key)) continue;
1097
+ }
1098
+ updated.push(line);
1099
+ }
1100
+ return updated;
1101
+ };
1040
1102
  const mergeCodexToml = (content, input) => {
1041
- const updated = [...content.length ? content.split(/\r?\n/) : []];
1103
+ const updated = [...stripLegacyRootMarkers(content.length ? content.split(/\r?\n/) : [])];
1042
1104
  const rootValueMap = rootValues(input);
1043
1105
  const providerValueMap = providerValues();
1044
1106
  let currentSection = null;
@@ -1093,10 +1155,12 @@ const mergeCodexToml = (content, input) => {
1093
1155
  if (missingProvider.length > 0) updated.splice(providerEnd, 0, ...missingProvider);
1094
1156
  return updated.join("\n");
1095
1157
  };
1096
- const mergeAuthJson = (data, apiKey) => ({
1097
- ...data,
1098
- OPENAI_API_KEY: apiKey
1099
- });
1158
+ const mergeAuthJson = (data, apiKey) => {
1159
+ const next = { ...data };
1160
+ for (const key of LEGACY_AUTH_MARKERS) if (key in next) delete next[key];
1161
+ next.OPENAI_API_KEY = apiKey;
1162
+ return next;
1163
+ };
1100
1164
  const stripGetrouterProviderSection = (lines) => {
1101
1165
  const updated = [];
1102
1166
  let skipSection = false;
@@ -1114,61 +1178,87 @@ const stripGetrouterProviderSection = (lines) => {
1114
1178
  }
1115
1179
  return updated;
1116
1180
  };
1117
- const stripRootKeys = (lines) => {
1118
- const updated = [];
1119
- let currentSection = null;
1120
- for (const line of lines) {
1121
- const headerMatch = matchHeader(line);
1122
- if (headerMatch) {
1123
- currentSection = headerMatch[1]?.trim() ?? null;
1124
- updated.push(line);
1125
- continue;
1126
- }
1127
- if (currentSection === null) {
1128
- if (/^\s*model\s*=/.test(line)) continue;
1129
- if (/^\s*model_reasoning_effort\s*=/.test(line)) continue;
1130
- if (/^\s*model_provider\s*=/.test(line)) continue;
1131
- }
1132
- updated.push(line);
1181
+ const stripLegacyMarkersFromRoot = (rootLines) => rootLines.filter((line) => {
1182
+ const key = matchKey(line)?.[1];
1183
+ return !(key && LEGACY_TOML_ROOT_MARKERS.includes(key));
1184
+ });
1185
+ const setOrDeleteRootKey = (rootLines, key, value) => {
1186
+ const idx = rootLines.findIndex((line) => matchKey(line)?.[1] === key);
1187
+ if (value === void 0) {
1188
+ if (idx !== -1) rootLines.splice(idx, 1);
1189
+ return;
1133
1190
  }
1134
- return updated;
1191
+ if (idx !== -1) rootLines[idx] = `${key} = ${value}`;
1192
+ else rootLines.push(`${key} = ${value}`);
1193
+ };
1194
+ const deleteRootKey = (rootLines, key) => {
1195
+ setOrDeleteRootKey(rootLines, key, void 0);
1135
1196
  };
1136
- const removeCodexConfig = (content) => {
1197
+ const hasLegacyRootMarkers = (lines) => lines.some((line) => {
1198
+ const key = matchKey(line)?.[1];
1199
+ return !!(key && LEGACY_TOML_ROOT_MARKERS.includes(key));
1200
+ });
1201
+ const removeCodexConfig = (content, options) => {
1202
+ const { restoreRoot, allowRootRemoval = true } = options ?? {};
1137
1203
  const lines = content.length ? content.split(/\r?\n/) : [];
1138
- let providerIsGetrouter = false;
1139
- let currentSection = null;
1140
- for (const line of lines) {
1141
- const headerMatch = matchHeader(line);
1142
- if (headerMatch) {
1143
- currentSection = headerMatch[1]?.trim() ?? null;
1144
- continue;
1145
- }
1146
- if (currentSection !== null) continue;
1147
- if ((matchProviderValue(line)?.[2]?.trim())?.toLowerCase() === CODEX_PROVIDER) providerIsGetrouter = true;
1204
+ const providerIsGetrouter = normalizeTomlString(readRootValue(lines, "model_provider")) === CODEX_PROVIDER;
1205
+ const canRemoveRoot = allowRootRemoval || hasLegacyRootMarkers(lines);
1206
+ const stripped = stripGetrouterProviderSection(lines);
1207
+ const firstHeaderIndex = stripped.findIndex((line) => matchHeader(line));
1208
+ const rootEnd = firstHeaderIndex === -1 ? stripped.length : firstHeaderIndex;
1209
+ const rootLines = stripLegacyMarkersFromRoot(stripped.slice(0, rootEnd));
1210
+ const restLines = stripped.slice(rootEnd);
1211
+ if (providerIsGetrouter && canRemoveRoot) if (restoreRoot) {
1212
+ setOrDeleteRootKey(rootLines, "model", restoreRoot.model);
1213
+ setOrDeleteRootKey(rootLines, "model_reasoning_effort", restoreRoot.reasoning);
1214
+ setOrDeleteRootKey(rootLines, "model_provider", restoreRoot.provider);
1215
+ } else {
1216
+ deleteRootKey(rootLines, "model");
1217
+ deleteRootKey(rootLines, "model_reasoning_effort");
1218
+ deleteRootKey(rootLines, "model_provider");
1148
1219
  }
1149
- let updated = stripGetrouterProviderSection(lines);
1150
- if (providerIsGetrouter) updated = stripRootKeys(updated);
1151
- const nextContent = updated.join("\n");
1220
+ const recombined = [...rootLines];
1221
+ if (recombined.length > 0 && restLines.length > 0 && recombined[recombined.length - 1]?.trim() !== "") recombined.push("");
1222
+ recombined.push(...restLines);
1223
+ const nextContent = recombined.join("\n");
1152
1224
  return {
1153
1225
  content: nextContent,
1154
1226
  changed: nextContent !== content
1155
1227
  };
1156
1228
  };
1157
- const removeAuthJson = (data) => {
1158
- if (!("OPENAI_API_KEY" in data)) return {
1159
- data,
1160
- changed: false
1161
- };
1162
- const { OPENAI_API_KEY: _ignored, ...rest } = data;
1229
+ const removeAuthJson = (data, options) => {
1230
+ const { installed, restore } = options ?? {};
1231
+ const next = { ...data };
1232
+ let changed = false;
1233
+ const legacyInstalled = typeof next._getrouter_codex_installed_openai_api_key === "string" ? next._getrouter_codex_installed_openai_api_key : void 0;
1234
+ const legacyRestore = typeof next._getrouter_codex_backup_openai_api_key === "string" ? next._getrouter_codex_backup_openai_api_key : void 0;
1235
+ const effectiveInstalled = installed ?? legacyInstalled;
1236
+ const effectiveRestore = restore ?? legacyRestore;
1237
+ for (const key of LEGACY_AUTH_MARKERS) if (key in next) {
1238
+ delete next[key];
1239
+ changed = true;
1240
+ }
1241
+ const current = typeof next.OPENAI_API_KEY === "string" ? next.OPENAI_API_KEY : void 0;
1242
+ const restoreValue = typeof effectiveRestore === "string" && effectiveRestore.trim().length > 0 ? effectiveRestore : void 0;
1243
+ if (effectiveInstalled && current && current === effectiveInstalled) {
1244
+ if (restoreValue) next.OPENAI_API_KEY = restoreValue;
1245
+ else delete next.OPENAI_API_KEY;
1246
+ changed = true;
1247
+ return {
1248
+ data: next,
1249
+ changed
1250
+ };
1251
+ }
1163
1252
  return {
1164
- data: rest,
1165
- changed: true
1253
+ data: next,
1254
+ changed
1166
1255
  };
1167
1256
  };
1168
1257
 
1169
1258
  //#endregion
1170
1259
  //#region src/cmd/codex.ts
1171
1260
  const CODEX_DIR = ".codex";
1261
+ const CODEX_BACKUP_FILE = "codex-backup.json";
1172
1262
  const readFileIfExists = (filePath) => fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
1173
1263
  const readAuthJson = (filePath) => {
1174
1264
  if (!fs.existsSync(filePath)) return {};
@@ -1184,6 +1274,18 @@ const ensureCodexDir = () => {
1184
1274
  return dir;
1185
1275
  };
1186
1276
  const resolveCodexDir = () => path.join(os.homedir(), CODEX_DIR);
1277
+ const resolveCodexBackupPath = () => path.join(resolveConfigDir(), CODEX_BACKUP_FILE);
1278
+ const readCodexBackup = () => {
1279
+ const raw = readJsonFile(resolveCodexBackupPath());
1280
+ if (!raw || typeof raw !== "object") return null;
1281
+ if (raw.version !== 1) return null;
1282
+ return raw;
1283
+ };
1284
+ const writeCodexBackup = (backup) => {
1285
+ const backupPath = resolveCodexBackupPath();
1286
+ writeJsonFile(backupPath, backup);
1287
+ if (process.platform !== "win32") fs.chmodSync(backupPath, 384);
1288
+ };
1187
1289
  const requireInteractive$1 = () => {
1188
1290
  if (!process.stdin.isTTY) throw new Error("Interactive mode required for codex configuration.");
1189
1291
  };
@@ -1220,12 +1322,38 @@ const registerCodexCommand = (program) => {
1220
1322
  const codexDir = ensureCodexDir();
1221
1323
  const configPath = path.join(codexDir, "config.toml");
1222
1324
  const authPath = path.join(codexDir, "auth.json");
1223
- const mergedConfig = mergeCodexToml(readFileIfExists(configPath), {
1325
+ const existingConfig = readFileIfExists(configPath);
1326
+ const existingRoot = readCodexTomlRootValues(existingConfig);
1327
+ const existingAuth = readAuthJson(authPath);
1328
+ const existingOpenaiKey = typeof existingAuth.OPENAI_API_KEY === "string" ? existingAuth.OPENAI_API_KEY : void 0;
1329
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1330
+ const installedRoot = {
1331
+ model: `"${model}"`,
1332
+ reasoning: `"${reasoningValue}"`,
1333
+ provider: `"getrouter"`
1334
+ };
1335
+ const backup = readCodexBackup() ?? {
1336
+ version: 1,
1337
+ createdAt: now,
1338
+ updatedAt: now
1339
+ };
1340
+ backup.updatedAt = now;
1341
+ backup.config ??= {};
1342
+ backup.config.previous ??= {};
1343
+ if (backup.config.previous.model === void 0 && existingRoot.model && existingRoot.model !== installedRoot.model) backup.config.previous.model = existingRoot.model;
1344
+ if (backup.config.previous.reasoning === void 0 && existingRoot.reasoning && existingRoot.reasoning !== installedRoot.reasoning) backup.config.previous.reasoning = existingRoot.reasoning;
1345
+ if (backup.config.previous.provider === void 0 && existingRoot.provider && existingRoot.provider !== installedRoot.provider) backup.config.previous.provider = existingRoot.provider;
1346
+ backup.config.installed = installedRoot;
1347
+ backup.auth ??= {};
1348
+ if (backup.auth.previousOpenaiKey === void 0 && existingOpenaiKey && existingOpenaiKey !== apiKey) backup.auth.previousOpenaiKey = existingOpenaiKey;
1349
+ backup.auth.installedOpenaiKey = apiKey;
1350
+ writeCodexBackup(backup);
1351
+ const mergedConfig = mergeCodexToml(existingConfig, {
1224
1352
  model,
1225
1353
  reasoning: reasoningValue
1226
1354
  });
1227
1355
  fs.writeFileSync(configPath, mergedConfig, "utf8");
1228
- const mergedAuth = mergeAuthJson(readAuthJson(authPath), apiKey);
1356
+ const mergedAuth = mergeAuthJson(existingAuth, apiKey);
1229
1357
  fs.writeFileSync(authPath, JSON.stringify(mergedAuth, null, 2));
1230
1358
  if (process.platform !== "win32") fs.chmodSync(authPath, 384);
1231
1359
  console.log("✅ Updated ~/.codex/config.toml");
@@ -1237,11 +1365,22 @@ const registerCodexCommand = (program) => {
1237
1365
  const authPath = path.join(codexDir, "auth.json");
1238
1366
  const configExists = fs.existsSync(configPath);
1239
1367
  const authExists = fs.existsSync(authPath);
1368
+ const backup = readCodexBackup();
1369
+ const restoreRoot = backup?.config?.previous;
1370
+ const restoreOpenaiKey = backup?.auth?.previousOpenaiKey;
1371
+ const installedOpenaiKey = backup?.auth?.installedOpenaiKey;
1372
+ const hasBackup = Boolean(backup);
1240
1373
  const configContent = configExists ? readFileIfExists(configPath) : "";
1241
- const configResult = configExists ? removeCodexConfig(configContent) : null;
1374
+ const configResult = configExists ? removeCodexConfig(configContent, {
1375
+ restoreRoot,
1376
+ allowRootRemoval: hasBackup
1377
+ }) : null;
1242
1378
  const authContent = authExists ? fs.readFileSync(authPath, "utf8").trim() : "";
1243
1379
  const authData = authExists ? authContent ? JSON.parse(authContent) : {} : null;
1244
- const authResult = authData ? removeAuthJson(authData) : null;
1380
+ const authResult = authData ? removeAuthJson(authData, {
1381
+ installed: installedOpenaiKey,
1382
+ restore: restoreOpenaiKey
1383
+ }) : null;
1245
1384
  if (!configExists) console.log(`ℹ️ ${configPath} not found`);
1246
1385
  else if (configResult?.changed) {
1247
1386
  fs.writeFileSync(configPath, configResult.content, "utf8");
@@ -1252,6 +1391,8 @@ const registerCodexCommand = (program) => {
1252
1391
  fs.writeFileSync(authPath, JSON.stringify(authResult.data, null, 2));
1253
1392
  console.log(`✅ Removed getrouter entries from ${authPath}`);
1254
1393
  } else console.log(`ℹ️ No getrouter entries in ${authPath}`);
1394
+ const backupPath = resolveCodexBackupPath();
1395
+ if (fs.existsSync(backupPath)) fs.unlinkSync(backupPath);
1255
1396
  });
1256
1397
  };
1257
1398
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getrouter/getrouter-cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "type": "module",
5
5
  "description": "CLI for getrouter.dev",
6
6
  "bin": {
package/src/cmd/codex.ts CHANGED
@@ -3,6 +3,8 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import type { Command } from "commander";
5
5
  import { createApiClients } from "../core/api/client";
6
+ import { readJsonFile, writeJsonFile } from "../core/config/fs";
7
+ import { resolveConfigDir } from "../core/config/paths";
6
8
  import {
7
9
  getCodexModelChoices,
8
10
  mapReasoningValue,
@@ -12,13 +14,30 @@ import {
12
14
  import { fuzzySelect } from "../core/interactive/fuzzy";
13
15
  import { selectConsumer } from "../core/interactive/keys";
14
16
  import {
17
+ type CodexTomlRootValues,
15
18
  mergeAuthJson,
16
19
  mergeCodexToml,
20
+ readCodexTomlRootValues,
17
21
  removeAuthJson,
18
22
  removeCodexConfig,
19
23
  } from "../core/setup/codex";
20
24
 
21
25
  const CODEX_DIR = ".codex";
26
+ const CODEX_BACKUP_FILE = "codex-backup.json";
27
+
28
+ type CodexBackupFile = {
29
+ version: 1;
30
+ createdAt: string;
31
+ updatedAt: string;
32
+ config?: {
33
+ previous?: CodexTomlRootValues;
34
+ installed?: Required<CodexTomlRootValues>;
35
+ };
36
+ auth?: {
37
+ previousOpenaiKey?: string;
38
+ installedOpenaiKey?: string;
39
+ };
40
+ };
22
41
 
23
42
  const readFileIfExists = (filePath: string) =>
24
43
  fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
@@ -41,6 +60,24 @@ const ensureCodexDir = () => {
41
60
  };
42
61
 
43
62
  const resolveCodexDir = () => path.join(os.homedir(), CODEX_DIR);
63
+ const resolveCodexBackupPath = () =>
64
+ path.join(resolveConfigDir(), CODEX_BACKUP_FILE);
65
+
66
+ const readCodexBackup = (): CodexBackupFile | null => {
67
+ const backupPath = resolveCodexBackupPath();
68
+ const raw = readJsonFile<CodexBackupFile>(backupPath);
69
+ if (!raw || typeof raw !== "object") return null;
70
+ if ((raw as CodexBackupFile).version !== 1) return null;
71
+ return raw;
72
+ };
73
+
74
+ const writeCodexBackup = (backup: CodexBackupFile) => {
75
+ const backupPath = resolveCodexBackupPath();
76
+ writeJsonFile(backupPath, backup);
77
+ if (process.platform !== "win32") {
78
+ fs.chmodSync(backupPath, 0o600);
79
+ }
80
+ };
44
81
 
45
82
  const requireInteractive = () => {
46
83
  if (!process.stdin.isTTY) {
@@ -110,13 +147,69 @@ export const registerCodexCommand = (program: Command) => {
110
147
  const authPath = path.join(codexDir, "auth.json");
111
148
 
112
149
  const existingConfig = readFileIfExists(configPath);
150
+ const existingRoot = readCodexTomlRootValues(existingConfig);
151
+
152
+ const existingAuth = readAuthJson(authPath);
153
+ const existingOpenaiKey =
154
+ typeof existingAuth.OPENAI_API_KEY === "string"
155
+ ? existingAuth.OPENAI_API_KEY
156
+ : undefined;
157
+
158
+ const now = new Date().toISOString();
159
+ const installedRoot = {
160
+ model: `"${model}"`,
161
+ reasoning: `"${reasoningValue}"`,
162
+ provider: `"getrouter"`,
163
+ };
164
+
165
+ const backup = readCodexBackup() ?? {
166
+ version: 1 as const,
167
+ createdAt: now,
168
+ updatedAt: now,
169
+ };
170
+ backup.updatedAt = now;
171
+ backup.config ??= {};
172
+ backup.config.previous ??= {};
173
+ if (
174
+ backup.config.previous.model === undefined &&
175
+ existingRoot.model &&
176
+ existingRoot.model !== installedRoot.model
177
+ ) {
178
+ backup.config.previous.model = existingRoot.model;
179
+ }
180
+ if (
181
+ backup.config.previous.reasoning === undefined &&
182
+ existingRoot.reasoning &&
183
+ existingRoot.reasoning !== installedRoot.reasoning
184
+ ) {
185
+ backup.config.previous.reasoning = existingRoot.reasoning;
186
+ }
187
+ if (
188
+ backup.config.previous.provider === undefined &&
189
+ existingRoot.provider &&
190
+ existingRoot.provider !== installedRoot.provider
191
+ ) {
192
+ backup.config.previous.provider = existingRoot.provider;
193
+ }
194
+ backup.config.installed = installedRoot;
195
+
196
+ backup.auth ??= {};
197
+ if (
198
+ backup.auth.previousOpenaiKey === undefined &&
199
+ existingOpenaiKey &&
200
+ existingOpenaiKey !== apiKey
201
+ ) {
202
+ backup.auth.previousOpenaiKey = existingOpenaiKey;
203
+ }
204
+ backup.auth.installedOpenaiKey = apiKey;
205
+ writeCodexBackup(backup);
206
+
113
207
  const mergedConfig = mergeCodexToml(existingConfig, {
114
208
  model,
115
209
  reasoning: reasoningValue,
116
210
  });
117
211
  fs.writeFileSync(configPath, mergedConfig, "utf8");
118
212
 
119
- const existingAuth = readAuthJson(authPath);
120
213
  const mergedAuth = mergeAuthJson(existingAuth, apiKey);
121
214
  fs.writeFileSync(authPath, JSON.stringify(mergedAuth, null, 2));
122
215
  if (process.platform !== "win32") {
@@ -138,9 +231,18 @@ export const registerCodexCommand = (program: Command) => {
138
231
  const configExists = fs.existsSync(configPath);
139
232
  const authExists = fs.existsSync(authPath);
140
233
 
234
+ const backup = readCodexBackup();
235
+ const restoreRoot = backup?.config?.previous;
236
+ const restoreOpenaiKey = backup?.auth?.previousOpenaiKey;
237
+ const installedOpenaiKey = backup?.auth?.installedOpenaiKey;
238
+ const hasBackup = Boolean(backup);
239
+
141
240
  const configContent = configExists ? readFileIfExists(configPath) : "";
142
241
  const configResult = configExists
143
- ? removeCodexConfig(configContent)
242
+ ? removeCodexConfig(configContent, {
243
+ restoreRoot,
244
+ allowRootRemoval: hasBackup,
245
+ })
144
246
  : null;
145
247
 
146
248
  const authContent = authExists
@@ -151,7 +253,12 @@ export const registerCodexCommand = (program: Command) => {
151
253
  ? (JSON.parse(authContent) as Record<string, unknown>)
152
254
  : {}
153
255
  : null;
154
- const authResult = authData ? removeAuthJson(authData) : null;
256
+ const authResult = authData
257
+ ? removeAuthJson(authData, {
258
+ installed: installedOpenaiKey,
259
+ restore: restoreOpenaiKey,
260
+ })
261
+ : null;
155
262
 
156
263
  if (!configExists) {
157
264
  console.log(`ℹ️ ${configPath} not found`);
@@ -170,5 +277,10 @@ export const registerCodexCommand = (program: Command) => {
170
277
  } else {
171
278
  console.log(`ℹ️ No getrouter entries in ${authPath}`);
172
279
  }
280
+
281
+ const backupPath = resolveCodexBackupPath();
282
+ if (fs.existsSync(backupPath)) {
283
+ fs.unlinkSync(backupPath);
284
+ }
173
285
  });
174
286
  };
@@ -139,7 +139,10 @@ export const selectConsumer = async (
139
139
  (res) => res?.nextPageToken || undefined,
140
140
  );
141
141
  if (consumers.length === 0) {
142
- throw new Error("No available API keys");
142
+ console.log(
143
+ "No available API keys. Create one at https://getrouter.dev/dashboard/keys",
144
+ );
145
+ return null;
143
146
  }
144
147
  const sorted = sortConsumersByUpdatedAtDesc(consumers);
145
148
  const nameCounts = buildNameCounts(sorted);
@@ -172,7 +175,10 @@ export const selectConsumerList = async (
172
175
  (res) => res?.nextPageToken || undefined,
173
176
  );
174
177
  if (consumers.length === 0) {
175
- throw new Error("No available API keys");
178
+ console.log(
179
+ "No available API keys. Create one at https://getrouter.dev/dashboard/keys",
180
+ );
181
+ return null;
176
182
  }
177
183
  const sorted = sortConsumersByUpdatedAtDesc(consumers);
178
184
  const nameCounts = buildNameCounts(sorted);
@@ -3,9 +3,29 @@ type CodexConfigInput = {
3
3
  reasoning: string;
4
4
  };
5
5
 
6
+ export type CodexTomlRootValues = {
7
+ model?: string;
8
+ reasoning?: string;
9
+ provider?: string;
10
+ };
11
+
6
12
  const CODEX_PROVIDER = "getrouter";
7
13
  const CODEX_BASE_URL = "https://api.getrouter.dev/codex";
8
14
 
15
+ const LEGACY_TOML_ROOT_MARKERS = [
16
+ "_getrouter_codex_backup_model",
17
+ "_getrouter_codex_backup_model_reasoning_effort",
18
+ "_getrouter_codex_backup_model_provider",
19
+ "_getrouter_codex_installed_model",
20
+ "_getrouter_codex_installed_model_reasoning_effort",
21
+ "_getrouter_codex_installed_model_provider",
22
+ ] as const;
23
+
24
+ const LEGACY_AUTH_MARKERS = [
25
+ "_getrouter_codex_backup_openai_api_key",
26
+ "_getrouter_codex_installed_openai_api_key",
27
+ ] as const;
28
+
9
29
  const ROOT_KEYS = [
10
30
  "model",
11
31
  "model_reasoning_effort",
@@ -34,12 +54,77 @@ const providerValues = () => ({
34
54
 
35
55
  const matchHeader = (line: string) => line.match(/^\s*\[([^\]]+)\]\s*$/);
36
56
  const matchKey = (line: string) => line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/);
37
- const matchProviderValue = (line: string) =>
38
- line.match(/^\s*model_provider\s*=\s*(['"]?)([^'"]+)\1\s*(?:#.*)?$/);
57
+
58
+ const parseTomlRhsValue = (rhs: string) => {
59
+ const trimmed = rhs.trim();
60
+ if (!trimmed) return "";
61
+ const first = trimmed[0];
62
+ if (first === '"' || first === "'") {
63
+ const end = trimmed.indexOf(first, 1);
64
+ return end === -1 ? trimmed : trimmed.slice(0, end + 1);
65
+ }
66
+ const hashIndex = trimmed.indexOf("#");
67
+ return (hashIndex === -1 ? trimmed : trimmed.slice(0, hashIndex)).trim();
68
+ };
69
+
70
+ const readRootValue = (lines: string[], key: string) => {
71
+ for (const line of lines) {
72
+ if (matchHeader(line)) break;
73
+ const keyMatch = matchKey(line);
74
+ if (keyMatch?.[1] === key) {
75
+ const parts = line.split("=");
76
+ parts.shift();
77
+ return parseTomlRhsValue(parts.join("="));
78
+ }
79
+ }
80
+ return undefined;
81
+ };
82
+
83
+ export const readCodexTomlRootValues = (
84
+ content: string,
85
+ ): CodexTomlRootValues => {
86
+ const lines = content.length ? content.split(/\r?\n/) : [];
87
+ return {
88
+ model: readRootValue(lines, "model"),
89
+ reasoning: readRootValue(lines, "model_reasoning_effort"),
90
+ provider: readRootValue(lines, "model_provider"),
91
+ };
92
+ };
93
+
94
+ const normalizeTomlString = (value?: string) => {
95
+ if (!value) return "";
96
+ const trimmed = value.trim();
97
+ if (
98
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
99
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
100
+ ) {
101
+ return trimmed.slice(1, -1).trim().toLowerCase();
102
+ }
103
+ return trimmed.replace(/['"]/g, "").trim().toLowerCase();
104
+ };
105
+
106
+ const stripLegacyRootMarkers = (lines: string[]) => {
107
+ const updated: string[] = [];
108
+ let inRoot = true;
109
+
110
+ for (const line of lines) {
111
+ if (matchHeader(line)) {
112
+ inRoot = false;
113
+ }
114
+ if (inRoot) {
115
+ const keyMatch = matchKey(line);
116
+ const key = keyMatch?.[1];
117
+ if (key && LEGACY_TOML_ROOT_MARKERS.includes(key as never)) continue;
118
+ }
119
+ updated.push(line);
120
+ }
121
+
122
+ return updated;
123
+ };
39
124
 
40
125
  export const mergeCodexToml = (content: string, input: CodexConfigInput) => {
41
126
  const lines = content.length ? content.split(/\r?\n/) : [];
42
- const updated = [...lines];
127
+ const updated = [...stripLegacyRootMarkers(lines)];
43
128
  const rootValueMap = rootValues(input);
44
129
  const providerValueMap = providerValues();
45
130
 
@@ -129,10 +214,16 @@ export const mergeCodexToml = (content: string, input: CodexConfigInput) => {
129
214
  export const mergeAuthJson = (
130
215
  data: Record<string, unknown>,
131
216
  apiKey: string,
132
- ): Record<string, unknown> => ({
133
- ...data,
134
- OPENAI_API_KEY: apiKey,
135
- });
217
+ ): Record<string, unknown> => {
218
+ const next: Record<string, unknown> = { ...data };
219
+ for (const key of LEGACY_AUTH_MARKERS) {
220
+ if (key in next) {
221
+ delete next[key];
222
+ }
223
+ }
224
+ next.OPENAI_API_KEY = apiKey;
225
+ return next;
226
+ };
136
227
 
137
228
  const stripGetrouterProviderSection = (lines: string[]) => {
138
229
  const updated: string[] = [];
@@ -156,62 +247,141 @@ const stripGetrouterProviderSection = (lines: string[]) => {
156
247
  return updated;
157
248
  };
158
249
 
159
- const stripRootKeys = (lines: string[]) => {
160
- const updated: string[] = [];
161
- let currentSection: string | null = null;
162
-
163
- for (const line of lines) {
164
- const headerMatch = matchHeader(line);
165
- if (headerMatch) {
166
- currentSection = headerMatch[1]?.trim() ?? null;
167
- updated.push(line);
168
- continue;
169
- }
250
+ const stripLegacyMarkersFromRoot = (rootLines: string[]) =>
251
+ rootLines.filter((line) => {
252
+ const keyMatch = matchKey(line);
253
+ const key = keyMatch?.[1];
254
+ return !(key && LEGACY_TOML_ROOT_MARKERS.includes(key as never));
255
+ });
170
256
 
171
- if (currentSection === null) {
172
- if (/^\s*model\s*=/.test(line)) continue;
173
- if (/^\s*model_reasoning_effort\s*=/.test(line)) continue;
174
- if (/^\s*model_provider\s*=/.test(line)) continue;
257
+ const setOrDeleteRootKey = (
258
+ rootLines: string[],
259
+ key: string,
260
+ value: string | undefined,
261
+ ) => {
262
+ const idx = rootLines.findIndex((line) => matchKey(line)?.[1] === key);
263
+ if (value === undefined) {
264
+ if (idx !== -1) {
265
+ rootLines.splice(idx, 1);
175
266
  }
176
-
177
- updated.push(line);
267
+ return;
178
268
  }
269
+ if (idx !== -1) {
270
+ rootLines[idx] = `${key} = ${value}`;
271
+ } else {
272
+ rootLines.push(`${key} = ${value}`);
273
+ }
274
+ };
179
275
 
180
- return updated;
276
+ const deleteRootKey = (rootLines: string[], key: string) => {
277
+ setOrDeleteRootKey(rootLines, key, undefined);
181
278
  };
182
279
 
183
- export const removeCodexConfig = (content: string) => {
184
- const lines = content.length ? content.split(/\r?\n/) : [];
185
- let providerIsGetrouter = false;
186
- let currentSection: string | null = null;
280
+ const hasLegacyRootMarkers = (lines: string[]) =>
281
+ lines.some((line) => {
282
+ const keyMatch = matchKey(line);
283
+ const key = keyMatch?.[1];
284
+ return !!(key && LEGACY_TOML_ROOT_MARKERS.includes(key as never));
285
+ });
187
286
 
188
- for (const line of lines) {
189
- const headerMatch = matchHeader(line);
190
- if (headerMatch) {
191
- currentSection = headerMatch[1]?.trim() ?? null;
192
- continue;
193
- }
287
+ export const removeCodexConfig = (
288
+ content: string,
289
+ options?: {
290
+ restoreRoot?: CodexTomlRootValues;
291
+ allowRootRemoval?: boolean;
292
+ },
293
+ ) => {
294
+ const { restoreRoot, allowRootRemoval = true } = options ?? {};
295
+ const lines = content.length ? content.split(/\r?\n/) : [];
296
+ const providerIsGetrouter =
297
+ normalizeTomlString(readRootValue(lines, "model_provider")) ===
298
+ CODEX_PROVIDER;
299
+ const canRemoveRoot = allowRootRemoval || hasLegacyRootMarkers(lines);
194
300
 
195
- if (currentSection !== null) continue;
301
+ const stripped = stripGetrouterProviderSection(lines);
302
+ const firstHeaderIndex = stripped.findIndex((line) => matchHeader(line));
303
+ const rootEnd = firstHeaderIndex === -1 ? stripped.length : firstHeaderIndex;
304
+ const rootLines = stripLegacyMarkersFromRoot(stripped.slice(0, rootEnd));
305
+ const restLines = stripped.slice(rootEnd);
196
306
 
197
- const providerMatch = matchProviderValue(line);
198
- const providerValue = providerMatch?.[2]?.trim();
199
- if (providerValue?.toLowerCase() === CODEX_PROVIDER) {
200
- providerIsGetrouter = true;
307
+ if (providerIsGetrouter && canRemoveRoot) {
308
+ if (restoreRoot) {
309
+ setOrDeleteRootKey(rootLines, "model", restoreRoot.model);
310
+ setOrDeleteRootKey(
311
+ rootLines,
312
+ "model_reasoning_effort",
313
+ restoreRoot.reasoning,
314
+ );
315
+ setOrDeleteRootKey(rootLines, "model_provider", restoreRoot.provider);
316
+ } else {
317
+ deleteRootKey(rootLines, "model");
318
+ deleteRootKey(rootLines, "model_reasoning_effort");
319
+ deleteRootKey(rootLines, "model_provider");
201
320
  }
202
321
  }
203
322
 
204
- let updated = stripGetrouterProviderSection(lines);
205
- if (providerIsGetrouter) {
206
- updated = stripRootKeys(updated);
323
+ const recombined: string[] = [...rootLines];
324
+ if (
325
+ recombined.length > 0 &&
326
+ restLines.length > 0 &&
327
+ recombined[recombined.length - 1]?.trim() !== ""
328
+ ) {
329
+ recombined.push("");
207
330
  }
331
+ recombined.push(...restLines);
208
332
 
209
- const nextContent = updated.join("\n");
333
+ const nextContent = recombined.join("\n");
210
334
  return { content: nextContent, changed: nextContent !== content };
211
335
  };
212
336
 
213
- export const removeAuthJson = (data: Record<string, unknown>) => {
214
- if (!("OPENAI_API_KEY" in data)) return { data, changed: false };
215
- const { OPENAI_API_KEY: _ignored, ...rest } = data;
216
- return { data: rest, changed: true };
337
+ export const removeAuthJson = (
338
+ data: Record<string, unknown>,
339
+ options?: {
340
+ installed?: string;
341
+ restore?: string;
342
+ },
343
+ ) => {
344
+ const { installed, restore } = options ?? {};
345
+ const next: Record<string, unknown> = { ...data };
346
+ let changed = false;
347
+
348
+ const legacyInstalled =
349
+ typeof next._getrouter_codex_installed_openai_api_key === "string"
350
+ ? (next._getrouter_codex_installed_openai_api_key as string)
351
+ : undefined;
352
+ const legacyRestore =
353
+ typeof next._getrouter_codex_backup_openai_api_key === "string"
354
+ ? (next._getrouter_codex_backup_openai_api_key as string)
355
+ : undefined;
356
+
357
+ const effectiveInstalled = installed ?? legacyInstalled;
358
+ const effectiveRestore = restore ?? legacyRestore;
359
+
360
+ for (const key of LEGACY_AUTH_MARKERS) {
361
+ if (key in next) {
362
+ delete next[key];
363
+ changed = true;
364
+ }
365
+ }
366
+
367
+ const current =
368
+ typeof next.OPENAI_API_KEY === "string"
369
+ ? (next.OPENAI_API_KEY as string)
370
+ : undefined;
371
+ const restoreValue =
372
+ typeof effectiveRestore === "string" && effectiveRestore.trim().length > 0
373
+ ? effectiveRestore
374
+ : undefined;
375
+
376
+ if (effectiveInstalled && current && current === effectiveInstalled) {
377
+ if (restoreValue) {
378
+ next.OPENAI_API_KEY = restoreValue;
379
+ } else {
380
+ delete next.OPENAI_API_KEY;
381
+ }
382
+ changed = true;
383
+ return { data: next, changed };
384
+ }
385
+
386
+ return { data: next, changed };
217
387
  };
@@ -15,6 +15,8 @@ const makeDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
15
15
  const codexConfigPath = (dir: string) =>
16
16
  path.join(dir, ".codex", "config.toml");
17
17
  const codexAuthPath = (dir: string) => path.join(dir, ".codex", "auth.json");
18
+ const codexBackupPath = (dir: string) =>
19
+ path.join(dir, ".getrouter", "codex-backup.json");
18
20
 
19
21
  const mockConsumer = { id: "c1", apiKey: "key-123" };
20
22
 
@@ -237,6 +239,7 @@ describe("codex command", () => {
237
239
  process.env.HOME = dir;
238
240
  const codexDir = path.join(dir, ".codex");
239
241
  fs.mkdirSync(codexDir, { recursive: true });
242
+ fs.mkdirSync(path.join(dir, ".getrouter"), { recursive: true });
240
243
  fs.writeFileSync(
241
244
  codexConfigPath(dir),
242
245
  [
@@ -257,6 +260,21 @@ describe("codex command", () => {
257
260
  codexAuthPath(dir),
258
261
  JSON.stringify({ OTHER: "keep", OPENAI_API_KEY: "old" }, null, 2),
259
262
  );
263
+ fs.writeFileSync(
264
+ codexBackupPath(dir),
265
+ JSON.stringify(
266
+ {
267
+ version: 1,
268
+ createdAt: "2026-01-01T00:00:00Z",
269
+ updatedAt: "2026-01-01T00:00:00Z",
270
+ auth: {
271
+ installedOpenaiKey: "old",
272
+ },
273
+ },
274
+ null,
275
+ 2,
276
+ ),
277
+ );
260
278
 
261
279
  const program = createProgram();
262
280
  await program.parseAsync(["node", "getrouter", "codex", "uninstall"]);
@@ -274,6 +292,144 @@ describe("codex command", () => {
274
292
  expect(auth.OPENAI_API_KEY).toBeUndefined();
275
293
  });
276
294
 
295
+ it("uninstall preserves existing keys when backup is missing", async () => {
296
+ const dir = makeDir();
297
+ process.env.HOME = dir;
298
+ const codexDir = path.join(dir, ".codex");
299
+ fs.mkdirSync(codexDir, { recursive: true });
300
+ fs.writeFileSync(
301
+ codexConfigPath(dir),
302
+ [
303
+ 'theme = "dark"',
304
+ 'model = "keep"',
305
+ 'model_reasoning_effort = "low"',
306
+ 'model_provider = "getrouter"',
307
+ "",
308
+ "[model_providers.getrouter]",
309
+ 'name = "getrouter"',
310
+ 'base_url = "https://api.getrouter.dev/codex"',
311
+ "",
312
+ "[model_providers.other]",
313
+ 'name = "other"',
314
+ ].join("\n"),
315
+ );
316
+ fs.writeFileSync(
317
+ codexAuthPath(dir),
318
+ JSON.stringify({ OTHER: "keep", OPENAI_API_KEY: "old" }, null, 2),
319
+ );
320
+
321
+ const program = createProgram();
322
+ await program.parseAsync(["node", "getrouter", "codex", "uninstall"]);
323
+
324
+ const config = fs.readFileSync(codexConfigPath(dir), "utf8");
325
+ expect(config).toContain('theme = "dark"');
326
+ expect(config).toContain('model = "keep"');
327
+ expect(config).toContain('model_provider = "getrouter"');
328
+ expect(config).toContain('model_reasoning_effort = "low"');
329
+
330
+ const auth = JSON.parse(fs.readFileSync(codexAuthPath(dir), "utf8"));
331
+ expect(auth.OTHER).toBe("keep");
332
+ expect(auth.OPENAI_API_KEY).toBe("old");
333
+ });
334
+
335
+ it("uninstall restores previous OPENAI_API_KEY when backup exists", async () => {
336
+ const dir = makeDir();
337
+ process.env.HOME = dir;
338
+ const codexDir = path.join(dir, ".codex");
339
+ fs.mkdirSync(codexDir, { recursive: true });
340
+ fs.mkdirSync(path.join(dir, ".getrouter"), { recursive: true });
341
+ fs.writeFileSync(
342
+ codexAuthPath(dir),
343
+ JSON.stringify(
344
+ {
345
+ OPENAI_API_KEY: "new-key",
346
+ _getrouter_codex_backup_openai_api_key: "legacy-backup",
347
+ _getrouter_codex_installed_openai_api_key: "legacy-installed",
348
+ OTHER: "keep",
349
+ },
350
+ null,
351
+ 2,
352
+ ),
353
+ );
354
+ fs.writeFileSync(
355
+ codexBackupPath(dir),
356
+ JSON.stringify(
357
+ {
358
+ version: 1,
359
+ createdAt: "2026-01-01T00:00:00Z",
360
+ updatedAt: "2026-01-01T00:00:00Z",
361
+ auth: {
362
+ previousOpenaiKey: "old-key",
363
+ installedOpenaiKey: "new-key",
364
+ },
365
+ },
366
+ null,
367
+ 2,
368
+ ),
369
+ );
370
+
371
+ const program = createProgram();
372
+ await program.parseAsync(["node", "getrouter", "codex", "uninstall"]);
373
+
374
+ const auth = JSON.parse(fs.readFileSync(codexAuthPath(dir), "utf8"));
375
+ expect(auth.OPENAI_API_KEY).toBe("old-key");
376
+ expect(auth._getrouter_codex_backup_openai_api_key).toBeUndefined();
377
+ expect(auth._getrouter_codex_installed_openai_api_key).toBeUndefined();
378
+ expect(auth.OTHER).toBe("keep");
379
+ expect(fs.existsSync(codexBackupPath(dir))).toBe(false);
380
+ });
381
+
382
+ it("uninstall restores previous model_provider and model", async () => {
383
+ setStdinTTY(true);
384
+ const dir = makeDir();
385
+ process.env.HOME = dir;
386
+ const codexDir = path.join(dir, ".codex");
387
+ fs.mkdirSync(codexDir, { recursive: true });
388
+ fs.writeFileSync(
389
+ codexConfigPath(dir),
390
+ [
391
+ 'model = "user-model"',
392
+ 'model_reasoning_effort = "low"',
393
+ 'model_provider = "openai"',
394
+ "",
395
+ "[model_providers.openai]",
396
+ 'name = "openai"',
397
+ ].join("\n"),
398
+ );
399
+
400
+ prompts.inject(["gpt-5.2-codex", "extra_high", mockConsumer]);
401
+ (createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
402
+ consumerService: {
403
+ ListConsumers: vi.fn().mockResolvedValue({
404
+ consumers: [
405
+ {
406
+ id: "c1",
407
+ name: "dev",
408
+ enabled: true,
409
+ createdAt: "2026-01-01T00:00:00Z",
410
+ },
411
+ ],
412
+ }),
413
+ GetConsumer: vi.fn().mockResolvedValue(mockConsumer),
414
+ } as unknown as ConsumerService,
415
+ });
416
+
417
+ const program = createProgram();
418
+ await program.parseAsync(["node", "getrouter", "codex"]);
419
+ expect(fs.existsSync(codexBackupPath(dir))).toBe(true);
420
+ await program.parseAsync(["node", "getrouter", "codex", "uninstall"]);
421
+ expect(fs.existsSync(codexBackupPath(dir))).toBe(false);
422
+
423
+ const config = fs.readFileSync(codexConfigPath(dir), "utf8");
424
+ expect(config).toContain('model = "user-model"');
425
+ expect(config).toContain('model_reasoning_effort = "low"');
426
+ expect(config).toContain('model_provider = "openai"');
427
+ expect(config).toContain("[model_providers.openai]");
428
+ expect(config).not.toContain("[model_providers.getrouter]");
429
+ expect(config).not.toContain("_getrouter_codex_backup");
430
+ expect(config).not.toContain("_getrouter_codex_installed");
431
+ });
432
+
277
433
  it("uninstall leaves root keys when provider is not getrouter", async () => {
278
434
  const dir = makeDir();
279
435
  process.env.HOME = dir;
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { mergeAuthJson, mergeCodexToml } from "../../../src/core/setup/codex";
2
+ import {
3
+ mergeAuthJson,
4
+ mergeCodexToml,
5
+ removeAuthJson,
6
+ removeCodexConfig,
7
+ } from "../../../src/core/setup/codex";
3
8
 
4
9
  describe("codex setup helpers", () => {
5
10
  it("merges codex toml at root and provider table", () => {
@@ -35,4 +40,85 @@ describe("codex setup helpers", () => {
35
40
  expect(output.OPENAI_API_KEY).toBe("key-123");
36
41
  expect(output.existing).toBe("keep");
37
42
  });
43
+
44
+ it("removes getrouter provider section and restores root keys when provided", () => {
45
+ const input = [
46
+ 'model = "gpt-5.2-codex"',
47
+ 'model_reasoning_effort = "xhigh"',
48
+ 'model_provider = "getrouter"',
49
+ "",
50
+ "[model_providers.getrouter]",
51
+ 'name = "getrouter"',
52
+ "",
53
+ "[model_providers.openai]",
54
+ 'name = "openai"',
55
+ ].join("\n");
56
+
57
+ const { content, changed } = removeCodexConfig(input, {
58
+ restoreRoot: {
59
+ model: '"user-model"',
60
+ reasoning: '"medium"',
61
+ provider: '"openai"',
62
+ },
63
+ });
64
+ expect(changed).toBe(true);
65
+ expect(content).toContain('model = "user-model"');
66
+ expect(content).toContain('model_reasoning_effort = "medium"');
67
+ expect(content).toContain('model_provider = "openai"');
68
+ expect(content).toContain("[model_providers.openai]");
69
+ expect(content).not.toContain("[model_providers.getrouter]");
70
+ });
71
+
72
+ it("removes root keys when provider is getrouter and no restore is provided", () => {
73
+ const input = [
74
+ 'model = "gpt-5.2-codex"',
75
+ 'model_reasoning_effort = "xhigh"',
76
+ 'model_provider = "getrouter"',
77
+ "",
78
+ "[model_providers.getrouter]",
79
+ 'name = "getrouter"',
80
+ ].join("\n");
81
+
82
+ const { content } = removeCodexConfig(input);
83
+ expect(content).not.toContain('model = "gpt-5.2-codex"');
84
+ expect(content).not.toContain('model_reasoning_effort = "xhigh"');
85
+ expect(content).not.toContain('model_provider = "getrouter"');
86
+ expect(content).not.toContain("[model_providers.getrouter]");
87
+ });
88
+
89
+ it("restores OPENAI_API_KEY when installed key matches current", () => {
90
+ const input = {
91
+ OPENAI_API_KEY: "new-key",
92
+ OTHER: "keep",
93
+ } as Record<string, unknown>;
94
+ const { data, changed } = removeAuthJson(input, {
95
+ installed: "new-key",
96
+ restore: "old-key",
97
+ });
98
+ expect(changed).toBe(true);
99
+ expect(data.OPENAI_API_KEY).toBe("old-key");
100
+ expect(data.OTHER).toBe("keep");
101
+ });
102
+
103
+ it("removes OPENAI_API_KEY when installed key matches and no restore is available", () => {
104
+ const input = {
105
+ OPENAI_API_KEY: "new-key",
106
+ OTHER: "keep",
107
+ } as Record<string, unknown>;
108
+ const { data, changed } = removeAuthJson(input, { installed: "new-key" });
109
+ expect(changed).toBe(true);
110
+ expect(data.OPENAI_API_KEY).toBeUndefined();
111
+ expect(data.OTHER).toBe("keep");
112
+ });
113
+
114
+ it("leaves OPENAI_API_KEY when not forced and not installed", () => {
115
+ const input = {
116
+ OPENAI_API_KEY: "user-key",
117
+ OTHER: "keep",
118
+ } as Record<string, unknown>;
119
+ const { data, changed } = removeAuthJson(input);
120
+ expect(changed).toBe(false);
121
+ expect(data.OPENAI_API_KEY).toBe("user-key");
122
+ expect(data.OTHER).toBe("keep");
123
+ });
38
124
  });