@ralphkrauss/codex-account-switcher 0.1.1-beta.0 → 0.1.2

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 CHANGED
@@ -5,30 +5,44 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
5
5
  import {
6
6
  CxError,
7
7
  authFileExists,
8
+ configureOnePasswordRemote,
8
9
  getCodexPaths,
10
+ inspectHermesStatus,
9
11
  inspectDoctor,
12
+ inspectRemoteStatus,
13
+ inspectSyncStatus,
10
14
  listAccounts,
11
15
  loginAccount,
12
16
  removeAccount,
13
17
  renameAccount,
14
18
  runCodex,
15
19
  saveAccount,
20
+ syncHermesAccount,
21
+ syncPullAccount,
22
+ syncPushAccount,
16
23
  useAccount,
24
+ useHermesAccount,
17
25
  validateAccountName,
18
26
  type AccountList,
19
27
  type DoctorReport,
28
+ type HermesStatus,
29
+ type RemoteStatus,
30
+ type SyncStatus,
20
31
  } from './index.js';
21
32
 
22
33
  const PACKAGE_NAME = '@ralphkrauss/codex-account-switcher';
23
34
  const SUBCOMMANDS = new Set([
24
35
  'doctor',
36
+ 'hermes',
25
37
  'help',
26
38
  'login',
27
39
  'ls',
28
40
  'rename',
41
+ 'remote',
29
42
  'rm',
30
43
  'run',
31
44
  'save',
45
+ 'sync',
32
46
  'use',
33
47
  ]);
34
48
 
@@ -42,6 +56,29 @@ interface ParsedForceArgs {
42
56
  readonly positionals: readonly string[];
43
57
  }
44
58
 
59
+ interface ParsedLoginArgs {
60
+ readonly force: boolean;
61
+ readonly name: string;
62
+ readonly loginArgs: readonly string[];
63
+ }
64
+
65
+ interface ParsedHermesArgs {
66
+ readonly json: boolean;
67
+ readonly noConfig: boolean;
68
+ readonly profile?: string;
69
+ readonly positionals: readonly string[];
70
+ }
71
+
72
+ interface ParsedJsonArgs {
73
+ readonly json: boolean;
74
+ readonly positionals: readonly string[];
75
+ }
76
+
77
+ interface ParsedRemoteConfigureArgs {
78
+ readonly vault: string;
79
+ readonly itemPrefix?: string;
80
+ }
81
+
45
82
  function write(stream: NodeJS.WritableStream, text: string): void {
46
83
  stream.write(text.endsWith('\n') ? text : `${text}\n`);
47
84
  }
@@ -68,10 +105,18 @@ Usage:
68
105
  cx ls
69
106
  cx save <name> [--force]
70
107
  cx use <name>
71
- cx login <name> [--force]
108
+ cx login <name> [--force] [codex login args...]
72
109
  cx rename <old> <new> [--force]
73
110
  cx rm <name>
74
111
  cx run [name] -- [codex args...]
112
+ cx hermes use <account> [--profile <name>] [--no-config]
113
+ cx hermes sync <account> [--profile <name>]
114
+ cx hermes status [--profile <name>] [--json]
115
+ cx remote configure 1password --vault <vault> [--item-prefix <prefix>]
116
+ cx remote status [--json]
117
+ cx sync push <account>
118
+ cx sync pull <account> [--force]
119
+ cx sync status [account] [--json]
75
120
  cx doctor [--json]
76
121
  cx --help
77
122
  cx --version
@@ -84,10 +129,191 @@ Data layout:
84
129
  Uses CODEX_HOME when set, otherwise ~/.codex.
85
130
  Accounts are stored as CODEX_HOME/accounts/<name>.json.
86
131
  The active account marker is CODEX_HOME/.current-account.
132
+ Remote sync config is stored as CODEX_HOME/remote.json.
87
133
 
88
134
  Account names may contain letters, numbers, dot, underscore, and dash only.`;
89
135
  }
90
136
 
137
+ function hermesHelpText(): string {
138
+ return `Usage:
139
+ cx hermes use <account> [--profile <name>] [--no-config]
140
+ cx hermes sync <account> [--profile <name>]
141
+ cx hermes status [--profile <name>] [--json]
142
+
143
+ Commands:
144
+ use Import CODEX_HOME/accounts/<account>.json into Hermes openai-codex auth.
145
+ Also sets model.provider=openai-codex unless --no-config is passed.
146
+ sync Copy Hermes openai-codex tokens back to the cx account slot.
147
+ status Show the selected Hermes home, auth/config state, and linked cx account.
148
+
149
+ Paths:
150
+ Default Hermes home uses HERMES_HOME when set, otherwise ~/.hermes.
151
+ --profile <name> explicitly targets ~/.hermes/profiles/<name>.`;
152
+ }
153
+
154
+ function remoteHelpText(): string {
155
+ return `Usage:
156
+ cx remote configure 1password --vault <vault> [--item-prefix <prefix>]
157
+ cx remote status [--json]
158
+
159
+ Commands:
160
+ configure Store remote sync settings in CODEX_HOME/remote.json.
161
+ status Show configured backend/vault/prefix and whether the op CLI is available.
162
+
163
+ Notes:
164
+ The default 1Password item prefix is cx-. Token contents are never printed.`;
165
+ }
166
+
167
+ function syncHelpText(): string {
168
+ return `Usage:
169
+ cx sync push <account>
170
+ cx sync pull <account> [--force]
171
+ cx sync status [account] [--json]
172
+
173
+ Commands:
174
+ push Upsert CODEX_HOME/accounts/<account>.json into the configured 1Password item.
175
+ pull Read the configured 1Password item into CODEX_HOME/accounts/<account>.json.
176
+ Refuses to overwrite unless --force is passed.
177
+ status Compare local account-file presence with remote item presence without printing tokens.`;
178
+ }
179
+
180
+ function parseHermesArgs(
181
+ command: string,
182
+ args: readonly string[],
183
+ allowed: { readonly json?: boolean; readonly noConfig?: boolean },
184
+ ): ParsedHermesArgs {
185
+ let json = false;
186
+ let noConfig = false;
187
+ let profile: string | undefined;
188
+ const positionals: string[] = [];
189
+
190
+ for (let index = 0; index < args.length; index += 1) {
191
+ const arg = args[index] ?? '';
192
+ if (arg === '--profile') {
193
+ const value = args[index + 1];
194
+ if (!value || value.startsWith('-')) {
195
+ throw new CxError(`usage: cx hermes ${command}`, 2);
196
+ }
197
+ profile = value;
198
+ index += 1;
199
+ continue;
200
+ }
201
+ if (arg === '--json') {
202
+ if (allowed.json !== true) {
203
+ throw new CxError(`unknown option '${arg}'`, 2);
204
+ }
205
+ json = true;
206
+ continue;
207
+ }
208
+ if (arg === '--no-config') {
209
+ if (allowed.noConfig !== true) {
210
+ throw new CxError(`unknown option '${arg}'`, 2);
211
+ }
212
+ noConfig = true;
213
+ continue;
214
+ }
215
+ if (arg.startsWith('--')) {
216
+ throw new CxError(`unknown option '${arg}'`, 2);
217
+ }
218
+ positionals.push(arg);
219
+ }
220
+
221
+ return { json, noConfig, ...(profile ? { profile } : {}), positionals };
222
+ }
223
+
224
+ function parseJsonArgs(args: readonly string[]): ParsedJsonArgs {
225
+ let json = false;
226
+ const positionals: string[] = [];
227
+ for (const arg of args) {
228
+ if (arg === '--json') {
229
+ json = true;
230
+ continue;
231
+ }
232
+ if (arg.startsWith('--')) {
233
+ throw new CxError(`unknown option '${arg}'`, 2);
234
+ }
235
+ positionals.push(arg);
236
+ }
237
+ return { json, positionals };
238
+ }
239
+
240
+ function parseRemoteConfigureArgs(args: readonly string[]): ParsedRemoteConfigureArgs {
241
+ const [backend, ...rest] = args;
242
+ if (backend !== '1password') {
243
+ throw new CxError('usage: cx remote configure 1password --vault <vault> [--item-prefix <prefix>]', 2);
244
+ }
245
+
246
+ let vault: string | undefined;
247
+ let itemPrefix: string | undefined;
248
+ for (let index = 0; index < rest.length; index += 1) {
249
+ const arg = rest[index] ?? '';
250
+ if (arg === '--vault') {
251
+ const value = rest[index + 1];
252
+ if (!value || value.startsWith('-')) {
253
+ throw new CxError('usage: cx remote configure 1password --vault <vault> [--item-prefix <prefix>]', 2);
254
+ }
255
+ vault = value;
256
+ index += 1;
257
+ continue;
258
+ }
259
+ if (arg === '--item-prefix') {
260
+ const value = rest[index + 1];
261
+ if (!value || value.startsWith('-')) {
262
+ throw new CxError('usage: cx remote configure 1password --vault <vault> [--item-prefix <prefix>]', 2);
263
+ }
264
+ itemPrefix = value;
265
+ index += 1;
266
+ continue;
267
+ }
268
+ if (arg.startsWith('--')) {
269
+ throw new CxError(`unknown option '${arg}'`, 2);
270
+ }
271
+ throw new CxError('usage: cx remote configure 1password --vault <vault> [--item-prefix <prefix>]', 2);
272
+ }
273
+
274
+ if (!vault) {
275
+ throw new CxError('usage: cx remote configure 1password --vault <vault> [--item-prefix <prefix>]', 2);
276
+ }
277
+ return { vault, ...(itemPrefix ? { itemPrefix } : {}) };
278
+ }
279
+
280
+ function parseLoginArgs(args: readonly string[]): ParsedLoginArgs {
281
+ let force = false;
282
+ let name: string | null = null;
283
+ const loginArgs: string[] = [];
284
+ let afterSeparator = false;
285
+
286
+ for (const arg of args) {
287
+ if (afterSeparator) {
288
+ loginArgs.push(arg);
289
+ continue;
290
+ }
291
+
292
+ if (arg === '--') {
293
+ afterSeparator = true;
294
+ continue;
295
+ }
296
+
297
+ if (arg === '--force') {
298
+ force = true;
299
+ continue;
300
+ }
301
+
302
+ if (name === null) {
303
+ name = arg;
304
+ continue;
305
+ }
306
+
307
+ loginArgs.push(arg);
308
+ }
309
+
310
+ if (name === null || name.startsWith('-')) {
311
+ throw new CxError('usage: cx login <name> [--force] [codex login args...]', 2);
312
+ }
313
+
314
+ return { force, name, loginArgs };
315
+ }
316
+
91
317
  function parseForceArgs(args: readonly string[]): ParsedForceArgs {
92
318
  let force = false;
93
319
  const positionals: string[] = [];
@@ -161,6 +387,88 @@ function formatDoctor(report: DoctorReport): string {
161
387
  return lines.join('\n');
162
388
  }
163
389
 
390
+ function yesNo(value: boolean): string {
391
+ return value ? 'yes' : 'no';
392
+ }
393
+
394
+ function formatHermesStatus(status: HermesStatus): string {
395
+ const tokenBits = status.hasTokens
396
+ ? `access=${yesNo(status.hasAccessToken)}, refresh=${yesNo(status.hasRefreshToken)}`
397
+ : 'missing';
398
+ const linked = status.linkedAccounts.length === 0
399
+ ? '(none detected)'
400
+ : status.linkedAccounts.join(', ');
401
+ const lines = [
402
+ 'Hermes Codex integration status',
403
+ `profile: ${status.profile ?? 'default'}`,
404
+ `hermes home: ${status.hermesHome}`,
405
+ `auth.json: ${status.authExists ? 'present' : 'missing'}`,
406
+ `auth readable: ${yesNo(status.authReadable)}`,
407
+ `openai-codex auth: ${status.openaiCodexAuthExists ? 'present' : 'missing'}`,
408
+ `tokens: ${tokenBits}`,
409
+ `last refresh: ${status.lastRefresh ?? '(unknown)'}`,
410
+ `auth mode: ${status.authMode ?? '(unknown)'}`,
411
+ `credential pool openai-codex entries: ${status.poolEntryCount}`,
412
+ `linked cx account: ${linked}`,
413
+ `configured provider: ${status.configuredProvider ?? '(not set)'}`,
414
+ `config.yaml: ${status.configExists ? 'present' : 'missing'}`,
415
+ ];
416
+
417
+ if (status.authError) {
418
+ lines.push(`auth warning: ${status.authError}`);
419
+ }
420
+ if (status.configError) {
421
+ lines.push(`config warning: ${status.configError}`);
422
+ }
423
+
424
+ return lines.join('\n');
425
+ }
426
+
427
+ function formatRemoteStatus(status: RemoteStatus): string {
428
+ const lines = [
429
+ 'Remote sync status',
430
+ `config: ${status.configPath}`,
431
+ `configured: ${yesNo(status.configured)}`,
432
+ `backend: ${status.backend ?? '(not configured)'}`,
433
+ `vault: ${status.vault ?? '(not configured)'}`,
434
+ `item prefix: ${status.itemPrefix ?? '(not configured)'}`,
435
+ `op CLI: ${status.opAvailable ? `available (${status.opPath ?? 'op'})` : 'not found'}`,
436
+ ];
437
+ return lines.join('\n');
438
+ }
439
+
440
+ function remotePresenceText(status: SyncStatus['accounts'][number]): string {
441
+ if (status.remote.presence === 'unknown') {
442
+ return status.remote.error ? `unknown (${status.remote.error})` : 'unknown';
443
+ }
444
+ return status.remote.presence;
445
+ }
446
+
447
+ function formatSyncStatus(status: SyncStatus): string {
448
+ const lines = [
449
+ 'Remote sync status',
450
+ `config: ${status.configPath}`,
451
+ `configured: ${yesNo(status.configured)}`,
452
+ `backend: ${status.backend ?? '(not configured)'}`,
453
+ `vault: ${status.vault ?? '(not configured)'}`,
454
+ `item prefix: ${status.itemPrefix ?? '(not configured)'}`,
455
+ `op CLI: ${status.opAvailable ? `available (${status.opPath ?? 'op'})` : 'not found'}`,
456
+ ];
457
+
458
+ if (status.accounts.length === 0) {
459
+ lines.push('accounts: (none)');
460
+ return lines.join('\n');
461
+ }
462
+
463
+ lines.push('accounts:');
464
+ for (const account of status.accounts) {
465
+ lines.push(` - ${account.account}: local=${account.local.exists ? 'present' : 'missing'}, remote=${remotePresenceText(account)}`);
466
+ lines.push(` file: ${account.local.file}`);
467
+ lines.push(` item: ${account.item ?? '(unknown)'}`);
468
+ }
469
+ return lines.join('\n');
470
+ }
471
+
164
472
  async function printList(io: CliIo, env: NodeJS.ProcessEnv): Promise<void> {
165
473
  write(io.stdout, formatAccounts(await listAccounts(getCodexPaths(env))));
166
474
  }
@@ -188,6 +496,153 @@ function parseRunArgs(args: readonly string[]): { account: string | null; codexA
188
496
  return { account, codexArgs };
189
497
  }
190
498
 
499
+ async function handleHermesCommand(
500
+ args: readonly string[],
501
+ env: NodeJS.ProcessEnv,
502
+ io: CliIo,
503
+ ): Promise<number> {
504
+ const [command, ...rest] = args;
505
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
506
+ write(io.stdout, hermesHelpText());
507
+ return 0;
508
+ }
509
+
510
+ switch (command) {
511
+ case 'use': {
512
+ const parsed = parseHermesArgs('use <account> [--profile <name>] [--no-config]', rest, { noConfig: true });
513
+ requireArity('hermes use <account> [--profile <name>] [--no-config]', parsed.positionals, 1);
514
+ const account = parsed.positionals[0] ?? '';
515
+ const result = await useHermesAccount(account, {
516
+ env,
517
+ ...(parsed.profile ? { profile: parsed.profile } : {}),
518
+ updateConfig: !parsed.noConfig,
519
+ });
520
+ write(io.stdout, `Hermes openai-codex auth now uses cx account '${result.account}'`);
521
+ write(io.stdout, `hermes home: ${result.hermesHome}`);
522
+ write(io.stdout, `auth.json: ${result.hermesAuthFile}`);
523
+ write(io.stdout, result.hermesConfigFile
524
+ ? `config.yaml: ${result.hermesConfigFile} (model.provider=openai-codex)`
525
+ : 'config.yaml: skipped (--no-config)');
526
+ return 0;
527
+ }
528
+
529
+ case 'sync': {
530
+ const parsed = parseHermesArgs('sync <account> [--profile <name>]', rest, {});
531
+ requireArity('hermes sync <account> [--profile <name>]', parsed.positionals, 1);
532
+ const account = parsed.positionals[0] ?? '';
533
+ const result = await syncHermesAccount(account, {
534
+ env,
535
+ ...(parsed.profile ? { profile: parsed.profile } : {}),
536
+ });
537
+ write(io.stdout, `synced Hermes openai-codex tokens to cx account '${result.account}'`);
538
+ write(io.stdout, `cx account file: ${result.codexAccountFile}`);
539
+ write(io.stdout, `hermes home: ${result.hermesHome}`);
540
+ return 0;
541
+ }
542
+
543
+ case 'status': {
544
+ const parsed = parseHermesArgs('status [--profile <name>] [--json]', rest, { json: true });
545
+ requireArity('hermes status [--profile <name>] [--json]', parsed.positionals, 0);
546
+ const status = await inspectHermesStatus({
547
+ env,
548
+ ...(parsed.profile ? { profile: parsed.profile } : {}),
549
+ });
550
+ write(io.stdout, parsed.json ? JSON.stringify(status, null, 2) : formatHermesStatus(status));
551
+ return 0;
552
+ }
553
+
554
+ default:
555
+ throw new CxError(`unknown hermes command '${command}'`, 2);
556
+ }
557
+ }
558
+
559
+ async function handleRemoteCommand(
560
+ args: readonly string[],
561
+ env: NodeJS.ProcessEnv,
562
+ io: CliIo,
563
+ ): Promise<number> {
564
+ const [command, ...rest] = args;
565
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
566
+ write(io.stdout, remoteHelpText());
567
+ return 0;
568
+ }
569
+
570
+ switch (command) {
571
+ case 'configure': {
572
+ const parsed = parseRemoteConfigureArgs(rest);
573
+ const result = await configureOnePasswordRemote(parsed, { paths: getCodexPaths(env) });
574
+ write(io.stdout, 'configured remote backend: 1password');
575
+ write(io.stdout, `config: ${result.configPath}`);
576
+ write(io.stdout, `vault: ${result.config.vault}`);
577
+ write(io.stdout, `item prefix: ${result.config.itemPrefix}`);
578
+ return 0;
579
+ }
580
+
581
+ case 'status': {
582
+ const parsed = parseJsonArgs(rest);
583
+ requireArity('remote status [--json]', parsed.positionals, 0);
584
+ const status = await inspectRemoteStatus({ env, paths: getCodexPaths(env) });
585
+ write(io.stdout, parsed.json ? JSON.stringify(status, null, 2) : formatRemoteStatus(status));
586
+ return 0;
587
+ }
588
+
589
+ default:
590
+ throw new CxError(`unknown remote command '${command}'`, 2);
591
+ }
592
+ }
593
+
594
+ async function handleSyncCommand(
595
+ args: readonly string[],
596
+ env: NodeJS.ProcessEnv,
597
+ io: CliIo,
598
+ ): Promise<number> {
599
+ const [command, ...rest] = args;
600
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
601
+ write(io.stdout, syncHelpText());
602
+ return 0;
603
+ }
604
+
605
+ switch (command) {
606
+ case 'push': {
607
+ requireArity('sync push <account>', rest, 1);
608
+ const account = rest[0] ?? '';
609
+ const result = await syncPushAccount(account, { env, paths: getCodexPaths(env) });
610
+ write(io.stdout, `pushed account '${result.account}' to 1Password item '${result.item}'`);
611
+ write(io.stdout, `vault: ${result.vault}`);
612
+ write(io.stdout, `operation: ${result.operation}`);
613
+ return 0;
614
+ }
615
+
616
+ case 'pull': {
617
+ const parsed = parseForceArgs(rest);
618
+ requireArity('sync pull <account> [--force]', parsed.positionals, 1);
619
+ const account = parsed.positionals[0] ?? '';
620
+ const result = await syncPullAccount(account, {
621
+ env,
622
+ paths: getCodexPaths(env),
623
+ force: parsed.force,
624
+ });
625
+ write(io.stdout, `pulled 1Password item '${result.item}' into account '${result.account}'`);
626
+ write(io.stdout, `account file: ${result.accountFile}`);
627
+ write(io.stdout, `overwrote local account: ${yesNo(result.overwritten)}`);
628
+ return 0;
629
+ }
630
+
631
+ case 'status': {
632
+ const parsed = parseJsonArgs(rest);
633
+ if (parsed.positionals.length > 1) {
634
+ throw new CxError('usage: cx sync status [account] [--json]', 2);
635
+ }
636
+ const status = await inspectSyncStatus(parsed.positionals[0], { env, paths: getCodexPaths(env) });
637
+ write(io.stdout, parsed.json ? JSON.stringify(status, null, 2) : formatSyncStatus(status));
638
+ return 0;
639
+ }
640
+
641
+ default:
642
+ throw new CxError(`unknown sync command '${command}'`, 2);
643
+ }
644
+ }
645
+
191
646
  export async function main(
192
647
  argv: readonly string[] = process.argv.slice(2),
193
648
  env: NodeJS.ProcessEnv = process.env,
@@ -259,11 +714,14 @@ export async function main(
259
714
  }
260
715
 
261
716
  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}'`);
717
+ const parsed = parseLoginArgs(rest);
718
+ await loginAccount(parsed.name, {
719
+ force: parsed.force,
720
+ loginArgs: parsed.loginArgs,
721
+ env,
722
+ paths: getCodexPaths(env),
723
+ });
724
+ write(io.stdout, `logged in and saved as '${parsed.name}'`);
267
725
  return 0;
268
726
  }
269
727
 
@@ -307,6 +765,15 @@ export async function main(
307
765
  return 0;
308
766
  }
309
767
 
768
+ case 'hermes':
769
+ return await handleHermesCommand(rest, env, io);
770
+
771
+ case 'remote':
772
+ return await handleRemoteCommand(rest, env, io);
773
+
774
+ case 'sync':
775
+ return await handleSyncCommand(rest, env, io);
776
+
310
777
  default:
311
778
  throw new CxError(`unknown command '${first}'`, 2);
312
779
  }