@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 +1 -0
- package/README.md +9 -0
- package/README.zh-cn.md +1 -0
- package/dist/bin.mjs +193 -52
- package/package.json +1 -1
- package/src/cmd/codex.ts +115 -3
- package/src/core/interactive/keys.ts +8 -2
- package/src/core/setup/codex.ts +218 -48
- package/tests/cmd/codex.test.ts +156 -0
- package/tests/core/setup/codex.test.ts +87 -1
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
|
+
[](https://github.com/getrouter/getrouter-cli/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@getrouter/getrouter-cli)
|
|
5
|
+
[](https://www.npmjs.com/package/@getrouter/getrouter-cli)
|
|
6
|
+
[](https://www.npmjs.com/package/@getrouter/getrouter-cli)
|
|
7
|
+
[](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
|
+
[](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.
|
|
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)
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
1118
|
-
const
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
-
|
|
1150
|
-
if (
|
|
1151
|
-
|
|
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
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
const
|
|
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:
|
|
1165
|
-
changed
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/core/setup/codex.ts
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
276
|
+
const deleteRootKey = (rootLines: string[], key: string) => {
|
|
277
|
+
setOrDeleteRootKey(rootLines, key, undefined);
|
|
181
278
|
};
|
|
182
279
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
205
|
-
if (
|
|
206
|
-
|
|
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 =
|
|
333
|
+
const nextContent = recombined.join("\n");
|
|
210
334
|
return { content: nextContent, changed: nextContent !== content };
|
|
211
335
|
};
|
|
212
336
|
|
|
213
|
-
export const removeAuthJson = (
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
};
|
package/tests/cmd/codex.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
});
|