@bjesuiter/codex-switcher 1.3.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 +108 -48
  2. package/cdx.mjs +560 -84
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -6,20 +6,25 @@ Switch the coding-agents [pi](https://pi.dev/), [codex](https://developers.opena
6
6
 
7
7
  ## Latest Changes
8
8
 
9
- ### 1.3.0
9
+ ### 1.4.0
10
10
 
11
11
  #### Features
12
12
 
13
- - Rename `cdx refresh` command to `cdx relogin`
13
+ - Add **beta Windows/Linux support** with platform-specific defaults for config/auth paths.
14
+ - Add secure-store adapters via `cross-keychain` for Windows Credential Manager and Linux Secret Service/keyring.
15
+ - Add `cdx doctor` to display auth file state with explicit paths plus runtime capability diagnostics.
16
+ - Improve `cdx status` output flow: account/token details first, usage fetch with spinner after.
17
+ - Require explicit one-time consent before using secure-store fallback backends (`CDX_ALLOW_SECURE_STORE_FALLBACK=1` override).
14
18
 
15
19
  #### Fixes
16
20
 
17
- - Fix `cdx relogin` selector flow exiting early after account selection (now continues into OAuth browser login)
21
+ - Use platform-neutral secure-store wording in output where macOS-specific keychain wording was misleading.
18
22
 
19
23
  #### Internal
20
24
 
21
- - Modularize CLI command wiring by moving command handlers into per-command modules under `lib/commands/`, keeping `cdx.ts` as a thin composition entrypoint
22
- - Update package dependencies and lockfile (`@clack/prompts`, `commander`, `tsdown`, `@types/bun`, `@types/node`)
25
+ - Add shared platform abstraction layer (`lib/platform/*`) for paths, browser launcher, and runtime capability detection.
26
+ - Expand platform/path/browser/secret-store test coverage.
27
+ - Update dependencies and lockfile for `cross-keychain`.
23
28
 
24
29
  see full changelog here: https://github.com/bjesuiter/codex-switcher/blob/main/CHANGELOG.md
25
30
 
@@ -41,9 +46,15 @@ So: switching between two $20 plans is the poor man's $100 plan for OpenAI. ^^
41
46
 
42
47
  ## Requirements
43
48
 
44
- - macOS (uses Keychain via the `security` command)
49
+ - macOS (uses Keychain), Windows (uses Windows Credential Manager), **or** Linux (uses Secret Service/keyring)
45
50
  - [Bun](https://bun.sh) runtime
46
51
 
52
+ ## Platform Support Status
53
+
54
+ - **macOS:** stable
55
+ - **Windows:** beta
56
+ - **Linux:** beta
57
+
47
58
  ## Install
48
59
 
49
60
  ```bash
@@ -54,63 +65,89 @@ This exposes the `cdx` binary globally.
54
65
 
55
66
  ## Usage
56
67
 
57
- ### Add your first account
68
+ ### macOS (stable)
69
+
70
+ 1. Install [Bun](https://bun.sh)
71
+ 2. Install `cdx`
72
+ 3. Run and verify:
73
+ - `cdx login`
74
+ - `cdx status`
75
+ - `cdx switch`
76
+ - `cdx relogin <account-id-or-label>`
77
+ 4. Confirm auth files are written correctly after switching:
78
+ - `~/.local/share/opencode/auth.json` (or `$XDG_DATA_HOME/opencode/auth.json`)
79
+ - `~/.codex/auth.json`
80
+ - `~/.pi/agent/auth.json` (or `$PI_CODING_AGENT_DIR/auth.json`)
81
+ 5. Credentials are stored in macOS Keychain.
82
+
83
+ ### Windows (beta)
84
+
85
+ Windows support is **test-ready** and suitable for friend/beta testing, but is not yet production-proven by broad real-world testing.
86
+
87
+ 1. Install [Bun](https://bun.sh)
88
+ 2. Install `cdx`
89
+ 3. Run and verify:
90
+ - `cdx login`
91
+ - `cdx status`
92
+ - `cdx switch`
93
+ - `cdx relogin <account-id-or-label>`
94
+ 4. Confirm auth files are written correctly after switching:
95
+ - `%LOCALAPPDATA%\\opencode\\auth.json`
96
+ - `%USERPROFILE%\\.codex\\auth.json`
97
+ - `%USERPROFILE%\\.pi\\agent\\auth.json` (or `%PI_CODING_AGENT_DIR%\\auth.json`)
98
+ 5. If prompted about secure-store fallback, explicitly choose whether to allow it for testing.
99
+ - Non-interactive override (if you accept the risk): `CDX_ALLOW_SECURE_STORE_FALLBACK=1`
100
+
101
+ ### Linux (beta)
102
+
103
+ Linux support is **test-ready** and suitable for friend/beta testing, but is not yet production-proven by broad real-world testing.
104
+
105
+ 1. Install [Bun](https://bun.sh)
106
+ 2. Ensure a Secret Service backend is available (for example GNOME Keyring with `secret-tool`)
107
+ 3. Install `cdx`
108
+ 4. Run and verify:
109
+ - `cdx login`
110
+ - `cdx status`
111
+ - `cdx switch`
112
+ - `cdx relogin <account-id-or-label>`
113
+ 5. Confirm auth files are written correctly after switching:
114
+ - `~/.local/share/opencode/auth.json` (or `$XDG_DATA_HOME/opencode/auth.json`)
115
+ - `~/.codex/auth.json`
116
+ - `~/.pi/agent/auth.json` (or `$PI_CODING_AGENT_DIR/auth.json`)
117
+ 6. If prompted about secure-store fallback, explicitly choose whether to allow it for testing.
118
+ - Non-interactive override (if you accept the risk): `CDX_ALLOW_SECURE_STORE_FALLBACK=1`
119
+
120
+ Please report the full command output and platform info (`cdx status`) for any failures.
121
+
122
+ ### Common command examples (all platforms)
123
+
124
+ Add your first account:
58
125
 
59
126
  ```bash
60
127
  cdx login
61
128
  ```
62
129
 
63
- Opens your browser to authenticate with OpenAI. After successful login, your credentials are stored securely in macOS Keychain.
64
-
65
- ### Switch between accounts
130
+ Switch between accounts:
66
131
 
67
132
  ```bash
68
133
  cdx switch
69
- ```
70
-
71
- Interactive picker to select an account. Writes credentials to:
72
- - `~/.local/share/opencode/auth.json` (OpenCode)
73
- - `~/.pi/agent/auth.json` (Pi agent, or `$PI_CODING_AGENT_DIR/auth.json` when `PI_CODING_AGENT_DIR` is set)
74
- - `~/.codex/auth.json` (Codex CLI; requires `id_token`)
75
-
76
- ```bash
77
134
  cdx switch --next
78
- ```
79
-
80
- Cycles to the next configured account without prompting.
81
-
82
- ```bash
83
135
  cdx switch <account-id-or-label>
84
136
  ```
85
137
 
86
- Switch directly to a specific account by ID or label.
87
-
88
- ### Label accounts
138
+ Label accounts:
89
139
 
90
140
  ```bash
91
141
  cdx label
92
- ```
93
-
94
- Interactive prompt to assign a friendly name to an account.
95
-
96
- ```bash
97
142
  cdx label <account> <new-label>
98
143
  ```
99
144
 
100
- Assign a label directly.
101
-
102
- ### Interactive mode
145
+ Interactive mode:
103
146
 
104
147
  ```bash
105
148
  cdx
106
149
  ```
107
150
 
108
- Running `cdx` without arguments opens an interactive menu to:
109
- - List all configured accounts
110
- - Switch to a different account
111
- - Add a new account (OAuth login)
112
- - Remove an account
113
-
114
151
  ## Commands
115
152
 
116
153
  | Command | Description |
@@ -124,7 +161,8 @@ Running `cdx` without arguments opens an interactive menu to:
124
161
  | `cdx switch <id>` | Switch to specific account |
125
162
  | `cdx label` | Label an account (interactive) |
126
163
  | `cdx label <account> <label>` | Assign label directly |
127
- | `cdx status` | Show account status, token expiry, usage, and auth file state |
164
+ | `cdx status` | Show account status, token expiry, and usage |
165
+ | `cdx doctor` | Show auth file paths/state and runtime capabilities |
128
166
  | `cdx usage` | Show usage overview for all accounts |
129
167
  | `cdx usage <account>` | Show detailed usage for a specific account |
130
168
  | `cdx help [command]` | Show help for all commands or one command |
@@ -134,12 +172,34 @@ Running `cdx` without arguments opens an interactive menu to:
134
172
 
135
173
  ## How It Works
136
174
 
137
- - OAuth credentials are stored securely in macOS Keychain
138
- - Account list is stored in `~/.config/cdx/accounts.json`
139
- - Active account credentials are written to:
140
- - `~/.local/share/opencode/auth.json`
141
- - `~/.pi/agent/auth.json` (or `$PI_CODING_AGENT_DIR/auth.json`)
142
- - `~/.codex/auth.json` (when `id_token` exists)
175
+ ### Secure credential storage
176
+
177
+ - **macOS:** macOS Keychain
178
+ - **Windows:** Windows Credential Manager
179
+ - **Linux:** Secret Service/keyring
180
+ - If only a fallback secure-store backend is available on your platform, `cdx` asks for one-time explicit consent before the first credential write and explains the security trade-off.
181
+ - Non-interactive override (if you accept the risk): set `CDX_ALLOW_SECURE_STORE_FALLBACK=1`
182
+
183
+ ### Account list path
184
+
185
+ - **macOS/Linux:** `~/.config/cdx/accounts.json` (or `$XDG_CONFIG_HOME/cdx/accounts.json`)
186
+ - **Windows:** `%APPDATA%\\cdx\\accounts.json`
187
+
188
+ ### Auth file paths
189
+
190
+ #### macOS / Linux
191
+
192
+ - **OpenCode:** `~/.local/share/opencode/auth.json` (or `$XDG_DATA_HOME/opencode/auth.json`)
193
+ - **Codex CLI:** `~/.codex/auth.json`
194
+ - **Pi Agent:** `~/.pi/agent/auth.json` (or `$PI_CODING_AGENT_DIR/auth.json`)
195
+
196
+ #### Windows
197
+
198
+ - **OpenCode:** `%LOCALAPPDATA%\\opencode\\auth.json`
199
+ - **Codex CLI:** `%USERPROFILE%\\.codex\\auth.json`
200
+ - **Pi Agent:** `%USERPROFILE%\\.pi\\agent\\auth.json` (or `%PI_CODING_AGENT_DIR%\\auth.json`)
201
+
202
+ `cdx` writes Codex CLI auth only when `id_token` exists.
143
203
 
144
204
  ## For Developers
145
205
 
@@ -162,7 +222,7 @@ bun link
162
222
 
163
223
  ### Manual Configuration (Advanced)
164
224
 
165
- You can also manually add accounts to Keychain:
225
+ You can also manually add accounts to Keychain (macOS only):
166
226
 
167
227
  ```bash
168
228
  security add-generic-password -a "ACCOUNT_ID" -s "cdx-openai-ACCOUNT_ID" -w '{"refresh":"REFRESH","access":"ACCESS","expires":1234567890,"accountId":"ACCOUNT_ID"}' -U
package/cdx.mjs CHANGED
@@ -6,12 +6,14 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import os from "node:os";
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.3.0";
16
+ var version = "1.4.0";
15
17
 
16
18
  //#endregion
17
19
  //#region lib/commands/errors.ts
@@ -21,23 +23,79 @@ const exitWithCommandError = (error) => {
21
23
  process.exit(1);
22
24
  };
23
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
+ };
70
+
24
71
  //#endregion
25
72
  //#region lib/paths.ts
26
- const defaultConfigDir = path.join(os.homedir(), ".config", "cdx");
27
- const resolvePiAuthPath = () => {
28
- const piAgentDir = process.env.PI_CODING_AGENT_DIR?.trim();
29
- if (piAgentDir) return path.join(piAgentDir, "auth.json");
30
- return path.join(os.homedir(), ".pi", "agent", "auth.json");
31
- };
32
- const createDefaultPaths = () => ({
33
- configDir: defaultConfigDir,
34
- configPath: path.join(defaultConfigDir, "accounts.json"),
35
- authPath: path.join(os.homedir(), ".local", "share", "opencode", "auth.json"),
36
- codexAuthPath: path.join(os.homedir(), ".codex", "auth.json"),
37
- 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
38
79
  });
39
- 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;
40
97
  const getPaths = () => currentPaths;
98
+ const getPathResolutionInfo = () => currentResolution;
41
99
  const setPaths = (paths) => {
42
100
  currentPaths = {
43
101
  ...currentPaths,
@@ -46,7 +104,9 @@ const setPaths = (paths) => {
46
104
  if (paths.configDir && !paths.configPath) currentPaths.configPath = path.join(paths.configDir, "accounts.json");
47
105
  };
48
106
  const resetPaths = () => {
49
- currentPaths = createDefaultPaths();
107
+ const next = createDefaultPaths();
108
+ currentPaths = next.paths;
109
+ currentResolution = next.resolution;
50
110
  };
51
111
  const createTestPaths = (testDir) => ({
52
112
  configDir: path.join(testDir, "config"),
@@ -157,11 +217,71 @@ const configExists = () => {
157
217
  return existsSync(configPath);
158
218
  };
159
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
+
160
280
  //#endregion
161
281
  //#region lib/keychain.ts
162
- const SERVICE_PREFIX = "cdx-openai-";
282
+ const SERVICE_PREFIX$2 = "cdx-openai-";
163
283
  const getKeychainService = (accountId) => {
164
- return `${SERVICE_PREFIX}${accountId}`;
284
+ return `${SERVICE_PREFIX$2}${accountId}`;
165
285
  };
166
286
  const runSecurity = (args) => {
167
287
  const result = Bun.spawnSync({
@@ -233,12 +353,300 @@ const listKeychainAccounts = () => {
233
353
  if (result.exitCode !== 0) return [];
234
354
  const output = result.stdout.toString();
235
355
  const accounts = [];
236
- const serviceRegex = new RegExp(`"svce"<blob>="${SERVICE_PREFIX}([^"]+)"`, "g");
356
+ const serviceRegex = new RegExp(`"svce"<blob>="${SERVICE_PREFIX$2}([^"]+)"`, "g");
237
357
  let match;
238
358
  while ((match = serviceRegex.exec(output)) !== null) if (match[1]) accounts.push(match[1]);
239
359
  return [...new Set(accounts)];
240
360
  };
241
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
+
242
650
  //#endregion
243
651
  //#region lib/oauth/constants.ts
244
652
  const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
@@ -437,31 +845,27 @@ const startOAuthServer = (state) => {
437
845
  //#endregion
438
846
  //#region lib/oauth/login.ts
439
847
  const openBrowser = (url) => {
440
- const cmd = process.platform === "darwin" ? "open" : "xdg-open";
441
- try {
442
- spawn(cmd, [url], {
443
- detached: true,
444
- stdio: "ignore"
445
- }).unref();
446
- } catch (error) {
447
- const msg = error instanceof Error ? error.message : String(error);
448
- p.log.warning(`Could not auto-open browser (${msg}).`);
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}).`);
449
852
  }
450
853
  };
451
854
  const addAccountToConfig = async (accountId, label) => {
452
855
  let config;
856
+ const secretStore = getSecretStoreAdapter();
453
857
  if (configExists()) {
454
858
  config = await loadConfig();
455
859
  if (!config.accounts.some((a) => a.accountId === accountId)) config.accounts.push({
456
860
  accountId,
457
- keychainService: getKeychainService(accountId),
861
+ keychainService: secretStore.getServiceName(accountId),
458
862
  ...label ? { label } : {}
459
863
  });
460
864
  } else config = {
461
865
  current: 0,
462
866
  accounts: [{
463
867
  accountId,
464
- keychainService: getKeychainService(accountId),
868
+ keychainService: secretStore.getServiceName(accountId),
465
869
  ...label ? { label } : {}
466
870
  }]
467
871
  };
@@ -521,16 +925,17 @@ const performRefresh = async (targetAccountId, label, options = {}) => {
521
925
  }
522
926
  if (spinner) spinner.message("Updating credentials...");
523
927
  else p.log.message("Updating credentials...");
524
- saveKeychainPayload(newAccountId, {
928
+ const payload = {
525
929
  refresh: tokenResult.refresh,
526
930
  access: tokenResult.access,
527
931
  expires: tokenResult.expires,
528
932
  accountId: newAccountId,
529
933
  ...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
530
- });
934
+ };
935
+ await getSecretStoreAdapter().save(newAccountId, payload);
531
936
  if (spinner) spinner.stop("Credentials refreshed!");
532
937
  else p.log.success("Credentials refreshed!");
533
- p.log.success(`Account "${displayName}" credentials updated in Keychain.`);
938
+ p.log.success(`Account "${displayName}" credentials updated in secure store.`);
534
939
  return { accountId: newAccountId };
535
940
  } finally {
536
941
  clearInterval(keepAlive);
@@ -568,13 +973,14 @@ const performLogin = async () => {
568
973
  return null;
569
974
  }
570
975
  spinner.message("Saving credentials...");
571
- saveKeychainPayload(accountId, {
976
+ const payload = {
572
977
  refresh: tokenResult.refresh,
573
978
  access: tokenResult.access,
574
979
  expires: tokenResult.expires,
575
980
  accountId,
576
981
  ...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
577
- });
982
+ };
983
+ await getSecretStoreAdapter().save(accountId, payload);
578
984
  spinner.stop("Login successful!");
579
985
  const labelInput = await p.text({
580
986
  message: "Enter a label for this account (or press Enter to skip):",
@@ -583,7 +989,7 @@ const performLogin = async () => {
583
989
  const label = !p.isCancel(labelInput) && labelInput?.trim() ? labelInput.trim() : void 0;
584
990
  await addAccountToConfig(accountId, label);
585
991
  const displayName = label ?? accountId;
586
- p.log.success(`Account "${displayName}" saved to Keychain and config.`);
992
+ p.log.success(`Account "${displayName}" saved to secure store and config.`);
587
993
  p.outro("You can now use 'cdx switch' to activate this account.");
588
994
  return { accountId };
589
995
  };
@@ -595,7 +1001,19 @@ const writeActiveAuthFilesIfCurrent = async (accountId) => {
595
1001
  const config = await loadConfig();
596
1002
  const current = config.accounts[config.current];
597
1003
  if (!current || current.accountId !== accountId) return null;
598
- 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
+ };
599
1017
  };
600
1018
 
601
1019
  //#endregion
@@ -680,12 +1098,13 @@ const readPiAuthAccount = async () => {
680
1098
  };
681
1099
  }
682
1100
  };
683
- const getAccountStatus = (accountId, isCurrent, label) => {
684
- const keychainExists = keychainPayloadExists(accountId);
1101
+ const getAccountStatus = async (accountId, isCurrent, label) => {
1102
+ const secretStore = getSecretStoreAdapter();
1103
+ const secureStoreExists = await secretStore.exists(accountId);
685
1104
  let expiresAt = null;
686
1105
  let hasIdToken = false;
687
- if (keychainExists) try {
688
- const payload = loadKeychainPayload(accountId);
1106
+ if (secureStoreExists) try {
1107
+ const payload = await secretStore.load(accountId);
689
1108
  expiresAt = payload.expires;
690
1109
  hasIdToken = !!payload.idToken;
691
1110
  } catch {}
@@ -693,7 +1112,7 @@ const getAccountStatus = (accountId, isCurrent, label) => {
693
1112
  accountId,
694
1113
  label,
695
1114
  isCurrent,
696
- keychainExists,
1115
+ secureStoreExists,
697
1116
  hasIdToken,
698
1117
  expiresAt,
699
1118
  expiresIn: formatExpiry(expiresAt)
@@ -705,7 +1124,7 @@ const getStatus = async () => {
705
1124
  const config = await loadConfig();
706
1125
  for (let i = 0; i < config.accounts.length; i++) {
707
1126
  const account = config.accounts[i];
708
- accounts.push(getAccountStatus(account.accountId, i === config.current, account.label));
1127
+ accounts.push(await getAccountStatus(account.accountId, i === config.current, account.label));
709
1128
  }
710
1129
  }
711
1130
  const [opencodeAuth, codexAuth, piAuth] = await Promise.all([
@@ -717,7 +1136,8 @@ const getStatus = async () => {
717
1136
  accounts,
718
1137
  opencodeAuth,
719
1138
  codexAuth,
720
- piAuth
1139
+ piAuth,
1140
+ capabilities: getRuntimeCapabilities()
721
1141
  };
722
1142
  };
723
1143
 
@@ -727,10 +1147,16 @@ const getAccountDisplay = (accountId, isCurrent, label) => {
727
1147
  const name = label ? `${label} (${accountId})` : accountId;
728
1148
  return isCurrent ? `${name} (current)` : name;
729
1149
  };
730
- const getRefreshExpiryState = (accountId) => {
731
- if (!keychainPayloadExists(accountId)) return "unknown [no keychain]";
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]";
732
1158
  try {
733
- return formatExpiry(loadKeychainPayload(accountId).expires);
1159
+ return formatExpiry((await loadStoredCredentials(accountId)).expires);
734
1160
  } catch {
735
1161
  return "unknown";
736
1162
  }
@@ -746,7 +1172,7 @@ const handleListAccounts = async () => {
746
1172
  for (const account of config.accounts) {
747
1173
  const marker = account.accountId === currentAccountId ? "→ " : " ";
748
1174
  const displayName = account.label ? `${account.label} (${account.accountId})` : account.accountId;
749
- const status = keychainPayloadExists(account.accountId) ? "" : " (missing credentials)";
1175
+ const status = await hasStoredCredentials(account.accountId) ? "" : " (missing credentials)";
750
1176
  p.log.message(`${marker}${displayName}${status}`);
751
1177
  }
752
1178
  };
@@ -784,7 +1210,7 @@ const handleSwitchAccount = async () => {
784
1210
  }
785
1211
  let payload;
786
1212
  try {
787
- payload = loadKeychainPayload(selectedAccount.accountId);
1213
+ payload = await loadStoredCredentials(selectedAccount.accountId);
788
1214
  } catch {
789
1215
  p.log.error(`Missing credentials for account ${selectedAccount.label ?? selectedAccount.accountId}. Re-login with 'cdx login'.`);
790
1216
  return;
@@ -815,10 +1241,10 @@ const handleReloginAccount = async () => {
815
1241
  return;
816
1242
  }
817
1243
  const currentAccountId = config.accounts[config.current]?.accountId;
818
- const options = config.accounts.map((account) => ({
1244
+ const options = await Promise.all(config.accounts.map(async (account) => ({
819
1245
  value: account.accountId,
820
- label: `${getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)} — ${getRefreshExpiryState(account.accountId)}`
821
- }));
1246
+ label: `${getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)} — ${await getRefreshExpiryState(account.accountId)}`
1247
+ })));
822
1248
  const selected = await p.select({
823
1249
  message: "Select account to re-login:",
824
1250
  options
@@ -829,7 +1255,7 @@ const handleReloginAccount = async () => {
829
1255
  }
830
1256
  const accountId = selected;
831
1257
  const account = config.accounts.find((a) => a.accountId === accountId);
832
- const expiryState = getRefreshExpiryState(accountId);
1258
+ const expiryState = await getRefreshExpiryState(accountId);
833
1259
  const displayName = account?.label ?? accountId;
834
1260
  p.log.info(`Current token status for ${displayName}: ${expiryState}`);
835
1261
  try {
@@ -884,7 +1310,7 @@ const handleRemoveAccount = async () => {
884
1310
  return;
885
1311
  }
886
1312
  try {
887
- deleteKeychainPayload(accountId);
1313
+ await removeStoredCredentials(accountId);
888
1314
  } catch {}
889
1315
  const previousAccountId = config.accounts[config.current]?.accountId;
890
1316
  config.accounts = config.accounts.filter((a) => a.accountId !== accountId);
@@ -948,9 +1374,9 @@ const handleStatus = async () => {
948
1374
  for (const account of status.accounts) {
949
1375
  const marker = account.isCurrent ? "→ " : " ";
950
1376
  const name = account.label ? `${account.label} (${account.accountId})` : account.accountId;
951
- const keychain = account.keychainExists ? "" : " [no keychain]";
1377
+ const secureStore = account.secureStoreExists ? "" : " [no secure store entry]";
952
1378
  const idToken = account.hasIdToken ? "" : " [no id_token]";
953
- p.log.message(`${marker}${name} — ${account.expiresIn}${keychain}${idToken}`);
1379
+ p.log.message(`${marker}${name} — ${account.expiresIn}${secureStore}${idToken}`);
954
1380
  }
955
1381
  const ocStatus = status.opencodeAuth.exists ? `active: ${status.opencodeAuth.accountId ?? "unknown"}` : "not found";
956
1382
  const cxStatus = status.codexAuth.exists ? `active: ${status.codexAuth.accountId ?? "unknown"}` : "not found";
@@ -964,7 +1390,7 @@ const runInteractiveMode = async () => {
964
1390
  p.intro("cdx - OpenAI Account Switcher");
965
1391
  let running = true;
966
1392
  while (running) {
967
- const keychainAccounts = listKeychainAccounts();
1393
+ const storedAccounts = await getStoredAccountIds();
968
1394
  let currentInfo = "";
969
1395
  if (configExists()) try {
970
1396
  const config = await loadConfig();
@@ -976,7 +1402,7 @@ const runInteractiveMode = async () => {
976
1402
  options: [
977
1403
  {
978
1404
  value: "list",
979
- label: `List accounts (${keychainAccounts.length} in Keychain)`
1405
+ label: `List accounts (${storedAccounts.length} in secure store)`
980
1406
  },
981
1407
  {
982
1408
  value: "switch",
@@ -1055,6 +1481,41 @@ const registerDefaultInteractiveAction = (program) => {
1055
1481
  });
1056
1482
  };
1057
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
+
1058
1519
  //#endregion
1059
1520
  //#region lib/commands/help.ts
1060
1521
  const registerHelpCommand = (program) => {
@@ -1144,14 +1605,15 @@ const registerReloginCommand = (program) => {
1144
1605
  if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1145
1606
  const displayName = target.label ?? target.accountId;
1146
1607
  let expiryState = "unknown";
1147
- let keychainState = "";
1148
- if (keychainPayloadExists(target.accountId)) try {
1149
- expiryState = formatExpiry(loadKeychainPayload(target.accountId).expires);
1608
+ let secureStoreState = "";
1609
+ const secretStore = getSecretStoreAdapter();
1610
+ if (await secretStore.exists(target.accountId)) try {
1611
+ expiryState = formatExpiry((await secretStore.load(target.accountId)).expires);
1150
1612
  } catch {
1151
1613
  expiryState = "unknown";
1152
1614
  }
1153
- else keychainState = " [no keychain]";
1154
- process.stdout.write(`Current token status for ${displayName}: ${expiryState}${keychainState}\n`);
1615
+ else secureStoreState = " [no secure store entry]";
1616
+ process.stdout.write(`Current token status for ${displayName}: ${expiryState}${secureStoreState}\n`);
1155
1617
  const result = await performRefresh(target.accountId, target.label);
1156
1618
  if (!result) {
1157
1619
  process.stderr.write("Re-login failed.\n");
@@ -1188,9 +1650,10 @@ const fetchUsageRaw = async (accessToken, accountId) => {
1188
1650
  * Fetches usage for an account. On 401, refreshes the token and retries once.
1189
1651
  */
1190
1652
  const fetchUsage = async (accountId) => {
1653
+ const secretStore = getSecretStoreAdapter();
1191
1654
  let payload;
1192
1655
  try {
1193
- payload = loadKeychainPayload(accountId);
1656
+ payload = await secretStore.load(accountId);
1194
1657
  } catch (err) {
1195
1658
  return {
1196
1659
  ok: false,
@@ -1218,7 +1681,7 @@ const fetchUsage = async (accountId) => {
1218
1681
  expires: refreshResult.expires,
1219
1682
  idToken: refreshResult.idToken ?? payload.idToken
1220
1683
  };
1221
- saveKeychainPayload(accountId, updatedPayload);
1684
+ await secretStore.save(accountId, updatedPayload);
1222
1685
  response = await fetchUsageRaw(updatedPayload.access, updatedPayload.accountId);
1223
1686
  if (!response.ok) return {
1224
1687
  ok: false,
@@ -1327,7 +1790,7 @@ const formatUsageOverview = (entries) => {
1327
1790
  //#endregion
1328
1791
  //#region lib/commands/status.ts
1329
1792
  const registerStatusCommand = (program) => {
1330
- program.command("status").description("Show account status, token expiry, usage, and auth file state").action(async () => {
1793
+ program.command("status").description("Show account status, token expiry, and usage").action(async () => {
1331
1794
  try {
1332
1795
  const status = await getStatus();
1333
1796
  if (status.accounts.length === 0) {
@@ -1339,31 +1802,43 @@ const registerStatusCommand = (program) => {
1339
1802
  const account = status.accounts[i];
1340
1803
  const marker = account.isCurrent ? "→ " : " ";
1341
1804
  const warnings = [];
1342
- if (!account.keychainExists) warnings.push("[no keychain]");
1805
+ if (!account.secureStoreExists) warnings.push("[no secure store entry]");
1343
1806
  if (!account.hasIdToken) warnings.push("[no id_token]");
1344
1807
  const warnStr = warnings.length > 0 ? ` ${warnings.join(" ")}` : "";
1345
1808
  const displayName = account.label ?? account.accountId;
1346
1809
  process.stdout.write(`${marker}${displayName}${warnStr}\n`);
1347
1810
  if (account.label) process.stdout.write(` ${account.accountId}\n`);
1348
1811
  process.stdout.write(` ${account.expiresIn}\n`);
1349
- const usageResult = await fetchUsage(account.accountId);
1350
- if (usageResult.ok) {
1351
- 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");
1352
1838
  for (const bar of bars) process.stdout.write(`${bar}\n`);
1353
- }
1839
+ } else process.stdout.write(` [usage unavailable] ${usageResult.value.error.message}\n`);
1354
1840
  if (i < status.accounts.length - 1) process.stdout.write("\n");
1355
1841
  }
1356
- const resolveLabel = (accountId) => {
1357
- if (!accountId) return "unknown";
1358
- return status.accounts.find((account) => account.accountId === accountId)?.label ?? accountId;
1359
- };
1360
- process.stdout.write("\nAuth files:\n");
1361
- const ocStatus = status.opencodeAuth.exists ? `active: ${resolveLabel(status.opencodeAuth.accountId)}` : "not found";
1362
- process.stdout.write(` OpenCode: ${ocStatus}\n`);
1363
- const cxStatus = status.codexAuth.exists ? `active: ${resolveLabel(status.codexAuth.accountId)}` : "not found";
1364
- process.stdout.write(` Codex CLI: ${cxStatus}\n`);
1365
- const piStatus = status.piAuth.exists ? `active: ${resolveLabel(status.piAuth.accountId)}` : "not found";
1366
- process.stdout.write(` Pi Agent: ${piStatus}\n`);
1367
1842
  process.stdout.write("\n");
1368
1843
  } catch (error) {
1369
1844
  exitWithCommandError(error);
@@ -1378,7 +1853,7 @@ const switchNext = async () => {
1378
1853
  const nextIndex = (config.current + 1) % config.accounts.length;
1379
1854
  const nextAccount = config.accounts[nextIndex];
1380
1855
  if (!nextAccount?.accountId) throw new Error("Account entry missing accountId.");
1381
- const payload = loadKeychainPayload(nextAccount.accountId);
1856
+ const payload = await getSecretStoreAdapter().load(nextAccount.accountId);
1382
1857
  const result = await writeAllAuthFiles(payload);
1383
1858
  config.current = nextIndex;
1384
1859
  await saveConfig(config);
@@ -1389,7 +1864,7 @@ const switchToAccount = async (identifier) => {
1389
1864
  const index = config.accounts.findIndex((account) => account.accountId === identifier || account.label === identifier);
1390
1865
  if (index === -1) throw new Error(`Account "${identifier}" not found. Use 'cdx login' to add it.`);
1391
1866
  const account = config.accounts[index];
1392
- const result = await writeAllAuthFiles(loadKeychainPayload(account.accountId));
1867
+ const result = await writeAllAuthFiles(await getSecretStoreAdapter().load(account.accountId));
1393
1868
  config.current = index;
1394
1869
  await saveConfig(config);
1395
1870
  writeSwitchSummary(account.label ?? account.accountId, result);
@@ -1465,6 +1940,7 @@ const createProgram = (deps = {}) => {
1465
1940
  registerSwitchCommand(program);
1466
1941
  registerLabelCommand(program);
1467
1942
  registerStatusCommand(program);
1943
+ registerDoctorCommand(program);
1468
1944
  registerUsageCommand(program);
1469
1945
  registerHelpCommand(program);
1470
1946
  registerVersionCommand(program, version);
@@ -1479,4 +1955,4 @@ if (import.meta.main) main().catch((error) => {
1479
1955
  });
1480
1956
 
1481
1957
  //#endregion
1482
- 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bjesuiter/codex-switcher",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "description": "CLI tool to switch between multiple OpenAI accounts for OpenCode",
6
6
  "bin": {
@@ -22,6 +22,7 @@
22
22
  "dependencies": {
23
23
  "@clack/prompts": "^1.0.0",
24
24
  "@openauthjs/openauth": "^0.4.3",
25
- "commander": "^14.0.3"
25
+ "commander": "^14.0.3",
26
+ "cross-keychain": "^1.1.0"
26
27
  }
27
28
  }