@bjesuiter/codex-switcher 1.5.1 → 1.7.1

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 +10 -3
  2. package/cdx.mjs +579 -116
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -6,12 +6,15 @@ 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.1
10
+
11
+ #### Features
12
+
13
+ - Add a release helper script (`scripts/wait-for-npm-latest.ts`) plus `bun run wait-npm-latest` to poll npm until the package `latest` tag matches the target version.
10
14
 
11
15
  #### Fixes
12
16
 
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.
17
+ - Fix Windows CI completion test behavior by providing `APPDATA` in the account-completion test environment, so account suggestions are resolved correctly on `windows-latest`.
15
18
 
16
19
  see full changelog here: https://github.com/bjesuiter/codex-switcher/blob/main/CHANGELOG.md
17
20
 
@@ -154,8 +157,11 @@ cdx migrate-secrets
154
157
  |---------|-------------|
155
158
  | `cdx` | Interactive mode |
156
159
  | `cdx login` | Add a new OpenAI account via OAuth |
160
+ | `cdx login --device-flow` | Add account using OAuth device flow (no local browser callback needed) |
157
161
  | `cdx relogin` | Re-authenticate an existing account via OAuth |
162
+ | `cdx relogin --device-flow` | Re-authenticate interactively using OAuth device flow |
158
163
  | `cdx relogin <account>` | Re-authenticate a specific account by ID or label |
164
+ | `cdx relogin <account> --device-flow` | Re-authenticate specific account using OAuth device flow |
159
165
  | `cdx switch` | Switch account (interactive picker) |
160
166
  | `cdx switch --next` | Cycle to next account |
161
167
  | `cdx switch <id>` | Switch to specific account |
@@ -187,6 +193,7 @@ source <(cdx complete bash)
187
193
  ```
188
194
 
189
195
  `cdx` also supports shell parse completion requests via `cdx complete -- ...`.
196
+ Completions include command names, options, `--secret-store` values, and account ID/label suggestions for commands like `switch`, `relogin`, `usage`, and `label`.
190
197
 
191
198
  ## How It Works
192
199
 
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.1";
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,75 @@ const createAuthorizationFlow = async () => {
904
1032
  url: url.toString()
905
1033
  };
906
1034
  };
1035
+ const startDeviceAuthorizationFlow = async () => {
1036
+ try {
1037
+ const res = await fetch(DEVICE_CODE_URL, {
1038
+ method: "POST",
1039
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1040
+ body: new URLSearchParams({
1041
+ client_id: CLIENT_ID,
1042
+ scope: SCOPE
1043
+ })
1044
+ });
1045
+ if (!res.ok) return null;
1046
+ const json = await res.json();
1047
+ if (!json?.device_code || !json?.user_code || !json?.verification_uri || typeof json?.expires_in !== "number") return null;
1048
+ return {
1049
+ deviceCode: json.device_code,
1050
+ userCode: json.user_code,
1051
+ verificationUri: json.verification_uri,
1052
+ verificationUriComplete: json.verification_uri_complete,
1053
+ expiresIn: json.expires_in,
1054
+ interval: typeof json.interval === "number" && json.interval > 0 ? json.interval : 5
1055
+ };
1056
+ } catch {
1057
+ return null;
1058
+ }
1059
+ };
1060
+ const pollDeviceAuthorizationToken = async (deviceCode) => {
1061
+ try {
1062
+ const res = await fetch(TOKEN_URL, {
1063
+ method: "POST",
1064
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1065
+ body: new URLSearchParams({
1066
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
1067
+ device_code: deviceCode,
1068
+ client_id: CLIENT_ID
1069
+ })
1070
+ });
1071
+ if (res.ok) {
1072
+ const json = await res.json();
1073
+ if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== "number") return { type: "failed" };
1074
+ return {
1075
+ type: "success",
1076
+ access: json.access_token,
1077
+ refresh: json.refresh_token,
1078
+ expires: Date.now() + json.expires_in * 1e3,
1079
+ idToken: json.id_token
1080
+ };
1081
+ }
1082
+ let errorCode;
1083
+ let interval;
1084
+ try {
1085
+ const json = await res.json();
1086
+ errorCode = json.error;
1087
+ interval = json.interval;
1088
+ } catch {}
1089
+ if (errorCode === "authorization_pending") return {
1090
+ type: "pending",
1091
+ interval: typeof interval === "number" && interval > 0 ? interval : 5
1092
+ };
1093
+ if (errorCode === "slow_down") return {
1094
+ type: "slow_down",
1095
+ interval: typeof interval === "number" && interval > 0 ? interval : 10
1096
+ };
1097
+ if (errorCode === "access_denied") return { type: "access_denied" };
1098
+ if (errorCode === "expired_token") return { type: "expired" };
1099
+ return { type: "failed" };
1100
+ } catch {
1101
+ return { type: "failed" };
1102
+ }
1103
+ };
907
1104
  const exchangeAuthorizationCode = async (code, verifier) => {
908
1105
  const res = await fetch(TOKEN_URL, {
909
1106
  method: "POST",
@@ -1067,12 +1264,225 @@ const startOAuthServer = (state) => {
1067
1264
 
1068
1265
  //#endregion
1069
1266
  //#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}).`);
1267
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1268
+ const isLikelyRemoteEnvironment = () => {
1269
+ if (process.platform !== "linux") return false;
1270
+ if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY) return true;
1271
+ return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
1272
+ };
1273
+ const parseOAuthCallbackInput = (input) => {
1274
+ const trimmed = input.trim();
1275
+ if (!trimmed) return null;
1276
+ if (!trimmed.includes("://") && !trimmed.includes("code=") && !trimmed.includes("?")) return { code: trimmed };
1277
+ try {
1278
+ const parsedUrl = new URL(trimmed);
1279
+ const code = parsedUrl.searchParams.get("code");
1280
+ if (code) return {
1281
+ code,
1282
+ state: parsedUrl.searchParams.get("state") ?? void 0
1283
+ };
1284
+ } catch {}
1285
+ const queryLike = trimmed.startsWith("?") || trimmed.startsWith("#") ? trimmed.slice(1) : trimmed.includes("?") ? trimmed.slice(trimmed.indexOf("?") + 1) : trimmed;
1286
+ const params = new URLSearchParams(queryLike);
1287
+ const code = params.get("code");
1288
+ if (!code) return null;
1289
+ return {
1290
+ code,
1291
+ state: params.get("state") ?? void 0
1292
+ };
1293
+ };
1294
+ const promptBrowserFallbackChoice = async () => {
1295
+ const remoteHint = isLikelyRemoteEnvironment();
1296
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1297
+ const selected = remoteHint ? "device" : "manual";
1298
+ p.log.info(`Non-interactive terminal detected. Falling back to ${selected === "device" ? "device OAuth flow" : "manual URL copy/paste flow"}.`);
1299
+ return selected;
1300
+ }
1301
+ const options = remoteHint ? [
1302
+ {
1303
+ value: "device",
1304
+ label: "Use device OAuth flow",
1305
+ hint: "Recommended on SSH/remote servers"
1306
+ },
1307
+ {
1308
+ value: "manual",
1309
+ label: "Finish manually by copying URL",
1310
+ hint: "Open URL on any machine and paste callback URL/code back here"
1311
+ },
1312
+ {
1313
+ value: "cancel",
1314
+ label: "Cancel login"
1315
+ }
1316
+ ] : [
1317
+ {
1318
+ value: "manual",
1319
+ label: "Finish manually by copying URL",
1320
+ hint: "Open URL on any machine and paste callback URL/code back here"
1321
+ },
1322
+ {
1323
+ value: "device",
1324
+ label: "Use device OAuth flow",
1325
+ hint: "Best for headless/remote environments"
1326
+ },
1327
+ {
1328
+ value: "cancel",
1329
+ label: "Cancel login"
1330
+ }
1331
+ ];
1332
+ const selection = await p.select({
1333
+ message: "Browser launcher is unavailable. How do you want to continue?",
1334
+ options
1335
+ });
1336
+ if (p.isCancel(selection)) return "cancel";
1337
+ return selection;
1338
+ };
1339
+ const promptManualAuthorizationCode = async (authorizationUrl, expectedState) => {
1340
+ p.log.info("Manual login selected.");
1341
+ p.log.message(`Open this URL in a browser:\n${authorizationUrl}`);
1342
+ p.log.message("After approving, copy the full callback URL (or just the 'code' value) and paste it below.");
1343
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
1344
+ const response = await p.text({
1345
+ message: "Paste callback URL or authorization code:",
1346
+ placeholder: "http://localhost:1455/auth/callback?code=...&state=..."
1347
+ });
1348
+ if (p.isCancel(response)) {
1349
+ p.log.info("Login cancelled.");
1350
+ return null;
1351
+ }
1352
+ const parsed = parseOAuthCallbackInput(String(response));
1353
+ if (!parsed) {
1354
+ p.log.warning("Could not parse input. Please paste a callback URL or code.");
1355
+ continue;
1356
+ }
1357
+ if (parsed.state && parsed.state !== expectedState) {
1358
+ p.log.error("State mismatch in callback URL. Please retry the login flow.");
1359
+ return null;
1360
+ }
1361
+ return parsed.code;
1075
1362
  }
1363
+ p.log.error("Failed to parse callback input after multiple attempts.");
1364
+ return null;
1365
+ };
1366
+ const runDeviceOAuthFlow = async (useSpinner) => {
1367
+ const deviceFlow = await startDeviceAuthorizationFlow();
1368
+ if (!deviceFlow) {
1369
+ p.log.error("Device OAuth flow is not available right now.");
1370
+ return null;
1371
+ }
1372
+ p.log.info("Using device OAuth flow.");
1373
+ p.log.message(`Verification URL: ${deviceFlow.verificationUri}`);
1374
+ p.log.message(`User code: ${deviceFlow.userCode}`);
1375
+ const launchResult = openBrowserUrl(deviceFlow.verificationUriComplete ?? deviceFlow.verificationUri);
1376
+ if (!launchResult.ok) {
1377
+ const msg = launchResult.error ?? "unknown error";
1378
+ p.log.warning(`Could not auto-open verification URL via ${launchResult.launcher.label} (${msg}).`);
1379
+ }
1380
+ const spinner = useSpinner ? p.spinner() : null;
1381
+ if (spinner) spinner.start("Waiting for device authorization...");
1382
+ else p.log.message("Waiting for device authorization...");
1383
+ let intervalMs = Math.max(deviceFlow.interval, 1) * 1e3;
1384
+ const deadline = Date.now() + deviceFlow.expiresIn * 1e3;
1385
+ while (Date.now() < deadline) {
1386
+ await sleep(intervalMs);
1387
+ const pollResult = await pollDeviceAuthorizationToken(deviceFlow.deviceCode);
1388
+ if (pollResult.type === "success") {
1389
+ if (spinner) spinner.stop("Device authorization completed.");
1390
+ else p.log.success("Device authorization completed.");
1391
+ return pollResult;
1392
+ }
1393
+ if (pollResult.type === "pending") {
1394
+ intervalMs = Math.max(pollResult.interval, 1) * 1e3;
1395
+ continue;
1396
+ }
1397
+ if (pollResult.type === "slow_down") {
1398
+ intervalMs = Math.max(pollResult.interval, Math.ceil(intervalMs / 1e3) + 5) * 1e3;
1399
+ continue;
1400
+ }
1401
+ if (pollResult.type === "access_denied") {
1402
+ if (spinner) spinner.stop("Device authorization was denied.");
1403
+ else p.log.error("Device authorization was denied.");
1404
+ return null;
1405
+ }
1406
+ if (pollResult.type === "expired") {
1407
+ if (spinner) spinner.stop("Device authorization expired.");
1408
+ else p.log.error("Device authorization expired.");
1409
+ return null;
1410
+ }
1411
+ if (spinner) spinner.stop("Device authorization failed.");
1412
+ else p.log.error("Device authorization failed.");
1413
+ return null;
1414
+ }
1415
+ if (spinner) spinner.stop("Device authorization timed out.");
1416
+ else p.log.error("Device authorization timed out.");
1417
+ return null;
1418
+ };
1419
+ const requestTokenViaOAuth = async (flow, options) => {
1420
+ if (options.authFlow === "device") return runDeviceOAuthFlow(options.useSpinner);
1421
+ const server = await startOAuthServer(flow.state);
1422
+ if (!server.ready) {
1423
+ p.log.error("Failed to start local server on port 1455.");
1424
+ p.log.info("Please ensure the port is not in use.");
1425
+ return null;
1426
+ }
1427
+ const spinner = options.useSpinner ? p.spinner() : null;
1428
+ let spinnerStarted = false;
1429
+ p.log.info("Opening browser for authentication...");
1430
+ const launchResult = openBrowserUrl(flow.url);
1431
+ if (!launchResult.ok) {
1432
+ const msg = launchResult.error ?? "unknown error";
1433
+ p.log.warning(`Could not auto-open browser via ${launchResult.launcher.label} (${msg}).`);
1434
+ }
1435
+ p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
1436
+ if (launchResult.ok) {
1437
+ if (spinner) {
1438
+ spinner.start("Waiting for authentication...");
1439
+ spinnerStarted = true;
1440
+ }
1441
+ const result = await server.waitForCode();
1442
+ server.close();
1443
+ if (!result) {
1444
+ if (spinner) spinner.stop("Authentication timed out or failed.");
1445
+ else p.log.warning("Authentication timed out or failed.");
1446
+ return null;
1447
+ }
1448
+ if (spinner) spinner.message("Exchanging authorization code...");
1449
+ else p.log.message("Exchanging authorization code...");
1450
+ const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
1451
+ if (tokenResult.type !== "success") {
1452
+ if (spinner) spinner.stop("Failed to exchange authorization code.");
1453
+ else p.log.error("Failed to exchange authorization code.");
1454
+ return null;
1455
+ }
1456
+ if (spinner) spinner.stop("Authentication completed.");
1457
+ return tokenResult;
1458
+ }
1459
+ const fallbackChoice = await promptBrowserFallbackChoice();
1460
+ if (fallbackChoice === "cancel") {
1461
+ server.close();
1462
+ p.log.info("Login cancelled.");
1463
+ return null;
1464
+ }
1465
+ if (fallbackChoice === "device") {
1466
+ server.close();
1467
+ return runDeviceOAuthFlow(options.useSpinner);
1468
+ }
1469
+ server.close();
1470
+ const code = await promptManualAuthorizationCode(flow.url, flow.state);
1471
+ if (!code) return null;
1472
+ if (spinner) if (spinnerStarted) spinner.message("Exchanging authorization code...");
1473
+ else {
1474
+ spinner.start("Exchanging authorization code...");
1475
+ spinnerStarted = true;
1476
+ }
1477
+ else p.log.message("Exchanging authorization code...");
1478
+ const tokenResult = await exchangeAuthorizationCode(code, flow.pkce.verifier);
1479
+ if (tokenResult.type !== "success") {
1480
+ if (spinner) spinner.stop("Failed to exchange authorization code.");
1481
+ else p.log.error("Failed to exchange authorization code.");
1482
+ return null;
1483
+ }
1484
+ if (spinner) spinner.stop("Authentication completed.");
1485
+ return tokenResult;
1076
1486
  };
1077
1487
  const addAccountToConfig = async (accountId, label) => {
1078
1488
  let config;
@@ -1100,54 +1510,35 @@ const performRefresh = async (targetAccountId, label, options = {}) => {
1100
1510
  const displayName = label ?? targetAccountId;
1101
1511
  p.log.step(`Re-authenticating account "${displayName}"...`);
1102
1512
  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;
1513
+ const authFlow = options.authFlow ?? "auto";
1514
+ let tokenResult = null;
1515
+ if (authFlow === "device") tokenResult = await runDeviceOAuthFlow(useSpinner);
1516
+ else {
1517
+ let flow;
1518
+ try {
1519
+ flow = await createAuthorizationFlow();
1520
+ } catch (error) {
1521
+ const msg = error instanceof Error ? error.message : String(error);
1522
+ p.log.error(`Failed to create authorization flow: ${msg}`);
1523
+ process.stderr.write(`Failed to create authorization flow: ${msg}\n`);
1524
+ return null;
1525
+ }
1526
+ tokenResult = await requestTokenViaOAuth(flow, {
1527
+ useSpinner,
1528
+ authFlow
1529
+ });
1137
1530
  }
1531
+ if (!tokenResult) return null;
1138
1532
  const newAccountId = extractAccountId(tokenResult.access);
1139
1533
  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.");
1534
+ p.log.error("Failed to extract account ID from token.");
1142
1535
  return null;
1143
1536
  }
1144
1537
  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.");
1538
+ p.log.error("Authentication completed for a different account.");
1147
1539
  throw new Error(`Account mismatch: expected "${targetAccountId}" but got "${newAccountId}". Make sure you log in with the correct OpenAI account.`);
1148
1540
  }
1149
- if (spinner) spinner.message("Updating credentials...");
1150
- else p.log.message("Updating credentials...");
1541
+ if (!useSpinner) p.log.message("Updating credentials...");
1151
1542
  const payload = {
1152
1543
  refresh: tokenResult.refresh,
1153
1544
  access: tokenResult.access,
@@ -1156,46 +1547,29 @@ const performRefresh = async (targetAccountId, label, options = {}) => {
1156
1547
  ...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
1157
1548
  };
1158
1549
  await getSecretStoreAdapter().save(newAccountId, payload);
1159
- if (spinner) spinner.stop("Credentials refreshed!");
1160
- else p.log.success("Credentials refreshed!");
1550
+ p.log.success("Credentials refreshed!");
1161
1551
  p.log.success(`Account "${displayName}" credentials updated in secure store.`);
1162
1552
  return { accountId: newAccountId };
1163
1553
  } finally {
1164
1554
  clearInterval(keepAlive);
1165
1555
  }
1166
1556
  };
1167
- const performLogin = async () => {
1557
+ const performLogin = async (options = {}) => {
1168
1558
  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
- }
1559
+ const authFlow = options.authFlow ?? "auto";
1560
+ let tokenResult = null;
1561
+ if (authFlow === "device") tokenResult = await runDeviceOAuthFlow(true);
1562
+ else tokenResult = await requestTokenViaOAuth(await createAuthorizationFlow(), {
1563
+ useSpinner: true,
1564
+ authFlow
1565
+ });
1566
+ if (!tokenResult) return null;
1193
1567
  const accountId = extractAccountId(tokenResult.access);
1194
1568
  if (!accountId) {
1195
- spinner.stop("Failed to extract account ID from token.");
1569
+ p.log.error("Failed to extract account ID from token.");
1196
1570
  return null;
1197
1571
  }
1198
- spinner.message("Saving credentials...");
1572
+ p.log.message("Saving credentials...");
1199
1573
  const payload = {
1200
1574
  refresh: tokenResult.refresh,
1201
1575
  access: tokenResult.access,
@@ -1204,7 +1578,7 @@ const performLogin = async () => {
1204
1578
  ...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
1205
1579
  };
1206
1580
  await getSecretStoreAdapter().save(accountId, payload);
1207
- spinner.stop("Login successful!");
1581
+ p.log.success("Login successful!");
1208
1582
  const labelInput = await p.text({
1209
1583
  message: "Enter a label for this account (or press Enter to skip):",
1210
1584
  placeholder: "e.g. Work, Personal"
@@ -1456,7 +1830,7 @@ const handleSwitchAccount = async () => {
1456
1830
  const handleAddAccount = async () => {
1457
1831
  await performLogin();
1458
1832
  };
1459
- const handleReloginAccount = async () => {
1833
+ const handleReloginAccount = async (reloginOptions = {}) => {
1460
1834
  if (!configExists()) {
1461
1835
  p.log.warning("No accounts configured. Use 'Add account' first.");
1462
1836
  return;
@@ -1485,7 +1859,10 @@ const handleReloginAccount = async () => {
1485
1859
  const displayName = account?.label ?? accountId;
1486
1860
  p.log.info(`Current token status for ${displayName}: ${expiryState}`);
1487
1861
  try {
1488
- const result = await performRefresh(accountId, account?.label, { useSpinner: false });
1862
+ const result = await performRefresh(accountId, account?.label, {
1863
+ useSpinner: false,
1864
+ authFlow: reloginOptions.authFlow
1865
+ });
1489
1866
  if (!result) p.log.warning("Re-login was not completed.");
1490
1867
  else {
1491
1868
  const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
@@ -1814,6 +2191,30 @@ const registerDoctorCommand = (program) => {
1814
2191
  process.stdout.write(` Secret store: ${status.capabilities.secretStore.label} — ${secretStoreState}\n`);
1815
2192
  const browserState = status.capabilities.browserLauncher.available ? "available" : "not found";
1816
2193
  process.stdout.write(` Browser launcher: ${status.capabilities.browserLauncher.label} — ${browserState}\n`);
2194
+ if (process.platform === "win32") {
2195
+ const secretStore = getSecretStoreAdapter();
2196
+ process.stdout.write("\nWindows secure-store checks:\n");
2197
+ if (status.accounts.length === 0) process.stdout.write(" No accounts configured in config.\n");
2198
+ else {
2199
+ let okCount = 0;
2200
+ for (const account of status.accounts) {
2201
+ const accountLabel = resolveLabel(account.accountId);
2202
+ try {
2203
+ await secretStore.load(account.accountId);
2204
+ okCount += 1;
2205
+ process.stdout.write(` ${accountLabel}: credential payload load OK\n`);
2206
+ } catch (error) {
2207
+ if (isMissingSecretStoreEntryError(error)) {
2208
+ process.stdout.write(` ⚠ ${accountLabel}: missing secure-store entry for configured account\n`);
2209
+ continue;
2210
+ }
2211
+ const message = error instanceof Error ? error.message : String(error);
2212
+ process.stdout.write(` ⚠ ${accountLabel}: secure-store load failed (${message})\n`);
2213
+ }
2214
+ }
2215
+ process.stdout.write(` Summary: ${okCount}/${status.accounts.length} configured account(s) passed secure-store load checks.\n`);
2216
+ }
2217
+ }
1817
2218
  if (process.platform === "darwin" && !options.checkKeychainAcl) {
1818
2219
  process.stdout.write(" ┌─ Optional keychain ACL check\n");
1819
2220
  process.stdout.write(" │ Run: cdx doctor --check-keychain-acl\n");
@@ -1909,9 +2310,9 @@ const registerLabelCommand = (program) => {
1909
2310
  //#region lib/commands/login.ts
1910
2311
  const registerLoginCommand = (program, deps = {}) => {
1911
2312
  const runLogin = deps.performLogin ?? performLogin;
1912
- program.command("login").description("Add a new OpenAI account via OAuth").action(async () => {
2313
+ 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
2314
  try {
1914
- if (!await runLogin()) {
2315
+ if (!await runLogin({ authFlow: options.deviceFlow ? "device" : "auto" })) {
1915
2316
  process.stderr.write("Login failed.\n");
1916
2317
  process.exit(1);
1917
2318
  }
@@ -2061,8 +2462,9 @@ const writeUpdatedAuthSummary = (result) => {
2061
2462
  //#endregion
2062
2463
  //#region lib/commands/refresh.ts
2063
2464
  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) => {
2465
+ 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
2466
  try {
2467
+ const authFlow = options.deviceFlow ? "device" : "auto";
2066
2468
  if (account) {
2067
2469
  const target = (await loadConfig()).accounts.find((entry) => entry.accountId === account || entry.label === account);
2068
2470
  if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
@@ -2077,7 +2479,7 @@ const registerReloginCommand = (program) => {
2077
2479
  }
2078
2480
  else secureStoreState = " [no secure store entry]";
2079
2481
  process.stdout.write(`Current token status for ${displayName}: ${expiryState}${secureStoreState}\n`);
2080
- const result = await performRefresh(target.accountId, target.label);
2482
+ const result = await performRefresh(target.accountId, target.label, { authFlow });
2081
2483
  if (!result) {
2082
2484
  process.stderr.write("Re-login failed.\n");
2083
2485
  process.exit(1);
@@ -2086,7 +2488,7 @@ const registerReloginCommand = (program) => {
2086
2488
  if (authResult) writeUpdatedAuthSummary(authResult);
2087
2489
  return;
2088
2490
  }
2089
- await handleReloginAccount();
2491
+ await handleReloginAccount({ authFlow });
2090
2492
  } catch (error) {
2091
2493
  exitWithCommandError(error);
2092
2494
  }
@@ -2406,6 +2808,66 @@ const getCompletionParseArgs = (argv) => {
2406
2808
  if (separatorIndex === -1 || separatorIndex <= completeIndex) return null;
2407
2809
  return argv.slice(separatorIndex + 1);
2408
2810
  };
2811
+ const readCompletionAccounts = () => {
2812
+ try {
2813
+ const { configPath } = getPaths();
2814
+ if (!existsSync(configPath)) return [];
2815
+ const raw = readFileSync(configPath, "utf8");
2816
+ const parsed = JSON.parse(raw);
2817
+ if (!Array.isArray(parsed.accounts)) return [];
2818
+ const accounts = [];
2819
+ for (const account of parsed.accounts) {
2820
+ if (typeof account.accountId !== "string" || !account.accountId.trim()) continue;
2821
+ accounts.push({
2822
+ accountId: account.accountId,
2823
+ ...typeof account.label === "string" && account.label.trim() ? { label: account.label } : {}
2824
+ });
2825
+ }
2826
+ return accounts;
2827
+ } catch {
2828
+ return [];
2829
+ }
2830
+ };
2831
+ const addConfiguredAccountCompletions = (complete) => {
2832
+ const accounts = readCompletionAccounts();
2833
+ const seen = /* @__PURE__ */ new Set();
2834
+ for (const account of accounts) {
2835
+ if (!seen.has(account.accountId)) {
2836
+ seen.add(account.accountId);
2837
+ const description = account.label ? `Account ID (${account.label})` : "Account ID";
2838
+ complete(account.accountId, description);
2839
+ }
2840
+ if (account.label && !seen.has(account.label)) {
2841
+ seen.add(account.label);
2842
+ complete(account.label, `Label for ${account.accountId}`);
2843
+ }
2844
+ }
2845
+ };
2846
+ const attachAccountArgumentCompletion = (completion, commandName, argumentName) => {
2847
+ const command = completion.commands.get(commandName);
2848
+ if (!command) return;
2849
+ command.argument(argumentName, (complete) => {
2850
+ addConfiguredAccountCompletions(complete);
2851
+ });
2852
+ };
2853
+ const configureTabCompletion = (completion) => {
2854
+ const secretStoreOption = completion.options.get("secret-store");
2855
+ if (secretStoreOption) secretStoreOption.handler = (complete) => {
2856
+ complete("auto", "Automatic backend selection");
2857
+ complete("legacy-keychain", "macOS legacy keychain backend");
2858
+ };
2859
+ attachAccountArgumentCompletion(completion, "switch", "account-id");
2860
+ attachAccountArgumentCompletion(completion, "relogin", "account");
2861
+ attachAccountArgumentCompletion(completion, "usage", "account");
2862
+ attachAccountArgumentCompletion(completion, "label", "account");
2863
+ const helpCommand = completion.commands.get("help");
2864
+ if (helpCommand) helpCommand.argument("command", (complete) => {
2865
+ for (const [name, command] of completion.commands.entries()) {
2866
+ if (name === "") continue;
2867
+ complete(name, command.description || "Command");
2868
+ }
2869
+ });
2870
+ };
2409
2871
  const getMacOSKeychainPromptWarning = (selection, platform = process.platform, backendId = null) => {
2410
2872
  if (platform !== "darwin") return null;
2411
2873
  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 +2913,7 @@ const createProgram = (deps = {}) => {
2451
2913
  const main = async () => {
2452
2914
  const program = createProgram();
2453
2915
  const completion = tab(program);
2916
+ configureTabCompletion(completion);
2454
2917
  const completionArgs = getCompletionParseArgs(process.argv);
2455
2918
  if (completionArgs) {
2456
2919
  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.1",
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
  }