@getrouter/getrouter-cli 0.1.4 → 0.1.6

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/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.4";
11
+ var version = "0.1.5";
12
12
 
13
13
  //#endregion
14
14
  //#region src/generated/router/dashboard/v1/index.ts
@@ -616,8 +616,12 @@ const promptKeyName = async (initial) => {
616
616
  message: "Key name",
617
617
  initial: initial ?? ""
618
618
  });
619
+ if (!("name" in response)) return { cancelled: true };
619
620
  const value = typeof response.name === "string" ? response.name.trim() : "";
620
- return value.length > 0 ? value : void 0;
621
+ return {
622
+ cancelled: false,
623
+ name: value.length > 0 ? value : void 0
624
+ };
621
625
  };
622
626
  const promptKeyEnabled = async (initial) => {
623
627
  const response = await prompts({
@@ -626,7 +630,11 @@ const promptKeyEnabled = async (initial) => {
626
630
  message: "Enable this key?",
627
631
  initial
628
632
  });
629
- return typeof response.enabled === "boolean" ? response.enabled : initial;
633
+ if (!("enabled" in response)) return { cancelled: true };
634
+ return {
635
+ cancelled: false,
636
+ enabled: typeof response.enabled === "boolean" ? response.enabled : initial
637
+ };
630
638
  };
631
639
  const selectConsumer = async (consumerService) => {
632
640
  const consumers = await fetchAllPages((pageToken) => consumerService.ListConsumers({
@@ -1006,6 +1014,7 @@ const providerValues = () => ({
1006
1014
  });
1007
1015
  const matchHeader = (line) => line.match(/^\s*\[([^\]]+)\]\s*$/);
1008
1016
  const matchKey = (line) => line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/);
1017
+ const matchProviderValue = (line) => line.match(/^\s*model_provider\s*=\s*(['"]?)([^'"]+)\1\s*(?:#.*)?$/);
1009
1018
  const mergeCodexToml = (content, input) => {
1010
1019
  const updated = [...content.length ? content.split(/\r?\n/) : []];
1011
1020
  const rootValueMap = rootValues(input);
@@ -1066,6 +1075,74 @@ const mergeAuthJson = (data, apiKey) => ({
1066
1075
  ...data,
1067
1076
  OPENAI_API_KEY: apiKey
1068
1077
  });
1078
+ const stripGetrouterProviderSection = (lines) => {
1079
+ const updated = [];
1080
+ let skipSection = false;
1081
+ for (const line of lines) {
1082
+ const headerMatch = matchHeader(line);
1083
+ if (headerMatch) {
1084
+ if ((headerMatch[1]?.trim() ?? "") === PROVIDER_SECTION) {
1085
+ skipSection = true;
1086
+ continue;
1087
+ }
1088
+ skipSection = false;
1089
+ }
1090
+ if (skipSection) continue;
1091
+ updated.push(line);
1092
+ }
1093
+ return updated;
1094
+ };
1095
+ const stripRootKeys = (lines) => {
1096
+ const updated = [];
1097
+ let currentSection = null;
1098
+ for (const line of lines) {
1099
+ const headerMatch = matchHeader(line);
1100
+ if (headerMatch) {
1101
+ currentSection = headerMatch[1]?.trim() ?? null;
1102
+ updated.push(line);
1103
+ continue;
1104
+ }
1105
+ if (currentSection === null) {
1106
+ if (/^\s*model\s*=/.test(line)) continue;
1107
+ if (/^\s*model_reasoning_effort\s*=/.test(line)) continue;
1108
+ if (/^\s*model_provider\s*=/.test(line)) continue;
1109
+ }
1110
+ updated.push(line);
1111
+ }
1112
+ return updated;
1113
+ };
1114
+ const removeCodexConfig = (content) => {
1115
+ const lines = content.length ? content.split(/\r?\n/) : [];
1116
+ let providerIsGetrouter = false;
1117
+ let currentSection = null;
1118
+ for (const line of lines) {
1119
+ const headerMatch = matchHeader(line);
1120
+ if (headerMatch) {
1121
+ currentSection = headerMatch[1]?.trim() ?? null;
1122
+ continue;
1123
+ }
1124
+ if (currentSection !== null) continue;
1125
+ if ((matchProviderValue(line)?.[2]?.trim())?.toLowerCase() === CODEX_PROVIDER) providerIsGetrouter = true;
1126
+ }
1127
+ let updated = stripGetrouterProviderSection(lines);
1128
+ if (providerIsGetrouter) updated = stripRootKeys(updated);
1129
+ const nextContent = updated.join("\n");
1130
+ return {
1131
+ content: nextContent,
1132
+ changed: nextContent !== content
1133
+ };
1134
+ };
1135
+ const removeAuthJson = (data) => {
1136
+ if (!("OPENAI_API_KEY" in data)) return {
1137
+ data,
1138
+ changed: false
1139
+ };
1140
+ const { OPENAI_API_KEY: _ignored, ...rest } = data;
1141
+ return {
1142
+ data: rest,
1143
+ changed: true
1144
+ };
1145
+ };
1069
1146
 
1070
1147
  //#endregion
1071
1148
  //#region src/cmd/codex.ts
@@ -1084,6 +1161,7 @@ const ensureCodexDir = () => {
1084
1161
  fs.mkdirSync(dir, { recursive: true });
1085
1162
  return dir;
1086
1163
  };
1164
+ const resolveCodexDir = () => path.join(os.homedir(), CODEX_DIR);
1087
1165
  const requireInteractive$1 = () => {
1088
1166
  if (!process.stdin.isTTY) throw new Error("Interactive mode required for codex configuration.");
1089
1167
  };
@@ -1099,7 +1177,8 @@ const promptReasoning = async (model) => await fuzzySelect({
1099
1177
  });
1100
1178
  const formatReasoningLabel = (id) => REASONING_CHOICES.find((choice) => choice.id === id)?.label ?? id;
1101
1179
  const registerCodexCommand = (program) => {
1102
- program.command("codex").description("Configure Codex").option("-m, --model <model>", "Set codex model (skips model selection)").action(async (options) => {
1180
+ const codex = program.command("codex").description("Configure Codex");
1181
+ codex.option("-m, --model <model>", "Set codex model (skips model selection)").action(async (options) => {
1103
1182
  requireInteractive$1();
1104
1183
  const model = options.model && options.model.trim().length > 0 ? options.model.trim() : await promptModel();
1105
1184
  if (!model) return;
@@ -1112,18 +1191,10 @@ const registerCodexCommand = (program) => {
1112
1191
  if (!apiKey) throw new Error("API key not found. Please create one or choose another.");
1113
1192
  const reasoningValue = mapReasoningValue(reasoningId);
1114
1193
  const keyName = selected.name?.trim() || "(unnamed)";
1115
- if (!(await prompts({
1116
- type: "confirm",
1117
- name: "confirm",
1118
- message: [
1119
- "Apply Codex configuration?",
1120
- `Model: ${model}`,
1121
- `Reasoning: ${formatReasoningLabel(reasoningId)} (${reasoningValue})`,
1122
- "Provider: getrouter",
1123
- `Key: ${keyName}`
1124
- ].join("\n"),
1125
- initial: true
1126
- })).confirm) return;
1194
+ console.log(`Model: ${model}`);
1195
+ console.log(`Reasoning: ${formatReasoningLabel(reasoningId)} (${reasoningValue})`);
1196
+ console.log("Provider: getrouter");
1197
+ console.log(`Key: ${keyName}`);
1127
1198
  const codexDir = ensureCodexDir();
1128
1199
  const configPath = path.join(codexDir, "config.toml");
1129
1200
  const authPath = path.join(codexDir, "auth.json");
@@ -1138,6 +1209,28 @@ const registerCodexCommand = (program) => {
1138
1209
  console.log("✅ Updated ~/.codex/config.toml");
1139
1210
  console.log("✅ Updated ~/.codex/auth.json");
1140
1211
  });
1212
+ codex.command("uninstall").description("Remove getrouter Codex configuration").action(() => {
1213
+ const codexDir = resolveCodexDir();
1214
+ const configPath = path.join(codexDir, "config.toml");
1215
+ const authPath = path.join(codexDir, "auth.json");
1216
+ const configExists = fs.existsSync(configPath);
1217
+ const authExists = fs.existsSync(authPath);
1218
+ const configContent = configExists ? readFileIfExists(configPath) : "";
1219
+ const configResult = configExists ? removeCodexConfig(configContent) : null;
1220
+ const authContent = authExists ? fs.readFileSync(authPath, "utf8").trim() : "";
1221
+ const authData = authExists ? authContent ? JSON.parse(authContent) : {} : null;
1222
+ const authResult = authData ? removeAuthJson(authData) : null;
1223
+ if (!configExists) console.log(`ℹ️ ${configPath} not found`);
1224
+ else if (configResult?.changed) {
1225
+ fs.writeFileSync(configPath, configResult.content, "utf8");
1226
+ console.log(`✅ Removed getrouter entries from ${configPath}`);
1227
+ } else console.log(`ℹ️ No getrouter entries in ${configPath}`);
1228
+ if (!authExists) console.log(`ℹ️ ${authPath} not found`);
1229
+ else if (authResult?.changed) {
1230
+ fs.writeFileSync(authPath, JSON.stringify(authResult.data, null, 2));
1231
+ console.log(`✅ Removed getrouter entries from ${authPath}`);
1232
+ } else console.log(`ℹ️ No getrouter entries in ${authPath}`);
1233
+ });
1141
1234
  };
1142
1235
 
1143
1236
  //#endregion
@@ -1243,10 +1336,12 @@ const resolveConsumerForDelete = async (consumerService, id) => {
1243
1336
  };
1244
1337
  const createConsumer = async (consumerService) => {
1245
1338
  requireInteractiveForAction("create");
1246
- const name = await promptKeyName();
1247
- const enabled = await promptKeyEnabled(true);
1339
+ const nameResult = await promptKeyName();
1340
+ if (nameResult.cancelled) return;
1341
+ const enabledResult = await promptKeyEnabled(true);
1342
+ if (enabledResult.cancelled) return;
1248
1343
  let consumer = await consumerService.CreateConsumer({});
1249
- consumer = await updateConsumer(consumerService, consumer, name, enabled);
1344
+ consumer = await updateConsumer(consumerService, consumer, nameResult.name, enabledResult.enabled);
1250
1345
  outputConsumerTable(consumer, true);
1251
1346
  console.log("Please store this API key securely.");
1252
1347
  };
@@ -1254,7 +1349,11 @@ const updateConsumerById = async (consumerService, id) => {
1254
1349
  requireInteractiveForAction("update");
1255
1350
  const selected = await resolveConsumerForUpdate(consumerService, id);
1256
1351
  if (!selected?.id) return;
1257
- outputConsumerTable(await updateConsumer(consumerService, selected, await promptKeyName(selected.name), await promptKeyEnabled(selected.enabled ?? true)), false);
1352
+ const nameResult = await promptKeyName(selected.name);
1353
+ if (nameResult.cancelled) return;
1354
+ const enabledResult = await promptKeyEnabled(selected.enabled ?? true);
1355
+ if (enabledResult.cancelled) return;
1356
+ outputConsumerTable(await updateConsumer(consumerService, selected, nameResult.name, enabledResult.enabled), false);
1258
1357
  };
1259
1358
  const deleteConsumerById = async (consumerService, id) => {
1260
1359
  requireInteractiveForAction("delete");
@@ -0,0 +1,34 @@
1
+ # Codex Uninstall Design
2
+
3
+ ## Goal
4
+ Add a `getrouter codex uninstall` subcommand that removes only getrouter-managed Codex configuration, without touching unrelated user settings.
5
+
6
+ ## Scope
7
+ - Remove the `[model_providers.getrouter]` section from `~/.codex/config.toml`.
8
+ - Remove `model`, `model_reasoning_effort`, and `model_provider` only when `model_provider` is set to `"getrouter"`.
9
+ - Remove `OPENAI_API_KEY` from `~/.codex/auth.json`.
10
+ - Preserve all other keys, providers, comments, and formatting as much as possible.
11
+
12
+ ## CLI Shape
13
+ - Keep the existing `getrouter codex` interactive flow unchanged.
14
+ - Add `getrouter codex uninstall` as a non-interactive subcommand.
15
+ - The uninstall action reports per-file status: removed, no-op, or missing.
16
+
17
+ ## Data Flow
18
+ 1. Resolve `~/.codex` via `os.homedir()`.
19
+ 2. For each file:
20
+ - If missing, log and continue.
21
+ - Read content, apply a removal helper, and write back only if changed.
22
+ 3. Abort with a clear error if parsing fails; avoid partial writes.
23
+
24
+ ## Implementation Notes
25
+ - Extend `src/core/setup/codex.ts` with:
26
+ - `removeCodexConfig(content)` for TOML line removal.
27
+ - `removeAuthJson(data)` for JSON key removal.
28
+ - Update `src/cmd/codex.ts` to register the uninstall subcommand.
29
+
30
+ ## Testing
31
+ - Add command tests for uninstall in `tests/cmd/codex.test.ts`:
32
+ - Removes getrouter entries but keeps other providers/keys.
33
+ - No-op when getrouter entries do not exist.
34
+ - Root keys only removed when `model_provider == "getrouter"`.
@@ -0,0 +1,252 @@
1
+ # Codex Uninstall Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Add `getrouter codex uninstall` to remove getrouter-managed Codex config entries while preserving user data.
6
+
7
+ **Architecture:** Extend `src/core/setup/codex.ts` with removal helpers for TOML and JSON. Wire a new `uninstall` subcommand in `src/cmd/codex.ts` that reads, cleans, and conditionally writes the files with clear status output.
8
+
9
+ **Tech Stack:** TypeScript, Commander.js, Vitest, Node fs/path/os.
10
+
11
+ ### Task 1: Add uninstall command tests
12
+
13
+ **Files:**
14
+ - Modify: `tests/cmd/codex.test.ts`
15
+
16
+ **Step 1: Write the failing tests**
17
+
18
+ ```ts
19
+ it("uninstall removes getrouter entries but keeps others", async () => {
20
+ const dir = makeDir();
21
+ process.env.HOME = dir;
22
+ const codexDir = path.join(dir, ".codex");
23
+ fs.mkdirSync(codexDir, { recursive: true });
24
+ fs.writeFileSync(
25
+ codexConfigPath(dir),
26
+ [
27
+ 'theme = "dark"',
28
+ 'model = "keep"',
29
+ 'model_reasoning_effort = "low"',
30
+ 'model_provider = "getrouter"',
31
+ "",
32
+ "[model_providers.getrouter]",
33
+ 'name = "getrouter"',
34
+ 'base_url = "https://api.getrouter.dev/codex"',
35
+ "",
36
+ "[model_providers.other]",
37
+ 'name = "other"',
38
+ ].join("\n"),
39
+ );
40
+ fs.writeFileSync(
41
+ codexAuthPath(dir),
42
+ JSON.stringify({ OTHER: "keep", OPENAI_API_KEY: "old" }, null, 2),
43
+ );
44
+
45
+ const program = createProgram();
46
+ await program.parseAsync(["node", "getrouter", "codex", "uninstall"]);
47
+
48
+ const config = fs.readFileSync(codexConfigPath(dir), "utf8");
49
+ expect(config).toContain('theme = "dark"');
50
+ expect(config).toContain('[model_providers.other]');
51
+ expect(config).not.toContain('[model_providers.getrouter]');
52
+ expect(config).not.toContain('model_provider = "getrouter"');
53
+
54
+ const auth = JSON.parse(fs.readFileSync(codexAuthPath(dir), "utf8"));
55
+ expect(auth.OTHER).toBe("keep");
56
+ expect(auth.OPENAI_API_KEY).toBeUndefined();
57
+ });
58
+
59
+ it("uninstall leaves root keys when provider is not getrouter", async () => {
60
+ const dir = makeDir();
61
+ process.env.HOME = dir;
62
+ const codexDir = path.join(dir, ".codex");
63
+ fs.mkdirSync(codexDir, { recursive: true });
64
+ fs.writeFileSync(
65
+ codexConfigPath(dir),
66
+ [
67
+ 'model = "keep"',
68
+ 'model_reasoning_effort = "low"',
69
+ 'model_provider = "other"',
70
+ "",
71
+ "[model_providers.getrouter]",
72
+ 'name = "getrouter"',
73
+ ].join("\n"),
74
+ );
75
+
76
+ const program = createProgram();
77
+ await program.parseAsync(["node", "getrouter", "codex", "uninstall"]);
78
+
79
+ const config = fs.readFileSync(codexConfigPath(dir), "utf8");
80
+ expect(config).toContain('model = "keep"');
81
+ expect(config).toContain('model_provider = "other"');
82
+ expect(config).not.toContain('[model_providers.getrouter]');
83
+ });
84
+ ```
85
+
86
+ **Step 2: Run test to verify it fails**
87
+
88
+ Run: `bun run test -- tests/cmd/codex.test.ts`
89
+ Expected: FAIL with an error like "unknown command 'uninstall'" or missing behavior.
90
+
91
+ **Step 3: Commit**
92
+
93
+ ```bash
94
+ git add tests/cmd/codex.test.ts
95
+ git commit -m "test: cover codex uninstall"
96
+ ```
97
+
98
+ ### Task 2: Implement removal helpers
99
+
100
+ **Files:**
101
+ - Modify: `src/core/setup/codex.ts`
102
+
103
+ **Step 1: Write the minimal implementation**
104
+
105
+ ```ts
106
+ export const removeCodexConfig = (content: string) => {
107
+ const lines = content.length ? content.split(/\r?\n/) : [];
108
+ const updated: string[] = [];
109
+ let inGetrouterSection = false;
110
+ let providerIsGetrouter = false;
111
+
112
+ for (let i = 0; i < lines.length; i += 1) {
113
+ const line = lines[i] ?? "";
114
+ const headerMatch = matchHeader(line);
115
+ if (headerMatch) {
116
+ const section = headerMatch[1]?.trim() ?? "";
117
+ inGetrouterSection = section === PROVIDER_SECTION;
118
+ if (inGetrouterSection) continue;
119
+ }
120
+ if (inGetrouterSection) continue;
121
+
122
+ const keyMatch = matchKey(line);
123
+ if (keyMatch && keyMatch[1] === "model_provider") {
124
+ providerIsGetrouter = /getrouter/i.test(line);
125
+ }
126
+
127
+ updated.push(line);
128
+ }
129
+
130
+ if (providerIsGetrouter) {
131
+ return {
132
+ content: updated
133
+ .filter(
134
+ (line) =>
135
+ !/^\s*model\s*=/.test(line) &&
136
+ !/^\s*model_reasoning_effort\s*=/.test(line) &&
137
+ !/^\s*model_provider\s*=/.test(line),
138
+ )
139
+ .join("\n"),
140
+ changed: true,
141
+ };
142
+ }
143
+
144
+ return { content: updated.join("\n"), changed: updated.join("\n") !== content };
145
+ };
146
+
147
+ export const removeAuthJson = (data: Record<string, unknown>) => {
148
+ if (!("OPENAI_API_KEY" in data)) return { data, changed: false };
149
+ const { OPENAI_API_KEY: _ignored, ...rest } = data;
150
+ return { data: rest, changed: true };
151
+ };
152
+ ```
153
+
154
+ **Step 2: Run test to verify it still fails**
155
+
156
+ Run: `bun run test -- tests/cmd/codex.test.ts`
157
+ Expected: FAIL until CLI wiring is added.
158
+
159
+ **Step 3: Commit**
160
+
161
+ ```bash
162
+ git add src/core/setup/codex.ts
163
+ git commit -m "feat: add codex uninstall helpers"
164
+ ```
165
+
166
+ ### Task 3: Wire the uninstall subcommand
167
+
168
+ **Files:**
169
+ - Modify: `src/cmd/codex.ts`
170
+
171
+ **Step 1: Add the uninstall command**
172
+
173
+ ```ts
174
+ import { removeAuthJson, removeCodexConfig } from "../core/setup/codex";
175
+
176
+ const logMissing = (filePath: string) =>
177
+ console.log(`ℹ️ ${filePath} not found`);
178
+
179
+ const logNoop = (filePath: string) =>
180
+ console.log(`ℹ️ No getrouter entries in ${filePath}`);
181
+
182
+ const logRemoved = (filePath: string) =>
183
+ console.log(`✅ Removed getrouter entries from ${filePath}`);
184
+
185
+ // in registerCodexCommand
186
+ const codex = program.command("codex").description("Configure Codex");
187
+
188
+ codex
189
+ .command("uninstall")
190
+ .description("Remove getrouter Codex configuration")
191
+ .action(() => {
192
+ const codexDir = ensureCodexDir();
193
+ const configPath = path.join(codexDir, "config.toml");
194
+ const authPath = path.join(codexDir, "auth.json");
195
+
196
+ if (!fs.existsSync(configPath)) {
197
+ logMissing(configPath);
198
+ } else {
199
+ const content = readFileIfExists(configPath);
200
+ const removed = removeCodexConfig(content);
201
+ if (removed.changed) {
202
+ fs.writeFileSync(configPath, removed.content, "utf8");
203
+ logRemoved(configPath);
204
+ } else {
205
+ logNoop(configPath);
206
+ }
207
+ }
208
+
209
+ if (!fs.existsSync(authPath)) {
210
+ logMissing(authPath);
211
+ } else {
212
+ const raw = fs.readFileSync(authPath, "utf8").trim();
213
+ const data = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
214
+ const removed = removeAuthJson(data);
215
+ if (removed.changed) {
216
+ fs.writeFileSync(authPath, JSON.stringify(removed.data, null, 2));
217
+ logRemoved(authPath);
218
+ } else {
219
+ logNoop(authPath);
220
+ }
221
+ }
222
+ });
223
+ ```
224
+
225
+ **Step 2: Run tests to verify they pass**
226
+
227
+ Run: `bun run test -- tests/cmd/codex.test.ts`
228
+ Expected: PASS
229
+
230
+ **Step 3: Commit**
231
+
232
+ ```bash
233
+ git add src/cmd/codex.ts tests/cmd/codex.test.ts
234
+ git commit -m "feat: add codex uninstall command"
235
+ ```
236
+
237
+ ### Task 4: Final verification
238
+
239
+ **Files:**
240
+ - Verify: `src/cmd/codex.ts`, `src/core/setup/codex.ts`, `tests/cmd/codex.test.ts`
241
+
242
+ **Step 1: Run full checks**
243
+
244
+ Run: `bun run test && bun run lint && bun run format`
245
+ Expected: PASS
246
+
247
+ **Step 2: Commit formatting (if needed)**
248
+
249
+ ```bash
250
+ git add src/cmd/codex.ts src/core/setup/codex.ts tests/cmd/codex.test.ts
251
+ git commit -m "chore: format codex uninstall"
252
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getrouter/getrouter-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "CLI for getrouter.dev",
6
6
  "bin": {
package/src/cmd/codex.ts CHANGED
@@ -2,7 +2,6 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import type { Command } from "commander";
5
- import prompts from "prompts";
6
5
  import { createApiClients } from "../core/api/client";
7
6
  import {
8
7
  getCodexModelChoices,
@@ -12,7 +11,12 @@ import {
12
11
  } from "../core/interactive/codex";
13
12
  import { fuzzySelect } from "../core/interactive/fuzzy";
14
13
  import { selectConsumer } from "../core/interactive/keys";
15
- import { mergeAuthJson, mergeCodexToml } from "../core/setup/codex";
14
+ import {
15
+ mergeAuthJson,
16
+ mergeCodexToml,
17
+ removeAuthJson,
18
+ removeCodexConfig,
19
+ } from "../core/setup/codex";
16
20
 
17
21
  const CODEX_DIR = ".codex";
18
22
 
@@ -36,6 +40,8 @@ const ensureCodexDir = () => {
36
40
  return dir;
37
41
  };
38
42
 
43
+ const resolveCodexDir = () => path.join(os.homedir(), CODEX_DIR);
44
+
39
45
  const requireInteractive = () => {
40
46
  if (!process.stdin.isTTY) {
41
47
  throw new Error("Interactive mode required for codex configuration.");
@@ -65,9 +71,9 @@ type CodexCommandOptions = {
65
71
  };
66
72
 
67
73
  export const registerCodexCommand = (program: Command) => {
68
- program
69
- .command("codex")
70
- .description("Configure Codex")
74
+ const codex = program.command("codex").description("Configure Codex");
75
+
76
+ codex
71
77
  .option("-m, --model <model>", "Set codex model (skips model selection)")
72
78
  .action(async (options: CodexCommandOptions) => {
73
79
  requireInteractive();
@@ -91,19 +97,13 @@ export const registerCodexCommand = (program: Command) => {
91
97
 
92
98
  const reasoningValue = mapReasoningValue(reasoningId);
93
99
  const keyName = selected.name?.trim() || "(unnamed)";
94
- const confirm = await prompts({
95
- type: "confirm",
96
- name: "confirm",
97
- message: [
98
- "Apply Codex configuration?",
99
- `Model: ${model}`,
100
- `Reasoning: ${formatReasoningLabel(reasoningId)} (${reasoningValue})`,
101
- "Provider: getrouter",
102
- `Key: ${keyName}`,
103
- ].join("\n"),
104
- initial: true,
105
- });
106
- if (!confirm.confirm) return;
100
+
101
+ console.log(`Model: ${model}`);
102
+ console.log(
103
+ `Reasoning: ${formatReasoningLabel(reasoningId)} (${reasoningValue})`,
104
+ );
105
+ console.log("Provider: getrouter");
106
+ console.log(`Key: ${keyName}`);
107
107
 
108
108
  const codexDir = ensureCodexDir();
109
109
  const configPath = path.join(codexDir, "config.toml");
@@ -126,4 +126,49 @@ export const registerCodexCommand = (program: Command) => {
126
126
  console.log("✅ Updated ~/.codex/config.toml");
127
127
  console.log("✅ Updated ~/.codex/auth.json");
128
128
  });
129
+
130
+ codex
131
+ .command("uninstall")
132
+ .description("Remove getrouter Codex configuration")
133
+ .action(() => {
134
+ const codexDir = resolveCodexDir();
135
+ const configPath = path.join(codexDir, "config.toml");
136
+ const authPath = path.join(codexDir, "auth.json");
137
+
138
+ const configExists = fs.existsSync(configPath);
139
+ const authExists = fs.existsSync(authPath);
140
+
141
+ const configContent = configExists ? readFileIfExists(configPath) : "";
142
+ const configResult = configExists
143
+ ? removeCodexConfig(configContent)
144
+ : null;
145
+
146
+ const authContent = authExists
147
+ ? fs.readFileSync(authPath, "utf8").trim()
148
+ : "";
149
+ const authData = authExists
150
+ ? authContent
151
+ ? (JSON.parse(authContent) as Record<string, unknown>)
152
+ : {}
153
+ : null;
154
+ const authResult = authData ? removeAuthJson(authData) : null;
155
+
156
+ if (!configExists) {
157
+ console.log(`ℹ️ ${configPath} not found`);
158
+ } else if (configResult?.changed) {
159
+ fs.writeFileSync(configPath, configResult.content, "utf8");
160
+ console.log(`✅ Removed getrouter entries from ${configPath}`);
161
+ } else {
162
+ console.log(`ℹ️ No getrouter entries in ${configPath}`);
163
+ }
164
+
165
+ if (!authExists) {
166
+ console.log(`ℹ️ ${authPath} not found`);
167
+ } else if (authResult?.changed) {
168
+ fs.writeFileSync(authPath, JSON.stringify(authResult.data, null, 2));
169
+ console.log(`✅ Removed getrouter entries from ${authPath}`);
170
+ } else {
171
+ console.log(`ℹ️ No getrouter entries in ${authPath}`);
172
+ }
173
+ });
129
174
  };
package/src/cmd/keys.ts CHANGED
@@ -132,10 +132,17 @@ const createConsumer = async (
132
132
  consumerService: Pick<ConsumerService, "CreateConsumer" | "UpdateConsumer">,
133
133
  ) => {
134
134
  requireInteractiveForAction("create");
135
- const name = await promptKeyName();
136
- const enabled = await promptKeyEnabled(true);
135
+ const nameResult = await promptKeyName();
136
+ if (nameResult.cancelled) return;
137
+ const enabledResult = await promptKeyEnabled(true);
138
+ if (enabledResult.cancelled) return;
137
139
  let consumer = await consumerService.CreateConsumer({});
138
- consumer = await updateConsumer(consumerService, consumer, name, enabled);
140
+ consumer = await updateConsumer(
141
+ consumerService,
142
+ consumer,
143
+ nameResult.name,
144
+ enabledResult.enabled,
145
+ );
139
146
  outputConsumerTable(consumer, true);
140
147
  console.log("Please store this API key securely.");
141
148
  };
@@ -150,13 +157,15 @@ const updateConsumerById = async (
150
157
  requireInteractiveForAction("update");
151
158
  const selected = await resolveConsumerForUpdate(consumerService, id);
152
159
  if (!selected?.id) return;
153
- const name = await promptKeyName(selected.name);
154
- const enabled = await promptKeyEnabled(selected.enabled ?? true);
160
+ const nameResult = await promptKeyName(selected.name);
161
+ if (nameResult.cancelled) return;
162
+ const enabledResult = await promptKeyEnabled(selected.enabled ?? true);
163
+ if (enabledResult.cancelled) return;
155
164
  const consumer = await updateConsumer(
156
165
  consumerService,
157
166
  selected,
158
- name,
159
- enabled,
167
+ nameResult.name,
168
+ enabledResult.enabled,
160
169
  );
161
170
  outputConsumerTable(consumer, false);
162
171
  };
@@ -78,25 +78,38 @@ export const selectKeyAction = async (): Promise<KeyMenuAction> => {
78
78
 
79
79
  export const promptKeyName = async (
80
80
  initial?: string,
81
- ): Promise<string | undefined> => {
81
+ ): Promise<
82
+ { cancelled: true } | { cancelled: false; name: string | undefined }
83
+ > => {
82
84
  const response = await prompts({
83
85
  type: "text",
84
86
  name: "name",
85
87
  message: "Key name",
86
88
  initial: initial ?? "",
87
89
  });
90
+ if (!("name" in response)) {
91
+ return { cancelled: true };
92
+ }
88
93
  const value = typeof response.name === "string" ? response.name.trim() : "";
89
- return value.length > 0 ? value : undefined;
94
+ return { cancelled: false, name: value.length > 0 ? value : undefined };
90
95
  };
91
96
 
92
- export const promptKeyEnabled = async (initial: boolean): Promise<boolean> => {
97
+ export const promptKeyEnabled = async (
98
+ initial: boolean,
99
+ ): Promise<{ cancelled: true } | { cancelled: false; enabled: boolean }> => {
93
100
  const response = await prompts({
94
101
  type: "confirm",
95
102
  name: "enabled",
96
103
  message: "Enable this key?",
97
104
  initial,
98
105
  });
99
- return typeof response.enabled === "boolean" ? response.enabled : initial;
106
+ if (!("enabled" in response)) {
107
+ return { cancelled: true };
108
+ }
109
+ return {
110
+ cancelled: false,
111
+ enabled: typeof response.enabled === "boolean" ? response.enabled : initial,
112
+ };
100
113
  };
101
114
 
102
115
  export const selectConsumer = async (
@@ -34,6 +34,8 @@ const providerValues = () => ({
34
34
 
35
35
  const matchHeader = (line: string) => line.match(/^\s*\[([^\]]+)\]\s*$/);
36
36
  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*(?:#.*)?$/);
37
39
 
38
40
  export const mergeCodexToml = (content: string, input: CodexConfigInput) => {
39
41
  const lines = content.length ? content.split(/\r?\n/) : [];
@@ -131,3 +133,85 @@ export const mergeAuthJson = (
131
133
  ...data,
132
134
  OPENAI_API_KEY: apiKey,
133
135
  });
136
+
137
+ const stripGetrouterProviderSection = (lines: string[]) => {
138
+ const updated: string[] = [];
139
+ let skipSection = false;
140
+
141
+ for (const line of lines) {
142
+ const headerMatch = matchHeader(line);
143
+ if (headerMatch) {
144
+ const section = headerMatch[1]?.trim() ?? "";
145
+ if (section === PROVIDER_SECTION) {
146
+ skipSection = true;
147
+ continue;
148
+ }
149
+ skipSection = false;
150
+ }
151
+
152
+ if (skipSection) continue;
153
+ updated.push(line);
154
+ }
155
+
156
+ return updated;
157
+ };
158
+
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
+ }
170
+
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;
175
+ }
176
+
177
+ updated.push(line);
178
+ }
179
+
180
+ return updated;
181
+ };
182
+
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;
187
+
188
+ for (const line of lines) {
189
+ const headerMatch = matchHeader(line);
190
+ if (headerMatch) {
191
+ currentSection = headerMatch[1]?.trim() ?? null;
192
+ continue;
193
+ }
194
+
195
+ if (currentSection !== null) continue;
196
+
197
+ const providerMatch = matchProviderValue(line);
198
+ const providerValue = providerMatch?.[2]?.trim();
199
+ if (providerValue?.toLowerCase() === CODEX_PROVIDER) {
200
+ providerIsGetrouter = true;
201
+ }
202
+ }
203
+
204
+ let updated = stripGetrouterProviderSection(lines);
205
+ if (providerIsGetrouter) {
206
+ updated = stripRootKeys(updated);
207
+ }
208
+
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 };
217
+ };
@@ -33,6 +33,7 @@ const originalEnv = Object.fromEntries(
33
33
 
34
34
  afterEach(() => {
35
35
  vi.unstubAllGlobals();
36
+ vi.clearAllMocks();
36
37
  setStdinTTY(originalIsTTY);
37
38
  prompts.inject([]);
38
39
  for (const key of ENV_KEYS) {
@@ -70,7 +71,7 @@ describe("codex command", () => {
70
71
  });
71
72
  vi.stubGlobal("fetch", fetchMock);
72
73
 
73
- prompts.inject(["gpt-5.2-codex", "extra_high", mockConsumer, true]);
74
+ prompts.inject(["gpt-5.2-codex", "extra_high", mockConsumer]);
74
75
  (createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
75
76
  consumerService: {
76
77
  ListConsumers: vi.fn().mockResolvedValue({
@@ -100,7 +101,7 @@ describe("codex command", () => {
100
101
  setStdinTTY(true);
101
102
  const dir = makeDir();
102
103
  process.env.HOME = dir;
103
- prompts.inject(["gpt-5.2-codex", "extra_high", mockConsumer, true]);
104
+ prompts.inject(["gpt-5.2-codex", "extra_high", mockConsumer]);
104
105
  (createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
105
106
  consumerService: {
106
107
  ListConsumers: vi.fn().mockResolvedValue({
@@ -162,7 +163,7 @@ describe("codex command", () => {
162
163
  codexAuthPath(dir),
163
164
  JSON.stringify({ OTHER: "keep", OPENAI_API_KEY: "old" }, null, 2),
164
165
  );
165
- prompts.inject(["gpt-5.2", "low", mockConsumer, true]);
166
+ prompts.inject(["gpt-5.2", "low", mockConsumer]);
166
167
  (createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
167
168
  consumerService: {
168
169
  ListConsumers: vi.fn().mockResolvedValue({
@@ -201,7 +202,7 @@ describe("codex command", () => {
201
202
  setStdinTTY(true);
202
203
  const dir = makeDir();
203
204
  process.env.HOME = dir;
204
- prompts.inject(["extra_high", mockConsumer, true]);
205
+ prompts.inject(["extra_high", mockConsumer]);
205
206
  (createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
206
207
  consumerService: {
207
208
  ListConsumers: vi.fn().mockResolvedValue({
@@ -230,4 +231,73 @@ describe("codex command", () => {
230
231
  const config = fs.readFileSync(codexConfigPath(dir), "utf8");
231
232
  expect(config).toContain('model = "legacy-model"');
232
233
  });
234
+
235
+ it("uninstall removes getrouter entries but keeps others", async () => {
236
+ const dir = makeDir();
237
+ process.env.HOME = dir;
238
+ const codexDir = path.join(dir, ".codex");
239
+ fs.mkdirSync(codexDir, { recursive: true });
240
+ fs.writeFileSync(
241
+ codexConfigPath(dir),
242
+ [
243
+ 'theme = "dark"',
244
+ 'model = "keep"',
245
+ 'model_reasoning_effort = "low"',
246
+ 'model_provider = "getrouter"',
247
+ "",
248
+ "[model_providers.getrouter]",
249
+ 'name = "getrouter"',
250
+ 'base_url = "https://api.getrouter.dev/codex"',
251
+ "",
252
+ "[model_providers.other]",
253
+ 'name = "other"',
254
+ ].join("\n"),
255
+ );
256
+ fs.writeFileSync(
257
+ codexAuthPath(dir),
258
+ JSON.stringify({ OTHER: "keep", OPENAI_API_KEY: "old" }, null, 2),
259
+ );
260
+
261
+ const program = createProgram();
262
+ await program.parseAsync(["node", "getrouter", "codex", "uninstall"]);
263
+
264
+ const config = fs.readFileSync(codexConfigPath(dir), "utf8");
265
+ expect(config).toContain('theme = "dark"');
266
+ expect(config).toContain("[model_providers.other]");
267
+ expect(config).not.toContain("[model_providers.getrouter]");
268
+ expect(config).not.toContain('model_provider = "getrouter"');
269
+ expect(config).not.toContain('model_reasoning_effort = "low"');
270
+ expect(config).not.toContain('model = "keep"');
271
+
272
+ const auth = JSON.parse(fs.readFileSync(codexAuthPath(dir), "utf8"));
273
+ expect(auth.OTHER).toBe("keep");
274
+ expect(auth.OPENAI_API_KEY).toBeUndefined();
275
+ });
276
+
277
+ it("uninstall leaves root keys when provider is not getrouter", async () => {
278
+ const dir = makeDir();
279
+ process.env.HOME = dir;
280
+ const codexDir = path.join(dir, ".codex");
281
+ fs.mkdirSync(codexDir, { recursive: true });
282
+ fs.writeFileSync(
283
+ codexConfigPath(dir),
284
+ [
285
+ 'model = "keep"',
286
+ 'model_reasoning_effort = "low"',
287
+ 'model_provider = "other"',
288
+ "",
289
+ "[model_providers.getrouter]",
290
+ 'name = "getrouter"',
291
+ ].join("\n"),
292
+ );
293
+
294
+ const program = createProgram();
295
+ await program.parseAsync(["node", "getrouter", "codex", "uninstall"]);
296
+
297
+ const config = fs.readFileSync(codexConfigPath(dir), "utf8");
298
+ expect(config).toContain('model = "keep"');
299
+ expect(config).toContain('model_provider = "other"');
300
+ expect(config).toContain('model_reasoning_effort = "low"');
301
+ expect(config).not.toContain("[model_providers.getrouter]");
302
+ });
233
303
  });