@ralphkrauss/codex-account-switcher 0.1.1 → 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
 
@@ -48,6 +62,23 @@ interface ParsedLoginArgs {
48
62
  readonly loginArgs: readonly string[];
49
63
  }
50
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
+
51
82
  function write(stream: NodeJS.WritableStream, text: string): void {
52
83
  stream.write(text.endsWith('\n') ? text : `${text}\n`);
53
84
  }
@@ -78,6 +109,14 @@ Usage:
78
109
  cx rename <old> <new> [--force]
79
110
  cx rm <name>
80
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]
81
120
  cx doctor [--json]
82
121
  cx --help
83
122
  cx --version
@@ -90,10 +129,154 @@ Data layout:
90
129
  Uses CODEX_HOME when set, otherwise ~/.codex.
91
130
  Accounts are stored as CODEX_HOME/accounts/<name>.json.
92
131
  The active account marker is CODEX_HOME/.current-account.
132
+ Remote sync config is stored as CODEX_HOME/remote.json.
93
133
 
94
134
  Account names may contain letters, numbers, dot, underscore, and dash only.`;
95
135
  }
96
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
+
97
280
  function parseLoginArgs(args: readonly string[]): ParsedLoginArgs {
98
281
  let force = false;
99
282
  let name: string | null = null;
@@ -204,6 +387,88 @@ function formatDoctor(report: DoctorReport): string {
204
387
  return lines.join('\n');
205
388
  }
206
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
+
207
472
  async function printList(io: CliIo, env: NodeJS.ProcessEnv): Promise<void> {
208
473
  write(io.stdout, formatAccounts(await listAccounts(getCodexPaths(env))));
209
474
  }
@@ -231,6 +496,153 @@ function parseRunArgs(args: readonly string[]): { account: string | null; codexA
231
496
  return { account, codexArgs };
232
497
  }
233
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
+
234
646
  export async function main(
235
647
  argv: readonly string[] = process.argv.slice(2),
236
648
  env: NodeJS.ProcessEnv = process.env,
@@ -353,6 +765,15 @@ export async function main(
353
765
  return 0;
354
766
  }
355
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
+
356
777
  default:
357
778
  throw new CxError(`unknown command '${first}'`, 2);
358
779
  }