1pub mod account_history;
2pub mod auth;
3pub mod cli;
4pub mod cycles;
5pub mod doctor;
6pub mod error;
7pub mod format;
8pub mod pricing;
9pub mod prompt;
10pub mod stats;
11pub mod storage;
12pub mod time;
13
14use crate::auth::{
15 format_auth_profile_entry, format_auth_profile_list, format_auth_status,
16 list_codex_auth_profiles, read_codex_auth_status, remove_codex_auth_profile,
17 save_current_codex_auth_profile, switch_codex_auth_profile, AuthCommandOptions,
18 AuthProfileEntry, AuthProfileListReport,
19};
20use crate::cli::{
21 AuthCliCommand, AuthCliPaths, AuthProfileCliOptions, AuthRemoveCliOptions,
22 AuthSelectCliOptions, AuthStatusCliOptions, CliCommand, CycleCliCommand, DoctorCliCommand,
23 DoctorCliPaths, ParsedCli, StatCliCommand,
24};
25use crate::cycles::{
26 run_cycle_add, run_cycle_current, run_cycle_history, run_cycle_list, run_cycle_remove,
27};
28use crate::doctor::{format_doctor_report, read_doctor_report, DoctorOptions};
29use crate::error::AppError;
30use crate::prompt::{DialoguerPrompt, Prompt};
31use crate::stats::run_stat_command;
32use chrono::{DateTime, Utc};
33use std::env;
34
35const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
36
37#[derive(Debug, Eq, PartialEq)]
38pub struct CliResult {
39 pub code: i32,
40 pub stdout: String,
41 pub stderr: String,
42}
43
44impl CliResult {
45 fn success(stdout: impl Into<String>) -> Self {
46 Self {
47 code: 0,
48 stdout: ensure_trailing_newline(stdout.into()),
49 stderr: String::new(),
50 }
51 }
52
53 fn app_error(error: AppError) -> Self {
54 Self {
55 code: error.exit_code(),
56 stdout: String::new(),
57 stderr: ensure_trailing_newline(error.message().to_string()),
58 }
59 }
60
61 fn parse_error(code: i32, stderr: impl Into<String>) -> Self {
62 Self {
63 code,
64 stdout: String::new(),
65 stderr: ensure_trailing_newline(stderr.into()),
66 }
67 }
68}
69
70pub fn run_cli<I, S>(args: I) -> CliResult
71where
72 I: IntoIterator<Item = S>,
73 S: Into<String>,
74{
75 let args: Vec<String> = args.into_iter().map(Into::into).collect();
76
77 match cli::parse_cli(&args) {
78 Ok(ParsedCli::Help(help)) => CliResult::success(help),
79 Ok(ParsedCli::Version) => CliResult::success(PACKAGE_VERSION),
80 Ok(ParsedCli::Command(command)) => match *command {
81 CliCommand::Auth(command) => run_auth(command),
82 CliCommand::Doctor(command) => run_doctor(command),
83 CliCommand::Stat(command) => run_stat(command),
84 CliCommand::Cycle(command) => run_cycle(command),
85 },
86 Err(error) => CliResult::parse_error(error.code, error.message),
87 }
88}
89
90fn run_auth(command: AuthCliCommand) -> CliResult {
91 match command {
92 AuthCliCommand::Status(options) => run_auth_status(options),
93 AuthCliCommand::Save(options) => run_auth_save(options),
94 AuthCliCommand::List(options) => run_auth_list(options),
95 AuthCliCommand::Select(options) => run_auth_select(options),
96 AuthCliCommand::Remove(options) => run_auth_remove(options),
97 }
98}
99
100fn run_auth_status(options: AuthStatusCliOptions) -> CliResult {
101 let result = (|| {
102 let auth_options = auth_command_options(&options.paths);
103 let report = read_codex_auth_status(&auth_options, cli_now()?)?;
104 format_auth_status(&report, options.json, options.include_token_claims)
105 })();
106
107 cli_result_from_string(result)
108}
109
110fn run_auth_save(options: AuthProfileCliOptions) -> CliResult {
111 let result = (|| {
112 let auth_options = auth_command_options(&options.paths);
113 let report = save_current_codex_auth_profile(&auth_options, cli_now()?)?;
114 Ok(format!(
115 "Saved auth profile: {}\nStore: {}\n",
116 format_auth_profile_entry(&report.profile),
117 report.store_dir
118 ))
119 })();
120
121 cli_result_from_string(result)
122}
123
124fn run_auth_list(options: AuthProfileCliOptions) -> CliResult {
125 let result = (|| {
126 let auth_options = auth_command_options(&options.paths);
127 let report = list_codex_auth_profiles(&auth_options, cli_now()?)?;
128 Ok(format_auth_profile_list(&report))
129 })();
130
131 cli_result_from_string(result)
132}
133
134fn run_auth_select(options: AuthSelectCliOptions) -> CliResult {
135 let result = (|| {
136 let now = cli_now()?;
137 let auth_options = auth_command_options(&options.paths);
138 let report = list_codex_auth_profiles(&auth_options, now)?;
139 if let Some(account_id) = options.account_id.as_deref() {
140 return select_auth_profile_by_account_id(account_id, &report, &auth_options, now);
141 }
142
143 if !prompt::stdin_and_stderr_are_terminals() {
144 return Err(AppError::new(
145 "auth select requires an interactive terminal unless --account-id is supplied.",
146 ));
147 }
148
149 let mut prompt = DialoguerPrompt::default();
150 select_auth_profile_interactively(&report, &auth_options, now, &mut prompt)
151 })();
152
153 cli_result_from_string(result)
154}
155
156fn run_auth_remove(options: AuthRemoveCliOptions) -> CliResult {
157 let result = (|| {
158 let now = cli_now()?;
159 let auth_options = auth_command_options(&options.paths);
160 let report = list_codex_auth_profiles(&auth_options, now)?;
161 if report.stored.is_empty() {
162 return Ok("No persisted auth profiles.\n".to_string());
163 }
164
165 if let Some(account_id) = options.account_id.as_deref() {
166 if !options.yes {
167 return Err(AppError::new(
168 "auth remove --account-id requires --yes when not running interactively.",
169 ));
170 }
171 return remove_auth_profile_by_account_id(account_id, &report, &auth_options, now);
172 }
173
174 if !prompt::stdin_and_stderr_are_terminals() {
175 return Err(AppError::new(
176 "auth remove requires an interactive terminal unless --account-id is supplied.",
177 ));
178 }
179
180 let mut prompt = DialoguerPrompt::default();
181 remove_auth_profiles_interactively(&report, &auth_options, now, &mut prompt)
182 })();
183
184 cli_result_from_string(result)
185}
186
187fn select_auth_profile_interactively(
188 report: &AuthProfileListReport,
189 options: &AuthCommandOptions,
190 now: DateTime<Utc>,
191 prompt: &mut impl Prompt,
192) -> Result<String, AppError> {
193 if report.stored.is_empty() {
194 return Err(AppError::new("No persisted auth profiles."));
195 }
196
197 let items = report
198 .stored
199 .iter()
200 .map(format_auth_profile_entry)
201 .collect::<Vec<_>>();
202 let selected_index = prompt
203 .select("Select auth profile", &items)?
204 .ok_or_else(|| AppError::new("auth select cancelled."))?;
205 let selected = report
206 .stored
207 .get(selected_index)
208 .ok_or_else(|| AppError::new("Prompt returned an invalid auth profile selection."))?;
209
210 select_auth_profile_entry(selected, report, options, now)
211}
212
213fn select_auth_profile_by_account_id(
214 account_id: &str,
215 report: &AuthProfileListReport,
216 options: &AuthCommandOptions,
217 now: DateTime<Utc>,
218) -> Result<String, AppError> {
219 let selected = report
220 .stored
221 .iter()
222 .find(|entry| entry.account_id == account_id)
223 .ok_or_else(|| {
224 AppError::new(format!(
225 "No persisted auth profile found for account id: {account_id}"
226 ))
227 })?;
228
229 select_auth_profile_entry(selected, report, options, now)
230}
231
232fn select_auth_profile_entry(
233 selected: &AuthProfileEntry,
234 report: &AuthProfileListReport,
235 options: &AuthCommandOptions,
236 now: DateTime<Utc>,
237) -> Result<String, AppError> {
238 if Some(&selected.account_id) == report.current.as_ref().map(|entry| &entry.account_id) {
239 return Ok(format!(
240 "Auth profile already active: {}\n",
241 format_auth_profile_entry(selected)
242 ));
243 }
244
245 let switched = switch_codex_auth_profile(&selected.account_id, options, now)?;
246 Ok(format!(
247 "Saved current auth profile: {}\nActivated auth profile: {}\n",
248 format_auth_profile_entry(&switched.saved_current),
249 format_auth_profile_entry(&switched.activated)
250 ))
251}
252
253fn remove_auth_profiles_interactively(
254 report: &AuthProfileListReport,
255 options: &AuthCommandOptions,
256 now: DateTime<Utc>,
257 prompt: &mut impl Prompt,
258) -> Result<String, AppError> {
259 let current_account_id = report
260 .current
261 .as_ref()
262 .map(|entry| entry.account_id.as_str());
263 let candidates = report
264 .stored
265 .iter()
266 .filter(|entry| Some(entry.account_id.as_str()) != current_account_id)
267 .collect::<Vec<_>>();
268 if candidates.is_empty() {
269 return Ok("No removable persisted auth profiles.\n".to_string());
270 }
271
272 let items = candidates
273 .iter()
274 .map(|entry| format_auth_profile_entry(entry))
275 .collect::<Vec<_>>();
276 let selected_indices = prompt
277 .multi_select("Remove auth profiles", &items)?
278 .ok_or_else(|| AppError::new("auth remove cancelled."))?;
279 if selected_indices.is_empty() {
280 return Err(AppError::new("auth remove cancelled."));
281 }
282
283 let selected = selected_indices
284 .into_iter()
285 .map(|index| {
286 candidates
287 .get(index)
288 .copied()
289 .ok_or_else(|| AppError::new("Prompt returned an invalid auth profile selection."))
290 })
291 .collect::<Result<Vec<_>, _>>()?;
292 let confirmed = prompt
293 .confirm("Remove selected auth profiles?", false)?
294 .unwrap_or(false);
295 if !confirmed {
296 return Err(AppError::new("auth remove cancelled."));
297 }
298
299 selected
300 .into_iter()
301 .map(|entry| remove_auth_profile_entry(entry, options, now))
302 .collect::<Result<Vec<_>, _>>()
303 .map(|lines| lines.join("\n"))
304}
305
306fn remove_auth_profile_by_account_id(
307 account_id: &str,
308 report: &AuthProfileListReport,
309 options: &AuthCommandOptions,
310 now: DateTime<Utc>,
311) -> Result<String, AppError> {
312 let selected = report
313 .stored
314 .iter()
315 .find(|entry| entry.account_id == account_id)
316 .ok_or_else(|| {
317 AppError::new(format!(
318 "No persisted auth profile found for account id: {account_id}"
319 ))
320 })?;
321
322 remove_auth_profile_entry(selected, options, now)
323}
324
325fn remove_auth_profile_entry(
326 selected: &AuthProfileEntry,
327 options: &AuthCommandOptions,
328 now: DateTime<Utc>,
329) -> Result<String, AppError> {
330 let removed = remove_codex_auth_profile(&selected.account_id, options, now)?;
331 Ok(format!(
332 "Removed auth profile: {}",
333 format_auth_profile_entry(&removed.removed)
334 ))
335}
336
337fn run_doctor(command: DoctorCliCommand) -> CliResult {
338 let DoctorCliCommand::Run(options) = command;
339 let result = (|| {
340 let doctor_options = doctor_command_options(&options.paths);
341 let report = read_doctor_report(&doctor_options, cli_now()?);
342 format_doctor_report(&report, options.json)
343 })();
344
345 cli_result_from_string(result)
346}
347
348fn run_stat(command: StatCliCommand) -> CliResult {
349 let result = (|| {
350 run_stat_command(
351 command.view.as_deref(),
352 command.session.as_deref(),
353 command.options,
354 cli_now()?,
355 )
356 })();
357 cli_result_from_string(result)
358}
359
360fn run_cycle(command: CycleCliCommand) -> CliResult {
361 let result = (|| {
362 let now = cli_now()?;
363 match command {
364 CycleCliCommand::Add {
365 time_parts,
366 options,
367 } => run_cycle_add(&time_parts, options, now),
368 CycleCliCommand::List { options } => run_cycle_list(options, now),
369 CycleCliCommand::Remove { anchor_id, options } => {
370 run_cycle_remove(&anchor_id, options, now)
371 }
372 CycleCliCommand::Current { options } => run_cycle_current(options, now),
373 CycleCliCommand::History { cycle_id, options } => {
374 run_cycle_history(cycle_id, options, now)
375 }
376 }
377 })();
378 cli_result_from_string(result)
379}
380
381fn auth_command_options(paths: &AuthCliPaths) -> AuthCommandOptions {
382 AuthCommandOptions {
383 auth_file: paths.auth_file.clone(),
384 codex_home: paths.codex_home.clone(),
385 store_dir: paths.store_dir.clone(),
386 account_history_file: paths.account_history_file.clone(),
387 }
388}
389
390fn doctor_command_options(paths: &DoctorCliPaths) -> DoctorOptions {
391 DoctorOptions {
392 auth_file: paths.auth_file.clone(),
393 codex_home: paths.codex_home.clone(),
394 sessions_dir: paths.sessions_dir.clone(),
395 cycle_file: paths.cycle_file.clone(),
396 }
397}
398
399fn cli_now() -> Result<DateTime<Utc>, AppError> {
400 match env::var("CODEX_OPS_FIXED_NOW") {
401 Ok(value) if !value.trim().is_empty() => DateTime::parse_from_rfc3339(value.trim())
402 .map(|date| date.with_timezone(&Utc))
403 .map_err(|_| {
404 AppError::new("Invalid CODEX_OPS_FIXED_NOW. Expected an ISO date string.")
405 }),
406 _ => Ok(Utc::now()),
407 }
408}
409
410fn cli_result_from_string(result: Result<String, AppError>) -> CliResult {
411 match result {
412 Ok(stdout) => CliResult::success(stdout),
413 Err(error) => CliResult::app_error(error),
414 }
415}
416
417fn ensure_trailing_newline(mut value: String) -> String {
418 if !value.ends_with('\n') {
419 value.push('\n');
420 }
421 value
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn root_help_lists_top_level_commands() {
430 let result = run_cli(["--help"]);
431
432 assert_eq!(result.code, 0);
433 assert!(result.stderr.is_empty());
434 assert!(result.stdout.contains("Usage: codex-ops"));
435 assert!(result.stdout.contains("auth"));
436 assert!(result.stdout.contains("doctor"));
437 assert!(result.stdout.contains("stat"));
438 assert!(result.stdout.contains("cycle"));
439 }
440
441 #[test]
442 fn auth_help_lists_profile_commands() {
443 let result = run_cli(["auth", "--help"]);
444
445 assert_eq!(result.code, 0);
446 assert!(result.stdout.contains("status"));
447 assert!(result.stdout.contains("save"));
448 assert!(result.stdout.contains("list"));
449 assert!(result.stdout.contains("select"));
450 assert!(result.stdout.contains("remove"));
451 }
452
453 #[test]
454 fn child_help_works() {
455 let result = run_cli(["stat", "--help"]);
456
457 assert_eq!(result.code, 0);
458 assert!(result.stdout.contains("--group-by"));
459 assert!(result.stdout.contains("sessions"));
460 }
461
462 #[test]
463 fn cycle_leaf_validates_arguments() {
464 let result = run_cli(["cycle", "add"]);
465
466 assert_eq!(result.code, 1);
467 assert!(result.stdout.is_empty());
468 assert!(result
469 .stderr
470 .contains("cycle add requires at least one weekly cycle start time"));
471 }
472
473 #[test]
474 fn unknown_command_returns_error() {
475 let result = run_cli(["missing"]);
476
477 assert_eq!(result.code, 2);
478 assert!(result.stderr.contains("unrecognized subcommand 'missing'"));
479 }
480
481 #[test]
482 fn interactive_auth_select_switches_profile_and_writes_history() {
483 let fixture = AuthPromptFixture::new("select-switch");
484 let now = fixed_now();
485 let current_content = auth_content("account-a", "a@example.test", "plus");
486 let selected_content = auth_content("account-b", "b@example.test", "pro");
487
488 fixture.save_profile(&selected_content, now);
489 std::fs::write(&fixture.auth_file, ¤t_content).expect("write current auth");
490 let options = fixture.options();
491 let report = list_codex_auth_profiles(&options, now).expect("list profiles");
492 let mut prompt = FakePrompt {
493 select: Some(Some(0)),
494 ..FakePrompt::default()
495 };
496
497 let output =
498 select_auth_profile_interactively(&report, &options, now, &mut prompt).unwrap();
499
500 assert!(output.contains("Activated auth profile: b@example.test(account-b) - pro"));
501 assert_eq!(
502 std::fs::read_to_string(&fixture.auth_file).expect("read auth"),
503 selected_content
504 );
505 let history: serde_json::Value = serde_json::from_str(
506 &std::fs::read_to_string(&fixture.history_file).expect("read history"),
507 )
508 .expect("parse history");
509 assert_eq!(history["switches"][0]["fromAccountId"], "account-a");
510 assert_eq!(history["switches"][0]["toAccountId"], "account-b");
511 assert_eq!(
512 prompt.select_items[0],
513 vec!["b@example.test(account-b) - pro".to_string()]
514 );
515 }
516
517 #[test]
518 fn interactive_auth_select_cancel_has_no_side_effects() {
519 let fixture = AuthPromptFixture::new("select-cancel");
520 let now = fixed_now();
521 let current_content = auth_content("account-a", "a@example.test", "plus");
522 let selected_content = auth_content("account-b", "b@example.test", "pro");
523
524 fixture.save_profile(&selected_content, now);
525 std::fs::write(&fixture.auth_file, ¤t_content).expect("write current auth");
526 let options = fixture.options();
527 let report = list_codex_auth_profiles(&options, now).expect("list profiles");
528 let mut prompt = FakePrompt {
529 select: Some(None),
530 ..FakePrompt::default()
531 };
532
533 let error =
534 select_auth_profile_interactively(&report, &options, now, &mut prompt).unwrap_err();
535
536 assert_eq!(error.message(), "auth select cancelled.");
537 assert_eq!(
538 std::fs::read_to_string(&fixture.auth_file).expect("read auth"),
539 current_content
540 );
541 assert!(!fixture.history_file.exists());
542 }
543
544 #[test]
545 fn interactive_auth_remove_deletes_selected_profiles_but_not_current() {
546 let fixture = AuthPromptFixture::new("remove-selected");
547 let now = fixed_now();
548 let current_content = auth_content("account-a", "a@example.test", "plus");
549 let other_content = auth_content("account-b", "b@example.test", "pro");
550 let third_content = auth_content("account-c", "c@example.test", "team");
551
552 fixture.save_profile(¤t_content, now);
553 fixture.save_profile(&other_content, now);
554 fixture.save_profile(&third_content, now);
555 std::fs::write(&fixture.auth_file, ¤t_content).expect("write current auth");
556 let options = fixture.options();
557 let report = list_codex_auth_profiles(&options, now).expect("list profiles");
558 let mut prompt = FakePrompt {
559 multi_select: Some(Some(vec![0])),
560 confirm: Some(Some(true)),
561 ..FakePrompt::default()
562 };
563
564 let output =
565 remove_auth_profiles_interactively(&report, &options, now, &mut prompt).unwrap();
566
567 assert!(output.contains("Removed auth profile: b@example.test(account-b) - pro"));
568 assert_eq!(
569 prompt.multi_select_items[0],
570 vec![
571 "b@example.test(account-b) - pro".to_string(),
572 "c@example.test(account-c) - team".to_string()
573 ]
574 );
575 assert!(fixture.profile_file("account-a").exists());
576 assert!(!fixture.profile_file("account-b").exists());
577 assert!(fixture.profile_file("account-c").exists());
578 }
579
580 #[test]
581 fn interactive_auth_remove_confirmation_reject_has_no_side_effects() {
582 let fixture = AuthPromptFixture::new("remove-cancel");
583 let now = fixed_now();
584 let current_content = auth_content("account-a", "a@example.test", "plus");
585 let other_content = auth_content("account-b", "b@example.test", "pro");
586
587 fixture.save_profile(¤t_content, now);
588 fixture.save_profile(&other_content, now);
589 std::fs::write(&fixture.auth_file, ¤t_content).expect("write current auth");
590 let options = fixture.options();
591 let report = list_codex_auth_profiles(&options, now).expect("list profiles");
592 let mut prompt = FakePrompt {
593 multi_select: Some(Some(vec![0])),
594 confirm: Some(Some(false)),
595 ..FakePrompt::default()
596 };
597
598 let error =
599 remove_auth_profiles_interactively(&report, &options, now, &mut prompt).unwrap_err();
600
601 assert_eq!(error.message(), "auth remove cancelled.");
602 assert!(fixture.profile_file("account-a").exists());
603 assert!(fixture.profile_file("account-b").exists());
604 }
605
606 #[derive(Default)]
607 struct FakePrompt {
608 select: Option<Option<usize>>,
609 multi_select: Option<Option<Vec<usize>>>,
610 confirm: Option<Option<bool>>,
611 select_items: Vec<Vec<String>>,
612 multi_select_items: Vec<Vec<String>>,
613 }
614
615 impl Prompt for FakePrompt {
616 fn select(&mut self, _prompt: &str, items: &[String]) -> Result<Option<usize>, AppError> {
617 self.select_items.push(items.to_vec());
618 self.select
619 .take()
620 .ok_or_else(|| AppError::new("missing fake select response"))
621 }
622
623 fn multi_select(
624 &mut self,
625 _prompt: &str,
626 items: &[String],
627 ) -> Result<Option<Vec<usize>>, AppError> {
628 self.multi_select_items.push(items.to_vec());
629 self.multi_select
630 .take()
631 .ok_or_else(|| AppError::new("missing fake multi-select response"))
632 }
633
634 fn confirm(&mut self, _prompt: &str, _default: bool) -> Result<Option<bool>, AppError> {
635 self.confirm
636 .take()
637 .ok_or_else(|| AppError::new("missing fake confirm response"))
638 }
639 }
640
641 struct AuthPromptFixture {
642 root: std::path::PathBuf,
643 auth_file: std::path::PathBuf,
644 store_dir: std::path::PathBuf,
645 history_file: std::path::PathBuf,
646 }
647
648 impl AuthPromptFixture {
649 fn new(label: &str) -> Self {
650 let root = temp_dir(&format!("codex-ops-auth-{label}"));
651 std::fs::create_dir_all(&root).expect("create auth fixture root");
652 Self {
653 auth_file: root.join("auth.json"),
654 store_dir: root.join("auth-profiles"),
655 history_file: root.join("auth-account-history.json"),
656 root,
657 }
658 }
659
660 fn options(&self) -> AuthCommandOptions {
661 AuthCommandOptions {
662 auth_file: Some(self.auth_file.clone()),
663 store_dir: Some(self.store_dir.clone()),
664 account_history_file: Some(self.history_file.clone()),
665 ..AuthCommandOptions::default()
666 }
667 }
668
669 fn save_profile(&self, content: &str, now: DateTime<Utc>) {
670 std::fs::write(&self.auth_file, content).expect("write auth profile content");
671 save_current_codex_auth_profile(
672 &AuthCommandOptions {
673 auth_file: Some(self.auth_file.clone()),
674 store_dir: Some(self.store_dir.clone()),
675 ..AuthCommandOptions::default()
676 },
677 now,
678 )
679 .expect("save auth profile");
680 }
681
682 fn profile_file(&self, account_id: &str) -> std::path::PathBuf {
683 self.store_dir.join(format!("{account_id}.json"))
684 }
685 }
686
687 impl Drop for AuthPromptFixture {
688 fn drop(&mut self) {
689 let _ = std::fs::remove_dir_all(&self.root);
690 }
691 }
692
693 fn fixed_now() -> DateTime<Utc> {
694 DateTime::parse_from_rfc3339("2026-05-13T00:00:00.000Z")
695 .expect("fixed now")
696 .with_timezone(&Utc)
697 }
698
699 fn auth_content(account_id: &str, email: &str, plan: &str) -> String {
700 let payload = serde_json::json!({
701 "sub": format!("auth0|{account_id}"),
702 "email": email,
703 "https://api.openai.com/auth": {
704 "chatgpt_account_id": account_id,
705 "chatgpt_plan_type": plan,
706 "chatgpt_user_id": format!("user-{account_id}"),
707 "user_id": format!("user-{account_id}")
708 }
709 });
710 let token = jwt(r#"{"alg":"RS256","kid":"key-1"}"#, &payload.to_string());
711 serde_json::to_string_pretty(&serde_json::json!({
712 "auth_mode": "chatgpt",
713 "tokens": {
714 "id_token": token,
715 "refresh_token": "synthetic-refresh-token",
716 "account_id": account_id
717 },
718 "last_refresh": "2026-05-12T05:32:41.917677755Z"
719 }))
720 .expect("serialize auth content")
721 }
722
723 fn jwt(header: &str, payload: &str) -> String {
724 format!(
725 "{}.{}.signature",
726 encode_base64url(header),
727 encode_base64url(payload)
728 )
729 }
730
731 fn encode_base64url(value: &str) -> String {
732 const TABLE: &[u8; 64] =
733 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
734 let bytes = value.as_bytes();
735 let mut output = String::new();
736 let mut index = 0;
737
738 while index < bytes.len() {
739 let b0 = bytes[index];
740 let b1 = *bytes.get(index + 1).unwrap_or(&0);
741 let b2 = *bytes.get(index + 2).unwrap_or(&0);
742 output.push(TABLE[(b0 >> 2) as usize] as char);
743 output.push(TABLE[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
744 if index + 1 < bytes.len() {
745 output.push(TABLE[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char);
746 }
747 if index + 2 < bytes.len() {
748 output.push(TABLE[(b2 & 0x3f) as usize] as char);
749 }
750 index += 3;
751 }
752
753 output
754 }
755
756 fn temp_dir(prefix: &str) -> std::path::PathBuf {
757 let millis = std::time::SystemTime::now()
758 .duration_since(std::time::UNIX_EPOCH)
759 .expect("system time")
760 .as_millis();
761 std::env::temp_dir().join(format!("{prefix}-{millis}-{}", std::process::id()))
762 }
763}