@carjms/codexswitch 0.3.3 → 0.3.5

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.ko.md CHANGED
@@ -203,7 +203,8 @@ cxs list # 계정별 5h/week 사용량 % 확인
203
203
  | `cxs login [이름]` | 새 계정 로그인 후 저장 (기존 로그인 유지, 이름 생략 시 이메일 사용) |
204
204
  | `cxs import [이름]` | 현재 `~/.codex`에 로그인된 계정을 저장소로 가져오기 |
205
205
  | `cxs add-key <이름> [키]` | OpenAI API 키 계정 등록 (키 생략 시 `$OPENAI_API_KEY` 사용) |
206
- | `cxs list` | 계정 목록: 활성 표시(`*`), 이메일, 플랜, 우선순위, 한도 상태 |
206
+ | `cxs list` | 계정 목록: 활성 표시, 이메일, 플랜, 우선순위, 한도 상태, 사용량 % |
207
+ | `cxs usage [이름]` | 계정별 사용량 대시보드: 5시간/주간 게이지 바, 리셋 카운트다운, 다음 로테이션 계정 (별칭: `status`) |
207
208
  | `cxs use <이름>` | 활성 계정 전환 |
208
209
  | `cxs current` | 현재 활성 계정 확인 |
209
210
  | `cxs next` | 설정된 순서의 다음 계정으로 전환 (끝에 오면 처음으로 순환) |
@@ -217,6 +218,7 @@ cxs list # 계정별 5h/week 사용량 % 확인
217
218
  | `cxs export <파일>` | 전체 계정·설정 백업 (⚠️ 토큰 포함 — 비밀번호처럼 취급) |
218
219
  | `cxs restore <파일>` | 백업 파일에서 계정 복원 (다른 PC 이전용) |
219
220
  | `cxs completion <bash\|zsh>` | 셸 자동완성 스크립트 출력 |
221
+ | `cxs <그 외 명령>` | codex로 그대로 전달 — `cxs resume`, `cxs goal ...`, `cxs apply` 등 codex의 모든 명령을 관리 계정으로 바로 사용 가능 |
220
222
  | `cxs remove <이름>` | 계정 삭제 |
221
223
  | `cxs rename <옛이름> <새이름>` | 계정 이름 변경 |
222
224
  | `cxs disable / enable <이름>` | 로테이션에서 임시 제외 / 복귀 |
package/README.md CHANGED
@@ -205,7 +205,8 @@ cxs list # per-account 5h/week usage columns
205
205
  | `cxs login [name]` | Log in to a new account and store it (existing login untouched; defaults to the email as the name) |
206
206
  | `cxs import [name]` | Import the account currently in `~/.codex` |
207
207
  | `cxs add-key <name> [key]` | Register an OpenAI API-key account (falls back to `$OPENAI_API_KEY`) |
208
- | `cxs list` | List accounts: active marker (`*`), email, plan, priority, limit status |
208
+ | `cxs list` | List accounts: active marker, email, plan, priority, limit status, usage % |
209
+ | `cxs usage [name]` | Per-account usage dashboard: 5h/weekly gauge bars, reset countdowns, next rotation pick (alias: `status`) |
209
210
  | `cxs use <name>` | Switch the active account |
210
211
  | `cxs current` | Show the active account |
211
212
  | `cxs next` | Switch to the next account in rotation order (wraps around) |
@@ -219,6 +220,7 @@ cxs list # per-account 5h/week usage columns
219
220
  | `cxs export <file>` | Back up all accounts + settings (⚠️ contains tokens — treat like a password) |
220
221
  | `cxs restore <file>` | Restore accounts from a backup (for moving machines) |
221
222
  | `cxs completion <bash\|zsh>` | Print a shell completion script |
223
+ | `cxs <anything else>` | Forwarded to codex under the managed account — `cxs resume`, `cxs goal ...`, `cxs apply` all work like their codex counterparts |
222
224
  | `cxs remove <name>` | Delete an account |
223
225
  | `cxs rename <old> <new>` | Rename an account |
224
226
  | `cxs disable / enable <name>` | Temporarily exclude from / restore to rotation |
@@ -4,7 +4,8 @@
4
4
  require('../src/cli.js').main(process.argv.slice(2)).then(
5
5
  (code) => process.exit(code || 0),
6
6
  (err) => {
7
- console.error(`error: ${err && err.message ? err.message : err}`);
7
+ const ui = require('../src/ui.js');
8
+ console.error(ui.fail(`${ui.red('error:')} ${err && err.message ? err.message : err}`));
8
9
  process.exit(1);
9
10
  }
10
11
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carjms/codexswitch",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Multi-account manager for OpenAI Codex CLI with quota-based rotation (inspired by KarpelesLab/teamclaude)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -6,6 +6,7 @@ const path = require('path');
6
6
  const { readJSONSafe, writeJSONAtomic, authInfo, ensureDir, fmtDate, fmtRemaining, table } = require('./util.js');
7
7
  const store = require('./store.js');
8
8
  const runner = require('./runner.js');
9
+ const ui = require('./ui.js');
9
10
 
10
11
  const HELP = `codexswitch — multi-account manager for the OpenAI Codex CLI
11
12
 
@@ -13,7 +14,9 @@ Accounts
13
14
  login [name] log in to a new account (isolated "codex login") and store it
14
15
  import [name] import the account currently in ~/.codex/auth.json
15
16
  add-key <name> [key] register an OpenAI API-key account (or read $OPENAI_API_KEY)
16
- list list stored accounts (alias: accounts, status)
17
+ list list stored accounts (alias: accounts)
18
+ usage [name] per-account 5h/weekly usage gauges with reset times
19
+ (alias: status)
17
20
  use <name> make <name> the active account in ~/.codex
18
21
  current show the active account
19
22
  next switch to the next account in rotation order (wraps around)
@@ -48,6 +51,10 @@ Maintenance
48
51
  completion <bash|zsh> print a shell completion script
49
52
  help show this help
50
53
 
54
+ Anything else is forwarded to codex under the managed account, so commands
55
+ like "codexswitch resume", "codexswitch goal ..." or "codexswitch apply"
56
+ work exactly like their codex counterparts.
57
+
51
58
  Environment
52
59
  CODEX_SWITCH_HOME data dir (default ~/.codex-switch)
53
60
  CODEX_HOME codex config dir codexswitch manages (default ~/.codex)
@@ -84,7 +91,7 @@ function storeAccount(name, auth) {
84
91
  delete meta.accounts[name].disabled;
85
92
  delete meta.accounts[name].limitedUntil;
86
93
  store.saveMeta(meta);
87
- out(`${existed ? 'updated' : 'added'} account "${name}"${info.email ? ` (${info.email}, ${info.plan || 'unknown plan'})` : ''}`);
94
+ out(ui.ok(`${existed ? 'updated' : 'added'} account ${ui.bold(`"${name}"`)}${info.email ? ui.dim(` (${info.email}, ${info.plan || 'unknown plan'})`) : ''}`));
88
95
  return name;
89
96
  }
90
97
 
@@ -107,7 +114,7 @@ function cmdLogin(args) {
107
114
  store.ensureDirs();
108
115
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codexswitch-login-'));
109
116
  try {
110
- out('opening codex login in an isolated profile (your current account is untouched)...');
117
+ out(ui.info('opening codex login in an isolated profile (your current account is untouched)...'));
111
118
  const r = runner.spawnCodexSync(['login'], {
112
119
  env: { ...process.env, CODEX_HOME: tmp },
113
120
  stdio: 'inherit',
@@ -136,30 +143,34 @@ function cmdList() {
136
143
  }
137
144
  const now = Date.now();
138
145
  const meta = store.loadMeta();
139
- const pct = (win) => (win && typeof win.pct === 'number' ? `${Math.round(win.pct)}%` : '-');
146
+ const pct = (win, threshold) => {
147
+ if (!win || typeof win.pct !== 'number') return ui.dim('-');
148
+ const v = `${Math.round(win.pct)}%`;
149
+ return win.pct >= threshold ? ui.red(v) : win.pct >= 70 ? ui.yellow(v) : ui.green(v);
150
+ };
140
151
  const rows = accounts.map((a) => {
141
152
  const over = store.overThreshold(a, meta, now);
142
153
  const status = a.disabled
143
- ? 'disabled'
154
+ ? ui.red('disabled')
144
155
  : a.limitedUntil && a.limitedUntil > now
145
- ? `limited ${fmtRemaining(a.limitedUntil)}`
156
+ ? ui.yellow(`limited ${fmtRemaining(a.limitedUntil)}`)
146
157
  : over
147
- ? `over-${over}`
148
- : 'ok';
158
+ ? ui.yellow(`over-${over}`)
159
+ : ui.green('ok');
149
160
  return [
150
- a.active ? '*' : '',
151
- a.name,
152
- a.email,
161
+ a.active ? ui.green('') : '',
162
+ a.active ? ui.bold(a.name) : a.name,
163
+ ui.dim(a.email || '-'),
153
164
  a.plan,
154
165
  a.priority,
155
166
  status,
156
- pct(a.usage && a.usage.p5h),
157
- pct(a.usage && a.usage.weekly),
158
- fmtDate(a.lastRefresh),
167
+ pct(a.usage && a.usage.p5h, meta.threshold5h),
168
+ pct(a.usage && a.usage.weekly, meta.thresholdWeekly),
169
+ ui.dim(fmtDate(a.lastRefresh)),
159
170
  ];
160
171
  });
161
172
  out(table(rows, ['', 'name', 'email', 'plan', 'prio', 'status', '5h', 'week', 'token refreshed']));
162
- out(`(rotate threshold: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}% — change with "codexswitch threshold")`);
173
+ out(ui.dim(`(rotate threshold: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}% — change with "codexswitch threshold")`));
163
174
  }
164
175
 
165
176
  function cmdUse(args) {
@@ -175,7 +186,7 @@ function cmdUse(args) {
175
186
  meta.accounts[name].lastUsed = Date.now();
176
187
  store.saveMeta(meta);
177
188
  const info = authInfo(auth);
178
- out(`now using "${name}"${info.email ? ` (${info.email}, ${info.plan || 'unknown plan'})` : ''}`);
189
+ out(ui.ok(`now using ${ui.bold(`"${name}"`)}${info.email ? ui.dim(` (${info.email}, ${info.plan || 'unknown plan'})`) : ''}`));
179
190
  }
180
191
 
181
192
  function cmdCurrent() {
@@ -192,9 +203,9 @@ function cmdCurrent() {
192
203
  (info.email && accounts.find((a) => a.email === info.email)) ||
193
204
  accounts.find((a) => a.accountId === info.accountId);
194
205
  const name = match ? match.name : '(not stored — run "codexswitch import")';
195
- const note = meta.active && match && meta.active !== match.name ? ` (meta says "${meta.active}" — out of sync)` : '';
196
- out(`active: ${name}${note}`);
197
- out(` email: ${info.email || '-'}\n plan: ${info.plan || '-'}\n token refreshed: ${fmtDate(info.lastRefresh)}`);
206
+ const note = meta.active && match && meta.active !== match.name ? ui.yellow(` (meta says "${meta.active}" — out of sync)`) : '';
207
+ out(`active: ${ui.bold(name)}${note}`);
208
+ out(ui.dim(` email: ${info.email || '-'}\n plan: ${info.plan || '-'}\n token refreshed: ${fmtDate(info.lastRefresh)}`));
198
209
  }
199
210
 
200
211
  function cmdNext() {
@@ -332,6 +343,49 @@ function cmdModel(args) {
332
343
  out(`default model set to "${meta.model}" (applied to run/exec)`);
333
344
  }
334
345
 
346
+ // Per-account usage dashboard: gauge bars for the 5h / weekly windows with
347
+ // reset countdowns, based on what codex recorded in its session files.
348
+ function cmdUsage(args) {
349
+ const meta = store.loadMeta();
350
+ let accounts = store.listAccounts();
351
+ if (args[0]) {
352
+ accounts = accounts.filter((a) => a.name === args[0]);
353
+ if (accounts.length === 0) throw new Error(`no such account: ${args[0]}`);
354
+ }
355
+ if (accounts.length === 0) {
356
+ out('no accounts yet — add one with "codexswitch login" or "codexswitch import"');
357
+ return;
358
+ }
359
+ const now = Date.now();
360
+ const bar = (pct, threshold) => {
361
+ const width = 20;
362
+ const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)));
363
+ const paint = pct >= threshold ? ui.red : pct >= 70 ? ui.yellow : ui.green;
364
+ return `[${paint('█'.repeat(filled))}${ui.dim('░'.repeat(width - filled))}]`;
365
+ };
366
+ const line = (label, win, threshold) => {
367
+ if (!win || typeof win.pct !== 'number') return ` ${label} ${ui.dim('no data')}`;
368
+ const pct = Math.round(win.pct);
369
+ const reset = win.resetAt && win.resetAt > now ? ` resets in ${fmtRemaining(win.resetAt)}` : '';
370
+ const overMark = win.pct >= threshold ? ` ${ui.red(`≥ threshold ${threshold}%`)}` : '';
371
+ return ` ${label} ${bar(win.pct, threshold)} ${String(pct).padStart(3)}%${ui.dim(reset)}${overMark}`;
372
+ };
373
+ for (const a of accounts) {
374
+ const mark = a.active ? ui.green('●') : ' ';
375
+ const state = a.disabled ? ui.red(' [disabled]') : a.limitedUntil && a.limitedUntil > now ? ui.yellow(` [limited ${fmtRemaining(a.limitedUntil)}]`) : '';
376
+ out(`${mark} ${ui.bold(a.name)}${ui.dim(` (${a.plan || a.email || '-'})`)}${state}`);
377
+ if (!a.usage) {
378
+ out(ui.dim(' no usage data yet — codex records it during runs (try "codexswitch run" once)'));
379
+ } else {
380
+ out(line('5h ', a.usage.p5h, meta.threshold5h));
381
+ out(line('weekly', a.usage.weekly, meta.thresholdWeekly));
382
+ if (a.usage.at) out(ui.dim(` measured ${fmtDate(a.usage.at)}`));
383
+ }
384
+ }
385
+ const next = store.pickAccount();
386
+ out(ui.dim(`\nrotation would pick: ${next ? next.name : '(none usable)'} · threshold 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`));
387
+ }
388
+
335
389
  // Rotate-early thresholds: how full the 5h / weekly window may get before
336
390
  // the account is skipped in rotation.
337
391
  function cmdThreshold(args) {
@@ -415,7 +469,7 @@ function cmdNames() {
415
469
  }
416
470
 
417
471
  const COMMANDS =
418
- 'login import add-key list use current next run exec order model remove rename ' +
472
+ 'login import add-key list usage use current next run exec order model remove rename ' +
419
473
  'enable disable priority clear-limit cooldown threshold patterns export restore sync completion help';
420
474
 
421
475
  function cmdCompletion(args) {
@@ -468,7 +522,7 @@ async function cmdRun(args) {
468
522
  } else if (meta.model && rest.length === 0) {
469
523
  rest = ['-m', meta.model];
470
524
  }
471
- out(`[codexswitch] running codex as "${name}"`);
525
+ out(ui.info(`running codex as ${ui.bold(`"${name}"`)}`));
472
526
  const startTs = Date.now();
473
527
  const res = await runner.runCodex(name, rest);
474
528
  runner.recordUsage(name, res.profile, startTs);
@@ -511,7 +565,7 @@ async function cmdExec(args) {
511
565
  }
512
566
  tried.push(name);
513
567
  console.error(
514
- `[codexswitch] exec as "${name}"${attempt > 0 ? ` (attempt ${attempt + 1}${useResume ? ', resuming session' : ''})` : ''}`
568
+ ui.info(`exec as ${ui.bold(`"${name}"`)}${ui.dim(attempt > 0 ? ` (attempt ${attempt + 1}${useResume ? ', resuming session' : ''})` : '')}`)
515
569
  );
516
570
  // On rotation, continue the same session with the next account instead
517
571
  // of restarting the whole prompt — the session files are shared.
@@ -523,6 +577,7 @@ async function cmdExec(args) {
523
577
  runner.recordUsage(name, res.profile, startTs);
524
578
  if (res.code === 0) {
525
579
  store.clearLimited(name);
580
+ console.error(ui.ok(`done as ${ui.bold(`"${name}"`)}`));
526
581
  warnIfOverThreshold(name);
527
582
  return 0;
528
583
  }
@@ -533,7 +588,7 @@ async function cmdExec(args) {
533
588
  useResume = true;
534
589
  }
535
590
  console.error(
536
- `[codexswitch] "${name}" hit a usage/rate limit (paused until ${fmtDate(until)}) — rotating${useResume ? ' and resuming the session' : ''}`
591
+ ui.warn(`"${name}" hit a usage/rate limit (paused until ${fmtDate(until)}) — rotating${useResume ? ' and resuming the session' : ''}`)
537
592
  );
538
593
  continue;
539
594
  }
@@ -542,13 +597,13 @@ async function cmdExec(args) {
542
597
  // rotation and keep the task going on the next one.
543
598
  setFlag(name, { disabled: true });
544
599
  console.error(
545
- `[codexswitch] "${name}" has a revoked/invalid login — disabled. Fix it with: codexswitch login ${name}`
600
+ ui.fail(`"${name}" has a revoked/invalid login — disabled. Fix it with: ${ui.bold(`codexswitch login ${name}`)}`)
546
601
  );
547
602
  continue;
548
603
  }
549
604
  return res.code; // real failure, don't burn other accounts on it
550
605
  }
551
- console.error('[codexswitch] all accounts are rate-limited, over threshold, or disabled');
606
+ console.error(ui.fail('all accounts are rate-limited, over threshold, or disabled'));
552
607
  return 2;
553
608
  }
554
609
 
@@ -560,7 +615,7 @@ function warnIfOverThreshold(name) {
560
615
  if (blocked) {
561
616
  const pct = blocked === '5h' ? account.usage.p5h.pct : account.usage.weekly.pct;
562
617
  console.error(
563
- `[codexswitch] "${name}" is at ${Math.round(pct)}% of its ${blocked} limit (threshold ${blocked === '5h' ? meta.threshold5h : meta.thresholdWeekly}%) — the next exec will rotate to another account`
618
+ ui.warn(`"${name}" is at ${Math.round(pct)}% of its ${blocked} limit (threshold ${blocked === '5h' ? meta.threshold5h : meta.thresholdWeekly}%) — the next exec will rotate to another account`)
564
619
  );
565
620
  }
566
621
  }
@@ -572,7 +627,12 @@ async function main(argv) {
572
627
  case 'help':
573
628
  case '--help':
574
629
  case '-h':
575
- out(HELP);
630
+ out(
631
+ HELP.replace(/^(codexswitch)(?= —)/, ui.bold('$1')).replace(
632
+ /^(Accounts|Running codex|Settings|Maintenance|Environment)$/gm,
633
+ (s) => ui.bold(ui.cyan(s))
634
+ )
635
+ );
576
636
  return 0;
577
637
  case 'login':
578
638
  return cmdLogin(args), 0;
@@ -580,8 +640,10 @@ async function main(argv) {
580
640
  return cmdImport(args), 0;
581
641
  case 'list':
582
642
  case 'accounts':
583
- case 'status':
584
643
  return cmdList(), 0;
644
+ case 'usage':
645
+ case 'status':
646
+ return cmdUsage(args), 0;
585
647
  case 'use':
586
648
  return cmdUse(args), 0;
587
649
  case 'current':
@@ -638,7 +700,10 @@ async function main(argv) {
638
700
  case 'exec':
639
701
  return cmdExec(args);
640
702
  default:
641
- throw new Error(`unknown command "${cmd}" (see "codexswitch help")`);
703
+ // Forward everything else to codex under the managed account, so
704
+ // codexswitch is a drop-in replacement: "cxs goal", "cxs resume", ...
705
+ console.error(ui.info(ui.dim(`forwarding to codex: codex ${argv.join(' ')}`)));
706
+ return cmdRun(argv);
642
707
  }
643
708
  }
644
709
 
package/src/ui.js ADDED
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ // Tiny ANSI color layer (zero deps). Colors turn off automatically when the
4
+ // output is piped, or when NO_COLOR is set — so scripts and tests always see
5
+ // plain text (https://no-color.org).
6
+ const enabled =
7
+ process.env.NO_COLOR == null &&
8
+ process.env.FORCE_COLOR !== '0' &&
9
+ (Boolean(process.stdout.isTTY) || Boolean(process.env.FORCE_COLOR));
10
+
11
+ const wrap = (open, close) => (s) => (enabled ? `[${open}m${s}[${close}m` : String(s));
12
+
13
+ const green = wrap(32, 39);
14
+ const red = wrap(31, 39);
15
+ const yellow = wrap(33, 39);
16
+ const cyan = wrap(36, 39);
17
+ const dim = wrap(2, 22);
18
+ const bold = wrap(1, 22);
19
+
20
+ module.exports = {
21
+ enabled,
22
+ green,
23
+ red,
24
+ yellow,
25
+ cyan,
26
+ dim,
27
+ bold,
28
+ // status line prefixes — one glyph per kind keeps progress scannable
29
+ ok: (s) => `${green('✓')} ${s}`,
30
+ info: (s) => `${cyan('›')} ${s}`,
31
+ warn: (s) => `${yellow('⚠')} ${s}`,
32
+ fail: (s) => `${red('✗')} ${s}`,
33
+ // strip ANSI codes (for width calculations)
34
+ visible: (s) => String(s).replace(/\[[0-9;]*m/g, ''),
35
+ };
package/src/util.js CHANGED
@@ -85,14 +85,17 @@ function fmtRemaining(untilTs) {
85
85
  if (ms <= 0) return 'now';
86
86
  const h = Math.floor(ms / 3600000);
87
87
  const m = Math.round((ms % 3600000) / 60000);
88
+ if (h >= 48) return `${Math.floor(h / 24)}d${h % 24}h`;
88
89
  return h > 0 ? `${h}h${m}m` : `${m}m`;
89
90
  }
90
91
 
91
92
  function table(rows, headers) {
93
+ const { visible, dim } = require('./ui.js');
92
94
  const all = [headers, ...rows].map((r) => r.map((c) => String(c == null ? '-' : c)));
93
- const widths = headers.map((_, i) => Math.max(...all.map((r) => r[i].length)));
94
- const line = (r) => r.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
95
- return [line(all[0]), line(widths.map((w) => '-'.repeat(w))), ...all.slice(1).map(line)].join('\n');
95
+ const widths = headers.map((_, i) => Math.max(...all.map((r) => visible(r[i]).length)));
96
+ const pad = (c, w) => c + ' '.repeat(Math.max(0, w - visible(c).length));
97
+ const line = (r) => r.map((c, i) => pad(c, widths[i])).join(' ').trimEnd();
98
+ return [dim(line(all[0])), dim(line(widths.map((w) => '-'.repeat(w)))), ...all.slice(1).map(line)].join('\n');
96
99
  }
97
100
 
98
101
  module.exports = {