1use anyhow::Result;
2use chrono::Utc;
3use std::path::{Path, PathBuf};
4use std::sync::mpsc;
5use std::thread;
6use std::time::Duration;
7
8use crate::auth;
9use crate::rate_limits::client::{UsageRequest, fetch_usage};
10use nils_common::env as shared_env;
11use nils_term::progress::{Progress, ProgressFinish, ProgressOptions};
12
13pub mod ansi;
14pub mod cache;
15pub mod client;
16pub mod render;
17pub mod writeback;
18
19#[derive(Clone, Debug)]
20pub struct RateLimitsOptions {
21 pub clear_cache: bool,
22 pub debug: bool,
23 pub cached: bool,
24 pub no_refresh_auth: bool,
25 pub json: bool,
26 pub one_line: bool,
27 pub all: bool,
28 pub async_mode: bool,
29 pub jobs: Option<String>,
30 pub secret: Option<String>,
31}
32
33pub fn run(args: &RateLimitsOptions) -> Result<i32> {
34 let cached_mode = args.cached;
35 let mut one_line = args.one_line;
36 let mut all_mode = args.all;
37 let output_json = args.json;
38
39 let mut debug_mode = args.debug;
40 if !debug_mode
41 && let Ok(raw) = std::env::var("ZSH_DEBUG")
42 && raw.parse::<i64>().unwrap_or(0) >= 2
43 {
44 debug_mode = true;
45 }
46
47 if args.async_mode {
48 return run_async_mode(args, debug_mode);
49 }
50
51 if cached_mode {
52 one_line = true;
53 if output_json {
54 eprintln!("codex-rate-limits: --json is not supported with --cached");
55 return Ok(64);
56 }
57 if args.clear_cache {
58 eprintln!("codex-rate-limits: -c is not compatible with --cached");
59 return Ok(64);
60 }
61 }
62
63 if output_json && one_line {
64 eprintln!("codex-rate-limits: --one-line is not compatible with --json");
65 return Ok(64);
66 }
67
68 if args.clear_cache
69 && let Err(err) = cache::clear_starship_cache()
70 {
71 eprintln!("{err}");
72 return Ok(1);
73 }
74
75 if !all_mode
76 && !output_json
77 && !cached_mode
78 && args.secret.is_none()
79 && shared_env::env_truthy("CODEX_RATE_LIMITS_DEFAULT_ALL_ENABLED")
80 {
81 all_mode = true;
82 }
83
84 if all_mode {
85 if output_json {
86 eprintln!("codex-rate-limits: --json is not supported with --all");
87 return Ok(64);
88 }
89 if args.secret.is_some() {
90 eprintln!(
91 "codex-rate-limits: usage: codex-rate-limits [-c] [-d] [--cached] [--no-refresh-auth] [--json] [--one-line] [--all] [secret.json]"
92 );
93 return Ok(64);
94 }
95 return run_all_mode(args, cached_mode, debug_mode);
96 }
97
98 run_single_mode(args, cached_mode, one_line, output_json)
99}
100
101struct AsyncEvent {
102 secret_name: String,
103 line: Option<String>,
104 rc: i32,
105 err: String,
106}
107
108struct AsyncFetchResult {
109 line: Option<String>,
110 rc: i32,
111 err: String,
112}
113
114fn run_async_mode(args: &RateLimitsOptions, debug_mode: bool) -> Result<i32> {
115 if args.json {
116 eprintln!("codex-rate-limits: --async does not support --json");
117 return Ok(64);
118 }
119 if args.one_line {
120 eprintln!("codex-rate-limits: --async does not support --one-line");
121 return Ok(64);
122 }
123 if let Some(secret) = args.secret.as_deref() {
124 eprintln!(
125 "codex-rate-limits: --async does not accept positional args: {}",
126 secret
127 );
128 eprintln!(
129 "codex-rate-limits: hint: async always queries all secrets under CODEX_SECRET_DIR"
130 );
131 return Ok(64);
132 }
133 if args.clear_cache && args.cached {
134 eprintln!("codex-rate-limits: --async: -c is not compatible with --cached");
135 return Ok(64);
136 }
137
138 let jobs = args
139 .jobs
140 .as_deref()
141 .and_then(|raw| raw.parse::<i64>().ok())
142 .filter(|value| *value > 0)
143 .map(|value| value as usize)
144 .unwrap_or(5);
145
146 if args.clear_cache
147 && let Err(err) = cache::clear_starship_cache()
148 {
149 eprintln!("{err}");
150 return Ok(1);
151 }
152
153 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
154 if !secret_dir.is_dir() {
155 eprintln!(
156 "codex-rate-limits-async: CODEX_SECRET_DIR not found: {}",
157 secret_dir.display()
158 );
159 return Ok(1);
160 }
161
162 let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)?
163 .flatten()
164 .map(|entry| entry.path())
165 .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
166 .collect();
167
168 if secret_files.is_empty() {
169 eprintln!(
170 "codex-rate-limits-async: no secrets found in {}",
171 secret_dir.display()
172 );
173 return Ok(1);
174 }
175
176 secret_files.sort();
177
178 let total = secret_files.len();
179 let progress = if total > 1 {
180 Some(Progress::new(
181 total as u64,
182 ProgressOptions::default()
183 .with_prefix("codex-rate-limits ")
184 .with_finish(ProgressFinish::Clear),
185 ))
186 } else {
187 None
188 };
189
190 let (tx, rx) = mpsc::channel();
191 let mut handles = Vec::new();
192 let mut index = 0usize;
193 let worker_count = jobs.min(total);
194
195 let spawn_worker = |path: PathBuf,
196 cached_mode: bool,
197 no_refresh_auth: bool,
198 tx: mpsc::Sender<AsyncEvent>|
199 -> thread::JoinHandle<()> {
200 thread::spawn(move || {
201 let secret_name = path
202 .file_name()
203 .and_then(|name| name.to_str())
204 .unwrap_or("")
205 .to_string();
206 let result = async_fetch_one_line(&path, cached_mode, no_refresh_auth, &secret_name);
207 let _ = tx.send(AsyncEvent {
208 secret_name,
209 line: result.line,
210 rc: result.rc,
211 err: result.err,
212 });
213 })
214 };
215
216 while index < total && handles.len() < worker_count {
217 let path = secret_files[index].clone();
218 index += 1;
219 handles.push(spawn_worker(
220 path,
221 args.cached,
222 args.no_refresh_auth,
223 tx.clone(),
224 ));
225 }
226
227 let mut events: std::collections::HashMap<String, AsyncEvent> =
228 std::collections::HashMap::new();
229 while events.len() < total {
230 let event = match rx.recv() {
231 Ok(event) => event,
232 Err(_) => break,
233 };
234 if let Some(progress) = &progress {
235 progress.set_message(event.secret_name.clone());
236 progress.inc(1);
237 }
238 events.insert(event.secret_name.clone(), event);
239
240 if index < total {
241 let path = secret_files[index].clone();
242 index += 1;
243 handles.push(spawn_worker(
244 path,
245 args.cached,
246 args.no_refresh_auth,
247 tx.clone(),
248 ));
249 }
250 }
251
252 if let Some(progress) = progress {
253 progress.finish_and_clear();
254 }
255
256 drop(tx);
257 for handle in handles {
258 let _ = handle.join();
259 }
260
261 println!("\n🚦 Codex rate limits for all accounts\n");
262
263 let mut rc = 0;
264 let mut rows: Vec<Row> = Vec::new();
265 let mut window_labels = std::collections::HashSet::new();
266 let mut stderr_map: std::collections::HashMap<String, String> =
267 std::collections::HashMap::new();
268
269 for secret_file in &secret_files {
270 let secret_name = secret_file
271 .file_name()
272 .and_then(|name| name.to_str())
273 .unwrap_or("")
274 .to_string();
275
276 let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
277 let event = events.get(&secret_name);
278 if let Some(event) = event {
279 if !event.err.is_empty() {
280 stderr_map.insert(secret_name.clone(), event.err.clone());
281 }
282 if !args.cached && event.rc != 0 {
283 rc = 1;
284 }
285
286 if let Some(line) = &event.line
287 && let Some(parsed) = parse_one_line_output(line)
288 {
289 row.window_label = parsed.window_label.clone();
290 row.non_weekly_remaining = parsed.non_weekly_remaining;
291 row.weekly_remaining = parsed.weekly_remaining;
292 row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
293
294 if args.cached {
295 if let Ok(cache_entry) = cache::read_cache_entry(secret_file) {
296 row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
297 row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
298 }
299 } else {
300 let values = crate::json::read_json(secret_file).ok();
301 if let Some(values) = values {
302 row.non_weekly_reset_epoch = crate::json::i64_at(
303 &values,
304 &["codex_rate_limits", "non_weekly_reset_at_epoch"],
305 );
306 row.weekly_reset_epoch = crate::json::i64_at(
307 &values,
308 &["codex_rate_limits", "weekly_reset_at_epoch"],
309 );
310 }
311 if (row.non_weekly_reset_epoch.is_none() || row.weekly_reset_epoch.is_none())
312 && let Ok(cache_entry) = cache::read_cache_entry(secret_file)
313 {
314 if row.non_weekly_reset_epoch.is_none() {
315 row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
316 }
317 if row.weekly_reset_epoch.is_none() {
318 row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
319 }
320 }
321 }
322
323 window_labels.insert(row.window_label.clone());
324 rows.push(row);
325 continue;
326 }
327 }
328
329 if !args.cached {
330 rc = 1;
331 }
332 rows.push(row);
333 }
334
335 let mut non_weekly_header = "Non-weekly".to_string();
336 let multiple_labels = window_labels.len() != 1;
337 if !multiple_labels && let Some(label) = window_labels.iter().next() {
338 non_weekly_header = label.clone();
339 }
340
341 let current_name = current_secret_basename(&secret_files);
342
343 let now_epoch = Utc::now().timestamp();
344
345 println!(
346 "{:<15} {:>8} {:>7} {:>8} {:>7} {:<18}",
347 "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
348 );
349 println!("----------------------------------------------------------------------------");
350
351 rows.sort_by_key(|row| row.sort_key());
352
353 for row in rows {
354 let display_non_weekly = if multiple_labels && !row.window_label.is_empty() {
355 if row.non_weekly_remaining >= 0 {
356 format!("{}:{}%", row.window_label, row.non_weekly_remaining)
357 } else {
358 "-".to_string()
359 }
360 } else if row.non_weekly_remaining >= 0 {
361 format!("{}%", row.non_weekly_remaining)
362 } else {
363 "-".to_string()
364 };
365
366 let non_weekly_left = row
367 .non_weekly_reset_epoch
368 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
369 .unwrap_or_else(|| "-".to_string());
370 let weekly_left = row
371 .weekly_reset_epoch
372 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
373 .unwrap_or_else(|| "-".to_string());
374 let reset_display = row
375 .weekly_reset_epoch
376 .and_then(render::format_epoch_local_datetime_with_offset)
377 .unwrap_or_else(|| "-".to_string());
378
379 let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
380 let weekly_display = if row.weekly_remaining >= 0 {
381 ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
382 } else {
383 ansi::format_percent_cell("-", 8, None)
384 };
385
386 let is_current = current_name.as_deref() == Some(row.name.as_str());
387 let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
388
389 println!(
390 "{} {} {:>7} {} {:>7} {:<18}",
391 name_display,
392 non_weekly_display,
393 non_weekly_left,
394 weekly_display,
395 weekly_left,
396 reset_display
397 );
398 }
399
400 if debug_mode {
401 let mut printed = false;
402 for secret_file in &secret_files {
403 let secret_name = secret_file
404 .file_name()
405 .and_then(|name| name.to_str())
406 .unwrap_or("")
407 .to_string();
408 if let Some(err) = stderr_map.get(&secret_name) {
409 if err.is_empty() {
410 continue;
411 }
412 if !printed {
413 printed = true;
414 eprintln!();
415 eprintln!("codex-rate-limits-async: per-account stderr (captured):");
416 }
417 eprintln!("---- {} ----", secret_name);
418 eprintln!("{err}");
419 }
420 }
421 }
422
423 Ok(rc)
424}
425
426fn async_fetch_one_line(
427 target_file: &Path,
428 cached_mode: bool,
429 no_refresh_auth: bool,
430 secret_name: &str,
431) -> AsyncFetchResult {
432 if cached_mode {
433 return fetch_one_line_cached(target_file);
434 }
435
436 let mut attempt = 1;
437 let max_attempts = 2;
438 let mut network_err: Option<String> = None;
439
440 let mut result = fetch_one_line_network(target_file, no_refresh_auth);
441 if !result.err.is_empty() {
442 network_err = Some(result.err.clone());
443 }
444
445 while attempt < max_attempts && result.rc == 3 {
446 thread::sleep(Duration::from_millis(250));
447 let next = fetch_one_line_network(target_file, no_refresh_auth);
448 if !next.err.is_empty() {
449 network_err = Some(next.err.clone());
450 }
451 result = next;
452 attempt += 1;
453 if result.rc != 3 {
454 break;
455 }
456 }
457
458 let mut errors: Vec<String> = Vec::new();
459 if let Some(err) = network_err {
460 errors.push(err);
461 }
462
463 let missing_line = result
464 .line
465 .as_ref()
466 .map(|line| line.trim().is_empty())
467 .unwrap_or(true);
468
469 if result.rc != 0 || missing_line {
470 let cached = fetch_one_line_cached(target_file);
471 if !cached.err.is_empty() {
472 errors.push(cached.err.clone());
473 }
474 if cached.rc == 0
475 && cached
476 .line
477 .as_ref()
478 .map(|line| !line.trim().is_empty())
479 .unwrap_or(false)
480 {
481 if result.rc != 0 {
482 errors.push(format!(
483 "codex-rate-limits-async: falling back to cache for {} (rc={})",
484 secret_name, result.rc
485 ));
486 }
487 result = AsyncFetchResult {
488 line: cached.line,
489 rc: 0,
490 err: String::new(),
491 };
492 }
493 }
494
495 let line = result.line.map(normalize_one_line);
496 let err = errors.join("\n");
497 AsyncFetchResult {
498 line,
499 rc: result.rc,
500 err,
501 }
502}
503
504fn fetch_one_line_network(target_file: &Path, no_refresh_auth: bool) -> AsyncFetchResult {
505 if !target_file.is_file() {
506 return AsyncFetchResult {
507 line: None,
508 rc: 1,
509 err: format!("codex-rate-limits: {} not found", target_file.display()),
510 };
511 }
512
513 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
514 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
515 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
516 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
517
518 let usage_request = UsageRequest {
519 target_file: target_file.to_path_buf(),
520 refresh_on_401: !no_refresh_auth,
521 base_url,
522 connect_timeout_seconds: connect_timeout,
523 max_time_seconds: max_time,
524 };
525
526 let usage = match fetch_usage(&usage_request) {
527 Ok(value) => value,
528 Err(err) => {
529 let msg = err.to_string();
530 if msg.contains("missing access_token") {
531 return AsyncFetchResult {
532 line: None,
533 rc: 2,
534 err: format!(
535 "codex-rate-limits: missing access_token in {}",
536 target_file.display()
537 ),
538 };
539 }
540 return AsyncFetchResult {
541 line: None,
542 rc: 3,
543 err: msg,
544 };
545 }
546 };
547
548 if let Err(err) = writeback::write_weekly(target_file, &usage.json) {
549 return AsyncFetchResult {
550 line: None,
551 rc: 4,
552 err: err.to_string(),
553 };
554 }
555
556 if is_auth_file(target_file) {
557 match sync_auth_silent() {
558 Ok((sync_rc, sync_err)) => {
559 if sync_rc != 0 {
560 return AsyncFetchResult {
561 line: None,
562 rc: 5,
563 err: sync_err.unwrap_or_default(),
564 };
565 }
566 }
567 Err(_) => {
568 return AsyncFetchResult {
569 line: None,
570 rc: 1,
571 err: String::new(),
572 };
573 }
574 }
575 }
576
577 let usage_data = match render::parse_usage(&usage.json) {
578 Some(value) => value,
579 None => {
580 return AsyncFetchResult {
581 line: None,
582 rc: 3,
583 err: "codex-rate-limits: invalid usage payload".to_string(),
584 };
585 }
586 };
587
588 let values = render::render_values(&usage_data);
589 let weekly = render::weekly_values(&values);
590
591 let fetched_at_epoch = Utc::now().timestamp();
592 if fetched_at_epoch > 0 {
593 let _ = cache::write_starship_cache(
594 target_file,
595 fetched_at_epoch,
596 &weekly.non_weekly_label,
597 weekly.non_weekly_remaining,
598 weekly.weekly_remaining,
599 weekly.weekly_reset_epoch,
600 weekly.non_weekly_reset_epoch,
601 );
602 }
603
604 AsyncFetchResult {
605 line: Some(format_one_line_output(
606 target_file,
607 &weekly.non_weekly_label,
608 weekly.non_weekly_remaining,
609 weekly.weekly_remaining,
610 weekly.weekly_reset_epoch,
611 )),
612 rc: 0,
613 err: String::new(),
614 }
615}
616
617fn fetch_one_line_cached(target_file: &Path) -> AsyncFetchResult {
618 match cache::read_cache_entry(target_file) {
619 Ok(entry) => AsyncFetchResult {
620 line: Some(format_one_line_output(
621 target_file,
622 &entry.non_weekly_label,
623 entry.non_weekly_remaining,
624 entry.weekly_remaining,
625 entry.weekly_reset_epoch,
626 )),
627 rc: 0,
628 err: String::new(),
629 },
630 Err(err) => AsyncFetchResult {
631 line: None,
632 rc: 1,
633 err: err.to_string(),
634 },
635 }
636}
637
638fn format_one_line_output(
639 target_file: &Path,
640 non_weekly_label: &str,
641 non_weekly_remaining: i64,
642 weekly_remaining: i64,
643 weekly_reset_epoch: i64,
644) -> String {
645 let prefix = cache::secret_name_for_target(target_file)
646 .map(|name| format!("{name} "))
647 .unwrap_or_default();
648 let weekly_reset_iso =
649 render::format_epoch_local_datetime(weekly_reset_epoch).unwrap_or_else(|| "?".to_string());
650
651 format!(
652 "{}{}:{}% W:{}% {}",
653 prefix, non_weekly_label, non_weekly_remaining, weekly_remaining, weekly_reset_iso
654 )
655}
656
657fn normalize_one_line(line: String) -> String {
658 line.replace(['\n', '\r', '\t'], " ")
659}
660
661fn sync_auth_silent() -> Result<(i32, Option<String>)> {
662 let auth_file = match crate::paths::resolve_auth_file() {
663 Some(path) => path,
664 None => return Ok((0, None)),
665 };
666
667 if !auth_file.is_file() {
668 return Ok((0, None));
669 }
670
671 let auth_key = match auth::identity_key_from_auth_file(&auth_file) {
672 Ok(Some(key)) => key,
673 _ => return Ok((0, None)),
674 };
675
676 let auth_last_refresh = auth::last_refresh_from_auth_file(&auth_file).unwrap_or(None);
677 let auth_hash = match crate::fs::sha256_file(&auth_file) {
678 Ok(hash) => hash,
679 Err(_) => {
680 return Ok((
681 1,
682 Some(format!("codex: failed to hash {}", auth_file.display())),
683 ));
684 }
685 };
686
687 if let Some(secret_dir) = crate::paths::resolve_secret_dir()
688 && let Ok(entries) = std::fs::read_dir(&secret_dir)
689 {
690 for entry in entries.flatten() {
691 let path = entry.path();
692 if path.extension().and_then(|s| s.to_str()) != Some("json") {
693 continue;
694 }
695 let candidate_key = match auth::identity_key_from_auth_file(&path) {
696 Ok(Some(key)) => key,
697 _ => continue,
698 };
699 if candidate_key != auth_key {
700 continue;
701 }
702
703 let secret_hash = match crate::fs::sha256_file(&path) {
704 Ok(hash) => hash,
705 Err(_) => {
706 return Ok((1, Some(format!("codex: failed to hash {}", path.display()))));
707 }
708 };
709 if secret_hash == auth_hash {
710 continue;
711 }
712
713 let contents = std::fs::read(&auth_file)?;
714 crate::fs::write_atomic(&path, &contents, crate::fs::SECRET_FILE_MODE)?;
715
716 let timestamp_path = secret_timestamp_path(&path)?;
717 crate::fs::write_timestamp(×tamp_path, auth_last_refresh.as_deref())?;
718 }
719 }
720
721 let auth_timestamp = secret_timestamp_path(&auth_file)?;
722 crate::fs::write_timestamp(&auth_timestamp, auth_last_refresh.as_deref())?;
723
724 Ok((0, None))
725}
726
727fn secret_timestamp_path(target_file: &Path) -> Result<PathBuf> {
728 let cache_dir = crate::paths::resolve_secret_cache_dir()
729 .ok_or_else(|| anyhow::anyhow!("CODEX_SECRET_CACHE_DIR not resolved"))?;
730 let name = target_file
731 .file_name()
732 .and_then(|name| name.to_str())
733 .unwrap_or("auth.json");
734 Ok(cache_dir.join(format!("{name}.timestamp")))
735}
736
737fn run_all_mode(args: &RateLimitsOptions, cached_mode: bool, debug_mode: bool) -> Result<i32> {
738 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
739 if !secret_dir.is_dir() {
740 eprintln!(
741 "codex-rate-limits: CODEX_SECRET_DIR not found: {}",
742 secret_dir.display()
743 );
744 return Ok(1);
745 }
746
747 let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)?
748 .flatten()
749 .map(|entry| entry.path())
750 .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
751 .collect();
752
753 if secret_files.is_empty() {
754 eprintln!(
755 "codex-rate-limits: no secrets found in {}",
756 secret_dir.display()
757 );
758 return Ok(1);
759 }
760
761 secret_files.sort();
762
763 let current_name = current_secret_basename(&secret_files);
764
765 let total = secret_files.len();
766 let progress = if total > 1 {
767 Some(Progress::new(
768 total as u64,
769 ProgressOptions::default()
770 .with_prefix("codex-rate-limits ")
771 .with_finish(ProgressFinish::Clear),
772 ))
773 } else {
774 None
775 };
776
777 let mut rc = 0;
778 let mut rows: Vec<Row> = Vec::new();
779 let mut window_labels = std::collections::HashSet::new();
780
781 for secret_file in secret_files {
782 let secret_name = secret_file
783 .file_name()
784 .and_then(|name| name.to_str())
785 .unwrap_or("")
786 .to_string();
787 if let Some(progress) = &progress {
788 progress.set_message(secret_name.clone());
789 }
790
791 let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
792 let output =
793 match single_one_line(&secret_file, cached_mode, args.no_refresh_auth, debug_mode) {
794 Ok(Some(line)) => line,
795 Ok(None) => String::new(),
796 Err(_) => String::new(),
797 };
798
799 if output.is_empty() {
800 if !cached_mode {
801 rc = 1;
802 }
803 rows.push(row);
804 continue;
805 }
806
807 if let Some(parsed) = parse_one_line_output(&output) {
808 row.window_label = parsed.window_label.clone();
809 row.non_weekly_remaining = parsed.non_weekly_remaining;
810 row.weekly_remaining = parsed.weekly_remaining;
811 row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
812
813 if cached_mode {
814 if let Ok(cache_entry) = cache::read_cache_entry(&secret_file) {
815 row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
816 row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
817 }
818 } else {
819 let values = crate::json::read_json(&secret_file).ok();
820 if let Some(values) = values {
821 row.non_weekly_reset_epoch = crate::json::i64_at(
822 &values,
823 &["codex_rate_limits", "non_weekly_reset_at_epoch"],
824 );
825 row.weekly_reset_epoch = crate::json::i64_at(
826 &values,
827 &["codex_rate_limits", "weekly_reset_at_epoch"],
828 );
829 }
830 }
831
832 window_labels.insert(row.window_label.clone());
833 rows.push(row);
834 } else {
835 if !cached_mode {
836 rc = 1;
837 }
838 rows.push(row);
839 }
840
841 if let Some(progress) = &progress {
842 progress.inc(1);
843 }
844 }
845
846 if let Some(progress) = progress {
847 progress.finish_and_clear();
848 }
849
850 println!("\n🚦 Codex rate limits for all accounts\n");
851
852 let mut non_weekly_header = "Non-weekly".to_string();
853 let multiple_labels = window_labels.len() != 1;
854 if !multiple_labels && let Some(label) = window_labels.iter().next() {
855 non_weekly_header = label.clone();
856 }
857
858 let now_epoch = Utc::now().timestamp();
859
860 println!(
861 "{:<15} {:>8} {:>7} {:>8} {:>7} {:<18}",
862 "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
863 );
864 println!("----------------------------------------------------------------------------");
865
866 rows.sort_by_key(|row| row.sort_key());
867
868 for row in rows {
869 let display_non_weekly = if multiple_labels && !row.window_label.is_empty() {
870 if row.non_weekly_remaining >= 0 {
871 format!("{}:{}%", row.window_label, row.non_weekly_remaining)
872 } else {
873 "-".to_string()
874 }
875 } else if row.non_weekly_remaining >= 0 {
876 format!("{}%", row.non_weekly_remaining)
877 } else {
878 "-".to_string()
879 };
880
881 let non_weekly_left = row
882 .non_weekly_reset_epoch
883 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
884 .unwrap_or_else(|| "-".to_string());
885 let weekly_left = row
886 .weekly_reset_epoch
887 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
888 .unwrap_or_else(|| "-".to_string());
889 let reset_display = row
890 .weekly_reset_epoch
891 .and_then(render::format_epoch_local_datetime_with_offset)
892 .unwrap_or_else(|| "-".to_string());
893
894 let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
895 let weekly_display = if row.weekly_remaining >= 0 {
896 ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
897 } else {
898 ansi::format_percent_cell("-", 8, None)
899 };
900
901 let is_current = current_name.as_deref() == Some(row.name.as_str());
902 let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
903
904 println!(
905 "{} {} {:>7} {} {:>7} {:<18}",
906 name_display,
907 non_weekly_display,
908 non_weekly_left,
909 weekly_display,
910 weekly_left,
911 reset_display
912 );
913 }
914
915 Ok(rc)
916}
917
918fn current_secret_basename(secret_files: &[PathBuf]) -> Option<String> {
919 let auth_file = crate::paths::resolve_auth_file()?;
920 if !auth_file.is_file() {
921 return None;
922 }
923
924 let auth_key = auth::identity_key_from_auth_file(&auth_file).ok().flatten();
925 let auth_hash = crate::fs::sha256_file(&auth_file).ok();
926
927 if let Some(auth_hash) = auth_hash.as_deref() {
928 for secret_file in secret_files {
929 if let Ok(secret_hash) = crate::fs::sha256_file(secret_file)
930 && secret_hash == auth_hash
931 && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
932 {
933 return Some(name.trim_end_matches(".json").to_string());
934 }
935 }
936 }
937
938 if let Some(auth_key) = auth_key.as_deref() {
939 for secret_file in secret_files {
940 if let Ok(Some(candidate_key)) = auth::identity_key_from_auth_file(secret_file)
941 && candidate_key == auth_key
942 && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
943 {
944 return Some(name.trim_end_matches(".json").to_string());
945 }
946 }
947 }
948
949 None
950}
951
952fn run_single_mode(
953 args: &RateLimitsOptions,
954 cached_mode: bool,
955 one_line: bool,
956 output_json: bool,
957) -> Result<i32> {
958 let target_file = match resolve_target(args.secret.as_deref()) {
959 Ok(path) => path,
960 Err(code) => return Ok(code),
961 };
962
963 if !target_file.is_file() {
964 eprintln!("codex-rate-limits: {} not found", target_file.display());
965 return Ok(1);
966 }
967
968 if cached_mode {
969 match cache::read_cache_entry(&target_file) {
970 Ok(entry) => {
971 let weekly_reset_iso =
972 render::format_epoch_local_datetime(entry.weekly_reset_epoch)
973 .unwrap_or_else(|| "?".to_string());
974 let prefix = cache::secret_name_for_target(&target_file)
975 .map(|name| format!("{name} "))
976 .unwrap_or_default();
977 println!(
978 "{}{}:{}% W:{}% {}",
979 prefix,
980 entry.non_weekly_label,
981 entry.non_weekly_remaining,
982 entry.weekly_remaining,
983 weekly_reset_iso
984 );
985 return Ok(0);
986 }
987 Err(err) => {
988 eprintln!("{err}");
989 return Ok(1);
990 }
991 }
992 }
993
994 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
995 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
996 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
997 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
998
999 let usage_request = UsageRequest {
1000 target_file: target_file.clone(),
1001 refresh_on_401: !args.no_refresh_auth,
1002 base_url,
1003 connect_timeout_seconds: connect_timeout,
1004 max_time_seconds: max_time,
1005 };
1006
1007 let usage = match fetch_usage(&usage_request) {
1008 Ok(value) => value,
1009 Err(err) => {
1010 let msg = err.to_string();
1011 if msg.contains("missing access_token") {
1012 eprintln!(
1013 "codex-rate-limits: missing access_token in {}",
1014 target_file.display()
1015 );
1016 return Ok(2);
1017 }
1018 eprintln!("{msg}");
1019 return Ok(3);
1020 }
1021 };
1022
1023 if let Err(err) = writeback::write_weekly(&target_file, &usage.json) {
1024 eprintln!("{err}");
1025 return Ok(4);
1026 }
1027
1028 if is_auth_file(&target_file) {
1029 let sync_rc = auth::sync::run()?;
1030 if sync_rc != 0 {
1031 return Ok(5);
1032 }
1033 }
1034
1035 if output_json {
1036 println!("{}", usage.body);
1037 return Ok(0);
1038 }
1039
1040 let usage_data = match render::parse_usage(&usage.json) {
1041 Some(value) => value,
1042 None => {
1043 eprintln!("codex-rate-limits: invalid usage payload");
1044 return Ok(3);
1045 }
1046 };
1047
1048 let values = render::render_values(&usage_data);
1049 let weekly = render::weekly_values(&values);
1050
1051 let fetched_at_epoch = Utc::now().timestamp();
1052 if fetched_at_epoch > 0 {
1053 let _ = cache::write_starship_cache(
1054 &target_file,
1055 fetched_at_epoch,
1056 &weekly.non_weekly_label,
1057 weekly.non_weekly_remaining,
1058 weekly.weekly_remaining,
1059 weekly.weekly_reset_epoch,
1060 weekly.non_weekly_reset_epoch,
1061 );
1062 }
1063
1064 if one_line {
1065 let prefix = cache::secret_name_for_target(&target_file)
1066 .map(|name| format!("{name} "))
1067 .unwrap_or_default();
1068 let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
1069 .unwrap_or_else(|| "?".to_string());
1070
1071 println!(
1072 "{}{}:{}% W:{}% {}",
1073 prefix,
1074 weekly.non_weekly_label,
1075 weekly.non_weekly_remaining,
1076 weekly.weekly_remaining,
1077 weekly_reset_iso
1078 );
1079 return Ok(0);
1080 }
1081
1082 println!("Rate limits remaining");
1083 let primary_reset = render::format_epoch_local_datetime(values.primary_reset_epoch)
1084 .unwrap_or_else(|| "?".to_string());
1085 let secondary_reset = render::format_epoch_local_datetime(values.secondary_reset_epoch)
1086 .unwrap_or_else(|| "?".to_string());
1087
1088 println!(
1089 "{} {}% • {}",
1090 values.primary_label, values.primary_remaining, primary_reset
1091 );
1092 println!(
1093 "{} {}% • {}",
1094 values.secondary_label, values.secondary_remaining, secondary_reset
1095 );
1096
1097 Ok(0)
1098}
1099
1100fn single_one_line(
1101 target_file: &Path,
1102 cached_mode: bool,
1103 no_refresh_auth: bool,
1104 debug_mode: bool,
1105) -> Result<Option<String>> {
1106 if !target_file.is_file() {
1107 if debug_mode {
1108 eprintln!("codex-rate-limits: {} not found", target_file.display());
1109 }
1110 return Ok(None);
1111 }
1112
1113 if cached_mode {
1114 return match cache::read_cache_entry(target_file) {
1115 Ok(entry) => {
1116 let weekly_reset_iso =
1117 render::format_epoch_local_datetime(entry.weekly_reset_epoch)
1118 .unwrap_or_else(|| "?".to_string());
1119 let prefix = cache::secret_name_for_target(target_file)
1120 .map(|name| format!("{name} "))
1121 .unwrap_or_default();
1122 Ok(Some(format!(
1123 "{}{}:{}% W:{}% {}",
1124 prefix,
1125 entry.non_weekly_label,
1126 entry.non_weekly_remaining,
1127 entry.weekly_remaining,
1128 weekly_reset_iso
1129 )))
1130 }
1131 Err(err) => {
1132 if debug_mode {
1133 eprintln!("{err}");
1134 }
1135 Ok(None)
1136 }
1137 };
1138 }
1139
1140 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1141 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1142 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1143 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1144
1145 let usage_request = UsageRequest {
1146 target_file: target_file.to_path_buf(),
1147 refresh_on_401: !no_refresh_auth,
1148 base_url,
1149 connect_timeout_seconds: connect_timeout,
1150 max_time_seconds: max_time,
1151 };
1152
1153 let usage = match fetch_usage(&usage_request) {
1154 Ok(value) => value,
1155 Err(err) => {
1156 if debug_mode {
1157 eprintln!("{err}");
1158 }
1159 return Ok(None);
1160 }
1161 };
1162
1163 let _ = writeback::write_weekly(target_file, &usage.json);
1164 if is_auth_file(target_file) {
1165 let _ = auth::sync::run();
1166 }
1167
1168 let usage_data = match render::parse_usage(&usage.json) {
1169 Some(value) => value,
1170 None => return Ok(None),
1171 };
1172 let values = render::render_values(&usage_data);
1173 let weekly = render::weekly_values(&values);
1174 let prefix = cache::secret_name_for_target(target_file)
1175 .map(|name| format!("{name} "))
1176 .unwrap_or_default();
1177 let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
1178 .unwrap_or_else(|| "?".to_string());
1179
1180 Ok(Some(format!(
1181 "{}{}:{}% W:{}% {}",
1182 prefix,
1183 weekly.non_weekly_label,
1184 weekly.non_weekly_remaining,
1185 weekly.weekly_remaining,
1186 weekly_reset_iso
1187 )))
1188}
1189
1190fn resolve_target(secret: Option<&str>) -> std::result::Result<PathBuf, i32> {
1191 if let Some(secret_name) = secret {
1192 if secret_name.is_empty() || secret_name.contains('/') || secret_name.contains("..") {
1193 eprintln!("codex-rate-limits: invalid secret file name: {secret_name}");
1194 return Err(64);
1195 }
1196 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
1197 return Ok(secret_dir.join(secret_name));
1198 }
1199
1200 if let Some(auth_file) = crate::paths::resolve_auth_file() {
1201 return Ok(auth_file);
1202 }
1203
1204 Err(1)
1205}
1206
1207fn is_auth_file(target_file: &Path) -> bool {
1208 if let Some(auth_file) = crate::paths::resolve_auth_file() {
1209 return auth_file == target_file;
1210 }
1211 false
1212}
1213
1214fn env_timeout(key: &str, default: u64) -> u64 {
1215 std::env::var(key)
1216 .ok()
1217 .and_then(|raw| raw.parse::<u64>().ok())
1218 .unwrap_or(default)
1219}
1220
1221struct Row {
1222 name: String,
1223 window_label: String,
1224 non_weekly_remaining: i64,
1225 non_weekly_reset_epoch: Option<i64>,
1226 weekly_remaining: i64,
1227 weekly_reset_epoch: Option<i64>,
1228 weekly_reset_iso: String,
1229}
1230
1231impl Row {
1232 fn empty(name: String) -> Self {
1233 Self {
1234 name,
1235 window_label: String::new(),
1236 non_weekly_remaining: -1,
1237 non_weekly_reset_epoch: None,
1238 weekly_remaining: -1,
1239 weekly_reset_epoch: None,
1240 weekly_reset_iso: String::new(),
1241 }
1242 }
1243
1244 fn sort_key(&self) -> (i32, i64, String) {
1245 if let Some(epoch) = self.weekly_reset_epoch {
1246 (0, epoch, self.name.clone())
1247 } else {
1248 (1, i64::MAX, self.name.clone())
1249 }
1250 }
1251}
1252
1253struct ParsedOneLine {
1254 window_label: String,
1255 non_weekly_remaining: i64,
1256 weekly_remaining: i64,
1257 weekly_reset_iso: String,
1258}
1259
1260fn parse_one_line_output(line: &str) -> Option<ParsedOneLine> {
1261 let parts: Vec<&str> = line.split_whitespace().collect();
1262 if parts.len() < 3 {
1263 return None;
1264 }
1265
1266 fn parse_fields(
1267 window_field: &str,
1268 weekly_field: &str,
1269 reset_iso: String,
1270 ) -> Option<ParsedOneLine> {
1271 let window_label = window_field
1272 .split(':')
1273 .next()?
1274 .trim_matches('"')
1275 .to_string();
1276 let non_weekly_remaining = window_field.split(':').nth(1)?;
1277 let non_weekly_remaining = non_weekly_remaining
1278 .trim_end_matches('%')
1279 .parse::<i64>()
1280 .ok()?;
1281
1282 let weekly_remaining = weekly_field.trim_start_matches("W:").trim_end_matches('%');
1283 let weekly_remaining = weekly_remaining.parse::<i64>().ok()?;
1284
1285 Some(ParsedOneLine {
1286 window_label,
1287 non_weekly_remaining,
1288 weekly_remaining,
1289 weekly_reset_iso: reset_iso,
1290 })
1291 }
1292
1293 let len = parts.len();
1294 let window_field = parts[len - 3];
1295 let weekly_field = parts[len - 2];
1296 let reset_iso = parts[len - 1].to_string();
1297
1298 if let Some(parsed) = parse_fields(window_field, weekly_field, reset_iso) {
1299 return Some(parsed);
1300 }
1301
1302 if len < 4 {
1303 return None;
1304 }
1305
1306 parse_fields(
1307 parts[len - 4],
1308 parts[len - 3],
1309 format!("{} {}", parts[len - 2], parts[len - 1]),
1310 )
1311}