@bjesuiter/codex-switcher 1.5.1 → 1.7.2

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 (3) hide show
  1. package/README.md +6 -3
  2. package/cdx.mjs +653 -116
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -6,12 +6,11 @@ Switch the coding-agents [pi](https://pi.dev/), [codex](https://developers.opena
6
6
 
7
7
  ## Latest Changes
8
8
 
9
- ### 1.5.1
9
+ ### 1.7.2
10
10
 
11
11
  #### Fixes
12
12
 
13
- - `cdx doctor --check-keychain-acl` now suggests running `cdx migrate-secrets` when keychain ACL/runtime mismatches are detected.
14
- - Clarify keychain ACL diagnostics wording to distinguish entries created by `cdx` (Bun runtime) vs the legacy Apple `security` CLI path, including why mismatches can trigger repeated keychain password prompts.
13
+ - Improve device OAuth failure diagnostics during login/relogin. When device flow startup or polling fails, `cdx` now prints technical details (HTTP status, OAuth error code, and response/body snippets where available) instead of only showing a generic "not available right now" message.
15
14
 
16
15
  see full changelog here: https://github.com/bjesuiter/codex-switcher/blob/main/CHANGELOG.md
17
16
 
@@ -154,8 +153,11 @@ cdx migrate-secrets
154
153
  |---------|-------------|
155
154
  | `cdx` | Interactive mode |
156
155
  | `cdx login` | Add a new OpenAI account via OAuth |
156
+ | `cdx login --device-flow` | Add account using OAuth device flow (no local browser callback needed) |
157
157
  | `cdx relogin` | Re-authenticate an existing account via OAuth |
158
+ | `cdx relogin --device-flow` | Re-authenticate interactively using OAuth device flow |
158
159
  | `cdx relogin <account>` | Re-authenticate a specific account by ID or label |
160
+ | `cdx relogin <account> --device-flow` | Re-authenticate specific account using OAuth device flow |
159
161
  | `cdx switch` | Switch account (interactive picker) |
160
162
  | `cdx switch --next` | Cycle to next account |
161
163
  | `cdx switch <id>` | Switch to specific account |
@@ -187,6 +189,7 @@ source <(cdx complete bash)
187
189
  ```
188
190
 
189
191
  `cdx` also supports shell parse completion requests via `cdx complete -- ...`.
192
+ Completions include command names, options, `--secret-store` values, and account ID/label suggestions for commands like `switch`, `relogin`, `usage`, and `label`.
190
193
 
191
194
  ## How It Works
192
195
 
package/cdx.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
+ import { existsSync, readFileSync } from "node:fs";
2
3
  import tab from "@bomb.sh/tab/commander";
3
4
  import { Command, InvalidArgumentError } from "commander";
4
- import { existsSync } from "node:fs";
5
5
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
@@ -9,12 +9,13 @@ import * as p from "@clack/prompts";
9
9
  import { spawn } from "node:child_process";
10
10
  import { deletePassword, getPassword, listBackends, setPassword, useBackend } from "@bjesuiter/cross-keychain";
11
11
  import { createInterface } from "node:readline/promises";
12
- import { generatePKCE } from "@openauthjs/openauth/pkce";
13
12
  import { randomBytes } from "node:crypto";
13
+ import { Decrypter, Encrypter } from "age-encryption";
14
+ import { generatePKCE } from "@openauthjs/openauth/pkce";
14
15
  import http from "node:http";
15
16
 
16
17
  //#region package.json
17
- var version = "1.5.1";
18
+ var version = "1.7.2";
18
19
 
19
20
  //#endregion
20
21
  //#region lib/platform/path-resolver.ts
@@ -271,13 +272,24 @@ const getBrowserLauncherCapability = (platform = process.platform) => {
271
272
  available: isCommandAvailable(launcher.command, platform)
272
273
  };
273
274
  };
274
- const openBrowserUrl = (url, spawnImpl = spawn) => {
275
- const launcher = getBrowserLauncher(process.platform, url);
275
+ const openBrowserUrl = (url, options = {}) => {
276
+ const platform = options.platform ?? process.platform;
277
+ const spawnImpl = options.spawnImpl ?? spawn;
278
+ const commandAvailable = options.isCommandAvailableImpl ?? isCommandAvailable;
279
+ const launcher = getBrowserLauncher(platform, url);
280
+ if (!commandAvailable(launcher.command, platform)) return {
281
+ ok: false,
282
+ launcher,
283
+ reason: "launcher_missing",
284
+ error: `${launcher.command} not found in PATH`
285
+ };
276
286
  try {
277
- spawnImpl(launcher.command, launcher.args, {
287
+ const child = spawnImpl(launcher.command, launcher.args, {
278
288
  detached: true,
279
289
  stdio: "ignore"
280
- }).unref();
290
+ });
291
+ child.once("error", () => {});
292
+ child.unref();
281
293
  return {
282
294
  ok: true,
283
295
  launcher
@@ -286,6 +298,7 @@ const openBrowserUrl = (url, spawnImpl = spawn) => {
286
298
  return {
287
299
  ok: false,
288
300
  launcher,
301
+ reason: "spawn_failed",
289
302
  error: error instanceof Error ? error.message : String(error)
290
303
  };
291
304
  }
@@ -503,18 +516,18 @@ const parsePayload$2 = (accountId, raw) => {
503
516
  if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
504
517
  return parsed;
505
518
  };
506
- const withService$2 = async (accountId, run, options = {}) => {
519
+ const withService$1 = async (accountId, run, options = {}) => {
507
520
  await ensureLinuxBackend(options);
508
521
  return run(getLinuxCrossKeychainService(accountId));
509
522
  };
510
- const saveLinuxCrossKeychainPayload = async (accountId, payload) => withService$2(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
523
+ const saveLinuxCrossKeychainPayload = async (accountId, payload) => withService$1(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
511
524
  const loadLinuxCrossKeychainPayload = async (accountId) => {
512
- const raw = await withService$2(accountId, (service) => getPassword(service, accountId));
525
+ const raw = await withService$1(accountId, (service) => getPassword(service, accountId));
513
526
  if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
514
527
  return parsePayload$2(accountId, raw);
515
528
  };
516
- const deleteLinuxCrossKeychainPayload = async (accountId) => withService$2(accountId, (service) => deletePassword(service, accountId));
517
- const linuxCrossKeychainPayloadExists = async (accountId) => withService$2(accountId, async (service) => await getPassword(service, accountId) !== null);
529
+ const deleteLinuxCrossKeychainPayload = async (accountId) => withService$1(accountId, (service) => deletePassword(service, accountId));
530
+ const linuxCrossKeychainPayloadExists = async (accountId) => withService$1(accountId, async (service) => await getPassword(service, accountId) !== null);
518
531
 
519
532
  //#endregion
520
533
  //#region lib/secrets/macos-cross-keychain.ts
@@ -568,23 +581,27 @@ const parsePayload$1 = (accountId, raw) => {
568
581
  if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
569
582
  return parsed;
570
583
  };
571
- const withService$1 = async (accountId, run, options = {}) => {
584
+ const withService = async (accountId, run, options = {}) => {
572
585
  await ensureMacOSBackend(options);
573
586
  return run(getMacOSCrossKeychainService(accountId));
574
587
  };
575
- const saveMacOSCrossKeychainPayload = async (accountId, payload) => withService$1(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
588
+ const saveMacOSCrossKeychainPayload = async (accountId, payload) => withService(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
576
589
  const loadMacOSCrossKeychainPayload = async (accountId) => {
577
- const raw = await withService$1(accountId, (service) => getPassword(service, accountId));
590
+ const raw = await withService(accountId, (service) => getPassword(service, accountId));
578
591
  if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
579
592
  return parsePayload$1(accountId, raw);
580
593
  };
581
- const deleteMacOSCrossKeychainPayload = async (accountId) => withService$1(accountId, (service) => deletePassword(service, accountId));
582
- const macosCrossKeychainPayloadExists = async (accountId) => withService$1(accountId, async (service) => await getPassword(service, accountId) !== null);
594
+ const deleteMacOSCrossKeychainPayload = async (accountId) => withService(accountId, (service) => deletePassword(service, accountId));
595
+ const macosCrossKeychainPayloadExists = async (accountId) => withService(accountId, async (service) => await getPassword(service, accountId) !== null);
583
596
 
584
597
  //#endregion
585
598
  //#region lib/secrets/windows-cross-keychain.ts
586
599
  const SERVICE_PREFIX = "cdx-openai-";
587
600
  const WINDOWS_FALLBACK_SCOPE = "win32:cross-keychain:windows";
601
+ const WINDOWS_VAULT_FILE = "accounts.windows.age";
602
+ const WINDOWS_VAULT_VERSION = 1;
603
+ const WINDOWS_VAULT_KEY_SERVICE = "cdx-openai-vault-passphrase";
604
+ const WINDOWS_VAULT_KEY_ACCOUNT = "windows-v1";
588
605
  let backendInitPromise = null;
589
606
  let selectedBackend = null;
590
607
  const tryUseBackend = async (backendId) => {
@@ -617,33 +634,143 @@ const ensureWindowsBackend = async (options = {}) => {
617
634
  }
618
635
  if (options.forWrite && selectedBackend === "windows") await ensureFallbackConsent(WINDOWS_FALLBACK_SCOPE, "⚠ Security warning: only the cross-keychain Windows fallback backend is available.\nThis path runs a PowerShell helper to access Windows Credential Manager.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while the helper runs.");
619
636
  };
620
- const getWindowsCrossKeychainService = (accountId) => `${SERVICE_PREFIX}${accountId}`;
621
- const parsePayload = (accountId, raw) => {
637
+ const withWindowsBackend = async (run, options = {}) => {
638
+ await ensureWindowsBackend(options);
639
+ return run();
640
+ };
641
+ const getWindowsVaultPath = () => path.join(getPaths().configDir, WINDOWS_VAULT_FILE);
642
+ const createVaultPassphrase = () => randomBytes(32).toString("hex");
643
+ const getVaultPassphrase = async (options = {}) => {
644
+ const current = await getPassword(WINDOWS_VAULT_KEY_SERVICE, WINDOWS_VAULT_KEY_ACCOUNT);
645
+ if (current) return current;
646
+ if (!options.createIfMissing) return null;
647
+ const generated = createVaultPassphrase();
648
+ await setPassword(WINDOWS_VAULT_KEY_SERVICE, WINDOWS_VAULT_KEY_ACCOUNT, generated);
649
+ return generated;
650
+ };
651
+ const createEmptyVault = () => ({
652
+ version: WINDOWS_VAULT_VERSION,
653
+ accounts: {}
654
+ });
655
+ const parsePayload = (accountId, input) => {
656
+ if (!input || typeof input !== "object") throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
657
+ const parsed = input;
658
+ if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
659
+ return {
660
+ refresh: parsed.refresh,
661
+ access: parsed.access,
662
+ expires: parsed.expires,
663
+ accountId: parsed.accountId,
664
+ ...parsed.idToken ? { idToken: parsed.idToken } : {}
665
+ };
666
+ };
667
+ const parseVault = (raw, source) => {
668
+ let parsed;
669
+ try {
670
+ parsed = JSON.parse(raw);
671
+ } catch {
672
+ throw new Error(`Stored Windows credential vault (${source}) is not valid JSON.`);
673
+ }
674
+ if (!parsed || typeof parsed !== "object") throw new Error(`Stored Windows credential vault (${source}) is not valid JSON.`);
675
+ const vault = parsed;
676
+ const rawAccounts = vault.accounts;
677
+ if (!rawAccounts || typeof rawAccounts !== "object") throw new Error(`Stored Windows credential vault (${source}) is missing account data.`);
678
+ const accounts = {};
679
+ for (const [accountId, payload] of Object.entries(rawAccounts)) accounts[accountId] = parsePayload(accountId, payload);
680
+ return {
681
+ version: typeof vault.version === "number" ? vault.version : WINDOWS_VAULT_VERSION,
682
+ accounts
683
+ };
684
+ };
685
+ const decryptVault = async (ciphertext, passphrase, source) => {
686
+ const decrypter = new Decrypter();
687
+ decrypter.addPassphrase(passphrase);
688
+ let plaintext;
689
+ try {
690
+ plaintext = await decrypter.decrypt(ciphertext, "text");
691
+ } catch {
692
+ throw new Error(`Failed to decrypt Windows credential vault (${source}). Stored passphrase or vault file may be invalid.`);
693
+ }
694
+ return parseVault(plaintext, source);
695
+ };
696
+ const encryptVault = async (vault, passphrase) => {
697
+ const encrypter = new Encrypter();
698
+ encrypter.setPassphrase(passphrase);
699
+ return encrypter.encrypt(JSON.stringify(vault));
700
+ };
701
+ const loadVault = async (passphrase) => {
702
+ const vaultPath = getWindowsVaultPath();
703
+ let ciphertext;
704
+ try {
705
+ ciphertext = await readFile(vaultPath);
706
+ } catch (error) {
707
+ if (error?.code === "ENOENT") return createEmptyVault();
708
+ throw error;
709
+ }
710
+ if (ciphertext.length === 0) return createEmptyVault();
711
+ return decryptVault(ciphertext, passphrase, vaultPath);
712
+ };
713
+ const saveVault = async (vault, passphrase) => {
714
+ const { configDir } = getPaths();
715
+ const vaultPath = getWindowsVaultPath();
716
+ await mkdir(configDir, { recursive: true });
717
+ await writeFile(vaultPath, await encryptVault(vault, passphrase));
718
+ };
719
+ const loadLegacyPayload = async (accountId) => {
720
+ const raw = await getPassword(getWindowsCrossKeychainService(accountId), accountId);
721
+ if (raw === null) return null;
622
722
  let parsed;
623
723
  try {
624
724
  parsed = JSON.parse(raw);
625
725
  } catch {
626
726
  throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
627
727
  }
628
- if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
629
- return parsed;
728
+ return parsePayload(accountId, parsed);
630
729
  };
631
- const withService = async (accountId, run, options = {}) => {
632
- await ensureWindowsBackend(options);
633
- return run(getWindowsCrossKeychainService(accountId));
730
+ const deleteLegacyPayload = async (accountId) => {
731
+ const service = getWindowsCrossKeychainService(accountId);
732
+ try {
733
+ await deletePassword(service, accountId);
734
+ } catch {}
634
735
  };
635
- const saveWindowsCrossKeychainPayload = async (accountId, payload) => withService(accountId, async (service) => {
636
- await setPassword(service, accountId, JSON.stringify(payload));
736
+ const getWindowsCrossKeychainService = (accountId) => `${SERVICE_PREFIX}${accountId}`;
737
+ const saveWindowsCrossKeychainPayload = async (accountId, payload) => withWindowsBackend(async () => {
738
+ const passphrase = await getVaultPassphrase({ createIfMissing: true });
739
+ if (!passphrase) throw new Error("Unable to resolve Windows credential vault passphrase.");
740
+ const vault = await loadVault(passphrase);
741
+ vault.accounts[accountId] = payload;
742
+ await saveVault(vault, passphrase);
743
+ await deleteLegacyPayload(accountId);
637
744
  }, { forWrite: true });
638
- const loadWindowsCrossKeychainPayload = async (accountId) => {
639
- const raw = await withService(accountId, (service) => getPassword(service, accountId));
640
- if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
641
- return parsePayload(accountId, raw);
642
- };
643
- const deleteWindowsCrossKeychainPayload = async (accountId) => withService(accountId, async (service) => {
644
- await deletePassword(service, accountId);
745
+ const loadWindowsCrossKeychainPayload = async (accountId) => withWindowsBackend(async () => {
746
+ const passphrase = await getVaultPassphrase();
747
+ if (passphrase) {
748
+ const payload = (await loadVault(passphrase)).accounts[accountId];
749
+ if (payload) return payload;
750
+ }
751
+ const legacyPayload = await loadLegacyPayload(accountId);
752
+ if (legacyPayload) return legacyPayload;
753
+ throw new Error(`No stored credentials found for account ${accountId}.`);
754
+ });
755
+ const deleteWindowsCrossKeychainPayload = async (accountId) => withWindowsBackend(async () => {
756
+ const passphrase = await getVaultPassphrase();
757
+ if (passphrase) {
758
+ const vault = await loadVault(passphrase);
759
+ if (vault.accounts[accountId]) {
760
+ delete vault.accounts[accountId];
761
+ if (Object.keys(vault.accounts).length === 0) await rm(getWindowsVaultPath(), { force: true });
762
+ else await saveVault(vault, passphrase);
763
+ }
764
+ }
765
+ await deleteLegacyPayload(accountId);
766
+ });
767
+ const windowsCrossKeychainPayloadExists = async (accountId) => withWindowsBackend(async () => {
768
+ const passphrase = await getVaultPassphrase();
769
+ if (passphrase) {
770
+ if ((await loadVault(passphrase)).accounts[accountId]) return true;
771
+ }
772
+ return await loadLegacyPayload(accountId) !== null;
645
773
  });
646
- const windowsCrossKeychainPayloadExists = async (accountId) => withService(accountId, async (service) => await getPassword(service, accountId) !== null);
647
774
 
648
775
  //#endregion
649
776
  //#region lib/secrets/store.ts
@@ -874,6 +1001,7 @@ const getSecretStoreCapability = () => {
874
1001
  //#region lib/oauth/constants.ts
875
1002
  const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
876
1003
  const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
1004
+ const DEVICE_CODE_URL = "https://auth.openai.com/oauth/device/code";
877
1005
  const TOKEN_URL = "https://auth.openai.com/oauth/token";
878
1006
  const REDIRECT_URI = "http://localhost:1455/auth/callback";
879
1007
  const SCOPE = "openid profile email offline_access";
@@ -904,6 +1032,138 @@ const createAuthorizationFlow = async () => {
904
1032
  url: url.toString()
905
1033
  };
906
1034
  };
1035
+ const truncateForLog = (value, maxLength = 300) => {
1036
+ if (value.length <= maxLength) return value;
1037
+ return `${value.slice(0, maxLength)}…`;
1038
+ };
1039
+ const startDeviceAuthorizationFlow = async () => {
1040
+ try {
1041
+ const res = await fetch(DEVICE_CODE_URL, {
1042
+ method: "POST",
1043
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1044
+ body: new URLSearchParams({
1045
+ client_id: CLIENT_ID,
1046
+ scope: SCOPE
1047
+ })
1048
+ });
1049
+ if (!res.ok) {
1050
+ let oauthError;
1051
+ let responseBody;
1052
+ try {
1053
+ const json = await res.json();
1054
+ oauthError = json.error;
1055
+ responseBody = truncateForLog(JSON.stringify({
1056
+ ...json.error ? { error: json.error } : {},
1057
+ ...json.error_description ? { error_description: json.error_description } : {}
1058
+ }));
1059
+ } catch {
1060
+ try {
1061
+ responseBody = truncateForLog(await res.text());
1062
+ } catch {
1063
+ responseBody = void 0;
1064
+ }
1065
+ }
1066
+ return {
1067
+ type: "failed",
1068
+ error: `Device code request failed with HTTP ${res.status} ${res.statusText}`,
1069
+ status: res.status,
1070
+ ...oauthError ? { oauthError } : {},
1071
+ ...responseBody ? { responseBody } : {}
1072
+ };
1073
+ }
1074
+ const json = await res.json();
1075
+ if (!json?.device_code || !json?.user_code || !json?.verification_uri || typeof json?.expires_in !== "number") return {
1076
+ type: "failed",
1077
+ error: "Device code response is missing required fields.",
1078
+ responseBody: truncateForLog(JSON.stringify(json))
1079
+ };
1080
+ return {
1081
+ type: "success",
1082
+ flow: {
1083
+ deviceCode: json.device_code,
1084
+ userCode: json.user_code,
1085
+ verificationUri: json.verification_uri,
1086
+ verificationUriComplete: json.verification_uri_complete,
1087
+ expiresIn: json.expires_in,
1088
+ interval: typeof json.interval === "number" && json.interval > 0 ? json.interval : 5
1089
+ }
1090
+ };
1091
+ } catch (error) {
1092
+ return {
1093
+ type: "failed",
1094
+ error: `Device code request failed: ${error instanceof Error ? error.message : String(error)}`
1095
+ };
1096
+ }
1097
+ };
1098
+ const pollDeviceAuthorizationToken = async (deviceCode) => {
1099
+ try {
1100
+ const res = await fetch(TOKEN_URL, {
1101
+ method: "POST",
1102
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1103
+ body: new URLSearchParams({
1104
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
1105
+ device_code: deviceCode,
1106
+ client_id: CLIENT_ID
1107
+ })
1108
+ });
1109
+ if (res.ok) {
1110
+ const json = await res.json();
1111
+ if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== "number") return {
1112
+ type: "failed",
1113
+ error: "Device token response is missing access_token/refresh_token/expires_in.",
1114
+ responseBody: truncateForLog(JSON.stringify(json))
1115
+ };
1116
+ return {
1117
+ type: "success",
1118
+ access: json.access_token,
1119
+ refresh: json.refresh_token,
1120
+ expires: Date.now() + json.expires_in * 1e3,
1121
+ idToken: json.id_token
1122
+ };
1123
+ }
1124
+ let errorCode;
1125
+ let interval;
1126
+ let responseBody;
1127
+ try {
1128
+ const json = await res.json();
1129
+ errorCode = json.error;
1130
+ interval = json.interval;
1131
+ responseBody = truncateForLog(JSON.stringify({
1132
+ ...json.error ? { error: json.error } : {},
1133
+ ...json.error_description ? { error_description: json.error_description } : {},
1134
+ ...typeof json.interval === "number" ? { interval: json.interval } : {}
1135
+ }));
1136
+ } catch {
1137
+ try {
1138
+ responseBody = truncateForLog(await res.text());
1139
+ } catch {
1140
+ responseBody = void 0;
1141
+ }
1142
+ }
1143
+ if (errorCode === "authorization_pending") return {
1144
+ type: "pending",
1145
+ interval: typeof interval === "number" && interval > 0 ? interval : 5
1146
+ };
1147
+ if (errorCode === "slow_down") return {
1148
+ type: "slow_down",
1149
+ interval: typeof interval === "number" && interval > 0 ? interval : 10
1150
+ };
1151
+ if (errorCode === "access_denied") return { type: "access_denied" };
1152
+ if (errorCode === "expired_token") return { type: "expired" };
1153
+ return {
1154
+ type: "failed",
1155
+ error: `Device token polling failed with HTTP ${res.status} ${res.statusText}`,
1156
+ status: res.status,
1157
+ ...errorCode ? { oauthError: errorCode } : {},
1158
+ ...responseBody ? { responseBody } : {}
1159
+ };
1160
+ } catch (error) {
1161
+ return {
1162
+ type: "failed",
1163
+ error: `Device token polling request failed: ${error instanceof Error ? error.message : String(error)}`
1164
+ };
1165
+ }
1166
+ };
907
1167
  const exchangeAuthorizationCode = async (code, verifier) => {
908
1168
  const res = await fetch(TOKEN_URL, {
909
1169
  method: "POST",
@@ -1067,12 +1327,236 @@ const startOAuthServer = (state) => {
1067
1327
 
1068
1328
  //#endregion
1069
1329
  //#region lib/oauth/login.ts
1070
- const openBrowser = (url) => {
1071
- const result = openBrowserUrl(url);
1072
- if (!result.ok) {
1073
- const msg = result.error ?? "unknown error";
1074
- p.log.warning(`Could not auto-open browser via ${result.launcher.label} (${msg}).`);
1330
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1331
+ const isLikelyRemoteEnvironment = () => {
1332
+ if (process.platform !== "linux") return false;
1333
+ if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY) return true;
1334
+ return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
1335
+ };
1336
+ const parseOAuthCallbackInput = (input) => {
1337
+ const trimmed = input.trim();
1338
+ if (!trimmed) return null;
1339
+ if (!trimmed.includes("://") && !trimmed.includes("code=") && !trimmed.includes("?")) return { code: trimmed };
1340
+ try {
1341
+ const parsedUrl = new URL(trimmed);
1342
+ const code = parsedUrl.searchParams.get("code");
1343
+ if (code) return {
1344
+ code,
1345
+ state: parsedUrl.searchParams.get("state") ?? void 0
1346
+ };
1347
+ } catch {}
1348
+ const queryLike = trimmed.startsWith("?") || trimmed.startsWith("#") ? trimmed.slice(1) : trimmed.includes("?") ? trimmed.slice(trimmed.indexOf("?") + 1) : trimmed;
1349
+ const params = new URLSearchParams(queryLike);
1350
+ const code = params.get("code");
1351
+ if (!code) return null;
1352
+ return {
1353
+ code,
1354
+ state: params.get("state") ?? void 0
1355
+ };
1356
+ };
1357
+ const promptBrowserFallbackChoice = async () => {
1358
+ const remoteHint = isLikelyRemoteEnvironment();
1359
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1360
+ const selected = remoteHint ? "device" : "manual";
1361
+ p.log.info(`Non-interactive terminal detected. Falling back to ${selected === "device" ? "device OAuth flow" : "manual URL copy/paste flow"}.`);
1362
+ return selected;
1363
+ }
1364
+ const options = remoteHint ? [
1365
+ {
1366
+ value: "device",
1367
+ label: "Use device OAuth flow",
1368
+ hint: "Recommended on SSH/remote servers"
1369
+ },
1370
+ {
1371
+ value: "manual",
1372
+ label: "Finish manually by copying URL",
1373
+ hint: "Open URL on any machine and paste callback URL/code back here"
1374
+ },
1375
+ {
1376
+ value: "cancel",
1377
+ label: "Cancel login"
1378
+ }
1379
+ ] : [
1380
+ {
1381
+ value: "manual",
1382
+ label: "Finish manually by copying URL",
1383
+ hint: "Open URL on any machine and paste callback URL/code back here"
1384
+ },
1385
+ {
1386
+ value: "device",
1387
+ label: "Use device OAuth flow",
1388
+ hint: "Best for headless/remote environments"
1389
+ },
1390
+ {
1391
+ value: "cancel",
1392
+ label: "Cancel login"
1393
+ }
1394
+ ];
1395
+ const selection = await p.select({
1396
+ message: "Browser launcher is unavailable. How do you want to continue?",
1397
+ options
1398
+ });
1399
+ if (p.isCancel(selection)) return "cancel";
1400
+ return selection;
1401
+ };
1402
+ const promptManualAuthorizationCode = async (authorizationUrl, expectedState) => {
1403
+ p.log.info("Manual login selected.");
1404
+ p.log.message(`Open this URL in a browser:\n${authorizationUrl}`);
1405
+ p.log.message("After approving, copy the full callback URL (or just the 'code' value) and paste it below.");
1406
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
1407
+ const response = await p.text({
1408
+ message: "Paste callback URL or authorization code:",
1409
+ placeholder: "http://localhost:1455/auth/callback?code=...&state=..."
1410
+ });
1411
+ if (p.isCancel(response)) {
1412
+ p.log.info("Login cancelled.");
1413
+ return null;
1414
+ }
1415
+ const parsed = parseOAuthCallbackInput(String(response));
1416
+ if (!parsed) {
1417
+ p.log.warning("Could not parse input. Please paste a callback URL or code.");
1418
+ continue;
1419
+ }
1420
+ if (parsed.state && parsed.state !== expectedState) {
1421
+ p.log.error("State mismatch in callback URL. Please retry the login flow.");
1422
+ return null;
1423
+ }
1424
+ return parsed.code;
1425
+ }
1426
+ p.log.error("Failed to parse callback input after multiple attempts.");
1427
+ return null;
1428
+ };
1429
+ const runDeviceOAuthFlow = async (useSpinner) => {
1430
+ const deviceFlowResult = await startDeviceAuthorizationFlow();
1431
+ if (deviceFlowResult.type !== "success") {
1432
+ p.log.error("Device OAuth flow is not available right now.");
1433
+ p.log.error(`Technical details: ${deviceFlowResult.error}`);
1434
+ if (typeof deviceFlowResult.status === "number") p.log.error(`HTTP status: ${deviceFlowResult.status}`);
1435
+ if (deviceFlowResult.oauthError) p.log.error(`OAuth error: ${deviceFlowResult.oauthError}`);
1436
+ if (deviceFlowResult.responseBody) p.log.error(`Response: ${deviceFlowResult.responseBody}`);
1437
+ return null;
1438
+ }
1439
+ const deviceFlow = deviceFlowResult.flow;
1440
+ p.log.info("Using device OAuth flow.");
1441
+ p.log.message(`Verification URL: ${deviceFlow.verificationUri}`);
1442
+ p.log.message(`User code: ${deviceFlow.userCode}`);
1443
+ const launchResult = openBrowserUrl(deviceFlow.verificationUriComplete ?? deviceFlow.verificationUri);
1444
+ if (!launchResult.ok) {
1445
+ const msg = launchResult.error ?? "unknown error";
1446
+ p.log.warning(`Could not auto-open verification URL via ${launchResult.launcher.label} (${msg}).`);
1447
+ }
1448
+ const spinner = useSpinner ? p.spinner() : null;
1449
+ if (spinner) spinner.start("Waiting for device authorization...");
1450
+ else p.log.message("Waiting for device authorization...");
1451
+ let intervalMs = Math.max(deviceFlow.interval, 1) * 1e3;
1452
+ const deadline = Date.now() + deviceFlow.expiresIn * 1e3;
1453
+ while (Date.now() < deadline) {
1454
+ await sleep(intervalMs);
1455
+ const pollResult = await pollDeviceAuthorizationToken(deviceFlow.deviceCode);
1456
+ if (pollResult.type === "success") {
1457
+ if (spinner) spinner.stop("Device authorization completed.");
1458
+ else p.log.success("Device authorization completed.");
1459
+ return pollResult;
1460
+ }
1461
+ if (pollResult.type === "pending") {
1462
+ intervalMs = Math.max(pollResult.interval, 1) * 1e3;
1463
+ continue;
1464
+ }
1465
+ if (pollResult.type === "slow_down") {
1466
+ intervalMs = Math.max(pollResult.interval, Math.ceil(intervalMs / 1e3) + 5) * 1e3;
1467
+ continue;
1468
+ }
1469
+ if (pollResult.type === "access_denied") {
1470
+ if (spinner) spinner.stop("Device authorization was denied.");
1471
+ else p.log.error("Device authorization was denied.");
1472
+ return null;
1473
+ }
1474
+ if (pollResult.type === "expired") {
1475
+ if (spinner) spinner.stop("Device authorization expired.");
1476
+ else p.log.error("Device authorization expired.");
1477
+ return null;
1478
+ }
1479
+ if (spinner) spinner.stop("Device authorization failed.");
1480
+ else p.log.error("Device authorization failed.");
1481
+ if (pollResult.type === "failed") {
1482
+ if (pollResult.error) p.log.error(`Technical details: ${pollResult.error}`);
1483
+ if (typeof pollResult.status === "number") p.log.error(`HTTP status: ${pollResult.status}`);
1484
+ if (pollResult.oauthError) p.log.error(`OAuth error: ${pollResult.oauthError}`);
1485
+ if (pollResult.responseBody) p.log.error(`Response: ${pollResult.responseBody}`);
1486
+ }
1487
+ return null;
1075
1488
  }
1489
+ if (spinner) spinner.stop("Device authorization timed out.");
1490
+ else p.log.error("Device authorization timed out.");
1491
+ return null;
1492
+ };
1493
+ const requestTokenViaOAuth = async (flow, options) => {
1494
+ if (options.authFlow === "device") return runDeviceOAuthFlow(options.useSpinner);
1495
+ const server = await startOAuthServer(flow.state);
1496
+ if (!server.ready) {
1497
+ p.log.error("Failed to start local server on port 1455.");
1498
+ p.log.info("Please ensure the port is not in use.");
1499
+ return null;
1500
+ }
1501
+ const spinner = options.useSpinner ? p.spinner() : null;
1502
+ let spinnerStarted = false;
1503
+ p.log.info("Opening browser for authentication...");
1504
+ const launchResult = openBrowserUrl(flow.url);
1505
+ if (!launchResult.ok) {
1506
+ const msg = launchResult.error ?? "unknown error";
1507
+ p.log.warning(`Could not auto-open browser via ${launchResult.launcher.label} (${msg}).`);
1508
+ }
1509
+ p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
1510
+ if (launchResult.ok) {
1511
+ if (spinner) {
1512
+ spinner.start("Waiting for authentication...");
1513
+ spinnerStarted = true;
1514
+ }
1515
+ const result = await server.waitForCode();
1516
+ server.close();
1517
+ if (!result) {
1518
+ if (spinner) spinner.stop("Authentication timed out or failed.");
1519
+ else p.log.warning("Authentication timed out or failed.");
1520
+ return null;
1521
+ }
1522
+ if (spinner) spinner.message("Exchanging authorization code...");
1523
+ else p.log.message("Exchanging authorization code...");
1524
+ const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
1525
+ if (tokenResult.type !== "success") {
1526
+ if (spinner) spinner.stop("Failed to exchange authorization code.");
1527
+ else p.log.error("Failed to exchange authorization code.");
1528
+ return null;
1529
+ }
1530
+ if (spinner) spinner.stop("Authentication completed.");
1531
+ return tokenResult;
1532
+ }
1533
+ const fallbackChoice = await promptBrowserFallbackChoice();
1534
+ if (fallbackChoice === "cancel") {
1535
+ server.close();
1536
+ p.log.info("Login cancelled.");
1537
+ return null;
1538
+ }
1539
+ if (fallbackChoice === "device") {
1540
+ server.close();
1541
+ return runDeviceOAuthFlow(options.useSpinner);
1542
+ }
1543
+ server.close();
1544
+ const code = await promptManualAuthorizationCode(flow.url, flow.state);
1545
+ if (!code) return null;
1546
+ if (spinner) if (spinnerStarted) spinner.message("Exchanging authorization code...");
1547
+ else {
1548
+ spinner.start("Exchanging authorization code...");
1549
+ spinnerStarted = true;
1550
+ }
1551
+ else p.log.message("Exchanging authorization code...");
1552
+ const tokenResult = await exchangeAuthorizationCode(code, flow.pkce.verifier);
1553
+ if (tokenResult.type !== "success") {
1554
+ if (spinner) spinner.stop("Failed to exchange authorization code.");
1555
+ else p.log.error("Failed to exchange authorization code.");
1556
+ return null;
1557
+ }
1558
+ if (spinner) spinner.stop("Authentication completed.");
1559
+ return tokenResult;
1076
1560
  };
1077
1561
  const addAccountToConfig = async (accountId, label) => {
1078
1562
  let config;
@@ -1100,54 +1584,35 @@ const performRefresh = async (targetAccountId, label, options = {}) => {
1100
1584
  const displayName = label ?? targetAccountId;
1101
1585
  p.log.step(`Re-authenticating account "${displayName}"...`);
1102
1586
  const useSpinner = options.useSpinner ?? true;
1103
- let flow;
1104
- try {
1105
- flow = await createAuthorizationFlow();
1106
- } catch (error) {
1107
- const msg = error instanceof Error ? error.message : String(error);
1108
- p.log.error(`Failed to create authorization flow: ${msg}`);
1109
- process.stderr.write(`Failed to create authorization flow: ${msg}\n`);
1110
- return null;
1111
- }
1112
- const server = await startOAuthServer(flow.state);
1113
- if (!server.ready) {
1114
- p.log.error("Failed to start local server on port 1455.");
1115
- p.log.info("Please ensure the port is not in use.");
1116
- return null;
1117
- }
1118
- const spinner = useSpinner ? p.spinner() : null;
1119
- p.log.info("Opening browser for authentication...");
1120
- openBrowser(flow.url);
1121
- p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
1122
- if (spinner) spinner.start("Waiting for authentication...");
1123
- const result = await server.waitForCode();
1124
- server.close();
1125
- if (!result) {
1126
- if (spinner) spinner.stop("Authentication timed out or failed.");
1127
- else p.log.warning("Authentication timed out or failed.");
1128
- return null;
1129
- }
1130
- if (spinner) spinner.message("Exchanging authorization code...");
1131
- else p.log.message("Exchanging authorization code...");
1132
- const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
1133
- if (tokenResult.type === "failed") {
1134
- if (spinner) spinner.stop("Failed to exchange authorization code.");
1135
- else p.log.error("Failed to exchange authorization code.");
1136
- return null;
1587
+ const authFlow = options.authFlow ?? "auto";
1588
+ let tokenResult = null;
1589
+ if (authFlow === "device") tokenResult = await runDeviceOAuthFlow(useSpinner);
1590
+ else {
1591
+ let flow;
1592
+ try {
1593
+ flow = await createAuthorizationFlow();
1594
+ } catch (error) {
1595
+ const msg = error instanceof Error ? error.message : String(error);
1596
+ p.log.error(`Failed to create authorization flow: ${msg}`);
1597
+ process.stderr.write(`Failed to create authorization flow: ${msg}\n`);
1598
+ return null;
1599
+ }
1600
+ tokenResult = await requestTokenViaOAuth(flow, {
1601
+ useSpinner,
1602
+ authFlow
1603
+ });
1137
1604
  }
1605
+ if (!tokenResult) return null;
1138
1606
  const newAccountId = extractAccountId(tokenResult.access);
1139
1607
  if (!newAccountId) {
1140
- if (spinner) spinner.stop("Failed to extract account ID from token.");
1141
- else p.log.error("Failed to extract account ID from token.");
1608
+ p.log.error("Failed to extract account ID from token.");
1142
1609
  return null;
1143
1610
  }
1144
1611
  if (newAccountId !== targetAccountId) {
1145
- if (spinner) spinner.stop("Authentication completed for a different account.");
1146
- else p.log.error("Authentication completed for a different account.");
1612
+ p.log.error("Authentication completed for a different account.");
1147
1613
  throw new Error(`Account mismatch: expected "${targetAccountId}" but got "${newAccountId}". Make sure you log in with the correct OpenAI account.`);
1148
1614
  }
1149
- if (spinner) spinner.message("Updating credentials...");
1150
- else p.log.message("Updating credentials...");
1615
+ if (!useSpinner) p.log.message("Updating credentials...");
1151
1616
  const payload = {
1152
1617
  refresh: tokenResult.refresh,
1153
1618
  access: tokenResult.access,
@@ -1156,46 +1621,29 @@ const performRefresh = async (targetAccountId, label, options = {}) => {
1156
1621
  ...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
1157
1622
  };
1158
1623
  await getSecretStoreAdapter().save(newAccountId, payload);
1159
- if (spinner) spinner.stop("Credentials refreshed!");
1160
- else p.log.success("Credentials refreshed!");
1624
+ p.log.success("Credentials refreshed!");
1161
1625
  p.log.success(`Account "${displayName}" credentials updated in secure store.`);
1162
1626
  return { accountId: newAccountId };
1163
1627
  } finally {
1164
1628
  clearInterval(keepAlive);
1165
1629
  }
1166
1630
  };
1167
- const performLogin = async () => {
1631
+ const performLogin = async (options = {}) => {
1168
1632
  p.intro("cdx login - Add OpenAI account");
1169
- const flow = await createAuthorizationFlow();
1170
- const server = await startOAuthServer(flow.state);
1171
- if (!server.ready) {
1172
- p.log.error("Failed to start local server on port 1455.");
1173
- p.log.info("Please ensure the port is not in use.");
1174
- return null;
1175
- }
1176
- const spinner = p.spinner();
1177
- p.log.info("Opening browser for authentication...");
1178
- openBrowser(flow.url);
1179
- p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
1180
- spinner.start("Waiting for authentication...");
1181
- const result = await server.waitForCode();
1182
- server.close();
1183
- if (!result) {
1184
- spinner.stop("Authentication timed out or failed.");
1185
- return null;
1186
- }
1187
- spinner.message("Exchanging authorization code...");
1188
- const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
1189
- if (tokenResult.type === "failed") {
1190
- spinner.stop("Failed to exchange authorization code.");
1191
- return null;
1192
- }
1633
+ const authFlow = options.authFlow ?? "auto";
1634
+ let tokenResult = null;
1635
+ if (authFlow === "device") tokenResult = await runDeviceOAuthFlow(true);
1636
+ else tokenResult = await requestTokenViaOAuth(await createAuthorizationFlow(), {
1637
+ useSpinner: true,
1638
+ authFlow
1639
+ });
1640
+ if (!tokenResult) return null;
1193
1641
  const accountId = extractAccountId(tokenResult.access);
1194
1642
  if (!accountId) {
1195
- spinner.stop("Failed to extract account ID from token.");
1643
+ p.log.error("Failed to extract account ID from token.");
1196
1644
  return null;
1197
1645
  }
1198
- spinner.message("Saving credentials...");
1646
+ p.log.message("Saving credentials...");
1199
1647
  const payload = {
1200
1648
  refresh: tokenResult.refresh,
1201
1649
  access: tokenResult.access,
@@ -1204,7 +1652,7 @@ const performLogin = async () => {
1204
1652
  ...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
1205
1653
  };
1206
1654
  await getSecretStoreAdapter().save(accountId, payload);
1207
- spinner.stop("Login successful!");
1655
+ p.log.success("Login successful!");
1208
1656
  const labelInput = await p.text({
1209
1657
  message: "Enter a label for this account (or press Enter to skip):",
1210
1658
  placeholder: "e.g. Work, Personal"
@@ -1456,7 +1904,7 @@ const handleSwitchAccount = async () => {
1456
1904
  const handleAddAccount = async () => {
1457
1905
  await performLogin();
1458
1906
  };
1459
- const handleReloginAccount = async () => {
1907
+ const handleReloginAccount = async (reloginOptions = {}) => {
1460
1908
  if (!configExists()) {
1461
1909
  p.log.warning("No accounts configured. Use 'Add account' first.");
1462
1910
  return;
@@ -1485,7 +1933,10 @@ const handleReloginAccount = async () => {
1485
1933
  const displayName = account?.label ?? accountId;
1486
1934
  p.log.info(`Current token status for ${displayName}: ${expiryState}`);
1487
1935
  try {
1488
- const result = await performRefresh(accountId, account?.label, { useSpinner: false });
1936
+ const result = await performRefresh(accountId, account?.label, {
1937
+ useSpinner: false,
1938
+ authFlow: reloginOptions.authFlow
1939
+ });
1489
1940
  if (!result) p.log.warning("Re-login was not completed.");
1490
1941
  else {
1491
1942
  const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
@@ -1814,6 +2265,30 @@ const registerDoctorCommand = (program) => {
1814
2265
  process.stdout.write(` Secret store: ${status.capabilities.secretStore.label} — ${secretStoreState}\n`);
1815
2266
  const browserState = status.capabilities.browserLauncher.available ? "available" : "not found";
1816
2267
  process.stdout.write(` Browser launcher: ${status.capabilities.browserLauncher.label} — ${browserState}\n`);
2268
+ if (process.platform === "win32") {
2269
+ const secretStore = getSecretStoreAdapter();
2270
+ process.stdout.write("\nWindows secure-store checks:\n");
2271
+ if (status.accounts.length === 0) process.stdout.write(" No accounts configured in config.\n");
2272
+ else {
2273
+ let okCount = 0;
2274
+ for (const account of status.accounts) {
2275
+ const accountLabel = resolveLabel(account.accountId);
2276
+ try {
2277
+ await secretStore.load(account.accountId);
2278
+ okCount += 1;
2279
+ process.stdout.write(` ${accountLabel}: credential payload load OK\n`);
2280
+ } catch (error) {
2281
+ if (isMissingSecretStoreEntryError(error)) {
2282
+ process.stdout.write(` ⚠ ${accountLabel}: missing secure-store entry for configured account\n`);
2283
+ continue;
2284
+ }
2285
+ const message = error instanceof Error ? error.message : String(error);
2286
+ process.stdout.write(` ⚠ ${accountLabel}: secure-store load failed (${message})\n`);
2287
+ }
2288
+ }
2289
+ process.stdout.write(` Summary: ${okCount}/${status.accounts.length} configured account(s) passed secure-store load checks.\n`);
2290
+ }
2291
+ }
1817
2292
  if (process.platform === "darwin" && !options.checkKeychainAcl) {
1818
2293
  process.stdout.write(" ┌─ Optional keychain ACL check\n");
1819
2294
  process.stdout.write(" │ Run: cdx doctor --check-keychain-acl\n");
@@ -1909,9 +2384,9 @@ const registerLabelCommand = (program) => {
1909
2384
  //#region lib/commands/login.ts
1910
2385
  const registerLoginCommand = (program, deps = {}) => {
1911
2386
  const runLogin = deps.performLogin ?? performLogin;
1912
- program.command("login").description("Add a new OpenAI account via OAuth").action(async () => {
2387
+ program.command("login").description("Add a new OpenAI account via OAuth").option("--device-flow", "Use OAuth device flow instead of browser callback flow").action(async (options) => {
1913
2388
  try {
1914
- if (!await runLogin()) {
2389
+ if (!await runLogin({ authFlow: options.deviceFlow ? "device" : "auto" })) {
1915
2390
  process.stderr.write("Login failed.\n");
1916
2391
  process.exit(1);
1917
2392
  }
@@ -2061,8 +2536,9 @@ const writeUpdatedAuthSummary = (result) => {
2061
2536
  //#endregion
2062
2537
  //#region lib/commands/refresh.ts
2063
2538
  const registerReloginCommand = (program) => {
2064
- program.command("relogin").description("Re-authenticate an existing account with full OAuth login (no duplicate account)").argument("[account]", "Account ID or label to re-login").action(async (account) => {
2539
+ program.command("relogin").description("Re-authenticate an existing account with full OAuth login (no duplicate account)").option("--device-flow", "Use OAuth device flow instead of browser callback flow").argument("[account]", "Account ID or label to re-login").action(async (account, options) => {
2065
2540
  try {
2541
+ const authFlow = options.deviceFlow ? "device" : "auto";
2066
2542
  if (account) {
2067
2543
  const target = (await loadConfig()).accounts.find((entry) => entry.accountId === account || entry.label === account);
2068
2544
  if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
@@ -2077,7 +2553,7 @@ const registerReloginCommand = (program) => {
2077
2553
  }
2078
2554
  else secureStoreState = " [no secure store entry]";
2079
2555
  process.stdout.write(`Current token status for ${displayName}: ${expiryState}${secureStoreState}\n`);
2080
- const result = await performRefresh(target.accountId, target.label);
2556
+ const result = await performRefresh(target.accountId, target.label, { authFlow });
2081
2557
  if (!result) {
2082
2558
  process.stderr.write("Re-login failed.\n");
2083
2559
  process.exit(1);
@@ -2086,7 +2562,7 @@ const registerReloginCommand = (program) => {
2086
2562
  if (authResult) writeUpdatedAuthSummary(authResult);
2087
2563
  return;
2088
2564
  }
2089
- await handleReloginAccount();
2565
+ await handleReloginAccount({ authFlow });
2090
2566
  } catch (error) {
2091
2567
  exitWithCommandError(error);
2092
2568
  }
@@ -2406,6 +2882,66 @@ const getCompletionParseArgs = (argv) => {
2406
2882
  if (separatorIndex === -1 || separatorIndex <= completeIndex) return null;
2407
2883
  return argv.slice(separatorIndex + 1);
2408
2884
  };
2885
+ const readCompletionAccounts = () => {
2886
+ try {
2887
+ const { configPath } = getPaths();
2888
+ if (!existsSync(configPath)) return [];
2889
+ const raw = readFileSync(configPath, "utf8");
2890
+ const parsed = JSON.parse(raw);
2891
+ if (!Array.isArray(parsed.accounts)) return [];
2892
+ const accounts = [];
2893
+ for (const account of parsed.accounts) {
2894
+ if (typeof account.accountId !== "string" || !account.accountId.trim()) continue;
2895
+ accounts.push({
2896
+ accountId: account.accountId,
2897
+ ...typeof account.label === "string" && account.label.trim() ? { label: account.label } : {}
2898
+ });
2899
+ }
2900
+ return accounts;
2901
+ } catch {
2902
+ return [];
2903
+ }
2904
+ };
2905
+ const addConfiguredAccountCompletions = (complete) => {
2906
+ const accounts = readCompletionAccounts();
2907
+ const seen = /* @__PURE__ */ new Set();
2908
+ for (const account of accounts) {
2909
+ if (!seen.has(account.accountId)) {
2910
+ seen.add(account.accountId);
2911
+ const description = account.label ? `Account ID (${account.label})` : "Account ID";
2912
+ complete(account.accountId, description);
2913
+ }
2914
+ if (account.label && !seen.has(account.label)) {
2915
+ seen.add(account.label);
2916
+ complete(account.label, `Label for ${account.accountId}`);
2917
+ }
2918
+ }
2919
+ };
2920
+ const attachAccountArgumentCompletion = (completion, commandName, argumentName) => {
2921
+ const command = completion.commands.get(commandName);
2922
+ if (!command) return;
2923
+ command.argument(argumentName, (complete) => {
2924
+ addConfiguredAccountCompletions(complete);
2925
+ });
2926
+ };
2927
+ const configureTabCompletion = (completion) => {
2928
+ const secretStoreOption = completion.options.get("secret-store");
2929
+ if (secretStoreOption) secretStoreOption.handler = (complete) => {
2930
+ complete("auto", "Automatic backend selection");
2931
+ complete("legacy-keychain", "macOS legacy keychain backend");
2932
+ };
2933
+ attachAccountArgumentCompletion(completion, "switch", "account-id");
2934
+ attachAccountArgumentCompletion(completion, "relogin", "account");
2935
+ attachAccountArgumentCompletion(completion, "usage", "account");
2936
+ attachAccountArgumentCompletion(completion, "label", "account");
2937
+ const helpCommand = completion.commands.get("help");
2938
+ if (helpCommand) helpCommand.argument("command", (complete) => {
2939
+ for (const [name, command] of completion.commands.entries()) {
2940
+ if (name === "") continue;
2941
+ complete(name, command.description || "Command");
2942
+ }
2943
+ });
2944
+ };
2409
2945
  const getMacOSKeychainPromptWarning = (selection, platform = process.platform, backendId = null) => {
2410
2946
  if (platform !== "darwin") return null;
2411
2947
  if (selection === "legacy-keychain") return "⚠ macOS keychain is using the legacy security CLI backend. Touch ID may not be offered for keychain prompts.";
@@ -2451,6 +2987,7 @@ const createProgram = (deps = {}) => {
2451
2987
  const main = async () => {
2452
2988
  const program = createProgram();
2453
2989
  const completion = tab(program);
2990
+ configureTabCompletion(completion);
2454
2991
  const completionArgs = getCompletionParseArgs(process.argv);
2455
2992
  if (completionArgs) {
2456
2993
  completion.parse(completionArgs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bjesuiter/codex-switcher",
3
- "version": "1.5.1",
3
+ "version": "1.7.2",
4
4
  "type": "module",
5
5
  "description": "CLI tool to switch between multiple OpenAI accounts for OpenCode",
6
6
  "bin": {
@@ -24,6 +24,7 @@
24
24
  "@bomb.sh/tab": "^0.0.13",
25
25
  "@clack/prompts": "^1.0.0",
26
26
  "@openauthjs/openauth": "^0.4.3",
27
+ "age-encryption": "^0.3.0",
27
28
  "commander": "^14.0.3"
28
29
  }
29
30
  }