@getrouter/getrouter-cli 0.1.10 → 0.1.11

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
@@ -1011,6 +1011,15 @@ const mapReasoningValue = (id) => REASONING_CHOICES.find((choice) => choice.id =
1011
1011
  //#region src/core/setup/codex.ts
1012
1012
  const CODEX_PROVIDER = "getrouter";
1013
1013
  const CODEX_BASE_URL = "https://api.getrouter.dev/codex";
1014
+ const LEGACY_TOML_ROOT_MARKERS = [
1015
+ "_getrouter_codex_backup_model",
1016
+ "_getrouter_codex_backup_model_reasoning_effort",
1017
+ "_getrouter_codex_backup_model_provider",
1018
+ "_getrouter_codex_installed_model",
1019
+ "_getrouter_codex_installed_model_reasoning_effort",
1020
+ "_getrouter_codex_installed_model_provider"
1021
+ ];
1022
+ const LEGACY_AUTH_MARKERS = ["_getrouter_codex_backup_openai_api_key", "_getrouter_codex_installed_openai_api_key"];
1014
1023
  const ROOT_KEYS = [
1015
1024
  "model",
1016
1025
  "model_reasoning_effort",
@@ -1036,9 +1045,56 @@ const providerValues = () => ({
1036
1045
  });
1037
1046
  const matchHeader = (line) => line.match(/^\s*\[([^\]]+)\]\s*$/);
1038
1047
  const matchKey = (line) => line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/);
1039
- const matchProviderValue = (line) => line.match(/^\s*model_provider\s*=\s*(['"]?)([^'"]+)\1\s*(?:#.*)?$/);
1048
+ const parseTomlRhsValue = (rhs) => {
1049
+ const trimmed = rhs.trim();
1050
+ if (!trimmed) return "";
1051
+ const first = trimmed[0];
1052
+ if (first === "\"" || first === "'") {
1053
+ const end = trimmed.indexOf(first, 1);
1054
+ return end === -1 ? trimmed : trimmed.slice(0, end + 1);
1055
+ }
1056
+ const hashIndex = trimmed.indexOf("#");
1057
+ return (hashIndex === -1 ? trimmed : trimmed.slice(0, hashIndex)).trim();
1058
+ };
1059
+ const readRootValue = (lines, key) => {
1060
+ for (const line of lines) {
1061
+ if (matchHeader(line)) break;
1062
+ if (matchKey(line)?.[1] === key) {
1063
+ const parts = line.split("=");
1064
+ parts.shift();
1065
+ return parseTomlRhsValue(parts.join("="));
1066
+ }
1067
+ }
1068
+ };
1069
+ const readCodexTomlRootValues = (content) => {
1070
+ const lines = content.length ? content.split(/\r?\n/) : [];
1071
+ return {
1072
+ model: readRootValue(lines, "model"),
1073
+ reasoning: readRootValue(lines, "model_reasoning_effort"),
1074
+ provider: readRootValue(lines, "model_provider")
1075
+ };
1076
+ };
1077
+ const normalizeTomlString = (value) => {
1078
+ if (!value) return "";
1079
+ const trimmed = value.trim();
1080
+ if (trimmed.startsWith("\"") && trimmed.endsWith("\"") || trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1).trim().toLowerCase();
1081
+ return trimmed.replace(/['"]/g, "").trim().toLowerCase();
1082
+ };
1083
+ const stripLegacyRootMarkers = (lines) => {
1084
+ const updated = [];
1085
+ let inRoot = true;
1086
+ for (const line of lines) {
1087
+ if (matchHeader(line)) inRoot = false;
1088
+ if (inRoot) {
1089
+ const key = matchKey(line)?.[1];
1090
+ if (key && LEGACY_TOML_ROOT_MARKERS.includes(key)) continue;
1091
+ }
1092
+ updated.push(line);
1093
+ }
1094
+ return updated;
1095
+ };
1040
1096
  const mergeCodexToml = (content, input) => {
1041
- const updated = [...content.length ? content.split(/\r?\n/) : []];
1097
+ const updated = [...stripLegacyRootMarkers(content.length ? content.split(/\r?\n/) : [])];
1042
1098
  const rootValueMap = rootValues(input);
1043
1099
  const providerValueMap = providerValues();
1044
1100
  let currentSection = null;
@@ -1093,10 +1149,12 @@ const mergeCodexToml = (content, input) => {
1093
1149
  if (missingProvider.length > 0) updated.splice(providerEnd, 0, ...missingProvider);
1094
1150
  return updated.join("\n");
1095
1151
  };
1096
- const mergeAuthJson = (data, apiKey) => ({
1097
- ...data,
1098
- OPENAI_API_KEY: apiKey
1099
- });
1152
+ const mergeAuthJson = (data, apiKey) => {
1153
+ const next = { ...data };
1154
+ for (const key of LEGACY_AUTH_MARKERS) if (key in next) delete next[key];
1155
+ next.OPENAI_API_KEY = apiKey;
1156
+ return next;
1157
+ };
1100
1158
  const stripGetrouterProviderSection = (lines) => {
1101
1159
  const updated = [];
1102
1160
  let skipSection = false;
@@ -1114,61 +1172,82 @@ const stripGetrouterProviderSection = (lines) => {
1114
1172
  }
1115
1173
  return updated;
1116
1174
  };
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);
1175
+ const stripLegacyMarkersFromRoot = (rootLines) => rootLines.filter((line) => {
1176
+ const key = matchKey(line)?.[1];
1177
+ return !(key && LEGACY_TOML_ROOT_MARKERS.includes(key));
1178
+ });
1179
+ const setOrDeleteRootKey = (rootLines, key, value) => {
1180
+ const idx = rootLines.findIndex((line) => matchKey(line)?.[1] === key);
1181
+ if (value === void 0) {
1182
+ if (idx !== -1) rootLines.splice(idx, 1);
1183
+ return;
1133
1184
  }
1134
- return updated;
1185
+ if (idx !== -1) rootLines[idx] = `${key} = ${value}`;
1186
+ else rootLines.push(`${key} = ${value}`);
1135
1187
  };
1136
- const removeCodexConfig = (content) => {
1188
+ const deleteRootKey = (rootLines, key) => {
1189
+ setOrDeleteRootKey(rootLines, key, void 0);
1190
+ };
1191
+ const removeCodexConfig = (content, options) => {
1192
+ const { restoreRoot } = options ?? {};
1137
1193
  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;
1194
+ const providerIsGetrouter = normalizeTomlString(readRootValue(lines, "model_provider")) === CODEX_PROVIDER;
1195
+ const stripped = stripGetrouterProviderSection(lines);
1196
+ const firstHeaderIndex = stripped.findIndex((line) => matchHeader(line));
1197
+ const rootEnd = firstHeaderIndex === -1 ? stripped.length : firstHeaderIndex;
1198
+ const rootLines = stripLegacyMarkersFromRoot(stripped.slice(0, rootEnd));
1199
+ const restLines = stripped.slice(rootEnd);
1200
+ if (providerIsGetrouter) if (restoreRoot) {
1201
+ setOrDeleteRootKey(rootLines, "model", restoreRoot.model);
1202
+ setOrDeleteRootKey(rootLines, "model_reasoning_effort", restoreRoot.reasoning);
1203
+ setOrDeleteRootKey(rootLines, "model_provider", restoreRoot.provider);
1204
+ } else {
1205
+ deleteRootKey(rootLines, "model");
1206
+ deleteRootKey(rootLines, "model_reasoning_effort");
1207
+ deleteRootKey(rootLines, "model_provider");
1148
1208
  }
1149
- let updated = stripGetrouterProviderSection(lines);
1150
- if (providerIsGetrouter) updated = stripRootKeys(updated);
1151
- const nextContent = updated.join("\n");
1209
+ const recombined = [...rootLines];
1210
+ if (recombined.length > 0 && restLines.length > 0 && recombined[recombined.length - 1]?.trim() !== "") recombined.push("");
1211
+ recombined.push(...restLines);
1212
+ const nextContent = recombined.join("\n");
1152
1213
  return {
1153
1214
  content: nextContent,
1154
1215
  changed: nextContent !== content
1155
1216
  };
1156
1217
  };
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;
1218
+ const removeAuthJson = (data, options) => {
1219
+ const { force = false, installed, restore } = options ?? {};
1220
+ const next = { ...data };
1221
+ let changed = false;
1222
+ for (const key of LEGACY_AUTH_MARKERS) if (key in next) {
1223
+ delete next[key];
1224
+ changed = true;
1225
+ }
1226
+ const current = typeof next.OPENAI_API_KEY === "string" ? next.OPENAI_API_KEY : void 0;
1227
+ const restoreValue = typeof restore === "string" && restore.trim().length > 0 ? restore : void 0;
1228
+ if (installed && current && current === installed) {
1229
+ if (restoreValue) next.OPENAI_API_KEY = restoreValue;
1230
+ else delete next.OPENAI_API_KEY;
1231
+ changed = true;
1232
+ return {
1233
+ data: next,
1234
+ changed
1235
+ };
1236
+ }
1237
+ if (force && current) {
1238
+ delete next.OPENAI_API_KEY;
1239
+ changed = true;
1240
+ }
1163
1241
  return {
1164
- data: rest,
1165
- changed: true
1242
+ data: next,
1243
+ changed
1166
1244
  };
1167
1245
  };
1168
1246
 
1169
1247
  //#endregion
1170
1248
  //#region src/cmd/codex.ts
1171
1249
  const CODEX_DIR = ".codex";
1250
+ const CODEX_BACKUP_FILE = "codex-backup.json";
1172
1251
  const readFileIfExists = (filePath) => fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
1173
1252
  const readAuthJson = (filePath) => {
1174
1253
  if (!fs.existsSync(filePath)) return {};
@@ -1184,6 +1263,18 @@ const ensureCodexDir = () => {
1184
1263
  return dir;
1185
1264
  };
1186
1265
  const resolveCodexDir = () => path.join(os.homedir(), CODEX_DIR);
1266
+ const resolveCodexBackupPath = () => path.join(resolveConfigDir(), CODEX_BACKUP_FILE);
1267
+ const readCodexBackup = () => {
1268
+ const raw = readJsonFile(resolveCodexBackupPath());
1269
+ if (!raw || typeof raw !== "object") return null;
1270
+ if (raw.version !== 1) return null;
1271
+ return raw;
1272
+ };
1273
+ const writeCodexBackup = (backup) => {
1274
+ const backupPath = resolveCodexBackupPath();
1275
+ writeJsonFile(backupPath, backup);
1276
+ if (process.platform !== "win32") fs.chmodSync(backupPath, 384);
1277
+ };
1187
1278
  const requireInteractive$1 = () => {
1188
1279
  if (!process.stdin.isTTY) throw new Error("Interactive mode required for codex configuration.");
1189
1280
  };
@@ -1220,12 +1311,38 @@ const registerCodexCommand = (program) => {
1220
1311
  const codexDir = ensureCodexDir();
1221
1312
  const configPath = path.join(codexDir, "config.toml");
1222
1313
  const authPath = path.join(codexDir, "auth.json");
1223
- const mergedConfig = mergeCodexToml(readFileIfExists(configPath), {
1314
+ const existingConfig = readFileIfExists(configPath);
1315
+ const existingRoot = readCodexTomlRootValues(existingConfig);
1316
+ const existingAuth = readAuthJson(authPath);
1317
+ const existingOpenaiKey = typeof existingAuth.OPENAI_API_KEY === "string" ? existingAuth.OPENAI_API_KEY : void 0;
1318
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1319
+ const installedRoot = {
1320
+ model: `"${model}"`,
1321
+ reasoning: `"${reasoningValue}"`,
1322
+ provider: `"getrouter"`
1323
+ };
1324
+ const backup = readCodexBackup() ?? {
1325
+ version: 1,
1326
+ createdAt: now,
1327
+ updatedAt: now
1328
+ };
1329
+ backup.updatedAt = now;
1330
+ backup.config ??= {};
1331
+ backup.config.previous ??= {};
1332
+ if (backup.config.previous.model === void 0 && existingRoot.model && existingRoot.model !== installedRoot.model) backup.config.previous.model = existingRoot.model;
1333
+ if (backup.config.previous.reasoning === void 0 && existingRoot.reasoning && existingRoot.reasoning !== installedRoot.reasoning) backup.config.previous.reasoning = existingRoot.reasoning;
1334
+ if (backup.config.previous.provider === void 0 && existingRoot.provider && existingRoot.provider !== installedRoot.provider) backup.config.previous.provider = existingRoot.provider;
1335
+ backup.config.installed = installedRoot;
1336
+ backup.auth ??= {};
1337
+ if (backup.auth.previousOpenaiKey === void 0 && existingOpenaiKey && existingOpenaiKey !== apiKey) backup.auth.previousOpenaiKey = existingOpenaiKey;
1338
+ backup.auth.installedOpenaiKey = apiKey;
1339
+ writeCodexBackup(backup);
1340
+ const mergedConfig = mergeCodexToml(existingConfig, {
1224
1341
  model,
1225
1342
  reasoning: reasoningValue
1226
1343
  });
1227
1344
  fs.writeFileSync(configPath, mergedConfig, "utf8");
1228
- const mergedAuth = mergeAuthJson(readAuthJson(authPath), apiKey);
1345
+ const mergedAuth = mergeAuthJson(existingAuth, apiKey);
1229
1346
  fs.writeFileSync(authPath, JSON.stringify(mergedAuth, null, 2));
1230
1347
  if (process.platform !== "win32") fs.chmodSync(authPath, 384);
1231
1348
  console.log("✅ Updated ~/.codex/config.toml");
@@ -1237,11 +1354,19 @@ const registerCodexCommand = (program) => {
1237
1354
  const authPath = path.join(codexDir, "auth.json");
1238
1355
  const configExists = fs.existsSync(configPath);
1239
1356
  const authExists = fs.existsSync(authPath);
1357
+ const backup = readCodexBackup();
1358
+ const restoreRoot = backup?.config?.previous;
1359
+ const restoreOpenaiKey = backup?.auth?.previousOpenaiKey;
1360
+ const installedOpenaiKey = backup?.auth?.installedOpenaiKey;
1240
1361
  const configContent = configExists ? readFileIfExists(configPath) : "";
1241
- const configResult = configExists ? removeCodexConfig(configContent) : null;
1362
+ const configResult = configExists ? removeCodexConfig(configContent, { restoreRoot }) : null;
1242
1363
  const authContent = authExists ? fs.readFileSync(authPath, "utf8").trim() : "";
1243
1364
  const authData = authExists ? authContent ? JSON.parse(authContent) : {} : null;
1244
- const authResult = authData ? removeAuthJson(authData) : null;
1365
+ const authResult = authData ? removeAuthJson(authData, {
1366
+ force: true,
1367
+ installed: installedOpenaiKey,
1368
+ restore: restoreOpenaiKey
1369
+ }) : null;
1245
1370
  if (!configExists) console.log(`ℹ️ ${configPath} not found`);
1246
1371
  else if (configResult?.changed) {
1247
1372
  fs.writeFileSync(configPath, configResult.content, "utf8");
@@ -1252,6 +1377,8 @@ const registerCodexCommand = (program) => {
1252
1377
  fs.writeFileSync(authPath, JSON.stringify(authResult.data, null, 2));
1253
1378
  console.log(`✅ Removed getrouter entries from ${authPath}`);
1254
1379
  } else console.log(`ℹ️ No getrouter entries in ${authPath}`);
1380
+ const backupPath = resolveCodexBackupPath();
1381
+ if (fs.existsSync(backupPath)) fs.unlinkSync(backupPath);
1255
1382
  });
1256
1383
  };
1257
1384
 
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.11",
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,14 @@ 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
+
141
239
  const configContent = configExists ? readFileIfExists(configPath) : "";
142
240
  const configResult = configExists
143
- ? removeCodexConfig(configContent)
241
+ ? removeCodexConfig(configContent, { restoreRoot })
144
242
  : null;
145
243
 
146
244
  const authContent = authExists
@@ -151,7 +249,13 @@ export const registerCodexCommand = (program: Command) => {
151
249
  ? (JSON.parse(authContent) as Record<string, unknown>)
152
250
  : {}
153
251
  : null;
154
- const authResult = authData ? removeAuthJson(authData) : null;
252
+ const authResult = authData
253
+ ? removeAuthJson(authData, {
254
+ force: true,
255
+ installed: installedOpenaiKey,
256
+ restore: restoreOpenaiKey,
257
+ })
258
+ : null;
155
259
 
156
260
  if (!configExists) {
157
261
  console.log(`ℹ️ ${configPath} not found`);
@@ -170,5 +274,10 @@ export const registerCodexCommand = (program: Command) => {
170
274
  } else {
171
275
  console.log(`ℹ️ No getrouter entries in ${authPath}`);
172
276
  }
277
+
278
+ const backupPath = resolveCodexBackupPath();
279
+ if (fs.existsSync(backupPath)) {
280
+ fs.unlinkSync(backupPath);
281
+ }
173
282
  });
174
283
  };
@@ -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,124 @@ 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;
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
+ });
162
256
 
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;
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);
169
266
  }
267
+ return;
268
+ }
269
+ if (idx !== -1) {
270
+ rootLines[idx] = `${key} = ${value}`;
271
+ } else {
272
+ rootLines.push(`${key} = ${value}`);
273
+ }
274
+ };
275
+
276
+ const deleteRootKey = (rootLines: string[], key: string) => {
277
+ setOrDeleteRootKey(rootLines, key, undefined);
278
+ };
170
279
 
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;
280
+ export const removeCodexConfig = (
281
+ content: string,
282
+ options?: { restoreRoot?: CodexTomlRootValues },
283
+ ) => {
284
+ const { restoreRoot } = options ?? {};
285
+ const lines = content.length ? content.split(/\r?\n/) : [];
286
+ const providerIsGetrouter =
287
+ normalizeTomlString(readRootValue(lines, "model_provider")) ===
288
+ CODEX_PROVIDER;
289
+
290
+ const stripped = stripGetrouterProviderSection(lines);
291
+ const firstHeaderIndex = stripped.findIndex((line) => matchHeader(line));
292
+ const rootEnd = firstHeaderIndex === -1 ? stripped.length : firstHeaderIndex;
293
+ const rootLines = stripLegacyMarkersFromRoot(stripped.slice(0, rootEnd));
294
+ const restLines = stripped.slice(rootEnd);
295
+
296
+ if (providerIsGetrouter) {
297
+ if (restoreRoot) {
298
+ setOrDeleteRootKey(rootLines, "model", restoreRoot.model);
299
+ setOrDeleteRootKey(
300
+ rootLines,
301
+ "model_reasoning_effort",
302
+ restoreRoot.reasoning,
303
+ );
304
+ setOrDeleteRootKey(rootLines, "model_provider", restoreRoot.provider);
305
+ } else {
306
+ deleteRootKey(rootLines, "model");
307
+ deleteRootKey(rootLines, "model_reasoning_effort");
308
+ deleteRootKey(rootLines, "model_provider");
175
309
  }
310
+ }
176
311
 
177
- updated.push(line);
312
+ const recombined: string[] = [...rootLines];
313
+ if (
314
+ recombined.length > 0 &&
315
+ restLines.length > 0 &&
316
+ recombined[recombined.length - 1]?.trim() !== ""
317
+ ) {
318
+ recombined.push("");
178
319
  }
320
+ recombined.push(...restLines);
179
321
 
180
- return updated;
322
+ const nextContent = recombined.join("\n");
323
+ return { content: nextContent, changed: nextContent !== content };
181
324
  };
182
325
 
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;
326
+ export const removeAuthJson = (
327
+ data: Record<string, unknown>,
328
+ options?: {
329
+ force?: boolean;
330
+ installed?: string;
331
+ restore?: string;
332
+ },
333
+ ) => {
334
+ const { force = false, installed, restore } = options ?? {};
335
+ const next: Record<string, unknown> = { ...data };
336
+ let changed = false;
187
337
 
188
- for (const line of lines) {
189
- const headerMatch = matchHeader(line);
190
- if (headerMatch) {
191
- currentSection = headerMatch[1]?.trim() ?? null;
192
- continue;
338
+ for (const key of LEGACY_AUTH_MARKERS) {
339
+ if (key in next) {
340
+ delete next[key];
341
+ changed = true;
193
342
  }
343
+ }
194
344
 
195
- if (currentSection !== null) continue;
345
+ const current =
346
+ typeof next.OPENAI_API_KEY === "string"
347
+ ? (next.OPENAI_API_KEY as string)
348
+ : undefined;
349
+ const restoreValue =
350
+ typeof restore === "string" && restore.trim().length > 0
351
+ ? restore
352
+ : undefined;
196
353
 
197
- const providerMatch = matchProviderValue(line);
198
- const providerValue = providerMatch?.[2]?.trim();
199
- if (providerValue?.toLowerCase() === CODEX_PROVIDER) {
200
- providerIsGetrouter = true;
354
+ if (installed && current && current === installed) {
355
+ if (restoreValue) {
356
+ next.OPENAI_API_KEY = restoreValue;
357
+ } else {
358
+ delete next.OPENAI_API_KEY;
201
359
  }
360
+ changed = true;
361
+ return { data: next, changed };
202
362
  }
203
363
 
204
- let updated = stripGetrouterProviderSection(lines);
205
- if (providerIsGetrouter) {
206
- updated = stripRootKeys(updated);
364
+ if (force && current) {
365
+ delete next.OPENAI_API_KEY;
366
+ changed = true;
207
367
  }
208
368
 
209
- const nextContent = updated.join("\n");
210
- return { content: nextContent, changed: nextContent !== content };
211
- };
212
-
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 };
369
+ return { data: next, changed };
217
370
  };
@@ -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
 
@@ -274,6 +276,104 @@ describe("codex command", () => {
274
276
  expect(auth.OPENAI_API_KEY).toBeUndefined();
275
277
  });
276
278
 
279
+ it("uninstall restores previous OPENAI_API_KEY when backup exists", async () => {
280
+ const dir = makeDir();
281
+ process.env.HOME = dir;
282
+ const codexDir = path.join(dir, ".codex");
283
+ fs.mkdirSync(codexDir, { recursive: true });
284
+ fs.mkdirSync(path.join(dir, ".getrouter"), { recursive: true });
285
+ fs.writeFileSync(
286
+ codexAuthPath(dir),
287
+ JSON.stringify(
288
+ {
289
+ OPENAI_API_KEY: "new-key",
290
+ _getrouter_codex_backup_openai_api_key: "legacy-backup",
291
+ _getrouter_codex_installed_openai_api_key: "legacy-installed",
292
+ OTHER: "keep",
293
+ },
294
+ null,
295
+ 2,
296
+ ),
297
+ );
298
+ fs.writeFileSync(
299
+ codexBackupPath(dir),
300
+ JSON.stringify(
301
+ {
302
+ version: 1,
303
+ createdAt: "2026-01-01T00:00:00Z",
304
+ updatedAt: "2026-01-01T00:00:00Z",
305
+ auth: {
306
+ previousOpenaiKey: "old-key",
307
+ installedOpenaiKey: "new-key",
308
+ },
309
+ },
310
+ null,
311
+ 2,
312
+ ),
313
+ );
314
+
315
+ const program = createProgram();
316
+ await program.parseAsync(["node", "getrouter", "codex", "uninstall"]);
317
+
318
+ const auth = JSON.parse(fs.readFileSync(codexAuthPath(dir), "utf8"));
319
+ expect(auth.OPENAI_API_KEY).toBe("old-key");
320
+ expect(auth._getrouter_codex_backup_openai_api_key).toBeUndefined();
321
+ expect(auth._getrouter_codex_installed_openai_api_key).toBeUndefined();
322
+ expect(auth.OTHER).toBe("keep");
323
+ expect(fs.existsSync(codexBackupPath(dir))).toBe(false);
324
+ });
325
+
326
+ it("uninstall restores previous model_provider and model", async () => {
327
+ setStdinTTY(true);
328
+ const dir = makeDir();
329
+ process.env.HOME = dir;
330
+ const codexDir = path.join(dir, ".codex");
331
+ fs.mkdirSync(codexDir, { recursive: true });
332
+ fs.writeFileSync(
333
+ codexConfigPath(dir),
334
+ [
335
+ 'model = "user-model"',
336
+ 'model_reasoning_effort = "low"',
337
+ 'model_provider = "openai"',
338
+ "",
339
+ "[model_providers.openai]",
340
+ 'name = "openai"',
341
+ ].join("\n"),
342
+ );
343
+
344
+ prompts.inject(["gpt-5.2-codex", "extra_high", mockConsumer]);
345
+ (createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
346
+ consumerService: {
347
+ ListConsumers: vi.fn().mockResolvedValue({
348
+ consumers: [
349
+ {
350
+ id: "c1",
351
+ name: "dev",
352
+ enabled: true,
353
+ createdAt: "2026-01-01T00:00:00Z",
354
+ },
355
+ ],
356
+ }),
357
+ GetConsumer: vi.fn().mockResolvedValue(mockConsumer),
358
+ } as unknown as ConsumerService,
359
+ });
360
+
361
+ const program = createProgram();
362
+ await program.parseAsync(["node", "getrouter", "codex"]);
363
+ expect(fs.existsSync(codexBackupPath(dir))).toBe(true);
364
+ await program.parseAsync(["node", "getrouter", "codex", "uninstall"]);
365
+ expect(fs.existsSync(codexBackupPath(dir))).toBe(false);
366
+
367
+ const config = fs.readFileSync(codexConfigPath(dir), "utf8");
368
+ expect(config).toContain('model = "user-model"');
369
+ expect(config).toContain('model_reasoning_effort = "low"');
370
+ expect(config).toContain('model_provider = "openai"');
371
+ expect(config).toContain("[model_providers.openai]");
372
+ expect(config).not.toContain("[model_providers.getrouter]");
373
+ expect(config).not.toContain("_getrouter_codex_backup");
374
+ expect(config).not.toContain("_getrouter_codex_installed");
375
+ });
376
+
277
377
  it("uninstall leaves root keys when provider is not getrouter", async () => {
278
378
  const dir = makeDir();
279
379
  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 forced 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, { force: true });
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
  });