@ralphkrauss/codex-account-switcher 0.1.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/src/cli.ts ADDED
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ import {
6
+ CxError,
7
+ authFileExists,
8
+ getCodexPaths,
9
+ inspectDoctor,
10
+ listAccounts,
11
+ loginAccount,
12
+ removeAccount,
13
+ renameAccount,
14
+ runCodex,
15
+ saveAccount,
16
+ useAccount,
17
+ validateAccountName,
18
+ type AccountList,
19
+ type DoctorReport,
20
+ } from './index.js';
21
+
22
+ const PACKAGE_NAME = '@ralphkrauss/codex-account-switcher';
23
+ const SUBCOMMANDS = new Set([
24
+ 'doctor',
25
+ 'help',
26
+ 'login',
27
+ 'ls',
28
+ 'rename',
29
+ 'rm',
30
+ 'run',
31
+ 'save',
32
+ 'use',
33
+ ]);
34
+
35
+ interface CliIo {
36
+ readonly stdout: NodeJS.WritableStream;
37
+ readonly stderr: NodeJS.WritableStream;
38
+ }
39
+
40
+ interface ParsedForceArgs {
41
+ readonly force: boolean;
42
+ readonly positionals: readonly string[];
43
+ }
44
+
45
+ function write(stream: NodeJS.WritableStream, text: string): void {
46
+ stream.write(text.endsWith('\n') ? text : `${text}\n`);
47
+ }
48
+
49
+ async function readPackageMetadata(): Promise<{ name: string; version: string }> {
50
+ try {
51
+ const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) as {
52
+ name?: unknown;
53
+ version?: unknown;
54
+ };
55
+ return {
56
+ name: typeof packageJson.name === 'string' ? packageJson.name : PACKAGE_NAME,
57
+ version: typeof packageJson.version === 'string' ? packageJson.version : '0.0.0',
58
+ };
59
+ } catch {
60
+ return { name: PACKAGE_NAME, version: '0.0.0' };
61
+ }
62
+ }
63
+
64
+ function helpText(metadata: { readonly name: string; readonly version: string }): string {
65
+ return `cx ${metadata.version} — safe Codex CLI account switcher
66
+
67
+ Usage:
68
+ cx ls
69
+ cx save <name> [--force]
70
+ cx use <name>
71
+ cx login <name> [--force]
72
+ cx rename <old> <new> [--force]
73
+ cx rm <name>
74
+ cx run [name] -- [codex args...]
75
+ cx doctor [--json]
76
+ cx --help
77
+ cx --version
78
+
79
+ Backward-friendly shortcuts:
80
+ cx <account> [codex args...] switch to <account>, then run codex
81
+ cx run codex with the current auth.json when present
82
+
83
+ Data layout:
84
+ Uses CODEX_HOME when set, otherwise ~/.codex.
85
+ Accounts are stored as CODEX_HOME/accounts/<name>.json.
86
+ The active account marker is CODEX_HOME/.current-account.
87
+
88
+ Account names may contain letters, numbers, dot, underscore, and dash only.`;
89
+ }
90
+
91
+ function parseForceArgs(args: readonly string[]): ParsedForceArgs {
92
+ let force = false;
93
+ const positionals: string[] = [];
94
+ for (const arg of args) {
95
+ if (arg === '--force') {
96
+ force = true;
97
+ continue;
98
+ }
99
+ if (arg.startsWith('--')) {
100
+ throw new CxError(`unknown option '${arg}'`, 2);
101
+ }
102
+ positionals.push(arg);
103
+ }
104
+ return { force, positionals };
105
+ }
106
+
107
+ function requireArity(command: string, positionals: readonly string[], expected: number): void {
108
+ if (positionals.length !== expected) {
109
+ throw new CxError(`usage: cx ${command}`, 2);
110
+ }
111
+ }
112
+
113
+ function formatAccounts(list: AccountList): string {
114
+ const lines = [`Codex accounts (home: ${list.home}):`];
115
+ if (list.accounts.length === 0) {
116
+ lines.push(' (none yet — run: cx save <name> to register the current login, or cx login <name>)');
117
+ } else {
118
+ for (const account of list.accounts) {
119
+ lines.push(account.active ? ` * ${account.name} (active)` : ` ${account.name}`);
120
+ }
121
+ }
122
+
123
+ if (list.currentMarker.state === 'invalid') {
124
+ lines.push('warning: .current-account is invalid; writeback will be skipped until fixed.');
125
+ } else if (list.currentMarker.state === 'valid' && list.current === null) {
126
+ lines.push(`warning: current marker '${list.currentMarker.name}' has no matching stored account.`);
127
+ }
128
+
129
+ return lines.join('\n');
130
+ }
131
+
132
+ function formatDoctor(report: DoctorReport): string {
133
+ const current = report.current.state === 'valid'
134
+ ? `${report.current.name ?? '(none)'} (${report.current.slotExists ? 'slot ok' : 'slot missing'})`
135
+ : report.current.state;
136
+ const lines = [
137
+ 'Codex Account Switcher doctor',
138
+ `package: ${report.packageName}@${report.version}`,
139
+ `node: ${report.nodeVersion}`,
140
+ `platform: ${report.platform}`,
141
+ `codex home: ${report.codexHome}`,
142
+ `home exists: ${report.homeExists ? 'yes' : 'no'}`,
143
+ `accounts dir: ${report.accountsDir}`,
144
+ `accounts dir exists: ${report.accountsDirExists ? 'yes' : 'no'}`,
145
+ `accounts: ${report.accounts.length === 0 ? '(none)' : report.accounts.join(', ')}`,
146
+ `current account: ${current}`,
147
+ `auth.json: ${report.authJson.exists ? `${report.authJson.size} bytes (${report.authJson.looksNonEmpty ? 'usable for save/writeback' : 'too small for save/writeback'})` : 'missing'}`,
148
+ `codex executable: ${report.codexExecutable ?? 'not found'}`,
149
+ ];
150
+
151
+ if (report.current.reason) {
152
+ lines.push(`current marker note: ${report.current.reason}`);
153
+ }
154
+ if (report.warnings.length > 0) {
155
+ lines.push('warnings:');
156
+ for (const warning of report.warnings) {
157
+ lines.push(` - ${warning}`);
158
+ }
159
+ }
160
+
161
+ return lines.join('\n');
162
+ }
163
+
164
+ async function printList(io: CliIo, env: NodeJS.ProcessEnv): Promise<void> {
165
+ write(io.stdout, formatAccounts(await listAccounts(getCodexPaths(env))));
166
+ }
167
+
168
+ function parseRunArgs(args: readonly string[]): { account: string | null; codexArgs: readonly string[] } {
169
+ const separatorIndex = args.indexOf('--');
170
+ if (separatorIndex >= 0) {
171
+ const beforeSeparator = args.slice(0, separatorIndex);
172
+ if (beforeSeparator.length > 1) {
173
+ throw new CxError('usage: cx run [name] -- [codex args...]', 2);
174
+ }
175
+ return {
176
+ account: beforeSeparator[0] ?? null,
177
+ codexArgs: args.slice(separatorIndex + 1),
178
+ };
179
+ }
180
+
181
+ if (args.length === 0) {
182
+ return { account: null, codexArgs: [] };
183
+ }
184
+ const [account, ...codexArgs] = args;
185
+ if (!account || account.startsWith('-')) {
186
+ throw new CxError("usage: cx run [name] -- [codex args...] (use '--' before codex flags)", 2);
187
+ }
188
+ return { account, codexArgs };
189
+ }
190
+
191
+ export async function main(
192
+ argv: readonly string[] = process.argv.slice(2),
193
+ env: NodeJS.ProcessEnv = process.env,
194
+ io: CliIo = { stdout: process.stdout, stderr: process.stderr },
195
+ ): Promise<number> {
196
+ const metadata = await readPackageMetadata();
197
+ const [first, ...rest] = argv;
198
+
199
+ if (first === '--help' || first === '-h' || first === 'help') {
200
+ write(io.stdout, helpText(metadata));
201
+ return 0;
202
+ }
203
+
204
+ if (first === '--version' || first === '-v') {
205
+ write(io.stdout, metadata.version);
206
+ return 0;
207
+ }
208
+
209
+ if (!first) {
210
+ const paths = getCodexPaths(env);
211
+ if (await authFileExists(paths)) {
212
+ return await runCodex([], { env });
213
+ }
214
+
215
+ write(io.stdout, helpText(metadata));
216
+ write(io.stdout, '');
217
+ write(io.stdout, `No live auth.json found at ${paths.authFile}.`);
218
+ write(io.stdout, 'Use cx ls, cx use <name>, cx save <name>, or cx login <name>.');
219
+ write(io.stdout, '');
220
+ write(io.stdout, formatAccounts(await listAccounts(paths)));
221
+ return 0;
222
+ }
223
+
224
+ if (!SUBCOMMANDS.has(first)) {
225
+ if (first.startsWith('-')) {
226
+ throw new CxError(`unknown option '${first}'`, 2);
227
+ }
228
+
229
+ validateAccountName(first);
230
+ await useAccount(first, { paths: getCodexPaths(env) });
231
+ write(io.stdout, `→ codex on '${first}'`);
232
+ return await runCodex(rest, { env });
233
+ }
234
+
235
+ switch (first) {
236
+ case 'ls': {
237
+ if (rest.length > 0) {
238
+ throw new CxError('usage: cx ls', 2);
239
+ }
240
+ await printList(io, env);
241
+ return 0;
242
+ }
243
+
244
+ case 'save': {
245
+ const parsed = parseForceArgs(rest);
246
+ requireArity('save <name> [--force]', parsed.positionals, 1);
247
+ const name = parsed.positionals[0] ?? '';
248
+ await saveAccount(name, { force: parsed.force, paths: getCodexPaths(env) });
249
+ write(io.stdout, `saved current login as '${name}'`);
250
+ return 0;
251
+ }
252
+
253
+ case 'use': {
254
+ requireArity('use <name>', rest, 1);
255
+ const name = rest[0] ?? '';
256
+ await useAccount(name, { paths: getCodexPaths(env) });
257
+ write(io.stdout, `active codex account: ${name}`);
258
+ return 0;
259
+ }
260
+
261
+ case 'login': {
262
+ const parsed = parseForceArgs(rest);
263
+ requireArity('login <name> [--force]', parsed.positionals, 1);
264
+ const name = parsed.positionals[0] ?? '';
265
+ await loginAccount(name, { force: parsed.force, env, paths: getCodexPaths(env) });
266
+ write(io.stdout, `logged in and saved as '${name}'`);
267
+ return 0;
268
+ }
269
+
270
+ case 'rename': {
271
+ const parsed = parseForceArgs(rest);
272
+ requireArity('rename <old> <new> [--force]', parsed.positionals, 2);
273
+ const oldName = parsed.positionals[0] ?? '';
274
+ const newName = parsed.positionals[1] ?? '';
275
+ await renameAccount(oldName, newName, { force: parsed.force, paths: getCodexPaths(env) });
276
+ write(io.stdout, `renamed '${oldName}' -> '${newName}'`);
277
+ return 0;
278
+ }
279
+
280
+ case 'rm': {
281
+ requireArity('rm <name>', rest, 1);
282
+ const name = rest[0] ?? '';
283
+ const result = await removeAccount(name, { paths: getCodexPaths(env) });
284
+ write(io.stdout, `removed '${name}'`);
285
+ if (result.wasActive) {
286
+ write(io.stderr, `warning: '${name}' was active; live auth.json was left in place until you switch or login.`);
287
+ }
288
+ return 0;
289
+ }
290
+
291
+ case 'run': {
292
+ const parsed = parseRunArgs(rest);
293
+ if (parsed.account) {
294
+ await useAccount(parsed.account, { paths: getCodexPaths(env) });
295
+ write(io.stdout, `→ codex on '${parsed.account}'`);
296
+ }
297
+ return await runCodex(parsed.codexArgs, { env });
298
+ }
299
+
300
+ case 'doctor': {
301
+ const json = rest.length === 1 && rest[0] === '--json';
302
+ if (rest.length > (json ? 1 : 0)) {
303
+ throw new CxError('usage: cx doctor [--json]', 2);
304
+ }
305
+ const report = await inspectDoctor({ packageName: metadata.name, version: metadata.version }, env);
306
+ write(io.stdout, json ? JSON.stringify(report, null, 2) : formatDoctor(report));
307
+ return 0;
308
+ }
309
+
310
+ default:
311
+ throw new CxError(`unknown command '${first}'`, 2);
312
+ }
313
+ }
314
+
315
+ function isEntrypoint(): boolean {
316
+ const invokedPath = process.argv[1];
317
+ if (!invokedPath) {
318
+ return false;
319
+ }
320
+
321
+ try {
322
+ return realpathSync(invokedPath) === realpathSync(fileURLToPath(import.meta.url));
323
+ } catch {
324
+ return import.meta.url === pathToFileURL(invokedPath).href;
325
+ }
326
+ }
327
+
328
+ if (isEntrypoint()) {
329
+ try {
330
+ process.exitCode = await main();
331
+ } catch (error) {
332
+ if (error instanceof CxError) {
333
+ write(process.stderr, error.message);
334
+ process.exitCode = error.exitCode;
335
+ } else if (error instanceof Error) {
336
+ write(process.stderr, error.message);
337
+ process.exitCode = 1;
338
+ } else {
339
+ write(process.stderr, String(error));
340
+ process.exitCode = 1;
341
+ }
342
+ }
343
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ export {
2
+ ACCOUNT_NAME_PATTERN,
3
+ AUTH_NON_EMPTY_BYTES,
4
+ CxError,
5
+ accountPathForName,
6
+ authFileExists,
7
+ authLooksNonEmpty,
8
+ getCodexHome,
9
+ getCodexPaths,
10
+ inspectDoctor,
11
+ listAccountNames,
12
+ listAccounts,
13
+ loginAccount,
14
+ readCurrentMarker,
15
+ removeAccount,
16
+ renameAccount,
17
+ resolveExecutable,
18
+ runCodex,
19
+ saveAccount,
20
+ switchAndRunCodex,
21
+ useAccount,
22
+ validateAccountName,
23
+ writebackCurrentAccount,
24
+ } from './accounts.js';
25
+
26
+ export type {
27
+ AccountEntry,
28
+ AccountList,
29
+ CodexPaths,
30
+ CurrentMarker,
31
+ DoctorReport,
32
+ ForceOptions,
33
+ OperationOptions,
34
+ RemoveResult,
35
+ RenameResult,
36
+ SpawnCodexOptions,
37
+ WritebackResult,
38
+ } from './accounts.js';