@getrouter/getrouter-cli 0.1.1 → 0.1.3

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.
Files changed (41) hide show
  1. package/.serena/project.yml +84 -0
  2. package/CLAUDE.md +52 -0
  3. package/biome.json +1 -1
  4. package/bun.lock +10 -10
  5. package/dist/bin.mjs +245 -94
  6. package/package.json +2 -2
  7. package/src/cli.ts +2 -1
  8. package/src/cmd/codex.ts +17 -7
  9. package/src/cmd/env.ts +1 -1
  10. package/src/cmd/keys.ts +46 -28
  11. package/src/cmd/models.ts +2 -1
  12. package/src/core/api/pagination.ts +25 -0
  13. package/src/core/api/providerModels.ts +32 -0
  14. package/src/core/auth/refresh.ts +68 -0
  15. package/src/core/config/fs.ts +33 -2
  16. package/src/core/config/index.ts +2 -8
  17. package/src/core/config/paths.ts +6 -3
  18. package/src/core/http/request.ts +71 -15
  19. package/src/core/http/retry.ts +68 -0
  20. package/src/core/interactive/codex.ts +21 -0
  21. package/src/core/interactive/keys.ts +19 -10
  22. package/src/core/output/usages.ts +11 -30
  23. package/src/core/setup/codex.ts +4 -0
  24. package/src/core/setup/env.ts +14 -6
  25. package/tests/auth/refresh.test.ts +149 -0
  26. package/tests/cmd/codex.test.ts +87 -1
  27. package/tests/cmd/keys.test.ts +48 -14
  28. package/tests/cmd/models.test.ts +5 -2
  29. package/tests/cmd/usages.test.ts +5 -5
  30. package/tests/config/fs.test.ts +22 -1
  31. package/tests/config/index.test.ts +16 -1
  32. package/tests/config/paths.test.ts +23 -0
  33. package/tests/core/api/pagination.test.ts +87 -0
  34. package/tests/core/interactive/codex.test.ts +25 -1
  35. package/tests/core/setup/env.test.ts +18 -4
  36. package/tests/http/request.test.ts +157 -0
  37. package/tests/http/retry.test.ts +152 -0
  38. package/tests/output/usages.test.ts +11 -12
  39. package/tsconfig.json +3 -2
  40. package/src/core/paths.ts +0 -4
  41. package/tests/paths.test.ts +0 -9
package/dist/bin.mjs CHANGED
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import fs from "node:fs";
4
- import os from "node:os";
5
4
  import path from "node:path";
5
+ import os from "node:os";
6
6
  import { execSync, spawn } from "node:child_process";
7
7
  import { randomInt } from "node:crypto";
8
8
  import prompts from "prompts";
9
9
 
10
+ //#region package.json
11
+ var version = "0.1.2";
12
+
13
+ //#endregion
10
14
  //#region src/generated/router/dashboard/v1/index.ts
11
15
  function createSubscriptionServiceClient(handler) {
12
16
  return { CurrentSubscription(request) {
@@ -200,16 +204,47 @@ function createUsageServiceClient(handler) {
200
204
 
201
205
  //#endregion
202
206
  //#region src/core/config/fs.ts
207
+ const getCorruptBackupPath = (filePath) => {
208
+ const dir = path.dirname(filePath);
209
+ const ext = path.extname(filePath);
210
+ const base = path.basename(filePath, ext);
211
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
212
+ const rand = Math.random().toString(16).slice(2, 8);
213
+ return path.join(dir, `${base}.corrupt-${stamp}-${rand}${ext}`);
214
+ };
203
215
  const readJsonFile = (filePath) => {
204
216
  if (!fs.existsSync(filePath)) return null;
205
- const raw = fs.readFileSync(filePath, "utf8");
206
- return JSON.parse(raw);
217
+ let raw;
218
+ try {
219
+ raw = fs.readFileSync(filePath, "utf8");
220
+ } catch {
221
+ console.warn(`⚠️ Unable to read ${filePath}. Continuing with defaults.`);
222
+ return null;
223
+ }
224
+ try {
225
+ return JSON.parse(raw);
226
+ } catch {
227
+ const backupPath = getCorruptBackupPath(filePath);
228
+ try {
229
+ fs.renameSync(filePath, backupPath);
230
+ console.warn(`⚠️ Invalid JSON in ${filePath}. Moved to ${backupPath} and continuing with defaults.`);
231
+ } catch {
232
+ console.warn(`⚠️ Invalid JSON in ${filePath}. Please fix or delete this file, then try again.`);
233
+ }
234
+ return null;
235
+ }
207
236
  };
208
237
  const writeJsonFile = (filePath, value) => {
209
238
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
210
239
  fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
211
240
  };
212
241
 
242
+ //#endregion
243
+ //#region src/core/config/paths.ts
244
+ const resolveConfigDir = () => process.env.GETROUTER_CONFIG_DIR || path.join(os.homedir(), ".getrouter");
245
+ const getConfigPath = () => path.join(resolveConfigDir(), "config.json");
246
+ const getAuthPath = () => path.join(resolveConfigDir(), "auth.json");
247
+
213
248
  //#endregion
214
249
  //#region src/core/config/types.ts
215
250
  const defaultConfig = () => ({
@@ -225,9 +260,6 @@ const defaultAuthState = () => ({
225
260
 
226
261
  //#endregion
227
262
  //#region src/core/config/index.ts
228
- const resolveConfigDir$1 = () => process.env.GETROUTER_CONFIG_DIR || path.join(os.homedir(), ".getrouter");
229
- const getConfigPath = () => path.join(resolveConfigDir$1(), "config.json");
230
- const getAuthPath = () => path.join(resolveConfigDir$1(), "auth.json");
231
263
  const readConfig = () => ({
232
264
  ...defaultConfig(),
233
265
  ...readJsonFile(getConfigPath()) ?? {}
@@ -242,6 +274,39 @@ const writeAuth = (auth) => {
242
274
  if (process.platform !== "win32") fs.chmodSync(authPath, 384);
243
275
  };
244
276
 
277
+ //#endregion
278
+ //#region src/core/http/url.ts
279
+ const getApiBase = () => {
280
+ return (readConfig().apiBase || "").replace(/\/+$/, "");
281
+ };
282
+ const buildApiUrl = (path$1) => {
283
+ const base = getApiBase();
284
+ const normalized = path$1.replace(/^\/+/, "");
285
+ return base ? `${base}/${normalized}` : `/${normalized}`;
286
+ };
287
+
288
+ //#endregion
289
+ //#region src/core/auth/refresh.ts
290
+ const EXPIRY_BUFFER_MS = 60 * 1e3;
291
+ const refreshAccessToken = async ({ fetchImpl }) => {
292
+ const auth = readAuth();
293
+ if (!auth.refreshToken) return null;
294
+ const res = await (fetchImpl ?? fetch)(buildApiUrl("v1/dashboard/auth/token"), {
295
+ method: "POST",
296
+ headers: { "Content-Type": "application/json" },
297
+ body: JSON.stringify({ refreshToken: auth.refreshToken })
298
+ });
299
+ if (!res.ok) return null;
300
+ const token = await res.json();
301
+ if (token.accessToken && token.refreshToken) writeAuth({
302
+ accessToken: token.accessToken,
303
+ refreshToken: token.refreshToken,
304
+ expiresAt: token.expiresAt ?? "",
305
+ tokenType: "Bearer"
306
+ });
307
+ return token;
308
+ };
309
+
245
310
  //#endregion
246
311
  //#region src/core/http/errors.ts
247
312
  const createApiError = (payload, fallbackMessage, status) => {
@@ -255,33 +320,71 @@ const createApiError = (payload, fallbackMessage, status) => {
255
320
  };
256
321
 
257
322
  //#endregion
258
- //#region src/core/http/url.ts
259
- const getApiBase = () => {
260
- return (readConfig().apiBase || "").replace(/\/+$/, "");
323
+ //#region src/core/http/retry.ts
324
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
325
+ const isRetryableError = (error) => {
326
+ if (error instanceof TypeError) return true;
327
+ if (typeof error === "object" && error !== null && "status" in error && typeof error.status === "number") {
328
+ const status = error.status;
329
+ return status >= 500 || status === 408 || status === 429;
330
+ }
331
+ return false;
261
332
  };
262
- const buildApiUrl = (path$1) => {
263
- const base = getApiBase();
264
- const normalized = path$1.replace(/^\/+/, "");
265
- return base ? `${base}/${normalized}` : `/${normalized}`;
333
+ const withRetry = async (fn, options = {}) => {
334
+ const { maxRetries = 3, initialDelayMs = 1e3, maxDelayMs = 1e4, shouldRetry = isRetryableError, onRetry, sleep = defaultSleep } = options;
335
+ let lastError;
336
+ let delay = initialDelayMs;
337
+ for (let attempt = 0; attempt <= maxRetries; attempt++) try {
338
+ return await fn();
339
+ } catch (error) {
340
+ lastError = error;
341
+ if (attempt >= maxRetries || !shouldRetry(error, attempt)) throw error;
342
+ onRetry?.(error, attempt + 1, delay);
343
+ await sleep(delay);
344
+ delay = Math.min(delay * 2, maxDelayMs);
345
+ }
346
+ throw lastError;
266
347
  };
348
+ const isServerError = (status) => status >= 500 || status === 408 || status === 429;
267
349
 
268
350
  //#endregion
269
351
  //#region src/core/http/request.ts
270
352
  const getAuthCookieName = () => process.env.GETROUTER_AUTH_COOKIE || process.env.KRATOS_AUTH_COOKIE || "access_token";
271
- const requestJson = async ({ path: path$1, method, body, fetchImpl }) => {
353
+ const buildHeaders = (accessToken) => {
272
354
  const headers = { "Content-Type": "application/json" };
273
- const auth = readAuth();
274
- if (auth.accessToken) {
275
- headers.Authorization = `Bearer ${auth.accessToken}`;
276
- headers.Cookie = `${getAuthCookieName()}=${auth.accessToken}`;
355
+ if (accessToken) {
356
+ headers.Authorization = `Bearer ${accessToken}`;
357
+ headers.Cookie = `${getAuthCookieName()}=${accessToken}`;
277
358
  }
278
- const res = await (fetchImpl ?? fetch)(buildApiUrl(path$1), {
359
+ return headers;
360
+ };
361
+ const doFetch = async (url, method, headers, body, fetchImpl) => {
362
+ return (fetchImpl ?? fetch)(url, {
279
363
  method,
280
364
  headers,
281
365
  body: body == null ? void 0 : JSON.stringify(body)
282
366
  });
283
- if (!res.ok) throw createApiError(await res.json().catch(() => null), res.statusText, res.status);
284
- return await res.json();
367
+ };
368
+ const shouldRetryResponse = (error) => {
369
+ if (typeof error === "object" && error !== null && "status" in error && typeof error.status === "number") return isServerError(error.status);
370
+ return error instanceof TypeError;
371
+ };
372
+ const requestJson = async ({ path: path$1, method, body, fetchImpl, maxRetries = 3, _retrySleep }) => {
373
+ return withRetry(async () => {
374
+ const auth = readAuth();
375
+ const url = buildApiUrl(path$1);
376
+ let res = await doFetch(url, method, buildHeaders(auth.accessToken), body, fetchImpl);
377
+ if (res.status === 401 && auth.refreshToken) {
378
+ const refreshed = await refreshAccessToken({ fetchImpl });
379
+ if (refreshed?.accessToken) res = await doFetch(url, method, buildHeaders(refreshed.accessToken), body, fetchImpl);
380
+ }
381
+ if (!res.ok) throw createApiError(await res.json().catch(() => null), res.statusText, res.status);
382
+ return await res.json();
383
+ }, {
384
+ maxRetries,
385
+ shouldRetry: shouldRetryResponse,
386
+ sleep: _retrySleep
387
+ });
285
388
  };
286
389
 
287
390
  //#endregion
@@ -420,6 +523,28 @@ const registerAuthCommands = (program) => {
420
523
  });
421
524
  };
422
525
 
526
+ //#endregion
527
+ //#region src/core/api/pagination.ts
528
+ /**
529
+ * Fetches all pages from a paginated API endpoint.
530
+ *
531
+ * @param fetchPage - Function that fetches a single page given a pageToken
532
+ * @param getItems - Function that extracts items from the response
533
+ * @param getNextToken - Function that extracts the next page token from the response
534
+ * @returns Array of all items across all pages
535
+ */
536
+ const fetchAllPages = async (fetchPage, getItems, getNextToken) => {
537
+ const allItems = [];
538
+ let pageToken;
539
+ do {
540
+ const response = await fetchPage(pageToken);
541
+ const items = getItems(response);
542
+ allItems.push(...items);
543
+ pageToken = getNextToken(response);
544
+ } while (pageToken);
545
+ return allItems;
546
+ };
547
+
423
548
  //#endregion
424
549
  //#region src/core/interactive/fuzzy.ts
425
550
  const normalize = (value) => value.toLowerCase();
@@ -504,10 +629,10 @@ const promptKeyEnabled = async (initial) => {
504
629
  return typeof response.enabled === "boolean" ? response.enabled : initial;
505
630
  };
506
631
  const selectConsumer = async (consumerService) => {
507
- const consumers = (await consumerService.ListConsumers({
632
+ const consumers = await fetchAllPages((pageToken) => consumerService.ListConsumers({
508
633
  pageSize: void 0,
509
- pageToken: void 0
510
- }))?.consumers ?? [];
634
+ pageToken
635
+ }), (res) => res?.consumers ?? [], (res) => res?.nextPageToken || void 0);
511
636
  if (consumers.length === 0) throw new Error("No available API keys");
512
637
  const sorted = sortByCreatedAtDesc(consumers);
513
638
  const nameCounts = buildNameCounts(sorted);
@@ -521,10 +646,10 @@ const selectConsumer = async (consumerService) => {
521
646
  }) ?? null;
522
647
  };
523
648
  const selectConsumerList = async (consumerService, message) => {
524
- const consumers = (await consumerService.ListConsumers({
649
+ const consumers = await fetchAllPages((pageToken) => consumerService.ListConsumers({
525
650
  pageSize: void 0,
526
- pageToken: void 0
527
- }))?.consumers ?? [];
651
+ pageToken
652
+ }), (res) => res?.consumers ?? [], (res) => res?.nextPageToken || void 0);
528
653
  if (consumers.length === 0) throw new Error("No available API keys");
529
654
  const sorted = sortByCreatedAtDesc(consumers);
530
655
  const nameCounts = buildNameCounts(sorted);
@@ -552,9 +677,13 @@ const confirmDelete = async (consumer) => {
552
677
 
553
678
  //#endregion
554
679
  //#region src/core/setup/env.ts
680
+ const quoteEnvValue = (shell, value) => {
681
+ if (shell === "ps1") return `'${value.replaceAll("'", "''")}'`;
682
+ return `'${value.replaceAll("'", "'\\''")}'`;
683
+ };
555
684
  const renderLine = (shell, key, value) => {
556
- if (shell === "ps1") return `$env:${key}="${value}"`;
557
- return `export ${key}=${value}`;
685
+ if (shell === "ps1") return `$env:${key}=${quoteEnvValue(shell, value)}`;
686
+ return `export ${key}=${quoteEnvValue(shell, value)}`;
558
687
  };
559
688
  const renderEnv = (shell, vars) => {
560
689
  const lines = [];
@@ -690,7 +819,6 @@ const appendRcIfMissing = (rcPath, line) => {
690
819
  fs.writeFileSync(rcPath, `${content}${prefix}${line}\n`, "utf8");
691
820
  return true;
692
821
  };
693
- const resolveConfigDir = () => process.env.GETROUTER_CONFIG_DIR || path.join(os.homedir(), ".getrouter");
694
822
 
695
823
  //#endregion
696
824
  //#region src/cmd/env.ts
@@ -750,6 +878,29 @@ const registerClaudeCommand = (program) => {
750
878
  });
751
879
  };
752
880
 
881
+ //#endregion
882
+ //#region src/core/api/providerModels.ts
883
+ const asTrimmedString = (value) => {
884
+ if (typeof value !== "string") return null;
885
+ const trimmed = value.trim();
886
+ return trimmed.length > 0 ? trimmed : null;
887
+ };
888
+ const buildProviderModelsPath = (tag) => {
889
+ const query = new URLSearchParams();
890
+ if (tag) query.set("tag", tag);
891
+ const qs = query.toString();
892
+ return `v1/dashboard/providers/models${qs ? `?${qs}` : ""}`;
893
+ };
894
+ const listProviderModels = async ({ tag, fetchImpl }) => {
895
+ const raw = (await requestJson({
896
+ path: buildProviderModelsPath(tag),
897
+ method: "GET",
898
+ fetchImpl,
899
+ maxRetries: 0
900
+ }))?.models;
901
+ return (Array.isArray(raw) ? raw : []).map(asTrimmedString).filter(Boolean);
902
+ };
903
+
753
904
  //#endregion
754
905
  //#region src/core/interactive/codex.ts
755
906
  const MODEL_CHOICES = [
@@ -778,6 +929,20 @@ const MODEL_CHOICES = [
778
929
  keywords: ["gpt-5.2"]
779
930
  }
780
931
  ];
932
+ const getCodexModelChoices = async () => {
933
+ try {
934
+ const remoteChoices = (await listProviderModels({ tag: "codex" })).map((model) => ({
935
+ title: model,
936
+ value: model,
937
+ keywords: [model, "codex"]
938
+ }));
939
+ if (remoteChoices.length > 0) {
940
+ remoteChoices.sort((a, b) => a.title.localeCompare(b.title));
941
+ return remoteChoices;
942
+ }
943
+ } catch {}
944
+ return MODEL_CHOICES;
945
+ };
781
946
  const REASONING_CHOICES = [
782
947
  {
783
948
  id: "low",
@@ -922,19 +1087,21 @@ const ensureCodexDir = () => {
922
1087
  const requireInteractive$1 = () => {
923
1088
  if (!process.stdin.isTTY) throw new Error("Interactive mode required for codex configuration.");
924
1089
  };
925
- const promptModel = async () => await fuzzySelect({
926
- message: "Select Model and Effort\nAccess legacy models by running codex -m <model_name> or in your config.toml",
927
- choices: MODEL_CHOICES
928
- });
1090
+ const promptModel = async () => {
1091
+ return await fuzzySelect({
1092
+ message: "Select Model and Effort\nAccess legacy models by running getrouter codex -m <model_name> or in your config.toml",
1093
+ choices: await getCodexModelChoices()
1094
+ });
1095
+ };
929
1096
  const promptReasoning = async (model) => await fuzzySelect({
930
1097
  message: `Select Reasoning Level for ${model}`,
931
1098
  choices: REASONING_FUZZY_CHOICES
932
1099
  });
933
1100
  const formatReasoningLabel = (id) => REASONING_CHOICES.find((choice) => choice.id === id)?.label ?? id;
934
1101
  const registerCodexCommand = (program) => {
935
- program.command("codex").description("Configure Codex").action(async () => {
1102
+ program.command("codex").description("Configure Codex").option("-m, --model <model>", "Set codex model (skips model selection)").action(async (options) => {
936
1103
  requireInteractive$1();
937
- const model = await promptModel();
1104
+ const model = options.model && options.model.trim().length > 0 ? options.model.trim() : await promptModel();
938
1105
  if (!model) return;
939
1106
  const reasoningId = await promptReasoning(model);
940
1107
  if (!reasoningId) return;
@@ -1024,21 +1191,23 @@ const consumerHeaders = [
1024
1191
  "CREATED_AT",
1025
1192
  "API_KEY"
1026
1193
  ];
1027
- const consumerRow = (consumer) => [
1028
- String(consumer.name ?? ""),
1029
- String(consumer.enabled ?? ""),
1030
- String(consumer.lastAccess ?? ""),
1031
- String(consumer.createdAt ?? ""),
1032
- String(consumer.apiKey ?? "")
1033
- ];
1034
- const outputConsumerTable = (consumer) => {
1035
- console.log(renderTable(consumerHeaders, [consumerRow(consumer)]));
1194
+ const consumerRow = (consumer, showApiKey) => {
1195
+ const { apiKey } = showApiKey ? consumer : redactSecrets(consumer);
1196
+ return [
1197
+ String(consumer.name ?? ""),
1198
+ String(consumer.enabled ?? ""),
1199
+ String(consumer.lastAccess ?? ""),
1200
+ String(consumer.createdAt ?? ""),
1201
+ String(apiKey ?? "")
1202
+ ];
1036
1203
  };
1037
- const outputConsumers = (consumers) => {
1038
- const rows = consumers.map(consumerRow);
1039
- console.log(renderTable(consumerHeaders, rows));
1204
+ const outputConsumerTable = (consumer, showApiKey) => {
1205
+ console.log(renderTable(consumerHeaders, [consumerRow(consumer, showApiKey)], { maxColWidth: 64 }));
1206
+ };
1207
+ const outputConsumers = (consumers, showApiKey) => {
1208
+ const rows = consumers.map((consumer) => consumerRow(consumer, showApiKey));
1209
+ console.log(renderTable(consumerHeaders, rows, { maxColWidth: 64 }));
1040
1210
  };
1041
- const redactConsumer = (consumer) => redactSecrets(consumer);
1042
1211
  const requireInteractive = (message) => {
1043
1212
  if (!process.stdin.isTTY) throw new Error(message);
1044
1213
  };
@@ -1056,11 +1225,11 @@ const updateConsumer = async (consumerService, consumer, name, enabled) => {
1056
1225
  updateMask
1057
1226
  });
1058
1227
  };
1059
- const listConsumers = async (consumerService) => {
1060
- outputConsumers(((await consumerService.ListConsumers({
1228
+ const listConsumers = async (consumerService, showApiKey) => {
1229
+ outputConsumers(await fetchAllPages((pageToken) => consumerService.ListConsumers({
1061
1230
  pageSize: void 0,
1062
- pageToken: void 0
1063
- }))?.consumers ?? []).map(redactConsumer));
1231
+ pageToken
1232
+ }), (res) => res?.consumers ?? [], (res) => res?.nextPageToken || void 0), showApiKey);
1064
1233
  };
1065
1234
  const resolveConsumerForUpdate = async (consumerService, id) => {
1066
1235
  if (id) return consumerService.GetConsumer({ id });
@@ -1078,14 +1247,14 @@ const createConsumer = async (consumerService) => {
1078
1247
  const enabled = await promptKeyEnabled(true);
1079
1248
  let consumer = await consumerService.CreateConsumer({});
1080
1249
  consumer = await updateConsumer(consumerService, consumer, name, enabled);
1081
- outputConsumerTable(consumer);
1250
+ outputConsumerTable(consumer, true);
1082
1251
  console.log("Please store this API key securely.");
1083
1252
  };
1084
1253
  const updateConsumerById = async (consumerService, id) => {
1085
1254
  requireInteractiveForAction("update");
1086
1255
  const selected = await resolveConsumerForUpdate(consumerService, id);
1087
1256
  if (!selected?.id) return;
1088
- outputConsumerTable(redactConsumer(await updateConsumer(consumerService, selected, await promptKeyName(selected.name), await promptKeyEnabled(selected.enabled ?? true))));
1257
+ outputConsumerTable(await updateConsumer(consumerService, selected, await promptKeyName(selected.name), await promptKeyEnabled(selected.enabled ?? true)), false);
1089
1258
  };
1090
1259
  const deleteConsumerById = async (consumerService, id) => {
1091
1260
  requireInteractiveForAction("delete");
@@ -1093,18 +1262,20 @@ const deleteConsumerById = async (consumerService, id) => {
1093
1262
  if (!selected?.id) return;
1094
1263
  if (!await confirmDelete(selected)) return;
1095
1264
  await consumerService.DeleteConsumer({ id: selected.id });
1096
- outputConsumerTable(redactConsumer(selected));
1265
+ outputConsumerTable(selected, false);
1097
1266
  };
1098
1267
  const registerKeysCommands = (program) => {
1099
1268
  const keys = program.command("keys").description("Manage API keys");
1269
+ keys.option("--show", "Show full API keys");
1100
1270
  keys.allowExcessArguments(false);
1101
- keys.action(async () => {
1271
+ keys.action(async (options) => {
1102
1272
  const { consumerService } = createApiClients({});
1103
- await listConsumers(consumerService);
1273
+ await listConsumers(consumerService, Boolean(options.show));
1104
1274
  });
1105
- keys.command("list").description("List API keys").action(async () => {
1275
+ keys.command("list").description("List API keys").option("--show", "Show full API keys").action(async (options, command) => {
1106
1276
  const { consumerService } = createApiClients({});
1107
- await listConsumers(consumerService);
1277
+ const parentShow = Boolean(command.parent?.opts().show);
1278
+ await listConsumers(consumerService, Boolean(options.show) || parentShow);
1108
1279
  });
1109
1280
  keys.command("create").description("Create an API key").action(async () => {
1110
1281
  const { consumerService } = createApiClients({});
@@ -1123,12 +1294,14 @@ const registerKeysCommands = (program) => {
1123
1294
  //#endregion
1124
1295
  //#region src/cmd/models.ts
1125
1296
  const modelHeaders = [
1297
+ "ID",
1126
1298
  "NAME",
1127
1299
  "AUTHOR",
1128
1300
  "ENABLED",
1129
1301
  "UPDATED_AT"
1130
1302
  ];
1131
1303
  const modelRow = (model) => [
1304
+ String(model.id ?? ""),
1132
1305
  String(model.name ?? ""),
1133
1306
  String(model.author ?? ""),
1134
1307
  String(model.enabled ?? ""),
@@ -1221,8 +1394,7 @@ const registerStatusCommand = (program) => {
1221
1394
 
1222
1395
  //#endregion
1223
1396
  //#region src/core/output/usages.ts
1224
- const INPUT_BLOCK = "█";
1225
- const OUTPUT_BLOCK = "▒";
1397
+ const TOTAL_BLOCK = "█";
1226
1398
  const DEFAULT_WIDTH = 24;
1227
1399
  const formatTokens = (value) => {
1228
1400
  const abs = Math.abs(value);
@@ -1253,46 +1425,25 @@ const renderUsageChart = (rows, width = DEFAULT_WIDTH) => {
1253
1425
  const header = "📊 Usage (last 7 days) · Tokens";
1254
1426
  if (rows.length === 0) return `${header}\n\nNo usage data available.`;
1255
1427
  const normalized = rows.map((row) => {
1256
- const input = Number(row.inputTokens);
1257
- const output = Number(row.outputTokens);
1258
- const safeInput = Number.isFinite(input) ? input : 0;
1259
- const safeOutput = Number.isFinite(output) ? output : 0;
1428
+ const total = Number(row.totalTokens);
1429
+ const safeTotal = Number.isFinite(total) ? total : 0;
1260
1430
  return {
1261
1431
  day: row.day,
1262
- input: safeInput,
1263
- output: safeOutput,
1264
- total: safeInput + safeOutput
1432
+ total: safeTotal
1265
1433
  };
1266
1434
  });
1267
1435
  const totals = normalized.map((row) => row.total);
1268
1436
  const maxTotal = Math.max(0, ...totals);
1269
- const lines = normalized.map((row) => {
1270
- const total = row.total;
1271
- if (maxTotal === 0 || total === 0) return `${row.day} ${"".padEnd(width, " ")} I:0 O:0`;
1272
- const scaled = Math.max(1, Math.round(total / maxTotal * width));
1273
- let inputBars = Math.round(row.input / total * scaled);
1274
- let outputBars = Math.max(0, scaled - inputBars);
1275
- if (row.input > 0 && row.output > 0) {
1276
- if (inputBars === 0) {
1277
- inputBars = 1;
1278
- outputBars = Math.max(0, scaled - 1);
1279
- } else if (outputBars === 0) {
1280
- outputBars = 1;
1281
- inputBars = Math.max(0, scaled - 1);
1282
- }
1283
- }
1284
- const bar = `${INPUT_BLOCK.repeat(inputBars)}${OUTPUT_BLOCK.repeat(outputBars)}`;
1285
- const inputLabel = formatTokens(row.input);
1286
- const outputLabel = formatTokens(row.output);
1287
- return `${row.day} ${bar.padEnd(width, " ")} I:${inputLabel} O:${outputLabel}`;
1288
- });
1289
- const legend = `Legend: ${INPUT_BLOCK} input ${OUTPUT_BLOCK} output`;
1290
1437
  return [
1291
1438
  header,
1292
1439
  "",
1293
- ...lines,
1294
- "",
1295
- legend
1440
+ ...normalized.map((row) => {
1441
+ if (maxTotal === 0 || row.total === 0) return `${row.day} ${"".padEnd(width, " ")} 0`;
1442
+ const scaled = Math.max(1, Math.round(row.total / maxTotal * width));
1443
+ const bar = TOTAL_BLOCK.repeat(scaled);
1444
+ const totalLabel = formatTokens(row.total);
1445
+ return `${row.day} ${bar.padEnd(width, " ")} ${totalLabel}`;
1446
+ })
1296
1447
  ].join("\n");
1297
1448
  };
1298
1449
 
@@ -1368,7 +1519,7 @@ const registerCommands = (program) => {
1368
1519
  //#region src/cli.ts
1369
1520
  const createProgram = () => {
1370
1521
  const program = new Command();
1371
- program.name("getrouter").description("CLI for getrouter.dev").version("0.1.0");
1522
+ program.name("getrouter").description("CLI for getrouter.dev").version(version);
1372
1523
  registerCommands(program);
1373
1524
  return program;
1374
1525
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getrouter/getrouter-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "CLI for getrouter.dev",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "prompts": "^2.4.2"
27
27
  },
28
28
  "devDependencies": {
29
- "@biomejs/biome": "^2.3.10",
29
+ "@biomejs/biome": "^2.3.11",
30
30
  "@types/node": "^25.0.3",
31
31
  "@types/prompts": "^2.4.9",
32
32
  "tsdown": "^0.18.4",
package/src/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Command } from "commander";
2
+ import { version } from "../package.json";
2
3
  import { registerCommands } from "./cmd";
3
4
 
4
5
  export const createProgram = () => {
@@ -6,7 +7,7 @@ export const createProgram = () => {
6
7
  program
7
8
  .name("getrouter")
8
9
  .description("CLI for getrouter.dev")
9
- .version("0.1.0");
10
+ .version(version);
10
11
  registerCommands(program);
11
12
  return program;
12
13
  };
package/src/cmd/codex.ts CHANGED
@@ -5,7 +5,7 @@ import type { Command } from "commander";
5
5
  import prompts from "prompts";
6
6
  import { createApiClients } from "../core/api/client";
7
7
  import {
8
- MODEL_CHOICES,
8
+ getCodexModelChoices,
9
9
  mapReasoningValue,
10
10
  REASONING_CHOICES,
11
11
  REASONING_FUZZY_CHOICES,
@@ -42,12 +42,14 @@ const requireInteractive = () => {
42
42
  }
43
43
  };
44
44
 
45
- const promptModel = async () =>
46
- await fuzzySelect({
45
+ const promptModel = async () => {
46
+ const choices = await getCodexModelChoices();
47
+ return await fuzzySelect({
47
48
  message:
48
- "Select Model and Effort\nAccess legacy models by running codex -m <model_name> or in your config.toml",
49
- choices: MODEL_CHOICES,
49
+ "Select Model and Effort\nAccess legacy models by running getrouter codex -m <model_name> or in your config.toml",
50
+ choices,
50
51
  });
52
+ };
51
53
 
52
54
  const promptReasoning = async (model: string) =>
53
55
  await fuzzySelect({
@@ -58,13 +60,21 @@ const promptReasoning = async (model: string) =>
58
60
  const formatReasoningLabel = (id: string) =>
59
61
  REASONING_CHOICES.find((choice) => choice.id === id)?.label ?? id;
60
62
 
63
+ type CodexCommandOptions = {
64
+ model?: string;
65
+ };
66
+
61
67
  export const registerCodexCommand = (program: Command) => {
62
68
  program
63
69
  .command("codex")
64
70
  .description("Configure Codex")
65
- .action(async () => {
71
+ .option("-m, --model <model>", "Set codex model (skips model selection)")
72
+ .action(async (options: CodexCommandOptions) => {
66
73
  requireInteractive();
67
- const model = await promptModel();
74
+ const model =
75
+ options.model && options.model.trim().length > 0
76
+ ? options.model.trim()
77
+ : await promptModel();
68
78
  if (!model) return;
69
79
  const reasoningId = await promptReasoning(model);
70
80
  if (!reasoningId) return;
package/src/cmd/env.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import os from "node:os";
2
2
  import type { Command } from "commander";
3
3
  import { createApiClients } from "../core/api/client";
4
+ import { resolveConfigDir } from "../core/config/paths";
4
5
  import { selectConsumer } from "../core/interactive/keys";
5
6
  import {
6
7
  appendRcIfMissing,
@@ -12,7 +13,6 @@ import {
12
13
  getHookFilePath,
13
14
  renderEnv,
14
15
  renderHook,
15
- resolveConfigDir,
16
16
  resolveEnvShell,
17
17
  resolveShellRcPath,
18
18
  trySourceEnv,