Skip to main content

codex_ops/
lib.rs

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, &current_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, &current_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(&current_content, now);
553        fixture.save_profile(&other_content, now);
554        fixture.save_profile(&third_content, now);
555        std::fs::write(&fixture.auth_file, &current_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(&current_content, now);
588        fixture.save_profile(&other_content, now);
589        std::fs::write(&fixture.auth_file, &current_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}