@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.
- package/README.md +108 -48
- package/cdx.mjs +560 -84
- 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.
|
|
9
|
+
### 1.4.0
|
|
10
10
|
|
|
11
11
|
#### Features
|
|
12
12
|
|
|
13
|
-
-
|
|
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
|
-
-
|
|
21
|
+
- Use platform-neutral secure-store wording in output where macOS-specific keychain wording was misleading.
|
|
18
22
|
|
|
19
23
|
#### Internal
|
|
20
24
|
|
|
21
|
-
-
|
|
22
|
-
-
|
|
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
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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.
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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 (
|
|
688
|
-
const payload =
|
|
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
|
-
|
|
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
|
|
731
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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}${
|
|
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
|
|
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 (${
|
|
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
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
|
1154
|
-
process.stdout.write(`Current token status for ${displayName}: ${expiryState}${
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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 =
|
|
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(
|
|
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
|
+
"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
|
}
|