@bjesuiter/codex-switcher 1.2.0 → 1.4.0

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 +136 -44
  2. package/cdx.mjs +857 -283
  3. package/package.json +4 -3
package/cdx.mjs CHANGED
@@ -1,35 +1,101 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Command } from "commander";
3
+ import * as p from "@clack/prompts";
3
4
  import { existsSync } from "node:fs";
4
5
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
6
  import path from "node:path";
6
7
  import os from "node:os";
7
- import * as p from "@clack/prompts";
8
8
  import { spawn } from "node:child_process";
9
+ import { deletePassword, getPassword, listBackends, setPassword, useBackend } from "cross-keychain";
10
+ import { createInterface } from "node:readline/promises";
9
11
  import { generatePKCE } from "@openauthjs/openauth/pkce";
10
12
  import { randomBytes } from "node:crypto";
11
13
  import http from "node:http";
12
14
 
13
15
  //#region package.json
14
- var version = "1.2.0";
16
+ var version = "1.4.0";
17
+
18
+ //#endregion
19
+ //#region lib/commands/errors.ts
20
+ const exitWithCommandError = (error) => {
21
+ const message = error instanceof Error ? error.message : String(error);
22
+ process.stderr.write(`${message}\n`);
23
+ process.exit(1);
24
+ };
25
+
26
+ //#endregion
27
+ //#region lib/platform/path-resolver.ts
28
+ const envValue = (env, key) => {
29
+ const value = env[key];
30
+ if (!value) return void 0;
31
+ const trimmed = value.trim();
32
+ return trimmed.length > 0 ? trimmed : void 0;
33
+ };
34
+ const resolvePiAuthPath = (env, homeDir, platform) => {
35
+ const piAgentDir = envValue(env, "PI_CODING_AGENT_DIR");
36
+ if (piAgentDir) return platform === "win32" ? path.win32.join(piAgentDir, "auth.json") : path.join(piAgentDir, "auth.json");
37
+ return platform === "win32" ? path.win32.join(homeDir, ".pi", "agent", "auth.json") : path.join(homeDir, ".pi", "agent", "auth.json");
38
+ };
39
+ const resolveXdgPaths = (env, homeDir, platform) => {
40
+ const configHome = envValue(env, "XDG_CONFIG_HOME") ?? path.join(homeDir, ".config");
41
+ const dataHome = envValue(env, "XDG_DATA_HOME") ?? path.join(homeDir, ".local", "share");
42
+ const configDir = path.join(configHome, "cdx");
43
+ return {
44
+ profile: "xdg",
45
+ configDir,
46
+ configPath: path.join(configDir, "accounts.json"),
47
+ authPath: path.join(dataHome, "opencode", "auth.json"),
48
+ codexAuthPath: path.join(homeDir, ".codex", "auth.json"),
49
+ piAuthPath: resolvePiAuthPath(env, homeDir, platform)
50
+ };
51
+ };
52
+ const resolveWindowsPaths = (env, homeDir) => {
53
+ const winPath = path.win32;
54
+ const appData = envValue(env, "APPDATA") ?? winPath.join(homeDir, "AppData", "Roaming");
55
+ const localAppData = envValue(env, "LOCALAPPDATA") ?? winPath.join(homeDir, "AppData", "Local");
56
+ const configDir = winPath.join(appData, "cdx");
57
+ return {
58
+ profile: "windows-appdata",
59
+ configDir,
60
+ configPath: winPath.join(configDir, "accounts.json"),
61
+ authPath: winPath.join(localAppData, "opencode", "auth.json"),
62
+ codexAuthPath: winPath.join(homeDir, ".codex", "auth.json"),
63
+ piAuthPath: resolvePiAuthPath(env, homeDir, "win32")
64
+ };
65
+ };
66
+ const resolveRuntimePaths = (input) => {
67
+ if (input.platform === "win32") return resolveWindowsPaths(input.env, input.homeDir);
68
+ return resolveXdgPaths(input.env, input.homeDir, input.platform);
69
+ };
15
70
 
16
71
  //#endregion
17
72
  //#region lib/paths.ts
18
- const defaultConfigDir = path.join(os.homedir(), ".config", "cdx");
19
- const resolvePiAuthPath = () => {
20
- const piAgentDir = process.env.PI_CODING_AGENT_DIR?.trim();
21
- if (piAgentDir) return path.join(piAgentDir, "auth.json");
22
- return path.join(os.homedir(), ".pi", "agent", "auth.json");
23
- };
24
- const createDefaultPaths = () => ({
25
- configDir: defaultConfigDir,
26
- configPath: path.join(defaultConfigDir, "accounts.json"),
27
- authPath: path.join(os.homedir(), ".local", "share", "opencode", "auth.json"),
28
- codexAuthPath: path.join(os.homedir(), ".codex", "auth.json"),
29
- piAuthPath: resolvePiAuthPath()
73
+ const toPathConfig = (paths) => ({
74
+ configDir: paths.configDir,
75
+ configPath: paths.configPath,
76
+ authPath: paths.authPath,
77
+ codexAuthPath: paths.codexAuthPath,
78
+ piAuthPath: paths.piAuthPath
30
79
  });
31
- let currentPaths = createDefaultPaths();
80
+ const createDefaultPaths = () => {
81
+ const resolved = resolveRuntimePaths({
82
+ platform: process.platform,
83
+ env: process.env,
84
+ homeDir: os.homedir()
85
+ });
86
+ return {
87
+ paths: toPathConfig(resolved),
88
+ resolution: {
89
+ platform: process.platform,
90
+ profile: resolved.profile
91
+ }
92
+ };
93
+ };
94
+ const initial = createDefaultPaths();
95
+ let currentPaths = initial.paths;
96
+ let currentResolution = initial.resolution;
32
97
  const getPaths = () => currentPaths;
98
+ const getPathResolutionInfo = () => currentResolution;
33
99
  const setPaths = (paths) => {
34
100
  currentPaths = {
35
101
  ...currentPaths,
@@ -38,7 +104,9 @@ const setPaths = (paths) => {
38
104
  if (paths.configDir && !paths.configPath) currentPaths.configPath = path.join(paths.configDir, "accounts.json");
39
105
  };
40
106
  const resetPaths = () => {
41
- currentPaths = createDefaultPaths();
107
+ const next = createDefaultPaths();
108
+ currentPaths = next.paths;
109
+ currentResolution = next.resolution;
42
110
  };
43
111
  const createTestPaths = (testDir) => ({
44
112
  configDir: path.join(testDir, "config"),
@@ -149,11 +217,71 @@ const configExists = () => {
149
217
  return existsSync(configPath);
150
218
  };
151
219
 
220
+ //#endregion
221
+ //#region lib/platform/browser.ts
222
+ const getBrowserLauncher = (platform = process.platform, url) => {
223
+ if (platform === "darwin") return {
224
+ command: "open",
225
+ args: [url],
226
+ label: "open"
227
+ };
228
+ if (platform === "win32") return {
229
+ command: "cmd",
230
+ args: [
231
+ "/c",
232
+ "start",
233
+ "",
234
+ url
235
+ ],
236
+ label: "cmd /c start"
237
+ };
238
+ return {
239
+ command: "xdg-open",
240
+ args: [url],
241
+ label: "xdg-open"
242
+ };
243
+ };
244
+ const isCommandAvailable = (command, platform = process.platform) => {
245
+ const probe = platform === "win32" ? "where" : "which";
246
+ return Bun.spawnSync({
247
+ cmd: [probe, command],
248
+ stdout: "pipe",
249
+ stderr: "pipe"
250
+ }).exitCode === 0;
251
+ };
252
+ const getBrowserLauncherCapability = (platform = process.platform) => {
253
+ const launcher = getBrowserLauncher(platform, "https://example.com");
254
+ return {
255
+ command: launcher.command,
256
+ label: launcher.label,
257
+ available: isCommandAvailable(launcher.command, platform)
258
+ };
259
+ };
260
+ const openBrowserUrl = (url, spawnImpl = spawn) => {
261
+ const launcher = getBrowserLauncher(process.platform, url);
262
+ try {
263
+ spawnImpl(launcher.command, launcher.args, {
264
+ detached: true,
265
+ stdio: "ignore"
266
+ }).unref();
267
+ return {
268
+ ok: true,
269
+ launcher
270
+ };
271
+ } catch (error) {
272
+ return {
273
+ ok: false,
274
+ launcher,
275
+ error: error instanceof Error ? error.message : String(error)
276
+ };
277
+ }
278
+ };
279
+
152
280
  //#endregion
153
281
  //#region lib/keychain.ts
154
- const SERVICE_PREFIX = "cdx-openai-";
282
+ const SERVICE_PREFIX$2 = "cdx-openai-";
155
283
  const getKeychainService = (accountId) => {
156
- return `${SERVICE_PREFIX}${accountId}`;
284
+ return `${SERVICE_PREFIX$2}${accountId}`;
157
285
  };
158
286
  const runSecurity = (args) => {
159
287
  const result = Bun.spawnSync({
@@ -225,12 +353,300 @@ const listKeychainAccounts = () => {
225
353
  if (result.exitCode !== 0) return [];
226
354
  const output = result.stdout.toString();
227
355
  const accounts = [];
228
- const serviceRegex = new RegExp(`"svce"<blob>="${SERVICE_PREFIX}([^"]+)"`, "g");
356
+ const serviceRegex = new RegExp(`"svce"<blob>="${SERVICE_PREFIX$2}([^"]+)"`, "g");
229
357
  let match;
230
358
  while ((match = serviceRegex.exec(output)) !== null) if (match[1]) accounts.push(match[1]);
231
359
  return [...new Set(accounts)];
232
360
  };
233
361
 
362
+ //#endregion
363
+ //#region lib/secrets/fallback-consent.ts
364
+ const CONSENT_FILE = "secure-store-fallback-consent.json";
365
+ const CONSENT_ENV_BYPASS = "CDX_ALLOW_SECURE_STORE_FALLBACK";
366
+ const isBypassEnabled = () => {
367
+ const value = process.env[CONSENT_ENV_BYPASS];
368
+ if (!value) return false;
369
+ return [
370
+ "1",
371
+ "true",
372
+ "yes",
373
+ "y"
374
+ ].includes(value.trim().toLowerCase());
375
+ };
376
+ const consentFilePath = () => path.join(getPaths().configDir, CONSENT_FILE);
377
+ const loadConsentMap = async () => {
378
+ try {
379
+ const raw = await readFile(consentFilePath(), "utf8");
380
+ return JSON.parse(raw).accepted ?? {};
381
+ } catch {
382
+ return {};
383
+ }
384
+ };
385
+ const saveConsentMap = async (accepted) => {
386
+ const { configDir } = getPaths();
387
+ await mkdir(configDir, { recursive: true });
388
+ const payload = { accepted };
389
+ await writeFile(consentFilePath(), JSON.stringify(payload, null, 2), "utf8");
390
+ };
391
+ const promptConsent = async (message) => {
392
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
393
+ process.stdout.write(`\n${message}\n\n`);
394
+ const rl = createInterface({
395
+ input: process.stdin,
396
+ output: process.stdout
397
+ });
398
+ try {
399
+ const normalized = (await rl.question("Do you want to continue with this fallback? [y/N]: ")).trim().toLowerCase();
400
+ return normalized === "y" || normalized === "yes";
401
+ } finally {
402
+ rl.close();
403
+ }
404
+ };
405
+ const ensureFallbackConsent = async (scope, warningMessage) => {
406
+ if (isBypassEnabled()) return;
407
+ const accepted = await loadConsentMap();
408
+ if (accepted[scope]) return;
409
+ if (!await promptConsent(warningMessage)) throw new Error(`Secure-store fallback usage was not approved for '${scope}'. Re-run in an interactive terminal to confirm, or set ${CONSENT_ENV_BYPASS}=1 if you accept the fallback risk.`);
410
+ accepted[scope] = { acceptedAt: (/* @__PURE__ */ new Date()).toISOString() };
411
+ await saveConsentMap(accepted);
412
+ };
413
+
414
+ //#endregion
415
+ //#region lib/secrets/linux-cross-keychain.ts
416
+ const SERVICE_PREFIX$1 = "cdx-openai-";
417
+ const LINUX_FALLBACK_SCOPE = "linux:cross-keychain:secret-service";
418
+ let backendInitPromise$1 = null;
419
+ let selectedBackend$1 = null;
420
+ const tryUseBackend$1 = async (backendId) => {
421
+ try {
422
+ await useBackend(backendId);
423
+ return true;
424
+ } catch {
425
+ return false;
426
+ }
427
+ };
428
+ const selectBackend$1 = async () => {
429
+ const backends = await listBackends();
430
+ const available = new Set(backends.map((backend) => backend.id));
431
+ if (available.has("native-linux") && await tryUseBackend$1("native-linux")) return "native-linux";
432
+ if (available.has("secret-service") && await tryUseBackend$1("secret-service")) return "secret-service";
433
+ if (await tryUseBackend$1("native-linux")) return "native-linux";
434
+ if (await tryUseBackend$1("secret-service")) return "secret-service";
435
+ throw new Error("Unable to initialize Linux secure-store backend via cross-keychain.");
436
+ };
437
+ const ensureLinuxBackend = async (options = {}) => {
438
+ if (!backendInitPromise$1) backendInitPromise$1 = (async () => {
439
+ selectedBackend$1 = await selectBackend$1();
440
+ })();
441
+ try {
442
+ await backendInitPromise$1;
443
+ } catch {
444
+ backendInitPromise$1 = null;
445
+ selectedBackend$1 = null;
446
+ throw new Error("Unable to initialize Linux secure-store backend via cross-keychain.");
447
+ }
448
+ if (options.forWrite && selectedBackend$1 === "secret-service") await ensureFallbackConsent(LINUX_FALLBACK_SCOPE, "⚠ Security warning: only the cross-keychain Linux fallback backend is available.\nThis path relies on shell-based `secret-tool` operations for Secret Service access.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while helper commands run.");
449
+ };
450
+ const getLinuxCrossKeychainService = (accountId) => `${SERVICE_PREFIX$1}${accountId}`;
451
+ const parsePayload$1 = (accountId, raw) => {
452
+ let parsed;
453
+ try {
454
+ parsed = JSON.parse(raw);
455
+ } catch {
456
+ throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
457
+ }
458
+ if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
459
+ return parsed;
460
+ };
461
+ const withService$1 = async (accountId, run, options = {}) => {
462
+ await ensureLinuxBackend(options);
463
+ return run(getLinuxCrossKeychainService(accountId));
464
+ };
465
+ const saveLinuxCrossKeychainPayload = async (accountId, payload) => withService$1(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
466
+ const loadLinuxCrossKeychainPayload = async (accountId) => {
467
+ const raw = await withService$1(accountId, (service) => getPassword(service, accountId));
468
+ if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
469
+ return parsePayload$1(accountId, raw);
470
+ };
471
+ const deleteLinuxCrossKeychainPayload = async (accountId) => withService$1(accountId, (service) => deletePassword(service, accountId));
472
+ const linuxCrossKeychainPayloadExists = async (accountId) => withService$1(accountId, async (service) => await getPassword(service, accountId) !== null);
473
+
474
+ //#endregion
475
+ //#region lib/secrets/windows-cross-keychain.ts
476
+ const SERVICE_PREFIX = "cdx-openai-";
477
+ const WINDOWS_FALLBACK_SCOPE = "win32:cross-keychain:windows";
478
+ let backendInitPromise = null;
479
+ let selectedBackend = null;
480
+ const tryUseBackend = async (backendId) => {
481
+ try {
482
+ await useBackend(backendId);
483
+ return true;
484
+ } catch {
485
+ return false;
486
+ }
487
+ };
488
+ const selectBackend = async () => {
489
+ const backends = await listBackends();
490
+ const available = new Set(backends.map((backend) => backend.id));
491
+ if (available.has("native-windows") && await tryUseBackend("native-windows")) return "native-windows";
492
+ if (available.has("windows") && await tryUseBackend("windows")) return "windows";
493
+ if (await tryUseBackend("native-windows")) return "native-windows";
494
+ if (await tryUseBackend("windows")) return "windows";
495
+ throw new Error("Unable to initialize Windows credential backend via cross-keychain.");
496
+ };
497
+ const ensureWindowsBackend = async (options = {}) => {
498
+ if (!backendInitPromise) backendInitPromise = (async () => {
499
+ selectedBackend = await selectBackend();
500
+ })();
501
+ try {
502
+ await backendInitPromise;
503
+ } catch {
504
+ backendInitPromise = null;
505
+ selectedBackend = null;
506
+ throw new Error("Unable to initialize Windows credential backend via cross-keychain.");
507
+ }
508
+ 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.");
509
+ };
510
+ const getWindowsCrossKeychainService = (accountId) => `${SERVICE_PREFIX}${accountId}`;
511
+ const parsePayload = (accountId, raw) => {
512
+ let parsed;
513
+ try {
514
+ parsed = JSON.parse(raw);
515
+ } catch {
516
+ throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
517
+ }
518
+ if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
519
+ return parsed;
520
+ };
521
+ const withService = async (accountId, run, options = {}) => {
522
+ await ensureWindowsBackend(options);
523
+ return run(getWindowsCrossKeychainService(accountId));
524
+ };
525
+ const saveWindowsCrossKeychainPayload = async (accountId, payload) => withService(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
526
+ const loadWindowsCrossKeychainPayload = async (accountId) => {
527
+ const raw = await withService(accountId, (service) => getPassword(service, accountId));
528
+ if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
529
+ return parsePayload(accountId, raw);
530
+ };
531
+ const deleteWindowsCrossKeychainPayload = async (accountId) => withService(accountId, (service) => deletePassword(service, accountId));
532
+ const windowsCrossKeychainPayloadExists = async (accountId) => withService(accountId, async (service) => await getPassword(service, accountId) !== null);
533
+
534
+ //#endregion
535
+ //#region lib/secrets/store.ts
536
+ const MAC_FALLBACK_SCOPE = "darwin:security-cli";
537
+ let macNativeStoreOptionPromise = null;
538
+ const unsupportedError = (platform) => /* @__PURE__ */ new Error(`No default secret store adapter configured for platform '${platform}'. Only macOS, Windows, and Linux adapters are wired by default right now.`);
539
+ const hasNativeMacStoreOption = async () => {
540
+ if (!macNativeStoreOptionPromise) macNativeStoreOptionPromise = (async () => {
541
+ try {
542
+ return (await listBackends()).some((backend) => backend.id === "native-macos");
543
+ } catch {
544
+ return false;
545
+ }
546
+ })();
547
+ return macNativeStoreOptionPromise;
548
+ };
549
+ const ensureMacFallbackConsentIfNeeded = async () => {
550
+ if (await hasNativeMacStoreOption()) return;
551
+ await ensureFallbackConsent(MAC_FALLBACK_SCOPE, "⚠ Security warning: only the macOS CLI secure-store path is available.\nThis path uses the `security` command to access Keychain.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while CLI commands run.");
552
+ };
553
+ const createMacOSKeychainAdapter = () => ({
554
+ id: "macos-keychain",
555
+ label: "macOS Keychain",
556
+ getServiceName: getKeychainService,
557
+ save: async (accountId, payload) => {
558
+ await ensureMacFallbackConsentIfNeeded();
559
+ saveKeychainPayload(accountId, payload);
560
+ },
561
+ load: async (accountId) => loadKeychainPayload(accountId),
562
+ delete: async (accountId) => deleteKeychainPayload(accountId),
563
+ exists: async (accountId) => keychainPayloadExists(accountId),
564
+ listAccountIds: async () => listKeychainAccounts(),
565
+ getCapability: () => ({ available: true })
566
+ });
567
+ const loadConfiguredAccountIds = async () => {
568
+ if (!configExists()) return [];
569
+ return (await loadConfig()).accounts.map((account) => account.accountId);
570
+ };
571
+ const createWindowsCrossKeychainAdapter = () => ({
572
+ id: "windows-cross-keychain",
573
+ label: "Windows Credential Manager (cross-keychain)",
574
+ getServiceName: getWindowsCrossKeychainService,
575
+ save: saveWindowsCrossKeychainPayload,
576
+ load: loadWindowsCrossKeychainPayload,
577
+ delete: deleteWindowsCrossKeychainPayload,
578
+ exists: windowsCrossKeychainPayloadExists,
579
+ listAccountIds: async () => {
580
+ const accountIds = await loadConfiguredAccountIds();
581
+ return (await Promise.all(accountIds.map(async (accountId) => ({
582
+ accountId,
583
+ exists: await windowsCrossKeychainPayloadExists(accountId)
584
+ })))).filter((item) => item.exists).map((item) => item.accountId);
585
+ },
586
+ getCapability: () => ({ available: true })
587
+ });
588
+ const createLinuxCrossKeychainAdapter = () => ({
589
+ id: "linux-cross-keychain",
590
+ label: "Linux Secret Service (cross-keychain)",
591
+ getServiceName: getLinuxCrossKeychainService,
592
+ save: saveLinuxCrossKeychainPayload,
593
+ load: loadLinuxCrossKeychainPayload,
594
+ delete: deleteLinuxCrossKeychainPayload,
595
+ exists: linuxCrossKeychainPayloadExists,
596
+ listAccountIds: async () => {
597
+ const accountIds = await loadConfiguredAccountIds();
598
+ return (await Promise.all(accountIds.map(async (accountId) => ({
599
+ accountId,
600
+ exists: await linuxCrossKeychainPayloadExists(accountId)
601
+ })))).filter((item) => item.exists).map((item) => item.accountId);
602
+ },
603
+ getCapability: () => ({ available: true })
604
+ });
605
+ const createUnsupportedAdapter = (platform) => ({
606
+ id: "unsupported",
607
+ label: "Unsupported (no adapter configured)",
608
+ getServiceName: (accountId) => `cdx-openai-${accountId}`,
609
+ save: async () => {
610
+ throw unsupportedError(platform);
611
+ },
612
+ load: async () => {
613
+ throw unsupportedError(platform);
614
+ },
615
+ delete: async () => {
616
+ throw unsupportedError(platform);
617
+ },
618
+ exists: async () => false,
619
+ listAccountIds: async () => [],
620
+ getCapability: () => ({
621
+ available: false,
622
+ reason: "No default secure-store adapter available for this platform."
623
+ })
624
+ });
625
+ const createRuntimeSecretStoreAdapter = (platform = process.platform) => {
626
+ if (platform === "darwin") return createMacOSKeychainAdapter();
627
+ if (platform === "win32") return createWindowsCrossKeychainAdapter();
628
+ if (platform === "linux") return createLinuxCrossKeychainAdapter();
629
+ return createUnsupportedAdapter(platform);
630
+ };
631
+ let currentSecretStoreAdapter = createRuntimeSecretStoreAdapter();
632
+ const getSecretStoreAdapter = () => currentSecretStoreAdapter;
633
+ const setSecretStoreAdapter = (adapter) => {
634
+ currentSecretStoreAdapter = adapter;
635
+ };
636
+ const resetSecretStoreAdapter = () => {
637
+ currentSecretStoreAdapter = createRuntimeSecretStoreAdapter();
638
+ };
639
+ const getSecretStoreCapability = () => {
640
+ const adapter = getSecretStoreAdapter();
641
+ const capability = adapter.getCapability();
642
+ return {
643
+ id: adapter.id,
644
+ label: adapter.label,
645
+ available: capability.available,
646
+ ...capability.reason ? { reason: capability.reason } : {}
647
+ };
648
+ };
649
+
234
650
  //#endregion
235
651
  //#region lib/oauth/constants.ts
236
652
  const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
@@ -429,83 +845,101 @@ const startOAuthServer = (state) => {
429
845
  //#endregion
430
846
  //#region lib/oauth/login.ts
431
847
  const openBrowser = (url) => {
432
- spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], {
433
- detached: true,
434
- stdio: "ignore"
435
- }).unref();
848
+ const result = openBrowserUrl(url);
849
+ if (!result.ok) {
850
+ const msg = result.error ?? "unknown error";
851
+ p.log.warning(`Could not auto-open browser via ${result.launcher.label} (${msg}).`);
852
+ }
436
853
  };
437
854
  const addAccountToConfig = async (accountId, label) => {
438
855
  let config;
856
+ const secretStore = getSecretStoreAdapter();
439
857
  if (configExists()) {
440
858
  config = await loadConfig();
441
859
  if (!config.accounts.some((a) => a.accountId === accountId)) config.accounts.push({
442
860
  accountId,
443
- keychainService: getKeychainService(accountId),
861
+ keychainService: secretStore.getServiceName(accountId),
444
862
  ...label ? { label } : {}
445
863
  });
446
864
  } else config = {
447
865
  current: 0,
448
866
  accounts: [{
449
867
  accountId,
450
- keychainService: getKeychainService(accountId),
868
+ keychainService: secretStore.getServiceName(accountId),
451
869
  ...label ? { label } : {}
452
870
  }]
453
871
  };
454
872
  await saveConfig(config);
455
873
  };
456
- const performRefresh = async (targetAccountId, label) => {
457
- const displayName = label ?? targetAccountId;
458
- p.log.step(`Refreshing credentials for "${displayName}"...`);
459
- let flow;
874
+ const performRefresh = async (targetAccountId, label, options = {}) => {
875
+ const keepAlive = setInterval(() => {}, 1e3);
460
876
  try {
461
- flow = await createAuthorizationFlow();
462
- } catch (error) {
463
- const msg = error instanceof Error ? error.message : String(error);
464
- p.log.error(`Failed to create authorization flow: ${msg}`);
465
- return null;
466
- }
467
- const server = await startOAuthServer(flow.state);
468
- if (!server.ready) {
469
- p.log.error("Failed to start local server on port 1455.");
470
- p.log.info("Please ensure the port is not in use.");
471
- return null;
472
- }
473
- const spinner = p.spinner();
474
- p.log.info("Opening browser for authentication...");
475
- openBrowser(flow.url);
476
- spinner.start("Waiting for authentication...");
477
- const result = await server.waitForCode();
478
- server.close();
479
- if (!result) {
480
- spinner.stop("Authentication timed out or failed.");
481
- return null;
482
- }
483
- spinner.message("Exchanging authorization code...");
484
- const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
485
- if (tokenResult.type === "failed") {
486
- spinner.stop("Failed to exchange authorization code.");
487
- return null;
488
- }
489
- const newAccountId = extractAccountId(tokenResult.access);
490
- if (!newAccountId) {
491
- spinner.stop("Failed to extract account ID from token.");
492
- return null;
493
- }
494
- if (newAccountId !== targetAccountId) {
495
- spinner.stop(`Account mismatch: expected "${targetAccountId}" but got "${newAccountId}". Make sure you log in with the correct OpenAI account.`);
496
- return null;
877
+ const displayName = label ?? targetAccountId;
878
+ p.log.step(`Re-authenticating account "${displayName}"...`);
879
+ const useSpinner = options.useSpinner ?? true;
880
+ let flow;
881
+ try {
882
+ flow = await createAuthorizationFlow();
883
+ } catch (error) {
884
+ const msg = error instanceof Error ? error.message : String(error);
885
+ p.log.error(`Failed to create authorization flow: ${msg}`);
886
+ process.stderr.write(`Failed to create authorization flow: ${msg}\n`);
887
+ return null;
888
+ }
889
+ const server = await startOAuthServer(flow.state);
890
+ if (!server.ready) {
891
+ p.log.error("Failed to start local server on port 1455.");
892
+ p.log.info("Please ensure the port is not in use.");
893
+ return null;
894
+ }
895
+ const spinner = useSpinner ? p.spinner() : null;
896
+ p.log.info("Opening browser for authentication...");
897
+ openBrowser(flow.url);
898
+ p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
899
+ if (spinner) spinner.start("Waiting for authentication...");
900
+ const result = await server.waitForCode();
901
+ server.close();
902
+ if (!result) {
903
+ if (spinner) spinner.stop("Authentication timed out or failed.");
904
+ else p.log.warning("Authentication timed out or failed.");
905
+ return null;
906
+ }
907
+ if (spinner) spinner.message("Exchanging authorization code...");
908
+ else p.log.message("Exchanging authorization code...");
909
+ const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
910
+ if (tokenResult.type === "failed") {
911
+ if (spinner) spinner.stop("Failed to exchange authorization code.");
912
+ else p.log.error("Failed to exchange authorization code.");
913
+ return null;
914
+ }
915
+ const newAccountId = extractAccountId(tokenResult.access);
916
+ if (!newAccountId) {
917
+ if (spinner) spinner.stop("Failed to extract account ID from token.");
918
+ else p.log.error("Failed to extract account ID from token.");
919
+ return null;
920
+ }
921
+ if (newAccountId !== targetAccountId) {
922
+ if (spinner) spinner.stop("Authentication completed for a different account.");
923
+ else p.log.error("Authentication completed for a different account.");
924
+ throw new Error(`Account mismatch: expected "${targetAccountId}" but got "${newAccountId}". Make sure you log in with the correct OpenAI account.`);
925
+ }
926
+ if (spinner) spinner.message("Updating credentials...");
927
+ else p.log.message("Updating credentials...");
928
+ const payload = {
929
+ refresh: tokenResult.refresh,
930
+ access: tokenResult.access,
931
+ expires: tokenResult.expires,
932
+ accountId: newAccountId,
933
+ ...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
934
+ };
935
+ await getSecretStoreAdapter().save(newAccountId, payload);
936
+ if (spinner) spinner.stop("Credentials refreshed!");
937
+ else p.log.success("Credentials refreshed!");
938
+ p.log.success(`Account "${displayName}" credentials updated in secure store.`);
939
+ return { accountId: newAccountId };
940
+ } finally {
941
+ clearInterval(keepAlive);
497
942
  }
498
- spinner.message("Updating credentials...");
499
- saveKeychainPayload(newAccountId, {
500
- refresh: tokenResult.refresh,
501
- access: tokenResult.access,
502
- expires: tokenResult.expires,
503
- accountId: newAccountId,
504
- ...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
505
- });
506
- spinner.stop("Credentials refreshed!");
507
- p.log.success(`Account "${displayName}" credentials updated in Keychain.`);
508
- return { accountId: newAccountId };
509
943
  };
510
944
  const performLogin = async () => {
511
945
  p.intro("cdx login - Add OpenAI account");
@@ -519,6 +953,7 @@ const performLogin = async () => {
519
953
  const spinner = p.spinner();
520
954
  p.log.info("Opening browser for authentication...");
521
955
  openBrowser(flow.url);
956
+ p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
522
957
  spinner.start("Waiting for authentication...");
523
958
  const result = await server.waitForCode();
524
959
  server.close();
@@ -538,13 +973,14 @@ const performLogin = async () => {
538
973
  return null;
539
974
  }
540
975
  spinner.message("Saving credentials...");
541
- saveKeychainPayload(accountId, {
976
+ const payload = {
542
977
  refresh: tokenResult.refresh,
543
978
  access: tokenResult.access,
544
979
  expires: tokenResult.expires,
545
980
  accountId,
546
981
  ...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
547
- });
982
+ };
983
+ await getSecretStoreAdapter().save(accountId, payload);
548
984
  spinner.stop("Login successful!");
549
985
  const labelInput = await p.text({
550
986
  message: "Enter a label for this account (or press Enter to skip):",
@@ -553,7 +989,7 @@ const performLogin = async () => {
553
989
  const label = !p.isCancel(labelInput) && labelInput?.trim() ? labelInput.trim() : void 0;
554
990
  await addAccountToConfig(accountId, label);
555
991
  const displayName = label ?? accountId;
556
- p.log.success(`Account "${displayName}" saved to Keychain and config.`);
992
+ p.log.success(`Account "${displayName}" saved to secure store and config.`);
557
993
  p.outro("You can now use 'cdx switch' to activate this account.");
558
994
  return { accountId };
559
995
  };
@@ -565,7 +1001,19 @@ const writeActiveAuthFilesIfCurrent = async (accountId) => {
565
1001
  const config = await loadConfig();
566
1002
  const current = config.accounts[config.current];
567
1003
  if (!current || current.accountId !== accountId) return null;
568
- return writeAllAuthFiles(loadKeychainPayload(accountId));
1004
+ return writeAllAuthFiles(await getSecretStoreAdapter().load(accountId));
1005
+ };
1006
+
1007
+ //#endregion
1008
+ //#region lib/platform/capabilities.ts
1009
+ const getRuntimeCapabilities = () => {
1010
+ const pathResolution = getPathResolutionInfo();
1011
+ return {
1012
+ platform: process.platform,
1013
+ pathProfile: pathResolution.profile,
1014
+ secretStore: getSecretStoreCapability(),
1015
+ browserLauncher: getBrowserLauncherCapability(process.platform)
1016
+ };
569
1017
  };
570
1018
 
571
1019
  //#endregion
@@ -650,12 +1098,13 @@ const readPiAuthAccount = async () => {
650
1098
  };
651
1099
  }
652
1100
  };
653
- const getAccountStatus = (accountId, isCurrent, label) => {
654
- const keychainExists = keychainPayloadExists(accountId);
1101
+ const getAccountStatus = async (accountId, isCurrent, label) => {
1102
+ const secretStore = getSecretStoreAdapter();
1103
+ const secureStoreExists = await secretStore.exists(accountId);
655
1104
  let expiresAt = null;
656
1105
  let hasIdToken = false;
657
- if (keychainExists) try {
658
- const payload = loadKeychainPayload(accountId);
1106
+ if (secureStoreExists) try {
1107
+ const payload = await secretStore.load(accountId);
659
1108
  expiresAt = payload.expires;
660
1109
  hasIdToken = !!payload.idToken;
661
1110
  } catch {}
@@ -663,7 +1112,7 @@ const getAccountStatus = (accountId, isCurrent, label) => {
663
1112
  accountId,
664
1113
  label,
665
1114
  isCurrent,
666
- keychainExists,
1115
+ secureStoreExists,
667
1116
  hasIdToken,
668
1117
  expiresAt,
669
1118
  expiresIn: formatExpiry(expiresAt)
@@ -675,7 +1124,7 @@ const getStatus = async () => {
675
1124
  const config = await loadConfig();
676
1125
  for (let i = 0; i < config.accounts.length; i++) {
677
1126
  const account = config.accounts[i];
678
- accounts.push(getAccountStatus(account.accountId, i === config.current, account.label));
1127
+ accounts.push(await getAccountStatus(account.accountId, i === config.current, account.label));
679
1128
  }
680
1129
  }
681
1130
  const [opencodeAuth, codexAuth, piAuth] = await Promise.all([
@@ -687,7 +1136,8 @@ const getStatus = async () => {
687
1136
  accounts,
688
1137
  opencodeAuth,
689
1138
  codexAuth,
690
- piAuth
1139
+ piAuth,
1140
+ capabilities: getRuntimeCapabilities()
691
1141
  };
692
1142
  };
693
1143
 
@@ -697,6 +1147,20 @@ const getAccountDisplay = (accountId, isCurrent, label) => {
697
1147
  const name = label ? `${label} (${accountId})` : accountId;
698
1148
  return isCurrent ? `${name} (current)` : name;
699
1149
  };
1150
+ const hasStoredCredentials = async (accountId) => getSecretStoreAdapter().exists(accountId);
1151
+ const loadStoredCredentials = async (accountId) => getSecretStoreAdapter().load(accountId);
1152
+ const getStoredAccountIds = async () => getSecretStoreAdapter().listAccountIds();
1153
+ const removeStoredCredentials = async (accountId) => {
1154
+ await getSecretStoreAdapter().delete(accountId);
1155
+ };
1156
+ const getRefreshExpiryState = async (accountId) => {
1157
+ if (!await hasStoredCredentials(accountId)) return "unknown [no secure store entry]";
1158
+ try {
1159
+ return formatExpiry((await loadStoredCredentials(accountId)).expires);
1160
+ } catch {
1161
+ return "unknown";
1162
+ }
1163
+ };
700
1164
  const handleListAccounts = async () => {
701
1165
  if (!configExists()) {
702
1166
  p.log.warning("No accounts configured. Use 'Add account' to get started.");
@@ -708,7 +1172,7 @@ const handleListAccounts = async () => {
708
1172
  for (const account of config.accounts) {
709
1173
  const marker = account.accountId === currentAccountId ? "→ " : " ";
710
1174
  const displayName = account.label ? `${account.label} (${account.accountId})` : account.accountId;
711
- const status = keychainPayloadExists(account.accountId) ? "" : " (missing credentials)";
1175
+ const status = await hasStoredCredentials(account.accountId) ? "" : " (missing credentials)";
712
1176
  p.log.message(`${marker}${displayName}${status}`);
713
1177
  }
714
1178
  };
@@ -746,7 +1210,7 @@ const handleSwitchAccount = async () => {
746
1210
  }
747
1211
  let payload;
748
1212
  try {
749
- payload = loadKeychainPayload(selectedAccount.accountId);
1213
+ payload = await loadStoredCredentials(selectedAccount.accountId);
750
1214
  } catch {
751
1215
  p.log.error(`Missing credentials for account ${selectedAccount.label ?? selectedAccount.accountId}. Re-login with 'cdx login'.`);
752
1216
  return;
@@ -766,23 +1230,23 @@ const handleSwitchAccount = async () => {
766
1230
  const handleAddAccount = async () => {
767
1231
  await performLogin();
768
1232
  };
769
- const handleRefreshAccount = async () => {
1233
+ const handleReloginAccount = async () => {
770
1234
  if (!configExists()) {
771
1235
  p.log.warning("No accounts configured. Use 'Add account' first.");
772
1236
  return;
773
1237
  }
774
1238
  const config = await loadConfig();
775
1239
  if (config.accounts.length === 0) {
776
- p.log.warning("No accounts to refresh.");
1240
+ p.log.warning("No accounts to re-login.");
777
1241
  return;
778
1242
  }
779
1243
  const currentAccountId = config.accounts[config.current]?.accountId;
780
- const options = config.accounts.map((account) => ({
1244
+ const options = await Promise.all(config.accounts.map(async (account) => ({
781
1245
  value: account.accountId,
782
- label: getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)
783
- }));
1246
+ label: `${getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)} — ${await getRefreshExpiryState(account.accountId)}`
1247
+ })));
784
1248
  const selected = await p.select({
785
- message: "Select account to refresh:",
1249
+ message: "Select account to re-login:",
786
1250
  options
787
1251
  });
788
1252
  if (p.isCancel(selected)) {
@@ -791,9 +1255,12 @@ const handleRefreshAccount = async () => {
791
1255
  }
792
1256
  const accountId = selected;
793
1257
  const account = config.accounts.find((a) => a.accountId === accountId);
1258
+ const expiryState = await getRefreshExpiryState(accountId);
1259
+ const displayName = account?.label ?? accountId;
1260
+ p.log.info(`Current token status for ${displayName}: ${expiryState}`);
794
1261
  try {
795
- const result = await performRefresh(accountId, account?.label);
796
- if (!result) p.log.warning("Refresh was not completed.");
1262
+ const result = await performRefresh(accountId, account?.label, { useSpinner: false });
1263
+ if (!result) p.log.warning("Re-login was not completed.");
797
1264
  else {
798
1265
  const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
799
1266
  if (authResult) {
@@ -807,7 +1274,7 @@ const handleRefreshAccount = async () => {
807
1274
  }
808
1275
  } catch (error) {
809
1276
  const msg = error instanceof Error ? error.message : String(error);
810
- p.log.error(`Refresh failed: ${msg}`);
1277
+ p.log.error(`Re-login failed: ${msg}`);
811
1278
  }
812
1279
  };
813
1280
  const handleRemoveAccount = async () => {
@@ -843,7 +1310,7 @@ const handleRemoveAccount = async () => {
843
1310
  return;
844
1311
  }
845
1312
  try {
846
- deleteKeychainPayload(accountId);
1313
+ await removeStoredCredentials(accountId);
847
1314
  } catch {}
848
1315
  const previousAccountId = config.accounts[config.current]?.accountId;
849
1316
  config.accounts = config.accounts.filter((a) => a.accountId !== accountId);
@@ -907,9 +1374,9 @@ const handleStatus = async () => {
907
1374
  for (const account of status.accounts) {
908
1375
  const marker = account.isCurrent ? "→ " : " ";
909
1376
  const name = account.label ? `${account.label} (${account.accountId})` : account.accountId;
910
- const keychain = account.keychainExists ? "" : " [no keychain]";
1377
+ const secureStore = account.secureStoreExists ? "" : " [no secure store entry]";
911
1378
  const idToken = account.hasIdToken ? "" : " [no id_token]";
912
- p.log.message(`${marker}${name} — ${account.expiresIn}${keychain}${idToken}`);
1379
+ p.log.message(`${marker}${name} — ${account.expiresIn}${secureStore}${idToken}`);
913
1380
  }
914
1381
  const ocStatus = status.opencodeAuth.exists ? `active: ${status.opencodeAuth.accountId ?? "unknown"}` : "not found";
915
1382
  const cxStatus = status.codexAuth.exists ? `active: ${status.codexAuth.accountId ?? "unknown"}` : "not found";
@@ -923,7 +1390,7 @@ const runInteractiveMode = async () => {
923
1390
  p.intro("cdx - OpenAI Account Switcher");
924
1391
  let running = true;
925
1392
  while (running) {
926
- const keychainAccounts = listKeychainAccounts();
1393
+ const storedAccounts = await getStoredAccountIds();
927
1394
  let currentInfo = "";
928
1395
  if (configExists()) try {
929
1396
  const config = await loadConfig();
@@ -935,7 +1402,7 @@ const runInteractiveMode = async () => {
935
1402
  options: [
936
1403
  {
937
1404
  value: "list",
938
- label: `List accounts (${keychainAccounts.length} in Keychain)`
1405
+ label: `List accounts (${storedAccounts.length} in secure store)`
939
1406
  },
940
1407
  {
941
1408
  value: "switch",
@@ -946,8 +1413,8 @@ const runInteractiveMode = async () => {
946
1413
  label: "Add account (OAuth login)"
947
1414
  },
948
1415
  {
949
- value: "refresh",
950
- label: "Refresh account (re-login)"
1416
+ value: "relogin",
1417
+ label: "Re-login account"
951
1418
  },
952
1419
  {
953
1420
  value: "remove",
@@ -981,8 +1448,8 @@ const runInteractiveMode = async () => {
981
1448
  case "add":
982
1449
  await handleAddAccount();
983
1450
  break;
984
- case "refresh":
985
- await handleRefreshAccount();
1451
+ case "relogin":
1452
+ await handleReloginAccount();
986
1453
  break;
987
1454
  case "remove":
988
1455
  await handleRemoveAccount();
@@ -1002,6 +1469,167 @@ const runInteractiveMode = async () => {
1002
1469
  p.outro("Goodbye!");
1003
1470
  };
1004
1471
 
1472
+ //#endregion
1473
+ //#region lib/commands/interactive.ts
1474
+ const registerDefaultInteractiveAction = (program) => {
1475
+ program.action(async () => {
1476
+ try {
1477
+ await runInteractiveMode();
1478
+ } catch (error) {
1479
+ exitWithCommandError(error);
1480
+ }
1481
+ });
1482
+ };
1483
+
1484
+ //#endregion
1485
+ //#region lib/commands/doctor.ts
1486
+ const registerDoctorCommand = (program) => {
1487
+ program.command("doctor").description("Show auth file paths and runtime capabilities").action(async () => {
1488
+ try {
1489
+ const status = await getStatus();
1490
+ const paths = getPaths();
1491
+ const resolveLabel = (accountId) => {
1492
+ if (!accountId) return "unknown";
1493
+ return status.accounts.find((account) => account.accountId === accountId)?.label ?? accountId;
1494
+ };
1495
+ process.stdout.write("\nAuth files:\n");
1496
+ const ocStatus = status.opencodeAuth.exists ? `active: ${resolveLabel(status.opencodeAuth.accountId)}` : "not found";
1497
+ process.stdout.write(` OpenCode: ${ocStatus}\n`);
1498
+ process.stdout.write(` Path: ${paths.authPath}\n`);
1499
+ const cxStatus = status.codexAuth.exists ? `active: ${resolveLabel(status.codexAuth.accountId)}` : "not found";
1500
+ process.stdout.write(` Codex CLI: ${cxStatus}\n`);
1501
+ process.stdout.write(` Path: ${paths.codexAuthPath}\n`);
1502
+ const piStatus = status.piAuth.exists ? `active: ${resolveLabel(status.piAuth.accountId)}` : "not found";
1503
+ process.stdout.write(` Pi Agent: ${piStatus}\n`);
1504
+ process.stdout.write(` Path: ${paths.piAuthPath}\n`);
1505
+ process.stdout.write("\nCapabilities:\n");
1506
+ process.stdout.write(` Platform: ${status.capabilities.platform}\n`);
1507
+ process.stdout.write(` Path profile: ${status.capabilities.pathProfile}\n`);
1508
+ const secretStoreState = status.capabilities.secretStore.available ? "available" : `unavailable${status.capabilities.secretStore.reason ? ` (${status.capabilities.secretStore.reason})` : ""}`;
1509
+ process.stdout.write(` Secret store: ${status.capabilities.secretStore.label} — ${secretStoreState}\n`);
1510
+ const browserState = status.capabilities.browserLauncher.available ? "available" : "not found";
1511
+ process.stdout.write(` Browser launcher: ${status.capabilities.browserLauncher.label} — ${browserState}\n`);
1512
+ process.stdout.write("\n");
1513
+ } catch (error) {
1514
+ exitWithCommandError(error);
1515
+ }
1516
+ });
1517
+ };
1518
+
1519
+ //#endregion
1520
+ //#region lib/commands/help.ts
1521
+ const registerHelpCommand = (program) => {
1522
+ program.command("help").description("Show available commands and usage information").argument("[command]", "Show help for a specific command").action((commandName) => {
1523
+ if (commandName) {
1524
+ const command = program.commands.find((entry) => entry.name() === commandName);
1525
+ if (command) {
1526
+ command.outputHelp();
1527
+ return;
1528
+ }
1529
+ process.stderr.write(`Unknown command: ${commandName}\n`);
1530
+ program.outputHelp();
1531
+ process.exit(1);
1532
+ }
1533
+ program.outputHelp();
1534
+ });
1535
+ };
1536
+
1537
+ //#endregion
1538
+ //#region lib/commands/label.ts
1539
+ const registerLabelCommand = (program) => {
1540
+ program.command("label").description("Add or change label for an account").argument("[account]", "Account ID or current label to relabel").argument("[new-label]", "New label to assign").action(async (account, newLabel) => {
1541
+ try {
1542
+ if (account && newLabel) {
1543
+ const config = await loadConfig();
1544
+ const target = config.accounts.find((entry) => entry.accountId === account || entry.label === account);
1545
+ if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1546
+ target.label = newLabel;
1547
+ await saveConfig(config);
1548
+ process.stdout.write(`Account ${target.accountId} labeled as "${newLabel}".\n`);
1549
+ return;
1550
+ }
1551
+ await handleLabelAccount();
1552
+ } catch (error) {
1553
+ exitWithCommandError(error);
1554
+ }
1555
+ });
1556
+ };
1557
+
1558
+ //#endregion
1559
+ //#region lib/commands/login.ts
1560
+ const registerLoginCommand = (program, deps = {}) => {
1561
+ const runLogin = deps.performLogin ?? performLogin;
1562
+ program.command("login").description("Add a new OpenAI account via OAuth").action(async () => {
1563
+ try {
1564
+ if (!await runLogin()) {
1565
+ process.stderr.write("Login failed.\n");
1566
+ process.exit(1);
1567
+ }
1568
+ } catch (error) {
1569
+ exitWithCommandError(error);
1570
+ }
1571
+ });
1572
+ };
1573
+
1574
+ //#endregion
1575
+ //#region lib/commands/output.ts
1576
+ const formatCodexMark = (result) => {
1577
+ if (result.codexWritten) return "✓";
1578
+ if (result.codexCleared) return "⚠ missing id_token (cleared)";
1579
+ return "⚠ missing id_token";
1580
+ };
1581
+ const writeSwitchSummary = (displayName, result) => {
1582
+ const piMark = result.piWritten ? "✓" : "✗";
1583
+ const codexMark = formatCodexMark(result);
1584
+ process.stdout.write(`Switched to account ${displayName}\n`);
1585
+ process.stdout.write(" OpenCode: ✓\n");
1586
+ process.stdout.write(` Pi Agent: ${piMark}\n`);
1587
+ process.stdout.write(` Codex CLI: ${codexMark}\n`);
1588
+ };
1589
+ const writeUpdatedAuthSummary = (result) => {
1590
+ const piMark = result.piWritten ? "✓" : "✗";
1591
+ const codexMark = formatCodexMark(result);
1592
+ process.stdout.write("Updated active auth files:\n");
1593
+ process.stdout.write(" OpenCode: ✓\n");
1594
+ process.stdout.write(` Pi Agent: ${piMark}\n`);
1595
+ process.stdout.write(` Codex CLI: ${codexMark}\n`);
1596
+ };
1597
+
1598
+ //#endregion
1599
+ //#region lib/commands/refresh.ts
1600
+ const registerReloginCommand = (program) => {
1601
+ 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) => {
1602
+ try {
1603
+ if (account) {
1604
+ const target = (await loadConfig()).accounts.find((entry) => entry.accountId === account || entry.label === account);
1605
+ if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1606
+ const displayName = target.label ?? target.accountId;
1607
+ let expiryState = "unknown";
1608
+ let secureStoreState = "";
1609
+ const secretStore = getSecretStoreAdapter();
1610
+ if (await secretStore.exists(target.accountId)) try {
1611
+ expiryState = formatExpiry((await secretStore.load(target.accountId)).expires);
1612
+ } catch {
1613
+ expiryState = "unknown";
1614
+ }
1615
+ else secureStoreState = " [no secure store entry]";
1616
+ process.stdout.write(`Current token status for ${displayName}: ${expiryState}${secureStoreState}\n`);
1617
+ const result = await performRefresh(target.accountId, target.label);
1618
+ if (!result) {
1619
+ process.stderr.write("Re-login failed.\n");
1620
+ process.exit(1);
1621
+ }
1622
+ const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
1623
+ if (authResult) writeUpdatedAuthSummary(authResult);
1624
+ return;
1625
+ }
1626
+ await handleReloginAccount();
1627
+ } catch (error) {
1628
+ exitWithCommandError(error);
1629
+ }
1630
+ });
1631
+ };
1632
+
1005
1633
  //#endregion
1006
1634
  //#region lib/usage.ts
1007
1635
  const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
@@ -1022,9 +1650,10 @@ const fetchUsageRaw = async (accessToken, accountId) => {
1022
1650
  * Fetches usage for an account. On 401, refreshes the token and retries once.
1023
1651
  */
1024
1652
  const fetchUsage = async (accountId) => {
1653
+ const secretStore = getSecretStoreAdapter();
1025
1654
  let payload;
1026
1655
  try {
1027
- payload = loadKeychainPayload(accountId);
1656
+ payload = await secretStore.load(accountId);
1028
1657
  } catch (err) {
1029
1658
  return {
1030
1659
  ok: false,
@@ -1052,7 +1681,7 @@ const fetchUsage = async (accountId) => {
1052
1681
  expires: refreshResult.expires,
1053
1682
  idToken: refreshResult.idToken ?? payload.idToken
1054
1683
  };
1055
- saveKeychainPayload(accountId, updatedPayload);
1684
+ await secretStore.save(accountId, updatedPayload);
1056
1685
  response = await fetchUsageRaw(updatedPayload.access, updatedPayload.accountId);
1057
1686
  if (!response.ok) return {
1058
1687
  ok: false,
@@ -1159,113 +1788,9 @@ const formatUsageOverview = (entries) => {
1159
1788
  };
1160
1789
 
1161
1790
  //#endregion
1162
- //#region cdx.ts
1163
- const switchNext = async () => {
1164
- const config = await loadConfig();
1165
- const nextIndex = (config.current + 1) % config.accounts.length;
1166
- const nextAccount = config.accounts[nextIndex];
1167
- if (!nextAccount?.accountId) throw new Error("Account entry missing accountId.");
1168
- const payload = loadKeychainPayload(nextAccount.accountId);
1169
- const result = await writeAllAuthFiles(payload);
1170
- config.current = nextIndex;
1171
- await saveConfig(config);
1172
- const displayName = nextAccount.label ?? payload.accountId;
1173
- const opencodeMark = "✓";
1174
- const piMark = result.piWritten ? "✓" : "✗";
1175
- const codexMark = result.codexWritten ? "✓" : result.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
1176
- process.stdout.write(`Switched to account ${displayName}\n`);
1177
- process.stdout.write(` OpenCode: ${opencodeMark}\n`);
1178
- process.stdout.write(` Pi Agent: ${piMark}\n`);
1179
- process.stdout.write(` Codex CLI: ${codexMark}\n`);
1180
- };
1181
- const switchToAccount = async (identifier) => {
1182
- const config = await loadConfig();
1183
- const index = config.accounts.findIndex((a) => a.accountId === identifier || a.label === identifier);
1184
- if (index === -1) throw new Error(`Account "${identifier}" not found. Use 'cdx login' to add it.`);
1185
- const account = config.accounts[index];
1186
- const result = await writeAllAuthFiles(loadKeychainPayload(account.accountId));
1187
- config.current = index;
1188
- await saveConfig(config);
1189
- const displayName = account.label ?? account.accountId;
1190
- const opencodeMark = "✓";
1191
- const piMark = result.piWritten ? "✓" : "✗";
1192
- const codexMark = result.codexWritten ? "✓" : result.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
1193
- process.stdout.write(`Switched to account ${displayName}\n`);
1194
- process.stdout.write(` OpenCode: ${opencodeMark}\n`);
1195
- process.stdout.write(` Pi Agent: ${piMark}\n`);
1196
- process.stdout.write(` Codex CLI: ${codexMark}\n`);
1197
- };
1198
- const interactiveMode = runInteractiveMode;
1199
- const createProgram = (deps = {}) => {
1200
- const program = new Command();
1201
- const runLogin = deps.performLogin ?? performLogin;
1202
- program.name("cdx").description("OpenAI account switcher - manage multiple OpenAI Pro subscriptions").version(version, "-v, --version");
1203
- program.command("login").description("Add a new OpenAI account via OAuth").action(async () => {
1204
- try {
1205
- if (!await runLogin()) {
1206
- process.stderr.write("Login failed.\n");
1207
- process.exit(1);
1208
- }
1209
- } catch (error) {
1210
- const message = error instanceof Error ? error.message : String(error);
1211
- process.stderr.write(`${message}\n`);
1212
- process.exit(1);
1213
- }
1214
- });
1215
- program.command("refresh").description("Re-authenticate an existing account (update tokens without creating a duplicate)").argument("[account]", "Account ID or label to refresh").action(async (account) => {
1216
- try {
1217
- if (account) {
1218
- const target = (await loadConfig()).accounts.find((a) => a.accountId === account || a.label === account);
1219
- if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1220
- const result = await performRefresh(target.accountId, target.label);
1221
- if (!result) {
1222
- process.stderr.write("Refresh failed.\n");
1223
- process.exit(1);
1224
- }
1225
- const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
1226
- if (authResult) {
1227
- const piMark = authResult.piWritten ? "✓" : "✗";
1228
- const codexMark = authResult.codexWritten ? "✓" : authResult.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
1229
- process.stdout.write("Updated active auth files:\n");
1230
- process.stdout.write(" OpenCode: ✓\n");
1231
- process.stdout.write(` Pi Agent: ${piMark}\n`);
1232
- process.stdout.write(` Codex CLI: ${codexMark}\n`);
1233
- }
1234
- } else await handleRefreshAccount();
1235
- } catch (error) {
1236
- const message = error instanceof Error ? error.message : String(error);
1237
- process.stderr.write(`${message}\n`);
1238
- process.exit(1);
1239
- }
1240
- });
1241
- program.command("switch").description("Switch OpenAI account (interactive picker, by name, or --next)").argument("[account-id]", "Account ID to switch to directly").option("-n, --next", "Cycle to the next configured account").action(async (accountId, options) => {
1242
- try {
1243
- if (options.next) await switchNext();
1244
- else if (accountId) await switchToAccount(accountId);
1245
- else await handleSwitchAccount();
1246
- } catch (error) {
1247
- const message = error instanceof Error ? error.message : String(error);
1248
- process.stderr.write(`${message}\n`);
1249
- process.exit(1);
1250
- }
1251
- });
1252
- program.command("label").description("Add or change label for an account").argument("[account]", "Account ID or current label to relabel").argument("[new-label]", "New label to assign").action(async (account, newLabel) => {
1253
- try {
1254
- if (account && newLabel) {
1255
- const config = await loadConfig();
1256
- const target = config.accounts.find((a) => a.accountId === account || a.label === account);
1257
- if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1258
- target.label = newLabel;
1259
- await saveConfig(config);
1260
- process.stdout.write(`Account ${target.accountId} labeled as "${newLabel}".\n`);
1261
- } else await handleLabelAccount();
1262
- } catch (error) {
1263
- const message = error instanceof Error ? error.message : String(error);
1264
- process.stderr.write(`${message}\n`);
1265
- process.exit(1);
1266
- }
1267
- });
1268
- program.command("status").description("Show account status, token expiry, and auth file state").action(async () => {
1791
+ //#region lib/commands/status.ts
1792
+ const registerStatusCommand = (program) => {
1793
+ program.command("status").description("Show account status, token expiry, and usage").action(async () => {
1269
1794
  try {
1270
1795
  const status = await getStatus();
1271
1796
  if (status.accounts.length === 0) {
@@ -1277,108 +1802,157 @@ const createProgram = (deps = {}) => {
1277
1802
  const account = status.accounts[i];
1278
1803
  const marker = account.isCurrent ? "→ " : " ";
1279
1804
  const warnings = [];
1280
- if (!account.keychainExists) warnings.push("[no keychain]");
1805
+ if (!account.secureStoreExists) warnings.push("[no secure store entry]");
1281
1806
  if (!account.hasIdToken) warnings.push("[no id_token]");
1282
1807
  const warnStr = warnings.length > 0 ? ` ${warnings.join(" ")}` : "";
1283
1808
  const displayName = account.label ?? account.accountId;
1284
1809
  process.stdout.write(`${marker}${displayName}${warnStr}\n`);
1285
1810
  if (account.label) process.stdout.write(` ${account.accountId}\n`);
1286
1811
  process.stdout.write(` ${account.expiresIn}\n`);
1287
- const usageResult = await fetchUsage(account.accountId);
1288
- if (usageResult.ok) {
1289
- const bars = formatUsageBars(usageResult.data);
1812
+ if (i < status.accounts.length - 1) process.stdout.write("\n");
1813
+ }
1814
+ const usageSpinner = p.spinner();
1815
+ const accountWord = status.accounts.length === 1 ? "account" : "accounts";
1816
+ usageSpinner.start(`Fetching usage for ${status.accounts.length} ${accountWord}...`);
1817
+ const usageResults = await Promise.allSettled(status.accounts.map((account) => fetchUsage(account.accountId)));
1818
+ const failedUsageCount = usageResults.filter((result) => result.status === "rejected" || result.status === "fulfilled" && !result.value.ok).length;
1819
+ if (failedUsageCount === 0) usageSpinner.stop("Usage loaded.");
1820
+ else {
1821
+ const failedWord = failedUsageCount === 1 ? "account" : "accounts";
1822
+ usageSpinner.stop(`Usage loaded (${failedUsageCount} ${failedWord} failed).`);
1823
+ }
1824
+ process.stdout.write("\nUsage:\n");
1825
+ for (let i = 0; i < status.accounts.length; i++) {
1826
+ const account = status.accounts[i];
1827
+ const marker = account.isCurrent ? "→ " : " ";
1828
+ const displayName = account.label ?? account.accountId;
1829
+ process.stdout.write(`${marker}${displayName}\n`);
1830
+ if (account.label) process.stdout.write(` ${account.accountId}\n`);
1831
+ const usageResult = usageResults[i];
1832
+ if (usageResult.status === "rejected") {
1833
+ const message = usageResult.reason instanceof Error ? usageResult.reason.message : "Fetch failed";
1834
+ process.stdout.write(` [usage unavailable] ${message}\n`);
1835
+ } else if (usageResult.value.ok) {
1836
+ const bars = formatUsageBars(usageResult.value.data);
1837
+ if (bars.length === 0) process.stdout.write(" Usage data unavailable\n");
1290
1838
  for (const bar of bars) process.stdout.write(`${bar}\n`);
1291
- }
1839
+ } else process.stdout.write(` [usage unavailable] ${usageResult.value.error.message}\n`);
1292
1840
  if (i < status.accounts.length - 1) process.stdout.write("\n");
1293
1841
  }
1294
- const resolveLabel = (id) => {
1295
- if (!id) return "unknown";
1296
- return status.accounts.find((a) => a.accountId === id)?.label ?? id;
1297
- };
1298
- process.stdout.write("\nAuth files:\n");
1299
- const ocStatus = status.opencodeAuth.exists ? `active: ${resolveLabel(status.opencodeAuth.accountId)}` : "not found";
1300
- process.stdout.write(` OpenCode: ${ocStatus}\n`);
1301
- const cxStatus = status.codexAuth.exists ? `active: ${resolveLabel(status.codexAuth.accountId)}` : "not found";
1302
- process.stdout.write(` Codex CLI: ${cxStatus}\n`);
1303
- const piStatus = status.piAuth.exists ? `active: ${resolveLabel(status.piAuth.accountId)}` : "not found";
1304
- process.stdout.write(` Pi Agent: ${piStatus}\n`);
1305
1842
  process.stdout.write("\n");
1306
1843
  } catch (error) {
1307
- const message = error instanceof Error ? error.message : String(error);
1308
- process.stderr.write(`${message}\n`);
1309
- process.exit(1);
1844
+ exitWithCommandError(error);
1310
1845
  }
1311
1846
  });
1847
+ };
1848
+
1849
+ //#endregion
1850
+ //#region lib/commands/switch.ts
1851
+ const switchNext = async () => {
1852
+ const config = await loadConfig();
1853
+ const nextIndex = (config.current + 1) % config.accounts.length;
1854
+ const nextAccount = config.accounts[nextIndex];
1855
+ if (!nextAccount?.accountId) throw new Error("Account entry missing accountId.");
1856
+ const payload = await getSecretStoreAdapter().load(nextAccount.accountId);
1857
+ const result = await writeAllAuthFiles(payload);
1858
+ config.current = nextIndex;
1859
+ await saveConfig(config);
1860
+ writeSwitchSummary(nextAccount.label ?? payload.accountId, result);
1861
+ };
1862
+ const switchToAccount = async (identifier) => {
1863
+ const config = await loadConfig();
1864
+ const index = config.accounts.findIndex((account) => account.accountId === identifier || account.label === identifier);
1865
+ if (index === -1) throw new Error(`Account "${identifier}" not found. Use 'cdx login' to add it.`);
1866
+ const account = config.accounts[index];
1867
+ const result = await writeAllAuthFiles(await getSecretStoreAdapter().load(account.accountId));
1868
+ config.current = index;
1869
+ await saveConfig(config);
1870
+ writeSwitchSummary(account.label ?? account.accountId, result);
1871
+ };
1872
+ const registerSwitchCommand = (program) => {
1873
+ program.command("switch").description("Switch OpenAI account (interactive picker, by name, or --next)").argument("[account-id]", "Account ID to switch to directly").option("-n, --next", "Cycle to the next configured account").action(async (accountId, options) => {
1874
+ try {
1875
+ if (options.next) await switchNext();
1876
+ else if (accountId) await switchToAccount(accountId);
1877
+ else await handleSwitchAccount();
1878
+ } catch (error) {
1879
+ exitWithCommandError(error);
1880
+ }
1881
+ });
1882
+ };
1883
+
1884
+ //#endregion
1885
+ //#region lib/commands/usage.ts
1886
+ const registerUsageCommand = (program) => {
1312
1887
  program.command("usage").description("Show OpenAI usage for all accounts (or detailed view for one)").argument("[account]", "Account ID or label (shows detailed single-account view)").action(async (account) => {
1313
1888
  try {
1314
1889
  const config = await loadConfig();
1315
1890
  if (account) {
1316
- const found = config.accounts.find((a) => a.accountId === account || a.label === account);
1891
+ const found = config.accounts.find((entry) => entry.accountId === account || entry.label === account);
1317
1892
  if (!found) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1318
1893
  const result = await fetchUsage(found.accountId);
1319
1894
  if (!result.ok) throw new Error(result.error.message);
1320
1895
  const displayName = found.label ? `${found.label} (${found.accountId})` : found.accountId;
1321
1896
  process.stdout.write(`\n${displayName}\n${formatUsage(result.data)}\n\n`);
1322
- } else {
1323
- if (config.accounts.length === 0) throw new Error("No accounts configured. Use 'cdx login' to add one.");
1324
- const results = await Promise.allSettled(config.accounts.map((a) => fetchUsage(a.accountId)));
1325
- const entries = config.accounts.map((a, i) => {
1326
- const settled = results[i];
1327
- const displayName = a.label ? `${a.label} (${a.accountId})` : a.accountId;
1328
- const result = settled.status === "fulfilled" ? settled.value : {
1329
- ok: false,
1330
- error: {
1331
- type: "network_error",
1332
- message: settled.reason?.message ?? "Fetch failed"
1333
- }
1334
- };
1335
- return {
1336
- displayName,
1337
- isCurrent: i === config.current,
1338
- result
1339
- };
1340
- });
1341
- process.stdout.write(`\n${formatUsageOverview(entries)}\n\n`);
1897
+ return;
1342
1898
  }
1899
+ if (config.accounts.length === 0) throw new Error("No accounts configured. Use 'cdx login' to add one.");
1900
+ const results = await Promise.allSettled(config.accounts.map((entry) => fetchUsage(entry.accountId)));
1901
+ const entries = config.accounts.map((entry, index) => {
1902
+ const settled = results[index];
1903
+ const displayName = entry.label ? `${entry.label} (${entry.accountId})` : entry.accountId;
1904
+ const result = settled.status === "fulfilled" ? settled.value : {
1905
+ ok: false,
1906
+ error: {
1907
+ type: "network_error",
1908
+ message: settled.reason?.message ?? "Fetch failed"
1909
+ }
1910
+ };
1911
+ return {
1912
+ displayName,
1913
+ isCurrent: index === config.current,
1914
+ result
1915
+ };
1916
+ });
1917
+ process.stdout.write(`\n${formatUsageOverview(entries)}\n\n`);
1343
1918
  } catch (error) {
1344
- const message = error instanceof Error ? error.message : String(error);
1345
- process.stderr.write(`${message}\n`);
1346
- process.exit(1);
1919
+ exitWithCommandError(error);
1347
1920
  }
1348
1921
  });
1349
- program.command("help").description("Show available commands and usage information").argument("[command]", "Show help for a specific command").action((commandName) => {
1350
- if (commandName) {
1351
- const cmd = program.commands.find((c) => c.name() === commandName);
1352
- if (cmd) cmd.outputHelp();
1353
- else {
1354
- process.stderr.write(`Unknown command: ${commandName}\n`);
1355
- program.outputHelp();
1356
- process.exit(1);
1357
- }
1358
- } else program.outputHelp();
1359
- });
1922
+ };
1923
+
1924
+ //#endregion
1925
+ //#region lib/commands/version.ts
1926
+ const registerVersionCommand = (program, version) => {
1360
1927
  program.command("version").description("Show CLI version").action(() => {
1361
1928
  process.stdout.write(`${version}\n`);
1362
1929
  });
1363
- program.action(async () => {
1364
- try {
1365
- await interactiveMode();
1366
- } catch (error) {
1367
- const message = error instanceof Error ? error.message : String(error);
1368
- process.stderr.write(`${message}\n`);
1369
- process.exit(1);
1370
- }
1371
- });
1930
+ };
1931
+
1932
+ //#endregion
1933
+ //#region cdx.ts
1934
+ const interactiveMode = runInteractiveMode;
1935
+ const createProgram = (deps = {}) => {
1936
+ const program = new Command();
1937
+ program.name("cdx").description("OpenAI account switcher - manage multiple OpenAI Pro subscriptions").version(version, "-v, --version");
1938
+ registerLoginCommand(program, deps);
1939
+ registerReloginCommand(program);
1940
+ registerSwitchCommand(program);
1941
+ registerLabelCommand(program);
1942
+ registerStatusCommand(program);
1943
+ registerDoctorCommand(program);
1944
+ registerUsageCommand(program);
1945
+ registerHelpCommand(program);
1946
+ registerVersionCommand(program, version);
1947
+ registerDefaultInteractiveAction(program);
1372
1948
  return program;
1373
1949
  };
1374
1950
  const main = async () => {
1375
1951
  await createProgram().parseAsync(process.argv);
1376
1952
  };
1377
1953
  if (import.meta.main) main().catch((error) => {
1378
- const message = error instanceof Error ? error.message : String(error);
1379
- process.stderr.write(`${message}\n`);
1380
- process.exit(1);
1954
+ exitWithCommandError(error);
1381
1955
  });
1382
1956
 
1383
1957
  //#endregion
1384
- export { createProgram, createTestPaths, getPaths, interactiveMode, loadConfig, resetPaths, runInteractiveMode, saveConfig, setPaths, switchNext, switchToAccount, writeAllAuthFiles, writeAuthFile, writeCodexAuthFile, writePiAuthFile };
1958
+ export { createProgram, createRuntimeSecretStoreAdapter, createTestPaths, getPaths, getSecretStoreAdapter, interactiveMode, loadConfig, resetPaths, resetSecretStoreAdapter, runInteractiveMode, saveConfig, setPaths, setSecretStoreAdapter, switchNext, switchToAccount, writeAllAuthFiles, writeAuthFile, writeCodexAuthFile, writePiAuthFile };